//! GPX import: turn a recorded ride into a gradient profile (§5.5).
//!
//! The hard part is not parsing — it is that **raw GPS elevation is far too
//! noisy to differentiate directly** (FR-5.2). Differentiating unsmoothed
//! elevation produces wild gradient spikes that would make the trainer lurch.
//! Elevation must be smoothed before gradients are derived, and the result
//! clamped (FR-5.3).

use crate::profile::{Block, Profile, TerrainPoint};

/// Mean Earth radius (IUGG), metres.
const EARTH_RADIUS_M: f64 = 6_371_008.8;

/// Upper bound on resampled points, so a 300 km track with a 10 cm spacing
/// cannot allocate gigabytes. Exceeding it widens the spacing instead.
const MAX_RESAMPLED_POINTS: usize = 200_000;

/// A single trackpoint read from a GPX file.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrackPoint {
    pub lat_deg: f64,
    pub lon_deg: f64,
    pub elevation_m: f32,
}

/// Tuning for elevation smoothing and gradient derivation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SmoothingConfig {
    /// Resample the track to this spacing before differentiating, in metres.
    /// Larger values give smoother, less twitchy gradients.
    pub resample_m: f64,
    /// Width of the smoothing window, in metres.
    pub window_m: f64,
    pub min_gradient_pct: f32,
    pub max_gradient_pct: f32,
}

impl Default for SmoothingConfig {
    fn default() -> Self {
        Self {
            resample_m: 10.0,
            window_m: 100.0,
            min_gradient_pct: -10.0,
            max_gradient_pct: 15.0,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum GpxError {
    #[error("malformed GPX: {0}")]
    Malformed(String),
    #[error("GPX contains no track points with elevation")]
    NoElevation,
    #[error("GPX track is too short to derive a gradient profile")]
    TooShort,
}

/// Parse the track points out of a GPX document.
///
/// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`,
/// missing `<ele>` on some points, multiple segments, and namespaced documents.
pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> {
    let doc = roxmltree::Document::parse(xml).map_err(|e| GpxError::Malformed(e.to_string()))?;

    // Match on the local name only: GPX 1.0 and 1.1 use different namespace
    // URIs and plenty of files in the wild declare neither.
    let mut coords: Vec<(f64, f64)> = Vec::new();
    let mut elevations: Vec<Option<f32>> = Vec::new();

    for node in doc.descendants() {
        if !node.is_element() {
            continue;
        }
        let name = node.tag_name().name();
        if name != "trkpt" && name != "rtept" && name != "wpt" {
            continue;
        }
        // A waypoint outside a track or route is a POI, not part of the line.
        if name == "wpt" && !has_ancestor(node, &["trkseg", "trk", "rte"]) {
            continue;
        }
        let (Some(lat), Some(lon)) = (
            node.attribute("lat")
                .and_then(|v| v.trim().parse::<f64>().ok()),
            node.attribute("lon")
                .and_then(|v| v.trim().parse::<f64>().ok()),
        ) else {
            // Tolerate a junk point rather than losing the whole file.
            continue;
        };
        if !lat.is_finite() || !lon.is_finite() {
            continue;
        }

        let ele = node
            .children()
            .find(|c| c.is_element() && c.tag_name().name() == "ele")
            .and_then(|c| c.text())
            .and_then(|t| t.trim().parse::<f32>().ok())
            .filter(|v| v.is_finite());

        coords.push((lat, lon));
        elevations.push(ele);
    }

    if coords.is_empty() {
        return Ok(Vec::new());
    }
    if elevations.iter().all(Option::is_none) {
        return Err(GpxError::NoElevation);
    }

    let filled = fill_missing_elevations(&elevations);
    Ok(coords
        .into_iter()
        .zip(filled)
        .map(|((lat_deg, lon_deg), elevation_m)| TrackPoint {
            lat_deg,
            lon_deg,
            elevation_m,
        })
        .collect())
}

fn has_ancestor(node: roxmltree::Node<'_, '_>, names: &[&str]) -> bool {
    node.ancestors()
        .any(|a| a.is_element() && names.contains(&a.tag_name().name()))
}

/// Points without `<ele>` are bridged from their neighbours rather than
/// dropped — dropping them would corrupt the distance axis, and a hole in the
/// elevation series would read as a cliff once differentiated.
fn fill_missing_elevations(elevations: &[Option<f32>]) -> Vec<f32> {
    let mut out = vec![0.0f32; elevations.len()];
    let mut last_known: Option<(usize, f32)> = None;

    for (i, known) in elevations.iter().enumerate() {
        let Some(value) = *known else { continue };
        match last_known {
            // Linearly bridge the gap by index.
            Some((prev_index, prev_value)) => {
                let span = (i - prev_index) as f32;
                for (offset, slot) in out[prev_index + 1..i].iter_mut().enumerate() {
                    let f = (offset + 1) as f32 / span;
                    *slot = prev_value + (value - prev_value) * f;
                }
            }
            // Leading gap: hold the first known value backwards.
            None => {
                for slot in out.iter_mut().take(i) {
                    *slot = value;
                }
            }
        }
        out[i] = value;
        last_known = Some((i, value));
    }

    // Trailing gap: hold the last known value forwards.
    if let Some((last_index, last_value)) = last_known {
        for slot in out.iter_mut().skip(last_index + 1) {
            *slot = last_value;
        }
    }
    out
}

/// Great-circle distance between two points, in metres.
pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
    let lat1 = a.lat_deg.to_radians();
    let lat2 = b.lat_deg.to_radians();
    let dlat = lat2 - lat1;
    let dlon = (b.lon_deg - a.lon_deg).to_radians();

    let h = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2);
    let d = 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin();
    if d.is_finite() {
        d
    } else {
        0.0
    }
}

/// Turn track points into a smoothed, clamped gradient profile.
///
/// The pipeline is deliberate and its order matters (FR-5.2):
///
/// 1. Accumulate ground distance with the haversine formula, discarding
///    repeated fixes so the distance axis is strictly increasing.
/// 2. Resample elevation onto an even `resample_m` grid. Uneven GPS spacing
///    otherwise weights a stationary cluster of fixes as heavily as a fast
///    descent.
///    2b. Reject *outliers* — single fixes metres away from their neighbours —
///    before any averaging. A moving average does not remove an outlier, it
///    smears it across the whole window, and the differentiation in step 4
///    then reads that smear as a sustained gradient. The commonest instance is
///    the first fix of a recorded activity, taken before the receiver has
///    settled: on a real 22.6 km file the opening fix sat 9 m above the road
///    and produced a phantom −9.5% descent that the trainer was then asked to
///    reproduce.
/// 3. Smooth elevation with two cascaded centred moving averages `window_m`
///    wide, over a reflected extension so the window never truncates at the
///    ends. Consumer GPS elevation carries metres of noise; differentiating it
///    directly gives gradients swinging tens of percent between neighbours.
/// 4. Differentiate over the *same* window rather than between neighbours. A
///    neighbour difference re-amplifies whatever noise survived smoothing;
///    taking the rise across ±half a window makes the run large enough that
///    residual noise is a fraction of a percent.
/// 5. Clamp to the configured range (FR-5.3).
///
/// On the ±1.5 m fixture in `testdata/` this holds the largest gradient change
/// between adjacent 10 m samples under 1%, while recovering the route's real
/// 6–8% climb and −4.5% descent.
pub fn to_terrain(
    points: &[TrackPoint],
    cfg: &SmoothingConfig,
) -> Result<Vec<TerrainPoint>, GpxError> {
    if points.len() < 2 {
        return Err(GpxError::TooShort);
    }

    // 1. Cumulative ground distance, dropping non-advancing fixes.
    let mut cum_m: Vec<f64> = Vec::with_capacity(points.len());
    let mut raw_ele: Vec<f32> = Vec::with_capacity(points.len());
    cum_m.push(0.0);
    raw_ele.push(points[0].elevation_m);
    let mut total = 0.0f64;
    for pair in points.windows(2) {
        let step = haversine_m(pair[0], pair[1]);
        if !step.is_finite() || step <= 0.0 {
            continue;
        }
        total += step;
        cum_m.push(total);
        raw_ele.push(pair[1].elevation_m);
    }
    if cum_m.len() < 2 || total <= 0.0 {
        return Err(GpxError::TooShort);
    }

    // 2. Even resampling.
    let mut spacing = if cfg.resample_m.is_finite() && cfg.resample_m > 0.0 {
        cfg.resample_m
    } else {
        SmoothingConfig::default().resample_m
    };
    if total / spacing > MAX_RESAMPLED_POINTS as f64 {
        spacing = total / MAX_RESAMPLED_POINTS as f64;
    }
    let count = (total / spacing).floor() as usize + 1;
    if count < 3 {
        return Err(GpxError::TooShort);
    }

    let mut grid_ele = Vec::with_capacity(count);
    let mut cursor = 0usize;
    for i in 0..count {
        let x = i as f64 * spacing;
        while cursor + 2 < cum_m.len() && cum_m[cursor + 1] < x {
            cursor += 1;
        }
        let (x0, x1) = (cum_m[cursor], cum_m[cursor + 1]);
        let (y0, y1) = (raw_ele[cursor], raw_ele[cursor + 1]);
        let span = x1 - x0;
        let f = if span > 0.0 {
            ((x - x0) / span).clamp(0.0, 1.0) as f32
        } else {
            0.0
        };
        grid_ele.push(y0 + (y1 - y0) * f);
    }

    // 2b. Reject outliers before averaging anything. See `despike`.
    despike(&mut grid_ele, spacing);

    // 3. Smooth, over a reflected extension of the series so that the window
    //    stays full width at the ends. Truncating the window instead leaves
    //    the first and last samples barely smoothed, and since step 4 reads
    //    exactly those samples the route would open and close with a gradient
    //    spike — the very thing this module exists to prevent.
    //
    //    Two passes, not one. A single boxcar has a poor stopband: neighbouring
    //    windows share all but two samples, so the residual after one pass is
    //    strongly correlated and re-emerges as a step change once
    //    differentiated. Cascading two boxcars gives a triangular kernel,
    //    which cuts that step-to-step residual by roughly a factor of five
    //    while still reproducing a constant gradient exactly.
    let window_m = if cfg.window_m.is_finite() && cfg.window_m > 0.0 {
        cfg.window_m
    } else {
        SmoothingConfig::default().window_m
    };
    let half = (((window_m / spacing) * 0.5).round().max(1.0) as usize).min(count - 1);
    // Two smoothing passes and the derivative each eat `half` at both ends.
    let pad = 3 * half;
    let padded = reflect_pad(&grid_ele, pad);
    let smoothed = moving_average(&moving_average(&padded, half), half);

    // 4. Differentiate over the smoothing window, then 5. clamp.
    let (lo, hi) = gradient_bounds(cfg);
    let run = 2.0 * half as f64 * spacing;
    let mut out = Vec::with_capacity(count);
    for i in 0..count {
        let centre = i + pad;
        let gradient = if run > 0.0 {
            100.0 * (smoothed[centre + half] - smoothed[centre - half]) as f64 / run
        } else {
            0.0
        };
        out.push(TerrainPoint {
            distance_m: i as f64 * spacing,
            gradient_pct: (gradient as f32).clamp(lo, hi),
            elevation_m: smoothed[centre],
        });
    }
    Ok(out)
}

/// Extend a series by `pad` samples at each end by reflecting *through* the
/// endpoint rather than about it: `x[-k] = 2·x[0] − x[k]`.
///
/// A plain mirror would fold a climb back on itself and read as a summit at
/// the trailhead. Reflecting through the endpoint continues the local trend
/// instead, so a constant gradient stays constant right to the edge.
fn reflect_pad(src: &[f32], pad: usize) -> Vec<f32> {
    let n = src.len();
    debug_assert!(n > 0);
    let last = n - 1;
    let mut out = Vec::with_capacity(n + 2 * pad);
    for k in (1..=pad).rev() {
        out.push(2.0 * src[0] - src[k.min(last)]);
    }
    out.extend_from_slice(src);
    for k in 1..=pad {
        out.push(2.0 * src[last] - src[last.saturating_sub(k)]);
    }
    out
}

/// A config with the bounds the wrong way round should not produce an empty
/// clamp range and a stream of NaN.
fn gradient_bounds(cfg: &SmoothingConfig) -> (f32, f32) {
    let defaults = SmoothingConfig::default();
    let lo = if cfg.min_gradient_pct.is_finite() {
        cfg.min_gradient_pct
    } else {
        defaults.min_gradient_pct
    };
    let hi = if cfg.max_gradient_pct.is_finite() {
        cfg.max_gradient_pct
    } else {
        defaults.max_gradient_pct
    };
    if lo <= hi {
        (lo, hi)
    } else {
        (hi, lo)
    }
}

/// Width of the outlier-rejection window, metres. Wide enough that the spread
/// estimate over it is stable — a window of a handful of samples produces a
/// noisy σ, and a noisy σ makes the filter fire on ordinary noise — and narrow
/// enough that a road's curvature across it stays below [`DESPIKE_FLOOR_M`].
const DESPIKE_WINDOW_M: f64 = 150.0;
/// How many robust standard deviations from the local trend counts as an
/// outlier.
const DESPIKE_K: f32 = 4.0;
/// A sample is never rejected for deviating less than this, in metres.
///
/// Two jobs. It stops a genuinely smooth stretch — where the estimated spread
/// is near zero — from having every millimetre of wobble called an outlier.
/// And it keeps the filter clear of ordinary consumer-GPS elevation noise,
/// which runs to ±1.5–3 m: that is the smoothing window's problem to solve,
/// not this one's. Set below the errors that actually matter, which are the
/// 5–10 m variety.
const DESPIKE_FLOOR_M: f32 = 4.0;

/// Lower-median of a slice, sorted in place. NaN-safe via `total_cmp`.
fn median(values: &mut [f32]) -> f32 {
    if values.is_empty() {
        return 0.0;
    }
    values.sort_by(|a, b| a.total_cmp(b));
    values[values.len() / 2]
}

/// Replace elevation samples that are not on the road with the road.
///
/// This is a Hampel filter with one addition that matters here: the local
/// trend is removed before the test. A plain median filter is already immune
/// to a *linear* trend in the middle of a series, because the median of a
/// symmetric window through a ramp is its centre value — but not at the ends,
/// where the window can only look one way. Since the single most common
/// outlier in a real GPX is the *first* fix of the recording, taken before the
/// receiver has settled, the ends are exactly where this has to work.
///
/// So each window's slope is estimated robustly (the median of its consecutive
/// differences, which one bad sample cannot move), every sample in the window
/// is projected along that slope to the position under test, and the median of
/// those projections is what the sample is compared against. A sample further
/// than `max(K·σ, floor)` from it is not terrain and is replaced.
///
/// Why this cannot be left to the smoothing that follows: averaging does not
/// remove an outlier, it spreads it over the whole window, and differentiating
/// that smear yields a gradient that is sustained rather than transient. A 9 m
/// first-fix error produced a −9.5% opening descent on a road that was flat,
/// and that gradient was commanded to the trainer.
fn despike(grid: &mut [f32], spacing: f64) {
    let n = grid.len();
    let radius = ((DESPIKE_WINDOW_M / spacing.max(f64::MIN_POSITIVE)) * 0.5).round();
    let radius = (radius.max(3.0) as usize).min(n);
    let width = 2 * radius + 1;
    if n < width {
        // Too short to tell an outlier from the shape of the road.
        return;
    }

    let src = grid.to_vec();
    let mut diffs = Vec::with_capacity(width - 1);
    let mut projected = Vec::with_capacity(width);
    let mut deviations = Vec::with_capacity(width);

    for (i, out) in grid.iter_mut().enumerate() {
        // A full-width window of the nearest samples: centred in the interior,
        // slid inward at the ends so the estimate never runs short of data.
        let start = i.saturating_sub(radius).min(n - width);
        let window = &src[start..start + width];

        diffs.clear();
        diffs.extend(window.windows(2).map(|w| w[1] - w[0]));
        let slope = median(&mut diffs);

        projected.clear();
        projected.extend(
            window
                .iter()
                .enumerate()
                .map(|(k, v)| v + slope * (i as f32 - (start + k) as f32)),
        );
        let predicted = median(&mut projected);

        deviations.clear();
        deviations.extend(projected.iter().map(|v| (v - predicted).abs()));
        // 1.4826·MAD estimates σ for normally distributed noise.
        let sigma = 1.4826 * median(&mut deviations);
        let threshold = (DESPIKE_K * sigma).max(DESPIKE_FLOOR_M);

        if (src[i] - predicted).abs() > threshold {
            *out = predicted;
        }
    }
}

/// Centred moving average over `2·half + 1` samples, with the window truncated
/// symmetrically at the ends so the series is not phase-shifted. Prefix sums
/// in f64 keep it O(n) without losing precision on long tracks.
fn moving_average(src: &[f32], half: usize) -> Vec<f32> {
    let n = src.len();
    let mut prefix = Vec::with_capacity(n + 1);
    prefix.push(0.0f64);
    for &v in src {
        prefix.push(prefix[prefix.len() - 1] + v as f64);
    }

    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        // Shrink from both sides equally near an edge, so the window stays
        // centred on `i`.
        let reach = half.min(i).min(n - 1 - i);
        let a = i - reach;
        let b = i + reach;
        let sum = prefix[b + 1] - prefix[a];
        out.push((sum / (b - a + 1) as f64) as f32);
    }
    out
}

/// Convenience: GPX document to a ready-to-ride single-block profile.
pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> {
    let points = parse(xml)?;
    let terrain = to_terrain(&points, cfg)?;
    Ok(Profile {
        name: name.to_string(),
        description: Some(format!(
            "Imported from GPX: {:.1} km",
            terrain.last().map(|p| p.distance_m).unwrap_or(0.0) / 1000.0
        )),
        blocks: vec![Block::Terrain { points: terrain }],
        // FR-5.6 leaves the choice to the rider; a real route finishes.
        looping: false,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE_CLIMB: &str = include_str!("../../../testdata/sample-climb.gpx");

    fn point(lat: f64, lon: f64, ele: f32) -> TrackPoint {
        TrackPoint {
            lat_deg: lat,
            lon_deg: lon,
            elevation_m: ele,
        }
    }

    // ---- haversine -------------------------------------------------------

    #[test]
    fn haversine_matches_one_degree_of_latitude() {
        // A degree of latitude on a sphere of radius R is π·R/180.
        let d = haversine_m(point(0.0, 0.0, 0.0), point(1.0, 0.0, 0.0));
        let expected = std::f64::consts::PI * EARTH_RADIUS_M / 180.0;
        assert!((d - expected).abs() < 1.0, "{d} vs {expected}");
        assert!((d - 111_194.9).abs() < 2.0);
    }

    #[test]
    fn haversine_matches_a_known_city_pair() {
        // Paris (Notre-Dame) to London (Charing Cross), ~343 km great circle.
        let paris = point(48.8530, 2.3499, 0.0);
        let london = point(51.5074, -0.1278, 0.0);
        let d = haversine_m(paris, london) / 1000.0;
        assert!((d - 343.0).abs() < 3.0, "{d} km");
    }

    #[test]
    fn haversine_is_symmetric_and_zero_for_identical_points() {
        let a = point(45.0, 6.0, 100.0);
        let b = point(45.001, 6.001, 100.0);
        assert_eq!(haversine_m(a, a), 0.0);
        assert!((haversine_m(a, b) - haversine_m(b, a)).abs() < 1e-9);
    }

    #[test]
    fn haversine_shrinks_with_latitude_for_a_fixed_longitude_step() {
        let equator = haversine_m(point(0.0, 0.0, 0.0), point(0.0, 1.0, 0.0));
        let high = haversine_m(point(60.0, 0.0, 0.0), point(60.0, 1.0, 0.0));
        // cos(60°) = 0.5.
        assert!((high / equator - 0.5).abs() < 1e-3);
    }

    // ---- parsing ---------------------------------------------------------

    #[test]
    fn parses_a_namespaced_track() {
        let xml = r#"<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
  <trk><name>t</name><trkseg>
    <trkpt lat="45.0" lon="6.0"><ele>100.0</ele></trkpt>
    <trkpt lat="45.001" lon="6.0"><ele>105.0</ele></trkpt>
  </trkseg></trk>
</gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 2);
        assert_eq!(points[0], point(45.0, 6.0, 100.0));
        assert_eq!(points[1].elevation_m, 105.0);
    }

    #[test]
    fn parses_a_prefixed_namespace() {
        let xml = r#"<g:gpx xmlns:g="http://www.topografix.com/GPX/1/1">
  <g:trk><g:trkseg>
    <g:trkpt lat="1.0" lon="2.0"><g:ele>10</g:ele></g:trkpt>
    <g:trkpt lat="1.001" lon="2.0"><g:ele>20</g:ele></g:trkpt>
  </g:trkseg></g:trk></g:gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 2);
        assert_eq!(points[1].elevation_m, 20.0);
    }

    #[test]
    fn parses_multiple_segments_in_order() {
        let xml = r#"<gpx><trk>
  <trkseg>
    <trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
    <trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
  </trkseg>
  <trkseg>
    <trkpt lat="45.002" lon="6.0"><ele>120</ele></trkpt>
  </trkseg>
</trk></gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 3);
        assert_eq!(points[2].elevation_m, 120.0);
    }

    #[test]
    fn parses_a_route_rather_than_a_track() {
        let xml = r#"<gpx><rte>
  <rtept lat="45.0" lon="6.0"><ele>100</ele></rtept>
  <rtept lat="45.001" lon="6.0"><ele>110</ele></rtept>
</rte></gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 2);
    }

    #[test]
    fn standalone_waypoints_are_ignored() {
        let xml = r#"<gpx>
  <wpt lat="10.0" lon="10.0"><ele>999</ele><name>café</name></wpt>
  <trk><trkseg>
    <trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
    <trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
  </trkseg></trk>
</gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 2);
        assert_eq!(points[0].lat_deg, 45.0);
    }

    #[test]
    fn missing_elevation_is_bridged_from_neighbours() {
        let xml = r#"<gpx><trk><trkseg>
    <trkpt lat="45.000" lon="6.0"><ele>100</ele></trkpt>
    <trkpt lat="45.001" lon="6.0"/>
    <trkpt lat="45.002" lon="6.0"/>
    <trkpt lat="45.003" lon="6.0"><ele>130</ele></trkpt>
</trkseg></trk></gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 4);
        assert!((points[1].elevation_m - 110.0).abs() < 1e-4);
        assert!((points[2].elevation_m - 120.0).abs() < 1e-4);
    }

    #[test]
    fn leading_and_trailing_missing_elevation_are_held() {
        let xml = r#"<gpx><trk><trkseg>
    <trkpt lat="45.000" lon="6.0"/>
    <trkpt lat="45.001" lon="6.0"><ele>100</ele></trkpt>
    <trkpt lat="45.002" lon="6.0"><ele>200</ele></trkpt>
    <trkpt lat="45.003" lon="6.0"/>
</trkseg></trk></gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points[0].elevation_m, 100.0);
        assert_eq!(points[3].elevation_m, 200.0);
    }

    #[test]
    fn a_track_with_no_elevation_at_all_is_an_error() {
        let xml = r#"<gpx><trk><trkseg>
    <trkpt lat="45.0" lon="6.0"/>
    <trkpt lat="45.001" lon="6.0"/>
</trkseg></trk></gpx>"#;
        assert!(matches!(parse(xml), Err(GpxError::NoElevation)));
    }

    #[test]
    fn broken_xml_is_reported_not_panicked() {
        assert!(matches!(parse("<gpx><trk>"), Err(GpxError::Malformed(_))));
        assert!(matches!(parse(""), Err(GpxError::Malformed(_))));
    }

    #[test]
    fn points_with_unparseable_coordinates_are_skipped() {
        let xml = r#"<gpx><trk><trkseg>
    <trkpt lat="45.0" lon="6.0"><ele>100</ele></trkpt>
    <trkpt lat="oops" lon="6.0"><ele>105</ele></trkpt>
    <trkpt lon="6.0"><ele>106</ele></trkpt>
    <trkpt lat="45.001" lon="6.0"><ele>110</ele></trkpt>
</trkseg></trk></gpx>"#;
        let points = parse(xml).unwrap();
        assert_eq!(points.len(), 2);
        assert_eq!(points[1].elevation_m, 110.0);
    }

    #[test]
    fn an_empty_gpx_yields_no_points() {
        assert!(parse("<gpx></gpx>").unwrap().is_empty());
    }

    // ---- smoothing -------------------------------------------------------

    /// Build a track running due north with a prescribed elevation series,
    /// spaced roughly `spacing_m` apart.
    fn synthetic_track(elevations: &[f32], spacing_m: f64) -> Vec<TrackPoint> {
        let dlat = spacing_m / (std::f64::consts::PI * EARTH_RADIUS_M / 180.0);
        elevations
            .iter()
            .enumerate()
            .map(|(i, &e)| point(45.0 + i as f64 * dlat, 6.0, e))
            .collect()
    }

    /// Deterministic pseudo-noise; no rand dependency in the core crate.
    fn noise(i: usize) -> f32 {
        let x = (i as f32 * 12.9898).sin() * 43758.547;
        (x - x.floor()) * 2.0 - 1.0
    }

    /// The largest gradient change between adjacent samples — the quantity a
    /// rider feels as a lurch.
    fn worst_gradient_step(terrain: &[TerrainPoint]) -> f32 {
        terrain
            .windows(2)
            .map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs())
            .fold(0.0f32, f32::max)
    }

    /// Mean gradient over a distance range, for asserting on route structure.
    fn mean_gradient(terrain: &[TerrainPoint], from_m: f64, to_m: f64) -> f32 {
        let values: Vec<f32> = terrain
            .iter()
            .filter(|p| p.distance_m >= from_m && p.distance_m <= to_m)
            .map(|p| p.gradient_pct)
            .collect();
        assert!(!values.is_empty(), "no samples in {from_m}..{to_m} m");
        values.iter().sum::<f32>() / values.len() as f32
    }

    #[test]
    fn noisy_elevation_yields_smooth_bounded_gradients() {
        // A true 5% climb, 2 km long, buried in ±3 m of GPS elevation noise —
        // differentiating this raw would swing by ±60% between samples.
        let spacing = 10.0;
        let elevations: Vec<f32> = (0..200)
            .map(|i| 1000.0 + i as f32 * spacing as f32 * 0.05 + noise(i) * 3.0)
            .collect();
        let track = synthetic_track(&elevations, spacing);

        let cfg = SmoothingConfig::default();
        let terrain = to_terrain(&track, &cfg).unwrap();
        assert!(terrain.len() > 100);

        for p in &terrain {
            assert!(p.gradient_pct.is_finite());
            assert!(
                (cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct),
                "gradient {} escaped the clamp",
                p.gradient_pct
            );
        }

        // Smooth: no violent sample-to-sample steps, anywhere including the
        // ends, where a truncated window would otherwise leave a spike.
        let worst_step = worst_gradient_step(&terrain);
        // At 10 m spacing and 20 km/h that is well under 0.5 %/s of gradient
        // change — below the trainer's own resolution, let alone the rider's.
        assert!(
            worst_step < 0.75,
            "gradient jumped by {worst_step}% in one step"
        );

        // Accurate: the interior tracks the true 5%.
        let interior = &terrain[20..terrain.len() - 20];
        let mean: f32 =
            interior.iter().map(|p| p.gradient_pct).sum::<f32>() / interior.len() as f32;
        assert!(
            (mean - 5.0).abs() < 0.5,
            "mean gradient {mean}%, expected 5%"
        );
        for p in interior {
            assert!(
                (p.gradient_pct - 5.0).abs() < 2.0,
                "noise survived smoothing: {}%",
                p.gradient_pct
            );
        }
    }

    #[test]
    fn naive_differentiation_would_have_failed_the_same_data() {
        // Guards the test above from being vacuous: confirm the input really
        // is too noisy to differentiate directly.
        let spacing = 10.0f32;
        let elevations: Vec<f32> = (0..200)
            .map(|i| 1000.0 + i as f32 * spacing * 0.05 + noise(i) * 3.0)
            .collect();
        let worst = elevations
            .windows(2)
            .map(|w| ((w[1] - w[0]) / spacing * 100.0).abs())
            .fold(0.0f32, f32::max);
        assert!(
            worst > 30.0,
            "test data is not actually noisy (peak {worst}%)"
        );
    }

    #[test]
    fn a_flat_track_produces_zero_gradient() {
        let track = synthetic_track(&[100.0; 100], 10.0);
        let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
        assert!(terrain.iter().all(|p| p.gradient_pct.abs() < 1e-3));
    }

    #[test]
    fn a_clean_ramp_recovers_its_true_gradient() {
        // 8% over 3 km, no noise.
        let elevations: Vec<f32> = (0..300).map(|i| 500.0 + i as f32 * 10.0 * 0.08).collect();
        let track = synthetic_track(&elevations, 10.0);
        let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
        let interior = &terrain[15..terrain.len() - 15];
        for p in interior {
            assert!((p.gradient_pct - 8.0).abs() < 0.2, "{}", p.gradient_pct);
        }
    }

    #[test]
    fn gradients_are_clamped_to_the_configured_range() {
        // A 40% wall — far beyond anything safe to send to a trainer.
        let elevations: Vec<f32> = (0..200).map(|i| i as f32 * 10.0 * 0.4).collect();
        let track = synthetic_track(&elevations, 10.0);
        let cfg = SmoothingConfig {
            min_gradient_pct: -8.0,
            max_gradient_pct: 12.0,
            ..Default::default()
        };
        let terrain = to_terrain(&track, &cfg).unwrap();
        assert!(terrain.iter().all(|p| p.gradient_pct <= 12.0));
        assert!(terrain.iter().all(|p| p.gradient_pct >= -8.0));
        assert!(terrain.iter().any(|p| (p.gradient_pct - 12.0).abs() < 1e-4));
    }

    #[test]
    fn descents_produce_negative_gradients() {
        let elevations: Vec<f32> = (0..200).map(|i| 1000.0 - i as f32 * 10.0 * 0.06).collect();
        let track = synthetic_track(&elevations, 10.0);
        let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
        let mid = terrain[terrain.len() / 2].gradient_pct;
        assert!((mid + 6.0).abs() < 0.2, "{mid}");
    }

    #[test]
    fn a_wider_window_gives_a_smoother_result() {
        let elevations: Vec<f32> = (0..400)
            .map(|i| 1000.0 + i as f32 * 0.3 + noise(i) * 4.0)
            .collect();
        let track = synthetic_track(&elevations, 10.0);

        let roughness = |window_m: f64| {
            let cfg = SmoothingConfig {
                window_m,
                ..Default::default()
            };
            let terrain = to_terrain(&track, &cfg).unwrap();
            terrain
                .windows(2)
                .map(|w| (w[1].gradient_pct - w[0].gradient_pct).abs())
                .sum::<f32>()
        };
        assert!(roughness(200.0) < roughness(30.0));
    }

    #[test]
    fn distance_axis_is_evenly_spaced_and_monotone() {
        let track = synthetic_track(&[100.0; 150], 7.0);
        let cfg = SmoothingConfig {
            resample_m: 25.0,
            ..Default::default()
        };
        let terrain = to_terrain(&track, &cfg).unwrap();
        for (i, p) in terrain.iter().enumerate() {
            assert!((p.distance_m - i as f64 * 25.0).abs() < 1e-6);
        }
    }

    #[test]
    fn stationary_and_duplicate_fixes_are_discarded() {
        let mut track = synthetic_track(&[100.0, 105.0, 110.0, 115.0], 100.0);
        // Insert repeats of the second fix, as a GPS does at a traffic light.
        for _ in 0..20 {
            track.insert(2, track[1]);
        }
        let terrain = to_terrain(&track, &SmoothingConfig::default()).unwrap();
        assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
        assert!((terrain.last().unwrap().distance_m - 300.0).abs() < 15.0);
    }

    #[test]
    fn short_or_degenerate_tracks_are_rejected() {
        assert!(matches!(
            to_terrain(&[], &SmoothingConfig::default()),
            Err(GpxError::TooShort)
        ));
        assert!(matches!(
            to_terrain(&[point(45.0, 6.0, 100.0)], &SmoothingConfig::default()),
            Err(GpxError::TooShort)
        ));
        // Two identical points: no distance at all.
        let same = [point(45.0, 6.0, 100.0), point(45.0, 6.0, 100.0)];
        assert!(matches!(
            to_terrain(&same, &SmoothingConfig::default()),
            Err(GpxError::TooShort)
        ));
        // Real but far shorter than one resample step.
        let tiny = synthetic_track(&[100.0, 101.0], 2.0);
        assert!(matches!(
            to_terrain(&tiny, &SmoothingConfig::default()),
            Err(GpxError::TooShort)
        ));
    }

    #[test]
    fn a_degenerate_config_falls_back_rather_than_dividing_by_zero() {
        let track = synthetic_track(&[100.0, 110.0, 120.0, 130.0, 140.0], 100.0);
        let cfg = SmoothingConfig {
            resample_m: 0.0,
            window_m: -5.0,
            min_gradient_pct: 15.0,
            max_gradient_pct: -10.0,
        };
        let terrain = to_terrain(&track, &cfg).unwrap();
        assert!(!terrain.is_empty());
        assert!(terrain
            .iter()
            .all(|p| p.gradient_pct.is_finite() && (-10.0..=15.0).contains(&p.gradient_pct)));
    }

    // ---- end to end ------------------------------------------------------

    #[test]
    fn the_shipped_sample_climb_imports_cleanly() {
        let points = parse(SAMPLE_CLIMB).unwrap();
        assert!(points.len() > 100, "{} points", points.len());

        let cfg = SmoothingConfig::default();
        let profile = import(SAMPLE_CLIMB, "Sample climb", &cfg).unwrap();
        assert_eq!(profile.name, "Sample climb");
        assert!(!profile.looping);
        assert_eq!(profile.blocks.len(), 1);
        profile.validate().unwrap();

        let Block::Terrain { points: terrain } = &profile.blocks[0] else {
            panic!("expected a terrain block");
        };
        assert!(terrain.len() > 10);
        for p in terrain {
            assert!(p.gradient_pct.is_finite());
            assert!((cfg.min_gradient_pct..=cfg.max_gradient_pct).contains(&p.gradient_pct));
        }
        // The fixture is noisy but is a genuine climb, so the mean must be up.
        let mean: f32 = terrain.iter().map(|p| p.gradient_pct).sum::<f32>() / terrain.len() as f32;
        assert!(mean > 0.0, "sample climb averaged {mean}%");

        // And the profile it produces is rideable.
        let extent = profile.total_extent();
        assert!(extent.metres.unwrap_or(0.0) > 100.0);
        assert!(profile
            .sample(crate::profile::Position {
                elapsed_s: 0.0,
                distance_m: 50.0,
            })
            .is_some());
    }

    /// The fixture carries ±1.5 m of elevation noise on every point over a
    /// route with known structure: ~500 m flat, ~1.8 km climbing at 6–8%
    /// (sinusoidally varying), then ~700 m descending at about −4.5%. The
    /// pipeline has to recover that structure, not the noise.
    #[test]
    fn the_shipped_sample_climb_recovers_its_real_structure() {
        let cfg = SmoothingConfig::default();
        let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap();
        let total = terrain.last().unwrap().distance_m;
        assert!((total - 3040.0).abs() < 100.0, "route measured {total} m");

        // No lurches anywhere on the route, ends included.
        let worst_step = worst_gradient_step(&terrain);
        assert!(
            worst_step < 1.0,
            "gradient jumped by {worst_step}% in one step"
        );

        // Opening flat.
        let flat = mean_gradient(&terrain, 0.0, 400.0);
        assert!(flat.abs() < 1.0, "flat section read {flat}%");

        // The climb, sampled clear of the transitions at either end.
        let climb = mean_gradient(&terrain, 700.0, 2200.0);
        assert!((3.0..8.0).contains(&climb), "climb averaged {climb}%");
        for p in terrain
            .iter()
            .filter(|p| (700.0..=2200.0).contains(&p.distance_m))
        {
            assert!(
                (2.0..10.0).contains(&p.gradient_pct),
                "climb sample at {} m read {}%",
                p.distance_m,
                p.gradient_pct
            );
        }

        // The closing descent.
        let descent = mean_gradient(&terrain, 2500.0, 2900.0);
        assert!(
            (-6.0..-3.0).contains(&descent),
            "descent averaged {descent}%"
        );

        // Net ascent, integrated from the smoothed gradient, matches the route.
        let spacing = cfg.resample_m as f32;
        let ascent: f32 = terrain
            .iter()
            .map(|p| (p.gradient_pct / 100.0 * spacing).max(0.0))
            .sum();
        assert!((60.0..110.0).contains(&ascent), "net ascent {ascent} m");
    }

    #[test]
    fn the_shipped_sample_climb_survives_a_tight_smoothing_window() {
        // Even at a third of the default window the result must stay usable:
        // noisier, but still free of step changes a rider would feel.
        let cfg = SmoothingConfig {
            window_m: 30.0,
            ..Default::default()
        };
        let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &cfg).unwrap();
        let worst_step = worst_gradient_step(&terrain);
        assert!(
            worst_step < 4.0,
            "gradient jumped by {worst_step}% in one step"
        );
        assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
    }

    // ---- outlier rejection ------------------------------------------------

    /// The shape of the shipped fixture, asserted end to end: flat opening,
    /// then a real climb, then a real descent.
    ///
    /// This is the test that catches a reversed distance axis, an off-by-one
    /// in the terrain lookup, or a sign error in the differentiation — any of
    /// which would put the climb where the descent is, or invert both.
    #[test]
    fn the_sample_climb_reads_flat_then_up_then_down_in_that_order() {
        let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &SmoothingConfig::default())
            .expect("fixture imports");

        let opening = mean_gradient(&terrain, 0.0, 400.0);
        let climb = mean_gradient(&terrain, 600.0, 2_200.0);
        let descent = mean_gradient(&terrain, 2_500.0, 2_900.0);

        assert!(opening.abs() < 1.0, "opening should be flat, got {opening}%");
        assert!(
            (4.0..9.0).contains(&climb),
            "climb should be 4–9%, got {climb}%"
        );
        assert!(
            (-6.0..-3.0).contains(&descent),
            "descent should be about -4.5%, got {descent}%"
        );

        // And the same read through the profile the app actually rides, so a
        // fault in the block indexing cannot hide behind a correct terrain
        // series.
        let profile = import(SAMPLE_CLIMB, "sample", &SmoothingConfig::default()).unwrap();
        let at = |d: f64| {
            profile
                .sample_channel(crate::profile::Position {
                    elapsed_s: 0.0,
                    distance_m: d,
                })
                .expect("in range")
                .1
        };
        assert!(at(200.0).abs() < 1.5, "flat at 200 m: {}", at(200.0));
        assert!(at(1_000.0) > 3.0, "climbing at 1 km: {}", at(1_000.0));
        assert!(at(2_700.0) < -2.0, "descending at 2.7 km: {}", at(2_700.0));
    }

    /// The first fix of a recorded activity is routinely metres out, because
    /// the receiver has not settled. Averaging spreads that error over the
    /// whole smoothing window and the differentiation then reads it as a
    /// sustained gradient — on a real 22.6 km file, a 9 m first fix produced a
    /// −9.5% descent at the start of a flat road, which was commanded to the
    /// trainer.
    #[test]
    fn a_bad_first_fix_does_not_become_an_opening_descent() {
        let mut elevations = vec![100.0f32; 200];
        let clean = to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default())
            .unwrap();
        assert!(clean[0].gradient_pct.abs() < 0.2, "control: {clean:?}");

        // One bad sample, at the worst possible place.
        elevations[0] = 109.0;
        let spiked =
            to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();

        assert!(
            spiked[0].gradient_pct.abs() < 1.0,
            "a 9 m first-fix error became a {}% gradient",
            spiked[0].gradient_pct
        );
        // And it must not have shifted the road it sits on either.
        assert!(
            (spiked[0].elevation_m - 100.0).abs() < 1.0,
            "elevation dragged to {} m",
            spiked[0].elevation_m
        );
    }

    /// The same treatment must leave a genuine gradient alone — including one
    /// that starts at the very first sample, where the rejection window can
    /// only look forwards.
    #[test]
    fn a_real_climb_is_not_mistaken_for_an_outlier() {
        // A constant 8% from the first metre.
        let elevations: Vec<f32> = (0..300).map(|i| 100.0 + i as f32 * 0.8).collect();
        let terrain =
            to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();

        for p in terrain.iter().take(50) {
            assert!(
                (7.0..9.0).contains(&p.gradient_pct),
                "real 8% climb reported as {}% at {} m",
                p.gradient_pct,
                p.distance_m
            );
        }
    }

    /// A spike in the middle of a ride — a dropped fix, a tunnel — is rejected
    /// on the same terms, and does not leave a gradient step behind.
    #[test]
    fn a_mid_ride_elevation_spike_is_rejected() {
        let mut elevations: Vec<f32> = (0..400).map(|i| 100.0 + i as f32 * 0.2).collect();
        let baseline =
            to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
        elevations[200] += 12.0;
        let spiked =
            to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();

        let worst = spiked
            .iter()
            .zip(&baseline)
            .map(|(a, b)| (a.gradient_pct - b.gradient_pct).abs())
            .fold(0.0f32, f32::max);
        assert!(worst < 1.0, "a 12 m spike moved the gradient by {worst}%");
    }

    #[test]
    fn import_propagates_parse_errors() {
        assert!(matches!(
            import("<gpx>", "n", &SmoothingConfig::default()),
            Err(GpxError::Malformed(_))
        ));
        assert!(matches!(
            import("<gpx></gpx>", "n", &SmoothingConfig::default()),
            Err(GpxError::TooShort)
        ));
    }
}
