//! Derived ride figures: ETA, distance remaining, rolling averages.
//!
//! All of this is computed in Rust, not the frontend (§4.3). `RideSnapshot` is
//! a frozen contract in `crates/core` and does not carry any of it, so it rides
//! alongside the snapshot in a [`RideFrame`].
//!
//! **ETA (FR-9.15).** The rule that matters: never derive it from
//! instantaneous speed. Trainer speed swings several km/h between samples and
//! an ETA computed from it flickers uselessly. Three cases:
//!
//! * **Time-based profile** — remaining time is *known*. No estimation.
//! * **Distance-based profile** — remaining distance over a 45-second rolling
//!   mean speed. When the rider stops, the last good ETA is *held* rather than
//!   diverging to infinity, and flagged as held so the UI can dim it.
//! * **Looping profile** — no finish exists. Report lap position instead of a
//!   number that would be a lie.

use std::collections::VecDeque;

use bikecontrol_core::energy;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::RideSnapshot;
use serde::Serialize;

use crate::profile_view::{self, ProfileGeometry, XUnit};

/// Rolling mean window for the speed that feeds ETA. Long enough to survive a
/// soft-pedal over a rise, short enough to react to a real change of pace.
const SPEED_WINDOW_S: f64 = 45.0;
/// Rolling mean window for the speed the rider *reads*.
///
/// Deliberately far shorter than the ETA's. An ETA wants a pace, and averaging
/// three quarters of a minute is right for that. A speedometer wants to answer
/// "what did that do?" — and with virtual gearing a shift changes the speed
/// immediately, so a 45 s mean takes most of a minute to show a change that
/// already happened. Shifting through the whole cassette inside one window
/// averages the lot and reads as the gears doing nothing at all.
const DISPLAY_SPEED_WINDOW_S: f64 = 3.0;
/// Rolling mean window for the displayed power (FR-9.11).
pub const POWER_WINDOW_S: f64 = 10.0;
/// Window for the normalised-power rolling mean (§12 glossary).
const NP_WINDOW_S: f64 = 30.0;
/// Below this the rider is coasting to a halt rather than riding, so the ETA
/// stops tracking and holds. Set well above walking pace: an ETA computed from
/// 1 km/h is arithmetically valid and completely useless.
const MIN_ETA_SPEED_KPH: f32 = 5.0;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EtaKind {
    /// Time-based profile: the remaining time is exact, not estimated.
    Exact,
    /// Distance-based: remaining distance over smoothed speed.
    Estimated,
    /// Rider has stopped; showing the last good estimate.
    Held,
    /// Looping profile — there is no finish.
    Looping,
    /// No profile, or one with no finite extent.
    Unavailable,
}

/// Everything the ride screen shows that is not in the frozen `RideSnapshot`.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Derived {
    // --- route (the primary readouts) ---------------------------------------
    pub eta_kind: EtaKind,
    /// Seconds to the finish. `None` when unavailable or looping.
    pub time_remaining_s: Option<f64>,
    pub distance_total_m: Option<f64>,
    pub distance_remaining_m: Option<f64>,
    /// Current altitude on the route, metres.
    pub elevation_m: Option<f32>,
    pub ascent_remaining_m: Option<f32>,
    /// Position on the profile's own axis (seconds or metres).
    pub position_x: f64,
    pub axis_unit: XUnit,
    pub axis_total: f64,
    /// Which lap of a looping profile, 1-based.
    pub loop_index: Option<u32>,

    // --- motion --------------------------------------------------------------
    /// 45-second rolling mean. This is what drives ETA; it is also the honest
    /// number to show a rider, because instantaneous speed is noise.
    pub smoothed_speed_kph: f32,
    /// The speed to put on screen: lightly smoothed, so a shift is visible at
    /// once. `smoothed_speed_kph` is the ETA's much longer mean and would take
    /// most of a minute to show the same change.
    pub display_speed_kph: f32,

    // --- effort (secondary) ---------------------------------------------------
    /// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
    pub rolling_power_w: f32,
    pub rolling_power_window_s: f64,
    pub avg_power_w: f32,
    pub max_power_w: i16,
    pub normalised_power_w: Option<f32>,
    pub avg_cadence_rpm: f32,
    pub energy_kj: f32,
    /// Estimated metabolic cost of the ride so far, kcal. See
    /// [`bikecontrol_core::energy`] — this is the rider's burn, not the
    /// mechanical work in `energy_kj`.
    pub calories_kcal: f32,
}

/// Rolling windows. One instance lives in the app state for the whole ride.
pub struct Deriver {
    speed: VecDeque<(f64, f32)>,
    /// Short window behind the speed on screen; `speed` is the ETA's.
    display_speed: VecDeque<(f64, f32)>,
    power: VecDeque<(f64, f32)>,
    np: VecDeque<(f64, f32)>,
    np_fourth_sum: f64,
    np_n: u64,
    power_sum: f64,
    power_n: u64,
    cadence_sum: f64,
    cadence_n: u64,
    max_power_w: i16,
    energy_kj: f32,
    /// Seconds the ride has actually been running. Drives the resting-burn
    /// half of the calorie estimate, so a paused ride does not accrue.
    active_s: f64,
    last_elapsed_s: f64,
    /// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
    last_eta_s: Option<f64>,
}

impl Default for Deriver {
    fn default() -> Self {
        Self {
            speed: VecDeque::new(),
            display_speed: VecDeque::new(),
            power: VecDeque::new(),
            np: VecDeque::new(),
            np_fourth_sum: 0.0,
            np_n: 0,
            power_sum: 0.0,
            power_n: 0,
            cadence_sum: 0.0,
            cadence_n: 0,
            max_power_w: 0,
            energy_kj: 0.0,
            active_s: 0.0,
            last_elapsed_s: 0.0,
            last_eta_s: None,
        }
    }
}

fn push_window(window: &mut VecDeque<(f64, f32)>, t: f64, v: f32, span: f64) {
    window.push_back((t, v));
    while let Some((t0, _)) = window.front() {
        if t - t0 > span {
            window.pop_front();
        } else {
            break;
        }
    }
}

fn mean(window: &VecDeque<(f64, f32)>) -> f32 {
    if window.is_empty() {
        return 0.0;
    }
    window.iter().map(|(_, v)| *v as f64).sum::<f64>() as f32 / window.len() as f32
}

impl Deriver {
    pub fn reset(&mut self) {
        *self = Self::default();
    }

    /// Fold one snapshot in and produce the derived figures.
    ///
    /// `rider_kg` is the configured rider mass; it only feeds the calorie
    /// estimate, and zero simply means the resting term is skipped.
    pub fn update(
        &mut self,
        snapshot: &RideSnapshot,
        running: bool,
        rider_kg: f32,
        profile: Option<&Profile>,
        geom: Option<&ProfileGeometry>,
    ) -> Derived {
        let t = snapshot.elapsed_ms as f64 / 1000.0;
        let dt = (t - self.last_elapsed_s).max(0.0);
        self.last_elapsed_s = t;

        let power = snapshot.telemetry.power_w.unwrap_or(0) as f32;
        let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0);

        // Only fold a sample in when the ride clock actually moved.
        //
        // These windows are trimmed by timestamp, so a sample taken while the
        // clock is frozen can never expire: `t - t0 > span` is `0 > span`. The
        // tick loop runs at a fixed 4 Hz whether or not the ride is running,
        // and `elapsed_ms` only advances while it is — so every second spent
        // sitting on the ride screen before pressing start used to push four
        // more zero-speed samples at t = 0 that nothing would ever evict.
        //
        // The rider then set off and watched the speed read a fraction of their
        // real pace, because the mean was still mostly those zeros, and it only
        // came right 45 seconds in when the frozen samples finally aged out.
        // Rolling power, normalised power and the ETA all had it too.
        if dt > 0.0 {
            push_window(
                &mut self.speed,
                t,
                snapshot.virtual_speed_kph,
                SPEED_WINDOW_S,
            );
            push_window(
                &mut self.display_speed,
                t,
                snapshot.virtual_speed_kph,
                DISPLAY_SPEED_WINDOW_S,
            );
            push_window(&mut self.power, t, power, POWER_WINDOW_S);
            push_window(&mut self.np, t, power, NP_WINDOW_S);
        }

        if running {
            self.power_sum += power as f64;
            self.power_n += 1;
            self.max_power_w = self.max_power_w.max(power as i16);
            if cadence > 1.0 {
                self.cadence_sum += cadence as f64;
                self.cadence_n += 1;
            }
            self.energy_kj += power * dt as f32 / 1000.0;
            self.active_s += dt;
            // Normalised power: 30 s rolling mean, raised to the fourth,
            // averaged, fourth root.
            let rolling = mean(&self.np) as f64;
            self.np_fourth_sum += rolling.powi(4);
            self.np_n += 1;
        }

        let smoothed_speed_kph = mean(&self.speed);
        let display_speed_kph = mean(&self.display_speed);

        // ---- route position and ETA ----------------------------------------
        let mut eta_kind = EtaKind::Unavailable;
        let mut time_remaining_s = None;
        let mut distance_total_m = None;
        let mut distance_remaining_m = None;
        let mut elevation_m = None;
        let mut ascent_remaining_m = None;
        let mut loop_index = None;
        let mut position_x = 0.0;
        let mut axis_unit = XUnit::Seconds;
        let mut axis_total = 0.0;

        if let (Some(profile), Some(geom)) = (profile, geom) {
            let elapsed_s = t;
            let distance_m = snapshot.virtual_distance_m;
            position_x = profile_view::position_x(geom, elapsed_s, distance_m);
            axis_unit = geom.x_unit;
            axis_total = geom.total_x;
            elevation_m = geom.elevation_at(position_x);
            ascent_remaining_m = geom.ascent_remaining(position_x);
            distance_total_m = geom.total_metres;

            if profile.looping {
                eta_kind = EtaKind::Looping;
                if geom.total_x > 0.0 {
                    let laps = match geom.x_unit {
                        XUnit::Metres => distance_m / geom.total_x,
                        XUnit::Seconds => elapsed_s / geom.total_x,
                    };
                    loop_index = Some(laps.floor() as u32 + 1);
                }
                if let Some(total) = geom.total_metres {
                    distance_remaining_m = Some((total - position_x).max(0.0));
                }
            } else {
                match (geom.total_seconds, geom.total_metres) {
                    // Time-based: remaining time is known exactly.
                    (Some(total_s), None) => {
                        eta_kind = EtaKind::Exact;
                        time_remaining_s = Some((total_s - elapsed_s).max(0.0));
                    }
                    // Distance-based (or mixed): estimate from smoothed speed.
                    (_, Some(total_m)) => {
                        let remaining = (total_m - distance_m).max(0.0);
                        distance_remaining_m = Some(remaining);
                        // A paused ride holds too — the clock is not running,
                        // so neither should the estimate.
                        if running && smoothed_speed_kph >= MIN_ETA_SPEED_KPH {
                            let eta = remaining / (smoothed_speed_kph as f64 * 1000.0 / 3600.0);
                            self.last_eta_s = Some(eta);
                            eta_kind = EtaKind::Estimated;
                            time_remaining_s = Some(eta);
                        } else {
                            eta_kind = EtaKind::Held;
                            time_remaining_s = self.last_eta_s;
                        }
                        // A mixed profile also has a hard time limit; take
                        // whichever finishes first.
                        if let Some(total_s) = geom.total_seconds {
                            let by_time = (total_s - elapsed_s).max(0.0);
                            time_remaining_s =
                                Some(time_remaining_s.map_or(by_time, |e: f64| e.min(by_time)));
                        }
                    }
                    (None, None) => {}
                }
            }
        }

        Derived {
            eta_kind,
            time_remaining_s,
            distance_total_m,
            distance_remaining_m,
            elevation_m,
            ascent_remaining_m,
            position_x,
            axis_unit,
            axis_total,
            loop_index,
            smoothed_speed_kph,
            display_speed_kph,
            rolling_power_w: mean(&self.power),
            rolling_power_window_s: POWER_WINDOW_S,
            avg_power_w: if self.power_n == 0 {
                0.0
            } else {
                (self.power_sum / self.power_n as f64) as f32
            },
            max_power_w: self.max_power_w,
            normalised_power_w: (self.np_n > 30)
                .then(|| (self.np_fourth_sum / self.np_n as f64).powf(0.25) as f32),
            avg_cadence_rpm: if self.cadence_n == 0 {
                0.0
            } else {
                (self.cadence_sum / self.cadence_n as f64) as f32
            },
            energy_kj: self.energy_kj,
            calories_kcal: energy::kcal(f64::from(self.energy_kj) * 1000.0, rider_kg, self.active_s)
                as f32,
        }
    }
}

/// What lands on the `ride://snapshot` channel: the frozen core snapshot plus
/// the derived view data that cannot live in it.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideFrame {
    pub snapshot: RideSnapshot,
    pub derived: Derived,
}

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

    use crate::profile_view;

    /// The default rider mass, so the calorie term is exercised everywhere.
    const RIDER_KG: f32 = 75.0;

    fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
        RideSnapshot {
            elapsed_ms: (elapsed_s * 1000.0) as u64,
            telemetry: Telemetry {
                power_w: Some(200),
                ..Telemetry::default()
            },
            virtual_speed_kph: speed_kph,
            virtual_distance_m: distance_m,
            gradient_pct: 0.0,
            elevation_gain_m: 0.0,
            gear: 6,
            gear_count: 12,
            development_m: 5.7,
            target_cadence_rpm: 0.0,
            speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
            pedal_force_n: 120.0,
            mode: bikecontrol_core::types::ControlMode::Profile,
            target: None,
            profile_progress: None,
        }
    }

    fn timed_profile() -> Profile {
        Profile {
            name: "timed".into(),
            description: None,
            looping: false,
            blocks: vec![Block::Constant {
                channel: Channel::Power,
                value: 200.0,
                extent: Extent::Seconds(600.0),
            }],
        }
    }

    fn distance_profile(looping: bool) -> Profile {
        Profile {
            name: "distance".into(),
            description: None,
            looping,
            blocks: vec![Block::Segments {
                segments: vec![
                    Segment {
                        distance_m: 1000.0,
                        gradient_pct: 4.0,
                    },
                    Segment {
                        distance_m: 1000.0,
                        gradient_pct: -2.0,
                    },
                ],
            }],
        }
    }

    /// A time-based profile knows exactly how long is left. No estimation, no
    /// dependence on speed.
    #[test]
    fn time_based_eta_is_exact() {
        let profile = timed_profile();
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        let out = d.update(
            &snapshot(120.0, 0.0, 0.0),
            true,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        assert_eq!(out.eta_kind, EtaKind::Exact);
        assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6);
    }

    /// Distance-based ETA uses the smoothed speed, not the instantaneous one.
    #[test]
    fn distance_based_eta_smooths_speed() {
        let profile = distance_profile(false);
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        // Ride at 36 km/h (10 m/s) until the speed window is full.
        let mut t = 0.0;
        for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 {
            t = i as f64 * 0.25;
            d.update(
                &snapshot(t, t * 10.0, 36.0),
                true,
                RIDER_KG,
                Some(&profile),
                Some(&geom),
            );
        }
        t += 0.25;
        let steady = d.update(
            &snapshot(t, t * 10.0, 36.0),
            true,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        t += 0.25;
        // One absurd sample: 90 km/h, two and a half times reality.
        let spike = d.update(
            &snapshot(t, t * 10.0, 90.0),
            true,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        assert_eq!(spike.eta_kind, EtaKind::Estimated);
        let base = steady.time_remaining_s.unwrap();
        let drift = (spike.time_remaining_s.unwrap() - base).abs();
        assert!(
            drift / base < 0.02,
            "one noisy sample moved the ETA by {drift:.1}s ({:.1}%)",
            drift / base * 100.0
        );
    }

    /// Stopping must hold the last estimate, not diverge to infinity.
    #[test]
    fn stopping_holds_the_last_eta() {
        let profile = distance_profile(false);
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        for i in 1..=200 {
            let t = i as f64 * 0.25;
            d.update(
                &snapshot(t, t * 8.0, 28.8),
                true,
                RIDER_KG,
                Some(&profile),
                Some(&geom),
            );
        }
        let moving = d.update(
            &snapshot(50.25, 402.0, 28.8),
            true,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        assert_eq!(moving.eta_kind, EtaKind::Estimated);

        // Now stop dead for long enough to flush the whole speed window.
        let mut prev = moving;
        let mut stopped = moving;
        for i in 1..=400 {
            let t = 50.25 + i as f64 * 0.25;
            prev = stopped;
            stopped = d.update(
                &snapshot(t, 402.0, 0.0),
                true,
                RIDER_KG,
                Some(&profile),
                Some(&geom),
            );
        }
        // The contract: finite, flagged as held, and no longer changing.
        assert_eq!(stopped.eta_kind, EtaKind::Held);
        let eta = stopped
            .time_remaining_s
            .expect("held ETA must still be a number");
        assert!(eta.is_finite(), "ETA diverged when the rider stopped");
        assert_eq!(
            prev.time_remaining_s, stopped.time_remaining_s,
            "held ETA still drifting"
        );
    }

    /// Pausing freezes the estimate rather than letting it creep.
    #[test]
    fn pausing_holds_the_eta() {
        let profile = distance_profile(false);
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        for i in 1..=200 {
            let t = i as f64 * 0.25;
            d.update(
                &snapshot(t, t * 8.0, 28.8),
                true,
                RIDER_KG,
                Some(&profile),
                Some(&geom),
            );
        }
        let paused = d.update(
            &snapshot(50.25, 402.0, 28.8),
            false,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        assert_eq!(paused.eta_kind, EtaKind::Held);
        assert!(paused.time_remaining_s.unwrap().is_finite());
    }

    /// A looping profile has no finish, so it reports a lap, never an ETA.
    #[test]
    fn looping_profile_reports_lap_not_eta() {
        let profile = distance_profile(true);
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        let out = d.update(
            &snapshot(300.0, 4500.0, 30.0),
            true,
            RIDER_KG,
            Some(&profile),
            Some(&geom),
        );
        assert_eq!(out.eta_kind, EtaKind::Looping);
        assert_eq!(out.time_remaining_s, None);
        assert_eq!(out.loop_index, Some(3));
        // Position wraps into the profile rather than running off the end.
        assert!(out.position_x < geom.total_x);
    }

    /// No profile means no invented numbers.
    #[test]
    fn no_profile_means_unavailable() {
        let mut d = Deriver::default();
        let out = d.update(&snapshot(60.0, 500.0, 30.0), true, RIDER_KG, None, None);
        assert_eq!(out.eta_kind, EtaKind::Unavailable);
        assert_eq!(out.time_remaining_s, None);
    }

    /// Rolling power must lag a step change — that is the entire point of it
    /// (FR-9.11).
    #[test]
    fn rolling_power_smooths_a_step() {
        let profile = timed_profile();
        let (_, geom) = profile_view::build(&profile, "test");
        let mut d = Deriver::default();
        let mut snap = snapshot(0.0, 0.0, 30.0);
        for i in 1..=40 {
            snap.elapsed_ms = (i * 250) as u64;
            snap.telemetry.power_w = Some(100);
            d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
        }
        snap.elapsed_ms = 10_250;
        snap.telemetry.power_w = Some(600);
        let out = d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
        assert!(
            out.rolling_power_w < 250.0,
            "rolling power tracked the spike too closely"
        );
    }

    /// An hour at 200 W: the work term dominates, the resting term is the
    /// smaller correction on top, and the total is in the range a rider would
    /// recognise from a head unit.
    #[test]
    fn calories_track_work_plus_a_resting_correction() {
        let mut d = Deriver::default();
        let mut out = None;
        for i in 1..=3600 {
            out = Some(d.update(&snapshot(i as f64, 0.0, 30.0), true, RIDER_KG, None, None));
        }
        let out = out.unwrap();
        assert!(
            (out.energy_kj - 720.0).abs() < 1.0,
            "work {} kJ",
            out.energy_kj
        );
        // 720 kJ of work plus 75 kcal of being alive for an hour.
        assert!(
            (out.calories_kcal - 763.0).abs() < 5.0,
            "burn {} kcal",
            out.calories_kcal
        );
    }

    /// A paused ride burns nothing this ride can claim: neither pedalling work
    /// nor the resting term accrues while the clock is stopped.
    #[test]
    fn a_paused_ride_accrues_no_calories() {
        let mut d = Deriver::default();
        let running = d.update(&snapshot(60.0, 0.0, 30.0), true, RIDER_KG, None, None);
        let paused = d.update(&snapshot(3600.0, 0.0, 0.0), false, RIDER_KG, None, None);
        assert!(running.calories_kcal > 0.0);
        assert_eq!(running.calories_kcal, paused.calories_kcal);
    }
}
