"""Total ascent and descent of a route from its elevation samples (Z2,
docs/reports/Z2_suma_podejsc_diagnoza.md), one method for every saved
route -- drawn (BRouter's SRTM heights) or imported (our DEM); the window
and the threshold depend on the activity (``method_for``):

* on foot (trekking, trail running) -- and any route whose activity is not
  known -- a 100 m window and a 3 m hysteresis;
* by bike (road, gravel, MTB, touring) -- a 200 m window and NO threshold:
  slow, long climbs of a few metres are real for a bike, and a threshold
  removes them. Calibrated on the owner's ride Zegrze--Legionowo (104 km,
  Strava/Garmin ~250 m): raw 358, 100 m 310, **200 m 247**, 100 m + 3 m
  108 (docs/reports/AV-053_raport.md section 4).


1. **Smoothing by distance:** each height becomes the mean of the heights
   within SMOOTHING_WINDOW_M/2 before and after it along the route (the
   model terrain's 1-2 m jitter between 20 m samples cancels out; a real
   climb longer than the window survives).
2. **Hysteresis:** a change counts only once the smoothed height has moved
   HYSTERESIS_M away from the last counted height; the remainder at the end
   is counted too, so ascent - descent is the smoothed end minus the
   smoothed start (within a metre or two of the raw difference).

Points without elevation (BRouter gaps: tunnels, ferries) split the route:
each run with heights is counted on its own, nothing is bridged or filled.
These are estimates from a terrain model, not a measurement
(``ASCENT_METHOD`` names the method in the API).
"""

from __future__ import annotations

import math
from typing import Sequence

SMOOTHING_WINDOW_M = 100.0
HYSTERESIS_M = 3.0
ASCENT_METHOD = "DEM_SMOOTHED_100M_HYSTERESIS_3M"
# A height outside this range is a "no data" marker (-32768, -9999, a NaN
# turned into a number), never a place on Earth (the Dead Sea shore is
# -430 m, Everest 8849 m): it is UNAVAILABLE, never smoothed into a sum.
PLAUSIBLE_ELEVATION_M = (-500.0, 9000.0)


def plausible_elevation(value) -> float | None:
    """``value`` if it can be a real height, else None (no data)."""
    if value is None:
        return None
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    low, high = PLAUSIBLE_ELEVATION_M
    return number if math.isfinite(number) and low <= number <= high else None


BIKE_SMOOTHING_WINDOW_M = 200.0
BIKE_HYSTERESIS_M = 0.0
BIKE_ASCENT_METHOD = "DEM_SMOOTHED_200M_NO_HYSTERESIS"


def method_for(activity_id: str | None) -> tuple[float, float, str]:
    """(window_m, hysteresis_m, method name) for a route's activity; an
    activity not known here (or none) counts as trekking."""
    from .activities import ACTIVITIES_BY_ID

    activity = ACTIVITIES_BY_ID.get(activity_id or "")
    if activity is not None and activity.family == "bike":
        return BIKE_SMOOTHING_WINDOW_M, BIKE_HYSTERESIS_M, BIKE_ASCENT_METHOD
    return SMOOTHING_WINDOW_M, HYSTERESIS_M, ASCENT_METHOD


def smoothed(distances: Sequence[float], heights: Sequence[float], window_m: float) -> list[float]:
    """Moving mean over +/- window_m/2 by distance (two pointers, O(n))."""
    half = window_m / 2.0
    out, total, lo, hi = [], 0.0, 0, 0
    n = len(heights)
    for i in range(n):
        while hi < n and distances[hi] <= distances[i] + half:
            total += heights[hi]
            hi += 1
        while distances[lo] < distances[i] - half:
            total -= heights[lo]
            lo += 1
        out.append(total / (hi - lo))
    return out


def _run_gain(heights: Sequence[float], hysteresis_m: float) -> tuple[float, float]:
    ascent = descent = 0.0
    reference = heights[0]
    for value in heights[1:]:
        change = value - reference
        if change >= hysteresis_m:
            ascent += change
            reference = value
        elif -change >= hysteresis_m:
            descent -= change
            reference = value
    rest = heights[-1] - reference
    if rest > 0:
        ascent += rest
    else:
        descent -= rest
    return ascent, descent


def ascent_descent(
    distances: Sequence[float],
    elevations: Sequence[float | None],
    *,
    window_m: float = SMOOTHING_WINDOW_M,
    hysteresis_m: float = HYSTERESIS_M,
    run_starts: Sequence[bool] | None = None,
) -> tuple[float, float]:
    """(ascent_m, descent_m) by the module's method; (0, 0) without heights."""
    ascent = descent = 0.0
    run_d: list[float] = []
    run_h: list[float] = []

    def close():
        nonlocal ascent, descent
        if len(run_h) >= 2:
            values = smoothed(run_d, run_h, window_m) if window_m > 0 else list(run_h)
            up, down = _run_gain(values, hysteresis_m)
            ascent += up
            descent += down
        run_d.clear()
        run_h.clear()

    for index, (distance, raw) in enumerate(zip(distances, elevations)):
        # AV-064: a transfer ends a run (run_starts[i]: a new run begins at
        # i) -- the height change across a train ride is no climb.
        if run_starts is not None and index < len(run_starts) and run_starts[index]:
            close()
        height = plausible_elevation(raw)  # no-data markers never reach a sum
        if height is None:
            close()
            continue
        run_d.append(distance)
        run_h.append(height)
    close()
    return ascent, descent
