//! Ride profiles: terrain segments, synthetic waveforms, and the file format
//! that carries both (§5.5, §5.6 of REQUIREMENTS.md).
//!
//! A profile is an ordered list of blocks. Each block drives one channel
//! (gradient, resistance or power) for either a duration or a distance. The
//! engine asks the profile for a target given elapsed time and distance
//! travelled, and the profile decides which block is active and what it wants.

use serde::{Deserialize, Serialize};

use crate::types::ControlTarget;

/// Which trainer parameter a block drives (FR-6.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
    Gradient,
    Resistance,
    Power,
}

/// Waveform shapes (FR-6.1). All are evaluated as a function of phase in
/// `[0, 1)` and produce a value in `[-1, 1]`, which the block then scales by
/// amplitude and offsets by midpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Waveform {
    Sine,
    Square,
    Triangle,
    Sawtooth,
}

impl Waveform {
    /// Evaluate at `phase` in `[0, 1)`, returning `[-1, 1]`.
    pub fn eval(self, phase: f32) -> f32 {
        if !phase.is_finite() {
            return 0.0;
        }
        let p = phase.rem_euclid(1.0);
        match self {
            Waveform::Sine => (p * std::f32::consts::TAU).sin(),
            Waveform::Square => {
                if p < 0.5 {
                    1.0
                } else {
                    -1.0
                }
            }
            // Shifted-and-folded ramp: 0 at phase 0, peaking at ¼, back
            // through 0 at ½ and troughing at ¾ — the same shape and sign
            // convention as the sine, so swapping shapes keeps the same
            // interval structure.
            Waveform::Triangle => 1.0 - 4.0 * ((p + 0.25).rem_euclid(1.0) - 0.5).abs(),
            Waveform::Sawtooth => 2.0 * p - 1.0,
        }
    }
}

/// How a block measures its own extent — by time or by distance (FR-6.4).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Extent {
    Seconds(f64),
    Metres(f64),
}

impl Extent {
    /// The extent's magnitude, floored at zero and never NaN.
    fn amount(self) -> f64 {
        let v = match self {
            Extent::Seconds(s) | Extent::Metres(s) => s,
        };
        if v.is_finite() && v > 0.0 {
            v
        } else {
            0.0
        }
    }

    fn is_time(self) -> bool {
        matches!(self, Extent::Seconds(_))
    }
}

/// A single terrain segment: hold a gradient for a distance (FR-5.4).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Segment {
    pub distance_m: f64,
    pub gradient_pct: f32,
}

/// One block of a profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Block {
    /// Hold a fixed value.
    Constant {
        channel: Channel,
        value: f32,
        extent: Extent,
    },
    /// Linear sweep between two values.
    Ramp {
        channel: Channel,
        from: f32,
        to: f32,
        extent: Extent,
    },
    /// Oscillate around a midpoint.
    Wave {
        channel: Channel,
        shape: Waveform,
        midpoint: f32,
        amplitude: f32,
        /// Length of one full cycle.
        period: Extent,
        /// Number of cycles. Total extent = period × repeats.
        repeats: f32,
        /// Phase offset in `[0, 1)`.
        #[serde(default)]
        phase: f32,
    },
    /// A terrain profile: gradient as a function of distance, interpolated
    /// between points (FR-5.5).
    Segments { segments: Vec<Segment> },
    /// A gradient/distance profile derived from a GPX import. Points are
    /// cumulative distance in metres paired with gradient in percent.
    Terrain { points: Vec<TerrainPoint> },
}

/// One point of an elevation-derived gradient profile.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TerrainPoint {
    pub distance_m: f64,
    pub gradient_pct: f32,
    /// Elevation in metres, retained for display of the profile chart.
    pub elevation_m: f32,
}

/// A complete ride profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    pub blocks: Vec<Block>,
    /// Restart from the beginning on completion (FR-5.6).
    #[serde(default)]
    pub looping: bool,
}

/// Where the rider currently is within a profile.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Position {
    pub elapsed_s: f64,
    pub distance_m: f64,
}

#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
    #[error("failed to parse profile: {0}")]
    Parse(String),
    #[error("profile has no blocks")]
    Empty,
    #[error("block {index} is invalid: {reason}")]
    InvalidBlock { index: usize, reason: String },
}

impl Profile {
    /// Parse a profile from YAML.
    pub fn from_yaml(src: &str) -> Result<Self, ProfileError> {
        let profile: Profile =
            serde_yaml_ng::from_str(src).map_err(|e| ProfileError::Parse(e.to_string()))?;
        profile.validate()?;
        Ok(profile)
    }

    /// Reject profiles that cannot be evaluated, so failures surface at load
    /// time rather than mid-ride.
    pub fn validate(&self) -> Result<(), ProfileError> {
        if self.blocks.is_empty() {
            return Err(ProfileError::Empty);
        }
        for (index, block) in self.blocks.iter().enumerate() {
            block
                .validate()
                .map_err(|reason| ProfileError::InvalidBlock { index, reason })?;
        }
        Ok(())
    }

    /// The target this profile wants at `position`, or `None` if the profile
    /// has finished and is not looping.
    ///
    /// Implementations must clamp nothing here — safety clamping happens once,
    /// at transmission (SAF-3).
    pub fn sample(&self, position: Position) -> Option<ControlTarget> {
        let (channel, value) = self.sample_channel(position)?;
        Some(match channel {
            Channel::Gradient => ControlTarget::Gradient { percent: value },
            // `as` saturates on out-of-range floats, which is the only bound
            // applied here; the real limits are enforced at transmission.
            Channel::Resistance => ControlTarget::Resistance {
                level: round_or_zero(value) as i16,
            },
            Channel::Power => ControlTarget::Power {
                watts: round_or_zero(value) as u16,
            },
        })
    }

    /// The raw channel and numeric value at `position`. Kept separate from
    /// [`Profile::sample`] so the preview can chart values without going
    /// through the lossy integer target representation.
    pub fn sample_channel(&self, position: Position) -> Option<(Channel, f32)> {
        let (block, local) = self.locate(position)?;
        Some((block.channel(), block.value_at(local)))
    }

    /// Total extent of the profile, if finite. Used for progress display
    /// (FR-9.7) and to know when a non-looping profile has ended.
    ///
    /// A field is `None` when no block measures itself that way — a profile of
    /// purely distance-based blocks has no duration, and vice versa.
    pub fn total_extent(&self) -> ProfileExtent {
        let mut extent = ProfileExtent::default();
        for block in &self.blocks {
            let e = block.extent();
            let slot = if e.is_time() {
                &mut extent.seconds
            } else {
                &mut extent.metres
            };
            *slot = Some(slot.unwrap_or(0.0) + e.amount());
        }
        extent
    }

    /// Sample the whole profile ahead of time for the preview chart (FR-6.7).
    /// Returns `(x, value)` pairs where `x` is seconds or metres depending on
    /// the profile's dominant extent kind.
    ///
    /// Blocks are walked in order rather than by sampling a position, because
    /// a profile mixing time- and distance-based blocks has no single
    /// coordinate that visits every block: the distance cursor is only spent
    /// by riding. Each block is instead given a slice of the chart equal to
    /// its share of its own kind's total. For the usual single-kind profile
    /// that makes `x` exactly seconds or metres; for a mixed one it is the
    /// dominant unit stretched over the sequence.
    pub fn preview(&self, samples: usize) -> Vec<(f64, f32)> {
        if samples == 0 {
            return Vec::new();
        }
        let extent = self.total_extent();
        let axis = match self.dominant_kind() {
            Some(true) => extent.seconds.unwrap_or(0.0),
            Some(false) => extent.metres.unwrap_or(0.0),
            None => return Vec::new(),
        };
        if axis <= 0.0 {
            return Vec::new();
        }

        let share = |block: &Block| {
            let e = block.extent();
            let kind_total = if e.is_time() {
                extent.seconds.unwrap_or(0.0)
            } else {
                extent.metres.unwrap_or(0.0)
            };
            if kind_total > 0.0 {
                e.amount() / kind_total
            } else {
                0.0
            }
        };
        let total_share: f64 = self.blocks.iter().map(&share).sum();
        if total_share <= 0.0 {
            return Vec::new();
        }

        let mut out = Vec::with_capacity(samples);
        let mut consumed = 0.0f64;
        for block in &self.blocks {
            let block_share = share(block);
            if block_share <= 0.0 {
                continue;
            }
            let count = ((block_share / total_share) * samples as f64)
                .round()
                .max(1.0) as usize;
            let amount = block.extent().amount();
            for i in 0..count {
                let f = i as f64 / count as f64;
                let x = (consumed + block_share * f) / total_share * axis;
                out.push((x, block.value_at(amount * f)));
            }
            consumed += block_share;
        }
        out
    }

    /// `Some(true)` for a time axis, `Some(false)` for distance, `None` if the
    /// profile has no positive extent at all. Ties go to time.
    fn dominant_kind(&self) -> Option<bool> {
        let extent = self.total_extent();
        match (
            extent.seconds.filter(|s| *s > 0.0),
            extent.metres.filter(|m| *m > 0.0),
        ) {
            (Some(_), Some(_)) => {
                let time_blocks = self.blocks.iter().filter(|b| b.extent().is_time()).count();
                Some(time_blocks * 2 >= self.blocks.len())
            }
            (Some(_), None) => Some(true),
            (None, Some(_)) => Some(false),
            (None, None) => None,
        }
    }

    /// Find the active block and the offset into it, in that block's own unit.
    ///
    /// Blocks run in sequence, but a time-based block only consumes time and a
    /// distance-based block only consumes distance, so both coordinates are
    /// tracked as the list is walked.
    fn locate(&self, position: Position) -> Option<(&Block, f64)> {
        let mut elapsed = clamp_non_negative(position.elapsed_s);
        let mut distance = clamp_non_negative(position.distance_m);

        if self.looping {
            let extent = self.total_extent();
            let per_loop_s = extent.seconds.unwrap_or(0.0);
            let per_loop_m = extent.metres.unwrap_or(0.0);

            // A lap is only complete once *both* coordinates have paid for the
            // whole block list, so the number of finished laps is the smaller
            // of the two counts. Kinds the profile does not use impose no bound.
            let mut laps = f64::INFINITY;
            if per_loop_s > 0.0 {
                laps = laps.min((elapsed / per_loop_s).floor());
            }
            if per_loop_m > 0.0 {
                laps = laps.min((distance / per_loop_m).floor());
            }

            if laps.is_finite() && laps > 0.0 {
                elapsed = clamp_non_negative(elapsed - laps * per_loop_s);
                distance = clamp_non_negative(distance - laps * per_loop_m);
            }
        }

        // The reduction above is exact in principle; a rounding error could
        // still leave the cursor a hair past the end, so a looping profile
        // gets one extra attempt with another lap removed.
        let attempts = if self.looping { 2 } else { 1 };
        for attempt in 0..attempts {
            if attempt > 0 {
                let extent = self.total_extent();
                elapsed = clamp_non_negative(elapsed - extent.seconds.unwrap_or(0.0));
                distance = clamp_non_negative(distance - extent.metres.unwrap_or(0.0));
            }
            if let Some(found) = self.walk(elapsed, distance) {
                return Some(found);
            }
        }
        None
    }

    fn walk(&self, mut elapsed: f64, mut distance: f64) -> Option<(&Block, f64)> {
        for block in &self.blocks {
            let extent = block.extent();
            let amount = extent.amount();
            let cursor = if extent.is_time() {
                &mut elapsed
            } else {
                &mut distance
            };
            if *cursor < amount {
                return Some((block, *cursor));
            }
            *cursor -= amount;
        }
        None
    }
}

/// Total length of a profile, which may be measured in time, distance, both or
/// neither (a profile of only unbounded blocks).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ProfileExtent {
    pub seconds: Option<f64>,
    pub metres: Option<f64>,
}

impl ProfileExtent {
    /// Fraction of the profile consumed at `position`, if it has any finite
    /// extent. A profile measured both ways is only finished when both
    /// coordinates are, so the lesser fraction governs.
    pub fn progress(&self, position: Position) -> Option<f32> {
        let by_time = self
            .seconds
            .filter(|s| *s > 0.0)
            .map(|s| position.elapsed_s / s);
        let by_distance = self
            .metres
            .filter(|m| *m > 0.0)
            .map(|m| position.distance_m / m);
        let fraction = match (by_time, by_distance) {
            (Some(t), Some(d)) => t.min(d),
            (Some(t), None) => t,
            (None, Some(d)) => d,
            (None, None) => return None,
        };
        Some(fraction.clamp(0.0, 1.0) as f32)
    }
}

impl Block {
    pub fn channel(&self) -> Channel {
        match self {
            Block::Constant { channel, .. }
            | Block::Ramp { channel, .. }
            | Block::Wave { channel, .. } => *channel,
            Block::Segments { .. } | Block::Terrain { .. } => Channel::Gradient,
        }
    }

    /// How much of its own coordinate this block consumes.
    pub fn extent(&self) -> Extent {
        match self {
            Block::Constant { extent, .. } | Block::Ramp { extent, .. } => *extent,
            Block::Wave {
                period, repeats, ..
            } => {
                let total = period.amount() * f64::from(*repeats).max(0.0);
                if period.is_time() {
                    Extent::Seconds(total)
                } else {
                    Extent::Metres(total)
                }
            }
            // Terrain is inherently a function of distance.
            Block::Segments { segments } => {
                Extent::Metres(segments.iter().map(|s| s.distance_m.max(0.0)).sum())
            }
            Block::Terrain { points } => {
                let span = match (points.first(), points.last()) {
                    (Some(first), Some(last)) => (last.distance_m - first.distance_m).max(0.0),
                    _ => 0.0,
                };
                Extent::Metres(span)
            }
        }
    }

    /// The block's value at `local`, an offset into the block in its own unit.
    pub fn value_at(&self, local: f64) -> f32 {
        let local = clamp_non_negative(local);
        match self {
            Block::Constant { value, .. } => *value,
            Block::Ramp {
                from, to, extent, ..
            } => {
                let span = extent.amount();
                if span <= 0.0 {
                    return *to;
                }
                let f = (local / span).clamp(0.0, 1.0) as f32;
                from + (to - from) * f
            }
            Block::Wave {
                shape,
                midpoint,
                amplitude,
                period,
                phase,
                ..
            } => {
                let cycle = period.amount();
                if cycle <= 0.0 {
                    return *midpoint;
                }
                // Reduce before narrowing to f32: a long ride is many periods
                // in, and f32 would quantise the phase visibly.
                let cycles = (local / cycle).rem_euclid(1.0) as f32;
                midpoint + amplitude * shape.eval(cycles + *phase)
            }
            // Both terrain forms interpolate rather than step (FR-5.5).
            Block::Segments { segments } => interpolate_segments(segments, local),
            Block::Terrain { points } => interpolate_terrain(points, local),
        }
    }

    fn validate(&self) -> Result<(), String> {
        match self {
            Block::Wave {
                repeats, period, ..
            } => {
                if *repeats <= 0.0 {
                    return Err("repeats must be positive".into());
                }
                match period {
                    Extent::Seconds(s) if *s <= 0.0 => Err("period must be positive".into()),
                    Extent::Metres(m) if *m <= 0.0 => Err("period must be positive".into()),
                    _ => Ok(()),
                }
            }
            Block::Segments { segments } if segments.is_empty() => {
                Err("segments block is empty".into())
            }
            Block::Terrain { points } if points.len() < 2 => {
                Err("terrain block needs at least two points".into())
            }
            _ => Ok(()),
        }
    }
}

/// Interpolate a hand-authored segment list.
///
/// A segment states a gradient for a stretch of road, so the natural control
/// point is the stretch's midpoint: interpolating between midpoints gives a
/// continuous gradient (FR-5.5) while keeping each segment's stated value at
/// its centre and preserving the profile's mean. Before the first midpoint and
/// after the last, the end segments' gradients are held.
fn interpolate_segments(segments: &[Segment], local: f64) -> f32 {
    if segments.is_empty() {
        return 0.0;
    }
    let mut previous: Option<(f64, f32)> = None;
    let mut start = 0.0f64;
    for segment in segments {
        let length = segment.distance_m.max(0.0);
        let centre = start + length * 0.5;
        if local <= centre {
            return match previous {
                Some((prev_centre, prev_grad)) => {
                    lerp(prev_centre, prev_grad, centre, segment.gradient_pct, local)
                }
                None => segment.gradient_pct,
            };
        }
        previous = Some((centre, segment.gradient_pct));
        start += length;
    }
    segments[segments.len() - 1].gradient_pct
}

/// Interpolate a GPX-derived terrain profile. Points carry absolute cumulative
/// distance, so `local` is measured from the first point.
fn interpolate_terrain(points: &[TerrainPoint], local: f64) -> f32 {
    let Some(first) = points.first() else {
        return 0.0;
    };
    let target = first.distance_m + local;
    if target <= first.distance_m {
        return first.gradient_pct;
    }
    for pair in points.windows(2) {
        let (a, b) = (pair[0], pair[1]);
        if target <= b.distance_m {
            return lerp(
                a.distance_m,
                a.gradient_pct,
                b.distance_m,
                b.gradient_pct,
                target,
            );
        }
    }
    points[points.len() - 1].gradient_pct
}

fn lerp(x0: f64, y0: f32, x1: f64, y1: f32, x: f64) -> f32 {
    let span = x1 - x0;
    if span <= 0.0 {
        return y1;
    }
    let f = ((x - x0) / span).clamp(0.0, 1.0) as f32;
    y0 + (y1 - y0) * f
}

fn clamp_non_negative(v: f64) -> f64 {
    if v.is_finite() && v > 0.0 {
        v
    } else {
        0.0
    }
}

fn round_or_zero(v: f32) -> f32 {
    if v.is_finite() {
        v.round()
    } else {
        0.0
    }
}

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

    fn at(elapsed_s: f64, distance_m: f64) -> Position {
        Position {
            elapsed_s,
            distance_m,
        }
    }

    fn gradient_of(target: ControlTarget) -> f32 {
        match target {
            ControlTarget::Gradient { percent } => percent,
            other => panic!("expected a gradient target, got {other:?}"),
        }
    }

    fn power_of(target: ControlTarget) -> u16 {
        match target {
            ControlTarget::Power { watts } => watts,
            other => panic!("expected a power target, got {other:?}"),
        }
    }

    // ---- waveforms -------------------------------------------------------

    #[test]
    fn sine_hits_known_phases() {
        assert!((Waveform::Sine.eval(0.0) - 0.0).abs() < 1e-6);
        assert!((Waveform::Sine.eval(0.25) - 1.0).abs() < 1e-6);
        assert!((Waveform::Sine.eval(0.5) - 0.0).abs() < 1e-6);
        assert!((Waveform::Sine.eval(0.75) + 1.0).abs() < 1e-6);
    }

    #[test]
    fn square_is_high_then_low() {
        assert_eq!(Waveform::Square.eval(0.0), 1.0);
        assert_eq!(Waveform::Square.eval(0.49), 1.0);
        assert_eq!(Waveform::Square.eval(0.5), -1.0);
        assert_eq!(Waveform::Square.eval(0.99), -1.0);
    }

    #[test]
    fn triangle_is_actually_a_triangle() {
        // Known phases: zero-crossings at 0 and ½, peak at ¼, trough at ¾.
        for (phase, expected) in [
            (0.0, 0.0),
            (0.125, 0.5),
            (0.25, 1.0),
            (0.375, 0.5),
            (0.5, 0.0),
            (0.625, -0.5),
            (0.75, -1.0),
            (0.875, -0.5),
        ] {
            let got = Waveform::Triangle.eval(phase);
            assert!(
                (got - expected).abs() < 1e-6,
                "triangle at {phase} gave {got}, expected {expected}"
            );
        }
    }

    #[test]
    fn triangle_stays_in_range_and_is_piecewise_linear() {
        let mut previous = Waveform::Triangle.eval(0.0);
        for i in 1..=2000 {
            let phase = i as f32 / 2000.0;
            let value = Waveform::Triangle.eval(phase);
            assert!(
                (-1.0..=1.0).contains(&value),
                "triangle escaped range: {value}"
            );
            // Slope magnitude is a constant 4 per unit phase.
            let slope = (value - previous).abs() * 2000.0;
            assert!((slope - 4.0).abs() < 0.05, "slope {slope} at {phase}");
            previous = value;
        }
    }

    #[test]
    fn sawtooth_ramps_and_wraps() {
        assert!((Waveform::Sawtooth.eval(0.0) + 1.0).abs() < 1e-6);
        assert!((Waveform::Sawtooth.eval(0.5) - 0.0).abs() < 1e-6);
        assert!((Waveform::Sawtooth.eval(0.999) - 0.998).abs() < 1e-3);
        // Phase wraps rather than running away.
        assert!((Waveform::Sawtooth.eval(2.25) - Waveform::Sawtooth.eval(0.25)).abs() < 1e-6);
        assert!((Waveform::Sawtooth.eval(-0.75) - Waveform::Sawtooth.eval(0.25)).abs() < 1e-6);
    }

    #[test]
    fn all_waveforms_stay_within_unit_range() {
        for shape in [
            Waveform::Sine,
            Waveform::Square,
            Waveform::Triangle,
            Waveform::Sawtooth,
        ] {
            for i in -500..1500 {
                let value = shape.eval(i as f32 / 500.0);
                assert!(
                    (-1.0..=1.0).contains(&value),
                    "{shape:?} produced {value} out of range"
                );
            }
            assert_eq!(shape.eval(f32::NAN), 0.0);
        }
    }

    // ---- block sequencing ------------------------------------------------

    fn time_profile() -> Profile {
        Profile {
            name: "time".into(),
            description: None,
            looping: false,
            blocks: vec![
                Block::Constant {
                    channel: Channel::Power,
                    value: 100.0,
                    extent: Extent::Seconds(60.0),
                },
                Block::Ramp {
                    channel: Channel::Power,
                    from: 100.0,
                    to: 200.0,
                    extent: Extent::Seconds(100.0),
                },
                Block::Constant {
                    channel: Channel::Power,
                    value: 50.0,
                    extent: Extent::Seconds(40.0),
                },
            ],
        }
    }

    #[test]
    fn blocks_run_in_sequence_on_a_time_axis() {
        let p = time_profile();
        assert_eq!(power_of(p.sample(at(0.0, 0.0)).unwrap()), 100);
        assert_eq!(power_of(p.sample(at(59.9, 0.0)).unwrap()), 100);
        // Straight into the ramp's first value.
        assert_eq!(power_of(p.sample(at(60.0, 0.0)).unwrap()), 100);
        assert_eq!(power_of(p.sample(at(110.0, 0.0)).unwrap()), 150);
        assert_eq!(power_of(p.sample(at(159.9, 0.0)).unwrap()), 200);
        assert_eq!(power_of(p.sample(at(160.0, 0.0)).unwrap()), 50);
        assert_eq!(power_of(p.sample(at(199.9, 0.0)).unwrap()), 50);
    }

    #[test]
    fn non_looping_profile_is_exhausted_at_its_end() {
        let p = time_profile();
        assert!(p.sample(at(200.0, 0.0)).is_none());
        assert!(p.sample(at(1.0e6, 0.0)).is_none());
    }

    #[test]
    fn total_extent_reports_only_the_kinds_in_use() {
        let extent = time_profile().total_extent();
        assert_eq!(extent.seconds, Some(200.0));
        assert_eq!(extent.metres, None);

        let distance_only = Profile {
            name: "d".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Segments {
                segments: vec![
                    Segment {
                        distance_m: 500.0,
                        gradient_pct: 0.0,
                    },
                    Segment {
                        distance_m: 800.0,
                        gradient_pct: 6.0,
                    },
                ],
            }],
        };
        let extent = distance_only.total_extent();
        assert_eq!(extent.seconds, None);
        assert_eq!(extent.metres, Some(1300.0));
    }

    /// A time block, then a distance block, then a time block — the awkward
    /// case, and the shape of `profiles/sawtooth-gradient.yaml`.
    fn mixed_profile() -> Profile {
        Profile {
            name: "mixed".into(),
            description: None,
            looping: false,
            blocks: vec![
                Block::Constant {
                    channel: Channel::Gradient,
                    value: 1.0,
                    extent: Extent::Seconds(300.0),
                },
                Block::Constant {
                    channel: Channel::Gradient,
                    value: 2.0,
                    extent: Extent::Metres(4000.0),
                },
                Block::Constant {
                    channel: Channel::Gradient,
                    value: 3.0,
                    extent: Extent::Seconds(300.0),
                },
            ],
        }
    }

    #[test]
    fn time_and_distance_extents_consume_independent_cursors() {
        let p = mixed_profile();
        // Distance alone cannot advance past the opening time block.
        assert_eq!(gradient_of(p.sample(at(0.0, 9999.0)).unwrap()), 1.0);
        // Time alone cannot advance past the distance block.
        assert_eq!(gradient_of(p.sample(at(9999.0, 0.0)).unwrap()), 2.0);
        // Both consumed: the closing time block.
        assert_eq!(gradient_of(p.sample(at(400.0, 4000.0)).unwrap()), 3.0);
        // Both fully consumed: finished.
        assert!(p.sample(at(600.0, 4000.0)).is_none());
        assert!(p.sample(at(601.0, 5000.0)).is_none());
    }

    #[test]
    fn mixed_total_extent_covers_both_axes() {
        let extent = mixed_profile().total_extent();
        assert_eq!(extent.seconds, Some(600.0));
        assert_eq!(extent.metres, Some(4000.0));
    }

    // ---- looping ---------------------------------------------------------

    #[test]
    fn looping_wraps_back_to_the_start() {
        let mut p = time_profile();
        p.looping = true;
        assert!(p.sample(at(200.0, 0.0)).is_some());
        for lap in 0..5 {
            let base = lap as f64 * 200.0;
            assert_eq!(power_of(p.sample(at(base, 0.0)).unwrap()), 100);
            assert_eq!(power_of(p.sample(at(base + 110.0, 0.0)).unwrap()), 150);
            assert_eq!(power_of(p.sample(at(base + 170.0, 0.0)).unwrap()), 50);
        }
        // Still going a hundred laps later.
        assert_eq!(power_of(p.sample(at(20_110.0, 0.0)).unwrap()), 150);
    }

    #[test]
    fn looping_distance_profile_wraps() {
        let p = Profile {
            name: "loop".into(),
            description: None,
            looping: true,
            blocks: vec![Block::Segments {
                segments: vec![
                    Segment {
                        distance_m: 1000.0,
                        gradient_pct: 5.0,
                    },
                    Segment {
                        distance_m: 1000.0,
                        gradient_pct: 5.0,
                    },
                ],
            }],
        };
        for lap in 0..10 {
            let d = lap as f64 * 2000.0 + 1000.0;
            assert_eq!(gradient_of(p.sample(at(0.0, d)).unwrap()), 5.0);
        }
        assert!(p.sample(at(0.0, 1.0e7)).is_some());
    }

    #[test]
    fn looping_mixed_profile_only_laps_when_both_axes_are_spent() {
        let mut p = mixed_profile();
        p.looping = true;
        // 600 s and 4000 m is exactly one lap; the next sample is block one.
        assert_eq!(gradient_of(p.sample(at(600.0, 4000.0)).unwrap()), 1.0);
        // Plenty of time but not enough distance: still in the lap.
        assert_eq!(gradient_of(p.sample(at(5000.0, 100.0)).unwrap()), 2.0);
        // One lap done, then 400 s and 4000 m into the second: past the first
        // time block and past the distance block, so the closing block.
        assert_eq!(gradient_of(p.sample(at(1000.0, 8000.0)).unwrap()), 3.0);
    }

    // ---- interpolation ---------------------------------------------------

    #[test]
    fn segments_interpolate_between_midpoints_rather_than_stepping() {
        let block = Block::Segments {
            segments: vec![
                Segment {
                    distance_m: 100.0,
                    gradient_pct: 0.0,
                },
                Segment {
                    distance_m: 100.0,
                    gradient_pct: 10.0,
                },
            ],
        };
        // Held at the stated value up to the first midpoint.
        assert_eq!(block.value_at(0.0), 0.0);
        assert_eq!(block.value_at(50.0), 0.0);
        // Ramps linearly between midpoints — no step at the 100 m boundary.
        assert!((block.value_at(100.0) - 5.0).abs() < 1e-5);
        assert!((block.value_at(75.0) - 2.5).abs() < 1e-5);
        assert!((block.value_at(125.0) - 7.5).abs() < 1e-5);
        // Held again past the last midpoint.
        assert_eq!(block.value_at(150.0), 10.0);
        assert_eq!(block.value_at(200.0), 10.0);
    }

    #[test]
    fn segment_gradient_is_continuous() {
        let block = Block::Segments {
            segments: vec![
                Segment {
                    distance_m: 500.0,
                    gradient_pct: 0.0,
                },
                Segment {
                    distance_m: 800.0,
                    gradient_pct: 6.5,
                },
                Segment {
                    distance_m: 200.0,
                    gradient_pct: 9.0,
                },
                Segment {
                    distance_m: 400.0,
                    gradient_pct: -3.0,
                },
            ],
        };
        let mut previous = block.value_at(0.0);
        let mut i = 1;
        while i <= 1900 {
            let value = block.value_at(i as f64);
            assert!(
                (value - previous).abs() < 0.1,
                "gradient stepped by {} at {i} m",
                value - previous
            );
            previous = value;
            i += 1;
        }
    }

    #[test]
    fn terrain_points_interpolate_by_distance() {
        let block = Block::Terrain {
            points: vec![
                TerrainPoint {
                    distance_m: 0.0,
                    gradient_pct: 0.0,
                    elevation_m: 100.0,
                },
                TerrainPoint {
                    distance_m: 100.0,
                    gradient_pct: 8.0,
                    elevation_m: 108.0,
                },
                TerrainPoint {
                    distance_m: 200.0,
                    gradient_pct: -4.0,
                    elevation_m: 104.0,
                },
            ],
        };
        assert_eq!(block.value_at(0.0), 0.0);
        assert!((block.value_at(25.0) - 2.0).abs() < 1e-5);
        assert!((block.value_at(50.0) - 4.0).abs() < 1e-5);
        assert!((block.value_at(100.0) - 8.0).abs() < 1e-5);
        assert!((block.value_at(150.0) - 2.0).abs() < 1e-5);
        assert_eq!(block.value_at(200.0), -4.0);
        assert_eq!(block.value_at(500.0), -4.0);
        assert_eq!(block.extent(), Extent::Metres(200.0));
    }

    #[test]
    fn terrain_points_with_a_non_zero_origin_are_offset_correctly() {
        let block = Block::Terrain {
            points: vec![
                TerrainPoint {
                    distance_m: 1000.0,
                    gradient_pct: 0.0,
                    elevation_m: 0.0,
                },
                TerrainPoint {
                    distance_m: 1100.0,
                    gradient_pct: 10.0,
                    elevation_m: 10.0,
                },
            ],
        };
        assert_eq!(block.extent(), Extent::Metres(100.0));
        assert!((block.value_at(50.0) - 5.0).abs() < 1e-5);
    }

    // ---- waves -----------------------------------------------------------

    #[test]
    fn wave_block_scales_and_offsets_the_shape() {
        let block = Block::Wave {
            channel: Channel::Power,
            shape: Waveform::Sine,
            midpoint: 240.0,
            amplitude: 40.0,
            period: Extent::Seconds(120.0),
            repeats: 8.0,
            phase: 0.0,
        };
        assert_eq!(block.extent(), Extent::Seconds(960.0));
        assert!((block.value_at(0.0) - 240.0).abs() < 1e-3);
        assert!((block.value_at(30.0) - 280.0).abs() < 1e-3);
        assert!((block.value_at(60.0) - 240.0).abs() < 1e-3);
        assert!((block.value_at(90.0) - 200.0).abs() < 1e-3);
        // Cycle 5 matches cycle 0.
        assert!((block.value_at(630.0) - 280.0).abs() < 1e-3);
    }

    #[test]
    fn wave_phase_offset_shifts_the_shape() {
        let make = |phase| Block::Wave {
            channel: Channel::Gradient,
            shape: Waveform::Sine,
            midpoint: 0.0,
            amplitude: 1.0,
            period: Extent::Seconds(100.0),
            repeats: 1.0,
            phase,
        };
        assert!((make(0.25).value_at(0.0) - 1.0).abs() < 1e-5);
        assert!((make(0.5).value_at(25.0) + 1.0).abs() < 1e-5);
    }

    #[test]
    fn distance_based_wave_uses_the_distance_cursor() {
        let p = Profile {
            name: "saw".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Wave {
                channel: Channel::Gradient,
                shape: Waveform::Sawtooth,
                midpoint: 4.0,
                amplitude: 4.0,
                period: Extent::Metres(400.0),
                repeats: 10.0,
                phase: 0.0,
            }],
        };
        assert_eq!(p.total_extent().metres, Some(4000.0));
        assert_eq!(p.total_extent().seconds, None);
        assert_eq!(gradient_of(p.sample(at(0.0, 0.0)).unwrap()), 0.0);
        assert!((gradient_of(p.sample(at(0.0, 200.0)).unwrap()) - 4.0).abs() < 1e-4);
        // Time does not advance a distance-based profile.
        assert_eq!(gradient_of(p.sample(at(99999.0, 0.0)).unwrap()), 0.0);
        assert!(p.sample(at(0.0, 4000.0)).is_none());
    }

    // ---- no clamping here ------------------------------------------------

    #[test]
    fn sample_does_not_clamp_absurd_values() {
        let p = Profile {
            name: "absurd".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 400.0,
                extent: Extent::Seconds(10.0),
            }],
        };
        // SAF-3: clamping belongs at transmission, not here.
        assert_eq!(gradient_of(p.sample(at(1.0, 0.0)).unwrap()), 400.0);
    }

    // ---- preview ---------------------------------------------------------

    #[test]
    fn preview_covers_the_profile_on_the_dominant_axis() {
        let p = time_profile();
        let preview = p.preview(200);
        assert_eq!(preview.len(), 200);
        assert_eq!(preview[0].0, 0.0);
        assert!(preview.last().unwrap().0 < 200.0);
        assert!(preview.last().unwrap().0 > 198.0);
        // Monotone x, and the values track the blocks.
        for pair in preview.windows(2) {
            assert!(pair[1].0 > pair[0].0);
        }
        assert!((preview[0].1 - 100.0).abs() < 1e-3);
        assert!((preview.last().unwrap().1 - 50.0).abs() < 1e-3);
    }

    #[test]
    fn preview_of_a_distance_profile_uses_metres() {
        let p = Profile {
            name: "d".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Segments {
                segments: vec![Segment {
                    distance_m: 1000.0,
                    gradient_pct: 5.0,
                }],
            }],
        };
        let preview = p.preview(50);
        assert_eq!(preview.len(), 50);
        assert!(preview.last().unwrap().0 > 900.0 && preview.last().unwrap().0 < 1000.0);
        assert!(preview.iter().all(|(_, v)| (*v - 5.0).abs() < 1e-5));
    }

    #[test]
    fn preview_of_a_mixed_profile_reaches_every_block() {
        let preview = mixed_profile().preview(300);
        assert!(!preview.is_empty());
        let values: Vec<f32> = preview.iter().map(|(_, v)| *v).collect();
        for expected in [1.0, 2.0, 3.0] {
            assert!(
                values.iter().any(|v| (*v - expected).abs() < 1e-5),
                "preview never reached the {expected}% block"
            );
        }
    }

    #[test]
    fn preview_of_zero_samples_is_empty() {
        assert!(time_profile().preview(0).is_empty());
    }

    // ---- progress --------------------------------------------------------

    #[test]
    fn progress_uses_the_slower_axis() {
        let extent = mixed_profile().total_extent();
        assert_eq!(extent.progress(at(300.0, 2000.0)), Some(0.5));
        // Half the time but all the distance: still only half done.
        assert_eq!(extent.progress(at(300.0, 4000.0)), Some(0.5));
        assert_eq!(extent.progress(at(9999.0, 9999.0)), Some(1.0));
        assert_eq!(
            time_profile().total_extent().progress(at(100.0, 0.0)),
            Some(0.5)
        );
    }

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

    #[test]
    fn shipped_profiles_parse_and_sample() {
        for src in [
            include_str!("../../../profiles/sine-overunders.yaml"),
            include_str!("../../../profiles/square-resistance.yaml"),
            include_str!("../../../profiles/sawtooth-gradient.yaml"),
            include_str!("../../../profiles/hill-repeats.yaml"),
        ] {
            let profile = Profile::from_yaml(src).expect("shipped profile must parse");
            let extent = profile.total_extent();
            assert!(extent.seconds.is_some() || extent.metres.is_some());
            assert!(profile.sample(at(0.0, 0.0)).is_some());
            assert!(!profile.preview(64).is_empty());
        }
    }

    #[test]
    fn hill_repeats_loops_forever() {
        let p = Profile::from_yaml(include_str!("../../../profiles/hill-repeats.yaml")).unwrap();
        assert!(p.looping);
        assert_eq!(p.total_extent().metres, Some(2200.0));
        assert!(p.sample(at(0.0, 1.0e6)).is_some());
    }

    #[test]
    fn empty_profile_is_rejected() {
        let p = Profile {
            name: "n".into(),
            description: None,
            blocks: vec![],
            looping: false,
        };
        assert!(matches!(p.validate(), Err(ProfileError::Empty)));
    }

    // ---- degenerate input ------------------------------------------------

    #[test]
    fn zero_extent_blocks_are_skipped_without_hanging() {
        let p = Profile {
            name: "zeroes".into(),
            description: None,
            looping: true,
            blocks: vec![
                Block::Constant {
                    channel: Channel::Gradient,
                    value: 1.0,
                    extent: Extent::Seconds(0.0),
                },
                Block::Constant {
                    channel: Channel::Gradient,
                    value: 2.0,
                    extent: Extent::Seconds(0.0),
                },
            ],
        };
        // Nothing consumes anything, so nothing is active — and crucially the
        // walk terminates rather than spinning.
        assert!(p.sample(at(0.0, 0.0)).is_none());
        assert!(p.sample(at(1000.0, 1000.0)).is_none());
        assert!(p.preview(10).is_empty());
    }

    #[test]
    fn non_finite_positions_are_treated_as_the_start() {
        let p = time_profile();
        assert_eq!(power_of(p.sample(at(f64::NAN, f64::NAN)).unwrap()), 100);
        assert_eq!(power_of(p.sample(at(-50.0, -50.0)).unwrap()), 100);
        assert_eq!(power_of(p.sample(at(f64::INFINITY, 0.0)).unwrap()), 100);
    }

    #[test]
    fn non_finite_block_values_do_not_produce_garbage_integers() {
        let p = Profile {
            name: "nan".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Power,
                value: f32::NAN,
                extent: Extent::Seconds(10.0),
            }],
        };
        assert_eq!(power_of(p.sample(at(1.0, 0.0)).unwrap()), 0);
    }
}
