//! The ride state machine: ties telemetry, physics and the active profile
//! together and decides what to command the trainer.
//!
//! This is the piece the Tauri layer drives. It takes telemetry in, produces
//! snapshots and control targets out, and knows nothing about BLE or the UI.

use crate::gearing::Gearing;
use crate::physics::PhysicsState;
use crate::profile::{Position, Profile};
use crate::types::{
    ControlMode, ControlTarget, LoadChannel, RideSnapshot, RiderConfig, SafetyLimits, SpeedSource,
    Telemetry,
};

/// Something the session wants the outside world to do or know about.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionEvent {
    /// Send this target to the trainer. Already clamped (SAF-3).
    Command(ControlTarget),
    /// A new snapshot is available for the UI.
    Snapshot(RideSnapshot),
    /// A non-looping profile reached its end.
    ProfileFinished,
    /// The rider crossed into a new lap.
    Lap { index: u32 },
}

/// Ride lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RideStatus {
    Idle,
    Running,
    Paused,
    Finished,
}

/// Smallest change worth spending a control-point write on. FR-2.8 caps writes
/// at 4 Hz; suppressing no-op targets keeps a 10 Hz tick loop comfortably
/// inside that without a timer, and avoids churning the trainer with values it
/// cannot resolve anyway.
const GRADIENT_EPSILON_PCT: f32 = 0.05;
/// Grid the *computed* road load is snapped to, watts.
///
/// The road power goes as v³, so an unquantised target would change on nearly
/// every tick as the speed wanders and spend the whole write budget on
/// differences no rider can feel. Snapping to five watts rate-limits the writes
/// by construction, without an epsilon in `changed_meaningfully` — which would
/// also have coarsened the deliberate wattages an ERG profile asks for, and
/// those must arrive exactly as written.
const LOAD_POWER_STEP_W: f32 = 5.0;

/// Time constant for the speed following cadence × gear, seconds.
///
/// Cadence arrives a few times a second and genuinely varies within one pedal
/// stroke, so the speed is filtered rather than stepped. Short enough that a
/// shift or a surge shows up almost at once, long enough that the readout does
/// not flicker at the rate the pedals go round.
const DRIVETRAIN_TAU_S: f32 = 0.8;

/// Above this the rider is driving the bike; at or below it they are coasting
/// and the flywheel is merely spinning down. Low enough that soft pedalling
/// still counts as riding, high enough that a trainer reporting a few stray
/// watts at rest does not.
///
/// This is the only reliable way to tell the two apart on this hardware:
/// cadence cannot, because the flywheel keeps the cranks turning for a rider
/// who has stopped.
const COASTING_POWER_W: f32 = 15.0;

/// Time constant for the speed settling to what a gradient sustains on no
/// power, seconds. Longer than the drivetrain's, because a rider easing off
/// should feel the bike run down rather than hit a wall — but nothing like the
/// minutes real momentum would give them, which on a climb is the difference
/// between stopping and freewheeling uphill for a quarter of a kilometre.
const COAST_TAU_S: f32 = 1.5;

pub struct RideSession {
    pub config: RiderConfig,
    pub limits: SafetyLimits,
    pub mode: ControlMode,
    pub status: RideStatus,
    physics: PhysicsState,
    profile: Option<Profile>,
    /// Manual gradient trim applied on top of the profile's gradient.
    gradient_offset_pct: f32,
    /// Level held in [`ControlMode::Resistance`] (FR-4.3).
    manual_resistance: i16,
    /// Wattage held in [`ControlMode::Erg`] (FR-4.6).
    erg_watts: u16,
    /// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
    /// fast the rider travels for a given power.
    pub gearing: Gearing,
    /// Which rule decided the speed on the last tick. Diagnostic only.
    speed_source: SpeedSource,
    elapsed_ms: u64,
    last_target: Option<ControlTarget>,
}

impl RideSession {
    pub fn new(config: RiderConfig, limits: SafetyLimits) -> Self {
        Self {
            config,
            limits,
            mode: ControlMode::ManualGrade,
            status: RideStatus::Idle,
            physics: PhysicsState::default(),
            profile: None,
            gradient_offset_pct: 0.0,
            manual_resistance: 0,
            erg_watts: 150,
            gearing: Gearing::default(),
            speed_source: SpeedSource::Stopped,
            elapsed_ms: 0,
            last_target: None,
        }
    }

    pub fn load_profile(&mut self, profile: Profile) {
        self.profile = Some(profile);
        self.mode = ControlMode::Profile;
    }

    pub fn profile(&self) -> Option<&Profile> {
        self.profile.as_ref()
    }

    pub fn position(&self) -> Position {
        Position {
            elapsed_s: self.elapsed_ms as f64 / 1000.0,
            distance_m: self.physics.distance_m,
        }
    }

    pub fn start(&mut self) {
        self.status = RideStatus::Running;
    }

    pub fn pause(&mut self) {
        self.status = RideStatus::Paused;
    }

    /// Adjust the manual gradient trim by `delta` percent (FR-4.2).
    pub fn nudge_gradient(&mut self, delta_pct: f32) {
        self.gradient_offset_pct += delta_pct;
    }

    pub fn reset_gradient_offset(&mut self) {
        self.gradient_offset_pct = 0.0;
    }

    pub fn gradient_offset_pct(&self) -> f32 {
        self.gradient_offset_pct
    }

    /// Set the resistance level held in [`ControlMode::Resistance`] (FR-4.3).
    ///
    /// Stored unclamped; `SafetyLimits` still has the final say at
    /// transmission, so the rider's setting is never silently rewritten here.
    pub fn set_resistance(&mut self, level: i16) {
        self.manual_resistance = level;
    }

    pub fn nudge_resistance(&mut self, delta: i16) {
        self.manual_resistance = self.manual_resistance.saturating_add(delta);
    }

    pub fn resistance_level(&self) -> i16 {
        self.manual_resistance
    }

    /// Set the wattage held in [`ControlMode::Erg`] (FR-4.6).
    pub fn set_erg_power(&mut self, watts: u16) {
        self.erg_watts = watts;
    }

    pub fn nudge_erg_power(&mut self, delta: i16) {
        self.erg_watts = self.erg_watts.saturating_add_signed(delta);
    }

    pub fn erg_power_w(&self) -> u16 {
        self.erg_watts
    }

    /// Read-only view of the physics model, for diagnostics and recording.
    pub fn physics(&self) -> &PhysicsState {
        &self.physics
    }

    /// The target most recently sent to the trainer, post-clamp (SAF-1: this
    /// is what should be held when input is lost).
    pub fn last_target(&self) -> Option<ControlTarget> {
        self.last_target
    }

    /// Cadence: measured if the trainer reports it, inferred from wheel speed
    /// if not.
    ///
    /// The inference is exact, not a fudge. With a Zwift Cog there is one
    /// sprocket and no freewheel between the cranks and the flywheel, so cadence
    /// and wheel speed are locked by the bike's physical development:
    ///
    /// ```text
    /// cadence = wheel_speed × 60 / physical_development
    /// ```
    ///
    /// It is the same rigid drivetrain the virtual gears are already built on,
    /// read in the other direction — and the trainer's own speed is the one
    /// signal this hardware reports reliably on every Indoor Bike Data packet.
    /// Depending on it instead of the vendor Zwift channel takes the whole ride
    /// off an undocumented protocol and puts it on a standard FTMS field.
    ///
    /// This is not a workaround for a bug we might later fix. The D100 is a
    /// rebadged Magene T110 with cadence disabled in firmware, and the absence
    /// is confirmed by others rather than only observed here:
    /// <https://github.com/cagnulein/qdomyos-zwift/issues/3282> — "Cadence is
    /// not broadcasted, at least not in the 0.106 firmware version". Inference
    /// is the only source of cadence this trainer will ever have.
    ///
    /// Note what this makes the virtual speed: `trainer_speed × virtual /
    /// physical`. Shifting up covers more ground per flywheel revolution and
    /// costs proportionally more to turn — which is exactly what a gear is.
    ///
    /// A measured cadence still wins where one exists. It is the same number on
    /// this drivetrain, and on a bike with a freewheel it would be the only
    /// honest one.
    fn effective_cadence(&self, telemetry: &Telemetry) -> Option<f32> {
        if let Some(rpm) = telemetry.cadence_rpm.filter(|c| c.is_finite() && *c >= 0.0) {
            return Some(rpm);
        }
        let speed_kph = telemetry.speed_kph.filter(|s| s.is_finite() && *s >= 0.0)?;
        let development = self.config.physical_development_m;
        if !development.is_finite() || development <= 0.1 {
            return None;
        }
        Some((speed_kph / 3.6 * 60.0 / development).clamp(0.0, 250.0))
    }

    /// Advance the ride by one tick.
    ///
    /// Feeds telemetry into the physics model, advances the profile, and
    /// returns whatever the outside world needs to act on. Must be safe to call
    /// when paused (no distance accrues) and when telemetry is missing power
    /// (treat as zero rather than panicking).
    pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> {
        let mut events = Vec::new();
        let dt = if dt_s.is_finite() { dt_s.max(0.0) } else { 0.0 };
        let running = self.status == RideStatus::Running;

        if running {
            self.elapsed_ms = self
                .elapsed_ms
                .saturating_add((dt as f64 * 1000.0).round() as u64);
        }

        // Resolve the target *before* stepping, so the physics see the same
        // gradient the trainer is being asked for this tick.
        let desired = self.desired_target();
        let exhausted = self.profile.is_some() && desired.is_none();

        if running {
            // A trainer that reports no power is a trainer the rider is not
            // pushing; nothing here may panic on a partial FTMS packet.
            let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
            let gradient = self.simulated_gradient_pct();

            // The drivetrain decides the speed; the road decides how hard it is.
            //
            // A bike's wheel is locked to its cranks, so while the rider is
            // driving it, road speed is cadence × development and nothing else.
            // The force balance is not bypassed — it moves to the other end of
            // the loop, setting the load the trainer applies (below). Too big a
            // gear on a climb therefore does what it does outdoors: the load
            // becomes unholdable, cadence falls, and the speed falls with it.
            //
            // Power, not cadence, is what says the rider is driving. On a
            // direct-drive trainer the flywheel keeps the cranks turning after
            // the rider stops, so cadence alone cannot tell riding from
            // freewheeling — it reads a healthy 80 rpm for someone doing
            // nothing at all.
            let driving = power_w > COASTING_POWER_W;
            let cadence = self.effective_cadence(&telemetry);

            let (source, target_mps, tau) = match (cadence, driving) {
                // Riding: the drivetrain owns the speed outright.
                (Some(rpm), true) => (
                    SpeedSource::Drivetrain,
                    rpm / 60.0 * self.gearing.development_m(),
                    DRIVETRAIN_TAU_S,
                ),
                // Coasting: the rider has stopped contributing, so the road
                // decides alone. The target is the speed this gradient sustains
                // on no power — zero on anything uphill, a real freewheeling
                // speed on a descent.
                //
                // Approached with a lag rather than integrated as momentum, to
                // stay consistent with the riding case: that has no momentum
                // either (speed is cadence × gear, instantly), and a model that
                // is momentum-free while pedalling but momentum-rich while
                // coasting is two models, not one. It is also what the rider is
                // actually experiencing — they are not moving, and stopping on
                // a climb should feel like stopping.
                (Some(_), false) => (
                    SpeedSource::Coasting,
                    crate::physics::equilibrium_speed_mps(0.0, gradient, &self.config),
                    COAST_TAU_S,
                ),
                // Neither a measured cadence nor a wheel speed to infer one
                // from: no drivetrain, no ride.
                //
                // There is deliberately no fallback to integrating power. That
                // is a different model — one where gears change the load and not
                // the speed — and substituting it silently means a dead
                // telemetry feed presents as a ride that merely feels a bit off,
                // for as long as it takes someone to notice. Stopping says it
                // outright. This now requires the trainer to be reporting
                // nothing at all, which is a genuine fault.
                (None, _) => (SpeedSource::NoCadence, 0.0, COAST_TAU_S),
            };
            self.speed_source = source;
            self.physics.advance_at(target_mps, gradient, tau, dt);

            // Kept as a readout only, and fed the same cadence the speed was
            // built from — passing the raw telemetry here would have it servo
            // against a cadence the ride is not using. With a rigid drivetrain
            // it is a tautology by construction, which is exactly what a rigid
            // drivetrain means.
            self.gearing
                .update(cadence, self.physics.speed_mps, dt);
        }

        // Only a running ride commands the trainer. When paused or finished the
        // last target simply stands (SAF-1) rather than being re-sent or reset.
        if running {
            if let Some(target) = desired {
                // Keep load under the pedals on descents so the rider's effort
                // still counts; the physics above already used the true route
                // gradient, so the descent stays as fast as the terrain says.
                let target = match target {
                    // Gearing applies to what the TRAINER is asked for, not to
                    // what the physics simulated: shifting changes effort, not
                    // the speed the terrain implies.
                    //
                    // This *replaces* the route gradient rather than adding to
                    // it. `resistive_force_n` is given that same gradient, so
                    // gravity is already inside the force being scaled; adding
                    // `percent` back on top would charge the rider for the hill
                    // twice.
                    ControlTarget::Gradient { percent } => {
                        let resistive_n = crate::physics::resistive_force_n(
                            self.physics.speed_mps,
                            percent,
                            &self.config,
                        );
                        // Same load, two ways of saying it. Which one the
                        // trainer can actually act on is a property of the
                        // hardware, not of the ride — see `LoadChannel`.
                        match self.config.load_channel {
                            LoadChannel::Gradient => {
                                let load_pct = self.gearing.load_gradient_pct(
                                    resistive_n,
                                    self.config.total_mass_kg(),
                                    self.config.physical_development_m,
                                );
                                ControlTarget::Gradient {
                                    percent: load_pct.max(self.config.descent_load_floor_pct),
                                }
                            }
                            LoadChannel::Power => {
                                let watts = self
                                    .gearing
                                    .load_power_w(resistive_n, self.physics.speed_mps);
                                let snapped = (watts / LOAD_POWER_STEP_W).round()
                                    * LOAD_POWER_STEP_W;
                                ControlTarget::Power {
                                    watts: snapped.clamp(0.0, u16::MAX as f32) as u16,
                                }
                            }
                        }
                    }
                    other => other,
                };
                let clamped = self.limits.clamp(target);
                if changed_meaningfully(self.last_target, clamped) {
                    self.last_target = Some(clamped);
                    events.push(SessionEvent::Command(clamped));
                }
            }
        }

        if exhausted && running {
            self.status = RideStatus::Finished;
            events.push(SessionEvent::ProfileFinished);
        }

        events.push(SessionEvent::Snapshot(self.snapshot(telemetry)));
        events
    }

    /// Build the snapshot the UI renders.
    pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot {
        RideSnapshot {
            elapsed_ms: self.elapsed_ms,
            telemetry,
            // A paused or finished ride is a rider who is not moving. Holding
            // the last *target* when input is lost is correct (SAF-1); holding
            // the last *speed* is not — it tells a stationary rider they are
            // doing 39 km/h. Distance is retained, because it happened.
            virtual_speed_kph: match self.status {
                RideStatus::Running => self.physics.speed_kph(),
                _ => 0.0,
            },
            virtual_distance_m: self.physics.distance_m,
            gradient_pct: self.simulated_gradient_pct(),
            elevation_gain_m: self.physics.elevation_gain_m,
            gear: self.gearing.gear(),
            gear_count: self.gearing.gear_count(),
            development_m: self.gearing.development_m(),
            // A stationary rider has no implied cadence, and reporting the one
            // their gear would imply at a speed they are not doing would be a
            // number the screen cannot justify.
            target_cadence_rpm: match self.status {
                RideStatus::Running => self.gearing.target_cadence_rpm(self.physics.speed_mps),
                _ => 0.0,
            },
            speed_source: match self.status {
                RideStatus::Running => self.speed_source,
                _ => SpeedSource::Stopped,
            },
            // A rider who is not riding is pushing against nothing.
            pedal_force_n: match self.status {
                RideStatus::Running => self.gearing.pedal_force_n(
                    crate::physics::resistive_force_n(
                        self.physics.speed_mps,
                        self.simulated_gradient_pct(),
                        &self.config,
                    ),
                    self.config.crank_length_m,
                ),
                _ => 0.0,
            },
            mode: self.mode,
            target: self.last_target,
            profile_progress: self.profile_progress(),
        }
    }

    /// Fractional progress through the loaded profile (FR-9.7). `None` for a
    /// looping profile, which never ends, or when nothing is loaded.
    pub fn profile_progress(&self) -> Option<f32> {
        let profile = self.profile.as_ref()?;
        if profile.looping {
            return None;
        }
        profile.total_extent().progress(self.position())
    }

    /// The target that should be in force right now, before clamping.
    fn desired_target(&self) -> Option<ControlTarget> {
        match self.mode {
            // No profile involved: the trim *is* the gradient.
            ControlMode::ManualGrade => Some(ControlTarget::Gradient {
                percent: self.gradient_offset_pct,
            }),
            ControlMode::Profile => {
                let sampled = self.profile.as_ref()?.sample(self.position())?;
                Some(match sampled {
                    // The D-pad trim rides on top of the route (FR-4.2 and
                    // FR-4.4 are simultaneously active, §5.4).
                    ControlTarget::Gradient { percent } => ControlTarget::Gradient {
                        percent: percent + self.gradient_offset_pct,
                    },
                    other => other,
                })
            }
            // These modes hold a value the rider set directly and ignore any
            // loaded profile — selecting the mode *is* the statement that the
            // rider is driving the trainer, not the route.
            ControlMode::Resistance => Some(ControlTarget::Resistance {
                level: self.manual_resistance,
            }),
            ControlMode::Erg => Some(ControlTarget::Power {
                watts: self.erg_watts,
            }),
        }
    }

    /// The gradient the physics model should simulate this tick: the profile's
    /// gradient, if it is driving one, plus the manual trim. A profile driving
    /// power or resistance contributes no slope, so the rider is on the flat
    /// plus whatever trim they have dialled in.
    fn simulated_gradient_pct(&self) -> f32 {
        let base = match self.mode {
            ControlMode::Profile => match self
                .profile
                .as_ref()
                .and_then(|p| p.sample(self.position()))
            {
                Some(ControlTarget::Gradient { percent }) => percent,
                _ => 0.0,
            },
            _ => 0.0,
        };
        base + self.gradient_offset_pct
    }
}

/// Whether a new target differs enough from the last one to be worth sending.
/// A change of channel always counts.
fn changed_meaningfully(previous: Option<ControlTarget>, next: ControlTarget) -> bool {
    match (previous, next) {
        (None, _) => true,
        (Some(ControlTarget::Gradient { percent: a }), ControlTarget::Gradient { percent: b }) => {
            (a - b).abs() >= GRADIENT_EPSILON_PCT
        }
        (Some(ControlTarget::Resistance { level: a }), ControlTarget::Resistance { level: b }) => {
            a != b
        }
        (Some(ControlTarget::Power { watts: a }), ControlTarget::Power { watts: b }) => a != b,
        _ => true,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::profile::{Block, Channel, Extent, Segment, Waveform};

    /// A session on the **gradient** channel.
    ///
    /// Most of this suite predates the power channel and tests the slope
    /// arithmetic, which is still exactly right for `LoadChannel::Gradient`.
    /// Named plainly rather than silently defaulted, so that a test asserting a
    /// gradient target is visibly asking for one.
    fn session() -> RideSession {
        let config = RiderConfig {
            load_channel: LoadChannel::Gradient,
            ..RiderConfig::default()
        };
        RideSession::new(config, SafetyLimits::default())
    }

    /// A session on the **power** channel — what actually ships.
    fn power_session() -> RideSession {
        RideSession::new(RiderConfig::default(), SafetyLimits::default())
    }

    /// A rider putting `watts` in and turning the cranks at a plausible rate.
    ///
    /// Cadence is not optional garnish here: speed comes from the drivetrain,
    /// so a sample with power but no cadence is a bike that is not moving. Any
    /// test that wants a moving rider needs both.
    fn powered(watts: i16) -> Telemetry {
        Telemetry {
            power_w: Some(watts),
            cadence_rpm: Some(if watts > 0 { 85.0 } else { 0.0 }),
            ..Default::default()
        }
    }

    fn commands(events: &[SessionEvent]) -> Vec<ControlTarget> {
        events
            .iter()
            .filter_map(|e| match e {
                SessionEvent::Command(t) => Some(*t),
                _ => None,
            })
            .collect()
    }

    fn snapshot_of(events: &[SessionEvent]) -> RideSnapshot {
        events
            .iter()
            .find_map(|e| match e {
                SessionEvent::Snapshot(s) => Some(*s),
                _ => None,
            })
            .expect("every tick emits a snapshot")
    }

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

    // ---- basic loop ------------------------------------------------------

    #[test]
    fn every_tick_emits_exactly_one_snapshot() {
        let mut s = session();
        s.start();
        for _ in 0..10 {
            let events = s.tick(powered(200), 1.0);
            let snapshots = events
                .iter()
                .filter(|e| matches!(e, SessionEvent::Snapshot(_)))
                .count();
            assert_eq!(snapshots, 1);
        }
    }

    #[test]
    fn running_accrues_time_distance_and_speed() {
        let mut s = session();
        s.start();
        for _ in 0..60 {
            s.tick(powered(250), 1.0);
        }
        let snap = snapshot_of(&s.tick(powered(250), 1.0));
        assert_eq!(snap.elapsed_ms, 61_000);
        assert!(snap.virtual_distance_m > 300.0);
        assert!(snap.virtual_speed_kph > 20.0);
    }

    // ---- pause -----------------------------------------------------------

    #[test]
    fn pausing_accrues_neither_time_nor_distance() {
        let mut s = session();
        s.start();
        for _ in 0..30 {
            s.tick(powered(250), 1.0);
        }
        let before = snapshot_of(&s.tick(powered(250), 1.0));

        s.pause();
        for _ in 0..100 {
            let events = s.tick(powered(250), 1.0);
            // Paused: nothing new is commanded, the last target stands (SAF-1).
            assert!(commands(&events).is_empty());
        }
        let after = snapshot_of(&s.tick(powered(250), 1.0));
        assert_eq!(after.virtual_distance_m, before.virtual_distance_m);
        assert_eq!(after.elapsed_ms, before.elapsed_ms);
        assert_eq!(after.elevation_gain_m, before.elevation_gain_m);
        assert_eq!(after.target, before.target);
    }

    #[test]
    fn an_idle_session_never_commands_the_trainer() {
        let mut s = session();
        for _ in 0..5 {
            assert!(commands(&s.tick(powered(300), 1.0)).is_empty());
        }
        assert_eq!(s.last_target(), None);
        assert_eq!(
            snapshot_of(&s.tick(powered(300), 1.0)).virtual_distance_m,
            0.0
        );
    }

    #[test]
    fn resuming_after_a_pause_continues_from_where_it_stopped() {
        let mut s = session();
        s.start();
        for _ in 0..30 {
            s.tick(powered(250), 1.0);
        }
        let mid = snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m;
        s.pause();
        s.tick(powered(250), 1.0);
        s.start();
        for _ in 0..10 {
            s.tick(powered(250), 1.0);
        }
        assert!(snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m > mid);
    }

    // ---- missing / hostile telemetry ------------------------------------

    #[test]
    fn missing_power_is_treated_as_zero() {
        let mut s = session();
        s.start();
        for _ in 0..30 {
            s.tick(powered(300), 1.0);
        }
        let moving = snapshot_of(&s.tick(powered(300), 1.0)).virtual_speed_kph;
        assert!(moving > 10.0);

        // Empty packets — a real FTMS possibility, not a hypothetical.
        for _ in 0..300 {
            s.tick(Telemetry::default(), 1.0);
        }
        let snap = snapshot_of(&s.tick(Telemetry::default(), 1.0));
        assert!(snap.virtual_speed_kph < moving);
        assert_eq!(snap.virtual_speed_kph, 0.0, "should coast to a stop");
    }

    #[test]
    fn negative_power_does_not_drive_the_rider_backwards() {
        let mut s = session();
        s.start();
        for _ in 0..50 {
            let snap = snapshot_of(&s.tick(powered(-500), 1.0));
            assert!(snap.virtual_speed_kph >= 0.0);
            assert_eq!(snap.virtual_distance_m, 0.0);
        }
    }

    #[test]
    fn hostile_dt_does_not_corrupt_the_ride() {
        let mut s = session();
        s.start();
        for dt in [f32::NAN, f32::INFINITY, -1.0, 0.0, 1e20] {
            let snap = snapshot_of(&s.tick(powered(200), dt));
            assert!(snap.virtual_speed_kph.is_finite() && snap.virtual_speed_kph >= 0.0);
            assert!(snap.virtual_distance_m.is_finite() && snap.virtual_distance_m >= 0.0);
        }
    }

    // ---- gradient offset -------------------------------------------------

    #[test]
    fn manual_grade_sets_the_road_and_the_load_follows_it() {
        // The trim states what the road is doing. What the trainer is *asked*
        // for is the load that road implies through the selected gear, which is
        // not the same number — see `crate::gearing`. The two must not be
        // conflated: the snapshot reports the road, the command reports the
        // load, and only the first is the rider's trim verbatim.
        let mut s = session();
        s.start();
        let flat = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);

        s.nudge_gradient(0.5);
        s.nudge_gradient(0.5);
        let events = s.tick(powered(0), 1.0);
        let climbing = gradient_of(commands(&events)[0]);
        assert_eq!(snapshot_of(&events).gradient_pct, 1.0, "the road is the trim");
        assert!(climbing > flat, "a 1% road must load more than a flat: {climbing} vs {flat}");

        s.reset_gradient_offset();
        let events = s.tick(powered(0), 1.0);
        assert_eq!(snapshot_of(&events).gradient_pct, 0.0);
        assert!(gradient_of(commands(&events)[0]) < climbing, "resetting must shed the load");
    }

    #[test]
    fn the_trim_adds_on_top_of_the_profile_gradient() {
        let mut s = session();
        s.load_profile(Profile {
            name: "flat-then-hill".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 4.0,
                extent: Extent::Seconds(600.0),
            }],
        });
        s.start();
        let steep = gradient_of(commands(&s.tick(powered(200), 1.0))[0]);

        s.nudge_gradient(-1.5);
        let events = s.tick(powered(200), 1.0);
        // The road is the profile plus the trim, exactly. The load commanded
        // for it is a separate quantity (see `crate::gearing`) — all that is
        // owed here is that trimming the road down eases the pedals.
        assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
        assert!(
            gradient_of(commands(&events)[0]) < steep,
            "trimming 1.5% off the route must shed load"
        );
    }

    #[test]
    fn the_trim_does_not_disturb_a_power_profile_target() {
        let mut s = session();
        s.load_profile(Profile {
            name: "erg".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Power,
                value: 220.0,
                extent: Extent::Seconds(600.0),
            }],
        });
        s.start();
        s.nudge_gradient(3.0);
        let events = s.tick(powered(220), 1.0);
        assert_eq!(commands(&events)[0], ControlTarget::Power { watts: 220 });
        // The trim still tilts the virtual road, which is what drives speed.
        assert_eq!(snapshot_of(&events).gradient_pct, 3.0);
    }

    // ---- safety clamping -------------------------------------------------

    #[test]
    fn out_of_range_gradients_are_clamped_before_transmission() {
        let mut s = session();
        s.start();
        s.nudge_gradient(90.0);
        let target = commands(&s.tick(powered(0), 1.0))[0];
        assert_eq!(gradient_of(target), s.limits.max_gradient_pct);

        // The descent load floor normally bites first, so disable it here to
        // prove the *safety* clamp still holds on its own.
        s.config.descent_load_floor_pct = f32::NEG_INFINITY;
        s.reset_gradient_offset();
        s.nudge_gradient(-90.0);
        // Two ticks: the first re-emits after the reset.
        s.tick(powered(0), 1.0);
        assert!(gradient_of(s.last_target().unwrap()) >= s.limits.min_gradient_pct);
        assert_eq!(
            gradient_of(s.last_target().unwrap()),
            s.limits.min_gradient_pct
        );
    }

    #[test]
    fn descents_keep_load_under_the_pedals() {
        // A steep descent commands almost no resistance, so on a single-cog
        // drivetrain the rider spins out and their effort stops counting. The
        // floor keeps something to push against.
        let mut s = session();
        s.start();
        s.config.descent_load_floor_pct = -1.0;
        s.nudge_gradient(-8.0);
        let commanded = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
        assert_eq!(commanded, -1.0, "descent should be floored for load");

        // But the *simulated* gradient stays true to the terrain, so the rider
        // still descends at the speed the route implies.
        assert!(
            s.snapshot(powered(0)).gradient_pct < -7.0,
            "physics must still see the real descent"
        );
    }

    #[test]
    fn an_absurd_profile_cannot_command_an_unsafe_target() {
        // SAF-6: parameter errors must be caught by SAF-3, not by the profile.
        let mut s = session();
        s.load_profile(Profile {
            name: "runaway".into(),
            description: None,
            looping: false,
            blocks: vec![
                Block::Constant {
                    channel: Channel::Power,
                    value: 5000.0,
                    extent: Extent::Seconds(10.0),
                },
                Block::Constant {
                    channel: Channel::Gradient,
                    value: -400.0,
                    extent: Extent::Seconds(10.0),
                },
                Block::Constant {
                    channel: Channel::Resistance,
                    value: 9000.0,
                    extent: Extent::Seconds(10.0),
                },
            ],
        });
        s.start();
        let mut seen = Vec::new();
        for _ in 0..29 {
            seen.extend(commands(&s.tick(powered(200), 1.0)));
        }
        assert!(!seen.is_empty());
        for target in seen {
            match target {
                ControlTarget::Power { watts } => {
                    assert!((s.limits.min_power_w..=s.limits.max_power_w).contains(&watts))
                }
                ControlTarget::Gradient { percent } => assert!((s.limits.min_gradient_pct
                    ..=s.limits.max_gradient_pct)
                    .contains(&percent)),
                ControlTarget::Resistance { level } => {
                    assert!((s.limits.min_resistance..=s.limits.max_resistance).contains(&level))
                }
            }
        }
    }

    #[test]
    fn custom_limits_are_honoured() {
        // Gradient limits, so a gradient channel — the power channel has its
        // own bounds and is covered separately.
        let mut s = RideSession::new(
            RiderConfig {
                load_channel: LoadChannel::Gradient,
                ..RiderConfig::default()
            },
            SafetyLimits {
                min_gradient_pct: -2.0,
                max_gradient_pct: 3.0,
                ..Default::default()
            },
        );
        s.start();
        s.nudge_gradient(10.0);
        assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 3.0);
    }

    // ---- rate limiting ---------------------------------------------------

    #[test]
    fn an_unchanged_target_is_not_resent() {
        let mut s = session();
        s.start();
        // Ride up to terminal speed first. The commanded load carries the
        // rider's own drag, which rises with v², so while they are still
        // accelerating the target is genuinely changing and writing it is
        // correct. What FR-2.8 forbids is churn once nothing is moving.
        for _ in 0..600 {
            s.tick(powered(200), 1.0);
        }
        for _ in 0..50 {
            assert!(
                commands(&s.tick(powered(200), 1.0)).is_empty(),
                "a settled ride must not be re-sent (FR-2.8)"
            );
        }
        s.nudge_gradient(1.0);
        assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
    }

    #[test]
    fn sub_threshold_gradient_drift_is_suppressed() {
        let mut s = session();
        s.start();
        s.tick(powered(0), 1.0);
        s.nudge_gradient(0.01);
        assert!(commands(&s.tick(powered(0), 1.0)).is_empty());
        for _ in 0..10 {
            s.nudge_gradient(0.01);
        }
        assert_eq!(commands(&s.tick(powered(0), 1.0)).len(), 1);
    }

    #[test]
    fn a_continuously_varying_profile_stays_well_inside_the_write_budget() {
        // A 10 Hz tick loop over a gradient ramp must not produce 10 writes a
        // second; FR-2.8 caps them at four.
        let mut s = session();
        s.load_profile(Profile {
            name: "ramp".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Ramp {
                channel: Channel::Gradient,
                from: 0.0,
                to: 6.0,
                extent: Extent::Seconds(600.0),
            }],
        });
        s.start();
        let mut writes = 0;
        for _ in 0..6000 {
            writes += commands(&s.tick(powered(200), 0.1)).len();
        }
        // The ramp itself is ~120 writes at a 0.05 % threshold; the rider's
        // drag changing as they speed up on it accounts for the rest. Still one
        // write every four seconds against a 4 Hz cap — two orders of magnitude
        // of headroom, which is what this test is actually guarding.
        assert!(writes <= 200, "{writes} writes in 600 s");
        assert!(writes > 100);
    }

    // ---- profile lifecycle ----------------------------------------------

    #[test]
    fn a_non_looping_profile_finishes_once() {
        let mut s = session();
        s.load_profile(Profile {
            name: "short".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 2.0,
                extent: Extent::Seconds(5.0),
            }],
        });
        s.start();
        let mut finishes = 0;
        for _ in 0..20 {
            finishes += s
                .tick(powered(200), 1.0)
                .iter()
                .filter(|e| matches!(e, SessionEvent::ProfileFinished))
                .count();
        }
        assert_eq!(finishes, 1);
        assert_eq!(s.status, RideStatus::Finished);
    }

    #[test]
    fn a_looping_profile_never_finishes() {
        let mut s = session();
        s.load_profile(Profile {
            name: "loop".into(),
            description: None,
            looping: true,
            blocks: vec![Block::Segments {
                segments: vec![
                    Segment {
                        distance_m: 400.0,
                        gradient_pct: 0.0,
                    },
                    Segment {
                        distance_m: 400.0,
                        gradient_pct: 5.0,
                    },
                ],
            }],
        });
        s.start();
        for _ in 0..1200 {
            let events = s.tick(powered(250), 1.0);
            assert!(!events
                .iter()
                .any(|e| matches!(e, SessionEvent::ProfileFinished)));
        }
        assert_eq!(s.status, RideStatus::Running);
        assert!(s.physics().distance_m > 2000.0, "should have lapped");
        assert_eq!(s.profile_progress(), None);
    }

    #[test]
    fn progress_advances_from_zero_to_one() {
        let mut s = session();
        s.load_profile(Profile {
            name: "p".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 0.0,
                extent: Extent::Seconds(100.0),
            }],
        });
        s.start();
        assert_eq!(
            snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
            Some(0.0)
        );
        for _ in 0..50 {
            s.tick(powered(200), 1.0);
        }
        let mid = snapshot_of(&s.tick(powered(200), 0.0))
            .profile_progress
            .unwrap();
        assert!((mid - 0.5).abs() < 0.02, "{mid}");
        for _ in 0..60 {
            s.tick(powered(200), 1.0);
        }
        assert_eq!(
            snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
            Some(1.0)
        );
    }

    #[test]
    fn a_distance_profile_advances_only_as_the_rider_rides() {
        let mut s = session();
        s.load_profile(Profile {
            name: "hill".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Segments {
                segments: vec![
                    Segment {
                        distance_m: 200.0,
                        gradient_pct: 0.0,
                    },
                    Segment {
                        distance_m: 200.0,
                        gradient_pct: 8.0,
                    },
                ],
            }],
        });
        s.start();
        // No power, so no distance, so no progress no matter how long it runs.
        for _ in 0..600 {
            s.tick(Telemetry::default(), 1.0);
        }
        assert_eq!(s.profile_progress(), Some(0.0));
        assert!(s.status == RideStatus::Running);

        for _ in 0..600 {
            s.tick(powered(250), 1.0);
        }
        assert_eq!(s.status, RideStatus::Finished);
    }

    #[test]
    fn a_wave_profile_drives_the_power_channel() {
        let mut s = session();
        s.load_profile(Profile {
            name: "over-unders".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Wave {
                channel: Channel::Power,
                shape: Waveform::Sine,
                midpoint: 240.0,
                amplitude: 40.0,
                period: Extent::Seconds(120.0),
                repeats: 2.0,
                phase: 0.0,
            }],
        });
        s.start();
        let mut watts = Vec::new();
        for _ in 0..240 {
            for target in commands(&s.tick(powered(240), 1.0)) {
                match target {
                    ControlTarget::Power { watts: w } => watts.push(w),
                    other => panic!("unexpected {other:?}"),
                }
            }
        }
        assert!(watts.contains(&280), "peak never reached: {watts:?}");
        assert!(watts.contains(&200), "trough never reached");
        assert!(watts.iter().all(|w| (200..=280).contains(w)));
    }

    #[test]
    fn loading_a_profile_switches_into_profile_mode() {
        let mut s = session();
        assert_eq!(s.mode, ControlMode::ManualGrade);
        s.load_profile(Profile {
            name: "p".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 1.0,
                extent: Extent::Seconds(10.0),
            }],
        });
        assert_eq!(s.mode, ControlMode::Profile);
        assert!(s.profile().is_some());
    }

    #[test]
    fn a_gradient_profile_accumulates_elevation() {
        let mut s = session();
        s.load_profile(Profile {
            name: "climb".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Gradient,
                value: 6.0,
                extent: Extent::Seconds(1200.0),
            }],
        });
        s.start();
        for _ in 0..600 {
            s.tick(powered(250), 1.0);
        }
        let snap = snapshot_of(&s.tick(powered(250), 0.0));
        let expected = snap.virtual_distance_m as f32 * (0.06f32.atan()).sin();
        assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02);
        assert!(snap.elevation_gain_m > 50.0);
    }

    #[test]
    fn a_paused_ride_reports_zero_speed_not_the_last_reading() {
        let mut s = session();
        s.start();
        // Build up real speed under power.
        for _ in 0..40 {
            s.tick(powered(250), 0.25);
        }
        let moving = s.snapshot(powered(250)).virtual_speed_kph;
        assert!(moving > 5.0, "expected to be moving, got {moving} kph");

        // Pause. The rider is stationary — reporting the last speed would tell
        // them they are still doing 30-odd kph while stood still.
        s.pause();
        let paused = s.snapshot(powered(0));
        assert_eq!(paused.virtual_speed_kph, 0.0);
        // Distance is retained: it happened.
        assert!(paused.virtual_distance_m > 0.0);

        // Resuming picks the speed back up rather than restarting from rest.
        s.start();
        assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
    }


    #[test]
    fn cadence_and_gear_pull_the_speed_toward_the_drivetrain_constraint() {
        // On a real bike wheel speed is locked to cadence x gear. The model
        // should not drift far from that, whatever the force balance says.
        let mut s = session();
        s.start();
        s.gearing.set_gear(6);
        let development = s.gearing.development_m();

        // Ride at a steady cadence with modest power for long enough to settle.
        let t = Telemetry {
            power_w: Some(150),
            cadence_rpm: Some(85.0),
            ..Default::default()
        };
        for _ in 0..400 {
            s.tick(t, 0.25);
        }

        // The servo's job is to make the rider's cadence agree with the gear,
        // by leaning on the load until it does — not to force the speed to
        // match. So what must converge is the cadence TARGET, not the speed.
        let target = s.gearing.target_cadence_rpm(s.physics().speed_mps);
        assert!(
            (target - 85.0).abs() < 25.0,
            "gear {development:.1} m implies {target:.0} rpm; rider is turning 85"
        );
    }

    /// The gradient the session most recently asked the trainer for.
    fn commanded_gradient(s: &RideSession) -> f32 {
        match s.last_target() {
            Some(ControlTarget::Gradient { percent }) => percent,
            other => panic!("expected a gradient target, got {other:?}"),
        }
    }

    #[test]
    fn a_shift_changes_the_load_on_the_very_next_tick() {
        // The point of the direct model. A rider who shifts and feels nothing
        // for three seconds has not shifted, they have waited.
        let mut s = session();
        s.start();
        s.gearing.set_gear(6);
        let t = Telemetry {
            power_w: Some(200),
            cadence_rpm: Some(85.0),
            ..Default::default()
        };
        for _ in 0..40 {
            s.tick(t, 0.25);
        }
        let before = commanded_gradient(&s);

        s.gearing.shift_up();
        s.tick(t, 0.25);
        let after = commanded_gradient(&s);

        assert!(
            after > before + 0.05,
            "one tick after shifting up the load must be higher: {before} -> {after}"
        );
    }

    #[test]
    fn the_gear_the_rider_selects_is_what_reaches_the_trainer() {
        // Bottom gear and top gear on the same road must not command the same
        // load, or the shifter is decoration.
        let mut s = session();
        s.start();
        let t = Telemetry {
            power_w: Some(200),
            cadence_rpm: Some(85.0),
            ..Default::default()
        };
        s.gearing.set_gear(1);
        for _ in 0..40 {
            s.tick(t, 0.25);
        }
        let bottom = commanded_gradient(&s);

        s.gearing.set_gear(s.gearing.gear_count());
        s.tick(t, 0.25);
        let top = commanded_gradient(&s);

        assert!(top > bottom, "top gear must load more: {top} vs {bottom}");
    }

    #[test]
    fn the_route_gradient_is_not_charged_twice() {
        // The commanded load already contains gravity, because the force it
        // scales was computed at the route's gradient. A 6% climb in a gear
        // close to the bike's real one should command something in the
        // neighbourhood of 6% — not 12%.
        let mut s = session();
        s.start();
        s.config.physical_development_m = s.gearing.development_m();
        s.nudge_gradient(6.0);
        let t = Telemetry {
            power_w: Some(200),
            cadence_rpm: Some(85.0),
            ..Default::default()
        };
        for _ in 0..40 {
            s.tick(t, 0.25);
        }
        let commanded = commanded_gradient(&s);
        assert!(
            (commanded - 6.0).abs() < 2.0,
            "6% road in a 1:1 gear should command about 6%, got {commanded}"
        );
    }

    /// Settle a ride at a fixed cadence and gear, and report its speed.
    fn ride_at(cadence: f32, gear: usize) -> RideSession {
        let mut s = session();
        s.start();
        s.gearing.set_gear(gear);
        let t = Telemetry {
            power_w: Some(200),
            cadence_rpm: Some(cadence),
            ..Default::default()
        };
        for _ in 0..80 {
            s.tick(t, 0.25);
        }
        s
    }

    /// Ride up to speed on `gradient`, then stop pedalling for `seconds` while
    /// the flywheel keeps the cranks turning. Returns (riding, coasting) km/h.
    fn coast_after_riding(gradient: f32, seconds: f32) -> (f32, f32) {
        let mut s = session();
        s.start();
        s.nudge_gradient(gradient);
        let riding = Telemetry {
            power_w: Some(250),
            cadence_rpm: Some(85.0),
            speed_kph: Some(28.0),
            ..Default::default()
        };
        for _ in 0..200 {
            s.tick(riding, 0.25);
        }
        let moving = s.physics().speed_kph();

        let coasting = Telemetry {
            power_w: Some(0),
            // The flywheel drives the cranks: cadence stays healthy for a
            // rider who has stopped doing anything.
            cadence_rpm: Some(80.0),
            speed_kph: Some(26.0),
            ..Default::default()
        };
        for _ in 0..((seconds / 0.25) as u32) {
            s.tick(coasting, 0.25);
        }
        (moving, s.physics().speed_kph())
    }

    #[test]
    fn stopping_pedalling_on_a_climb_stops_the_bike() {
        // The flywheel reports 80 rpm throughout, so anything keyed on cadence
        // alone would have the rider still climbing at speed. Power is what
        // says they have stopped.
        let (moving, coasting) = coast_after_riding(3.5, 5.0);
        assert!(moving > 20.0, "should have been riding: {moving}");
        assert!(
            coasting < 1.5,
            "five seconds after stopping on a 3.5% climb, expected a halt, got {coasting} kph"
        );
    }

    #[test]
    fn coasting_a_descent_keeps_rolling() {
        // The mirror image, and the reason coasting is not simply "stop": a
        // rider who stops pedalling downhill speeds up, and must not be brought
        // to a halt by a rule written for climbs.
        let (_, coasting) = coast_after_riding(-5.0, 5.0);
        assert!(
            coasting > 15.0,
            "freewheeling down a 5% descent should carry on, got {coasting} kph"
        );
    }

    #[test]
    fn speed_is_never_negative() {
        // Whatever the road, the telemetry or the gear, a readout that says the
        // rider is travelling backwards is never right.
        for gradient in [-25.0, -8.0, 0.0, 8.0, 25.0] {
            for (power, cadence) in
                [(0u16, None), (0, Some(0.0)), (0, Some(95.0)), (400, Some(95.0))]
            {
                let mut s = session();
                s.start();
                s.nudge_gradient(gradient);
                let t = Telemetry {
                    power_w: Some(power as i16),
                    cadence_rpm: cadence,
                    speed_kph: Some(0.0),
                    ..Default::default()
                };
                for _ in 0..200 {
                    let snap = snapshot_of(&s.tick(t, 0.25));
                    assert!(
                        snap.virtual_speed_kph >= 0.0,
                        "negative speed {} at {gradient}% / {power} W / {cadence:?}",
                        snap.virtual_speed_kph
                    );
                    assert!(snap.virtual_distance_m >= 0.0, "distance went backwards");
                }
            }
        }
    }

    /// The watts commanded after settling at a given flywheel speed and gear.
    fn commanded_watts(gear: usize, trainer_kph: f32) -> u16 {
        let mut s = power_session();
        s.start();
        s.gearing.set_gear(gear);
        let t = Telemetry {
            power_w: Some(180),
            cadence_rpm: None,
            speed_kph: Some(trainer_kph),
            ..Default::default()
        };
        for _ in 0..80 {
            s.tick(t, 0.25);
        }
        match s.last_target() {
            Some(ControlTarget::Power { watts }) => watts,
            other => panic!("expected a power target, got {other:?}"),
        }
    }

    #[test]
    fn the_road_gradient_reaches_the_trainer_as_watts() {
        // The D100 will honour the power channel or the gradient channel, not
        // both, so gravity has to travel on whichever one we chose. It does:
        // the climb is in the commanded watts, which is the whole reason
        // dropping the gradient channel costs nothing.
        let watts = |grade: f32| {
            let mut s = power_session();
            s.start();
            s.gearing.set_gear(6);
            s.nudge_gradient(grade);
            let t = Telemetry {
                power_w: Some(180),
                cadence_rpm: None,
                speed_kph: Some(20.0),
                ..Default::default()
            };
            for _ in 0..80 {
                s.tick(t, 0.25);
            }
            match s.last_target() {
                Some(ControlTarget::Power { watts }) => watts,
                other => panic!("expected a power target, got {other:?}"),
            }
        };
        let flat = watts(0.0);
        let climb = watts(3.0);
        let descent = watts(-5.0);
        assert!(climb > flat * 2, "a 3% climb must cost far more: {climb} vs {flat}");
        assert!(descent < flat, "a descent must cost less: {descent} vs {flat}");
        assert!(
            descent >= power_session().limits.min_power_w,
            "never below what the trainer can hold"
        );
    }

    #[test]
    fn the_power_channel_is_what_ships() {
        // The D100 declares 50-600 W in 1 W steps and 0-6% inclination in 0.1%
        // steps, and only the former has room for gearing to show up in.
        assert_eq!(RiderConfig::default().load_channel, LoadChannel::Power);
    }

    #[test]
    fn a_longer_gear_demands_more_watts() {
        // Not because power depends on the gear — at a given speed it does not
        // — but because a longer gear turns the same cadence into more speed,
        // and the road charges for speed.
        let bottom = commanded_watts(1, 20.0);
        let top = commanded_watts(12, 20.0);
        assert!(top > bottom * 3, "top gear must cost far more: {top} vs {bottom}");
    }

    #[test]
    fn the_commanded_watts_are_a_plausible_road_load() {
        // Sanity on the absolute scale. This is the number the trainer will
        // hold as a ceiling, so if it is wrong the ride is wrong.
        let watts = commanded_watts(6, 20.0);
        assert!(
            (40..=250).contains(&watts),
            "a middling gear on the flat should be ordinary riding: {watts} W"
        );
    }

    #[test]
    fn the_commanded_load_is_quantised_so_it_does_not_churn() {
        // The write budget depends on this: road power goes as v cubed, and an
        // unquantised target would change on nearly every tick.
        for gear in [2usize, 6, 10] {
            for kph in [12.0f32, 18.0, 25.0] {
                let watts = commanded_watts(gear, kph);
                assert_eq!(
                    watts % LOAD_POWER_STEP_W as u16,
                    0,
                    "{watts} W is off the {LOAD_POWER_STEP_W} W grid"
                );
            }
        }
    }

    #[test]
    fn a_steady_ride_on_the_power_channel_stays_inside_the_write_budget() {
        // FR-2.8 caps writes at 4 Hz. Once the speed has settled the demand
        // should stop moving entirely.
        let mut s = power_session();
        s.start();
        let t = Telemetry {
            power_w: Some(180),
            cadence_rpm: None,
            speed_kph: Some(20.0),
            ..Default::default()
        };
        for _ in 0..200 {
            s.tick(t, 0.25);
        }
        let mut writes = 0;
        for _ in 0..200 {
            writes += commands(&s.tick(t, 0.25)).len();
        }
        assert!(writes <= 2, "a settled ride rewrote the target {writes} times");
    }

    #[test]
    fn a_descent_never_asks_the_brake_for_negative_watts() {
        // No brake can push. The floor is zero, and the safety limits raise it
        // to whatever the trainer's real minimum is.
        let mut s = power_session();
        s.start();
        s.nudge_gradient(-12.0);
        let t = Telemetry {
            power_w: Some(0),
            cadence_rpm: Some(80.0),
            speed_kph: Some(35.0),
            ..Default::default()
        };
        for _ in 0..80 {
            s.tick(t, 0.25);
        }
        match s.last_target() {
            Some(ControlTarget::Power { watts }) => {
                assert!(watts <= s.limits.max_power_w);
            }
            other => panic!("expected a power target, got {other:?}"),
        }
    }

    #[test]
    fn speed_is_cadence_times_the_gear() {
        // The drivetrain constraint, exactly as on a bike: the wheel is locked
        // to the cranks, so this is arithmetic, not a force balance.
        let s = ride_at(90.0, 6);
        let expected = 90.0 / 60.0 * s.gearing.development_m();
        let actual = s.physics().speed_mps;
        assert!(
            (actual - expected).abs() < 0.05,
            "90 rpm in a {:.1} m gear is {expected:.2} m/s, got {actual:.2}",
            s.gearing.development_m()
        );
    }

    #[test]
    fn a_bigger_gear_at_the_same_cadence_goes_faster() {
        let small = ride_at(90.0, 2).physics().speed_mps;
        let big = ride_at(90.0, 11).physics().speed_mps;
        assert!(big > small * 1.5, "a longer gear must travel further: {big} vs {small}");
    }

    #[test]
    fn a_higher_cadence_in_the_same_gear_goes_faster() {
        let slow = ride_at(60.0, 6).physics().speed_mps;
        let fast = ride_at(100.0, 6).physics().speed_mps;
        assert!(fast > slow, "spinning faster must go faster: {fast} vs {slow}");
    }

    #[test]
    fn a_rigid_drivetrain_leaves_the_cadence_servo_nothing_to_say() {
        // With speed taken from cadence, the cadence the gear implies IS the
        // cadence the rider is turning. The servo is a tautology here, which is
        // what a rigid drivetrain means — and why it drives nothing.
        let s = ride_at(90.0, 6);
        assert!(
            s.gearing.correction_pct().abs() < 0.01,
            "expected no correction, got {}",
            s.gearing.correction_pct()
        );
    }

    #[test]
    fn wheel_speed_infers_cadence_on_a_single_cog_drivetrain() {
        // The trainer reports no cadence over FTMS but always reports speed,
        // and with one sprocket the two are locked. 20 km/h through a 5.1 m
        // development is 5.56 m/s / 5.1 m = 1.09 rev/s = 65 rpm.
        let mut s = session();
        s.config.physical_development_m = 5.1;
        let t = Telemetry {
            power_w: Some(150),
            cadence_rpm: None,
            speed_kph: Some(20.0),
            ..Default::default()
        };
        let rpm = s.effective_cadence(&t).expect("speed alone must yield a cadence");
        assert!((rpm - 65.4).abs() < 0.5, "expected ~65 rpm, got {rpm}");

        // And it drives the ride, rather than the ride standing still.
        s.start();
        for _ in 0..80 {
            s.tick(t, 0.25);
        }
        assert!(s.physics().speed_mps > 1.0, "inferred cadence must move the bike");
        assert_eq!(
            snapshot_of(&s.tick(t, 0.25)).speed_source,
            SpeedSource::Drivetrain
        );
    }

    #[test]
    fn a_measured_cadence_beats_an_inferred_one() {
        // On this drivetrain they agree; on a bike with a freewheel only the
        // measured one is honest, so it must win where it exists.
        let s = session();
        let t = Telemetry {
            cadence_rpm: Some(95.0),
            speed_kph: Some(20.0),
            ..Default::default()
        };
        assert_eq!(s.effective_cadence(&t), Some(95.0));
    }

    #[test]
    fn the_gear_scales_the_trainers_own_speed() {
        // What inference makes the model: virtual speed is the trainer's speed
        // times the gear ratio. Top gear covers more ground per flywheel
        // revolution than bottom, for the same flywheel.
        let ride = |gear: usize| {
            let mut s = session();
            s.start();
            s.gearing.set_gear(gear);
            let t = Telemetry {
                power_w: Some(200),
                cadence_rpm: None,
                speed_kph: Some(25.0),
                ..Default::default()
            };
            for _ in 0..80 {
                s.tick(t, 0.25);
            }
            s.physics().speed_kph()
        };
        let bottom = ride(1);
        let top = ride(12);
        assert!(top > bottom * 2.0, "top gear must cover more ground: {top} vs {bottom}");
    }

    #[test]
    fn a_silent_trainer_still_stops_the_ride() {
        // The inference needs a wheel speed. With neither cadence nor speed
        // there is genuinely nothing to ride on, and that must still be loud.
        let mut s = session();
        s.start();
        let t = Telemetry {
            power_w: Some(250),
            cadence_rpm: None,
            speed_kph: None,
            ..Default::default()
        };
        let mut snap = None;
        for _ in 0..40 {
            snap = Some(snapshot_of(&s.tick(t, 0.25)));
        }
        let snap = snap.unwrap();
        assert_eq!(snap.virtual_speed_kph, 0.0);
        assert_eq!(snap.speed_source, SpeedSource::NoCadence);
    }

    #[test]
    fn without_cadence_the_ride_does_not_move_and_says_why() {
        // Deliberate. Speed comes from the drivetrain, and with no cadence
        // there is no drivetrain to read. Inventing a speed from power instead
        // would let a broken cadence feed pass for a working ride that merely
        // felt a little wrong — for however long it took someone to notice.
        let mut s = session();
        s.start();
        let t = Telemetry { power_w: Some(250), cadence_rpm: None, ..Default::default() };
        let mut snap = None;
        for _ in 0..80 {
            snap = Some(snapshot_of(&s.tick(t, 0.25)));
        }
        let snap = snap.unwrap();
        assert_eq!(snap.virtual_speed_kph, 0.0);
        assert_eq!(
            snap.speed_source,
            SpeedSource::NoCadence,
            "the snapshot must name the fault, not just report a stopped bike"
        );
    }

    #[test]
    fn the_snapshot_names_which_rule_set_the_speed() {
        let mut s = session();
        s.start();
        s.tick(powered(200), 0.25);
        assert_eq!(
            snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
            SpeedSource::Drivetrain
        );

        let coasting = Telemetry {
            power_w: Some(0),
            cadence_rpm: Some(80.0),
            ..Default::default()
        };
        assert_eq!(
            snapshot_of(&s.tick(coasting, 0.25)).speed_source,
            SpeedSource::Coasting
        );

        s.pause();
        assert_eq!(
            snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
            SpeedSource::Stopped
        );
    }

    #[test]
    fn a_descent_in_a_big_gear_carries_the_rider() {
        // Replaces `the_gear_servo_finds_load_when_the_rider_spins_out`, whose
        // premise a rigid drivetrain removes: you cannot spin out of a gear
        // that is locked to the wheel. Turning 120 rpm in top gear now simply
        // *is* going fast, which is the outcome that test was reaching for by
        // a much longer route.
        let mut s = session();
        s.start();
        s.nudge_gradient(-6.0);
        while s.gearing.shift_up() {}

        let spinning = Telemetry {
            power_w: Some(40),
            cadence_rpm: Some(120.0),
            ..Default::default()
        };
        for _ in 0..200 {
            s.tick(spinning, 0.25);
        }
        let expected = 120.0 / 60.0 * s.gearing.development_m();
        assert!(
            (s.physics().speed_mps - expected).abs() < 0.05,
            "120 rpm in top gear is {expected:.1} m/s, got {:.1}",
            s.physics().speed_mps
        );
    }
}
