//! Application state and the two background loops that drive the UI.
//!
//! §4.3: the control loop lives here, in Rust. The webview never computes
//! anything — it receives `RideSnapshot`s on a timer and sends intents back as
//! commands.

use std::sync::{Arc, Mutex};
use std::time::Duration;

use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ConnectionState, ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};

use crate::backend::{RideBackend, RideInputs};
use crate::controller::{ControllerStatus, PodState};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::devices::DeviceRegistry;
use crate::events;
use crate::events::{
    ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
};
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::profile_view::{ProfileGeometry, ProfileView};
use crate::recording::{RecorderHandle, Recovered, RideSummary};
use crate::session_backend::SessionBackend;
use crate::trainer::{TrainerHandle, TrainerStatus};

/// Snapshot push rate. FTMS notifies at 1–4 Hz (NFR-2); we publish at the top
/// of that range and the frontend interpolates nothing.
pub const TICK_HZ: u64 = 4;
const TICK_MS: u64 = 1000 / TICK_HZ;
/// Device list refresh, deliberately slower than the ride loop.
const SCAN_TICK_MS: u64 = 500;

pub struct Inner {
    pub inputs: RideInputs,
    pub backend: Box<dyn RideBackend>,
    pub devices: DeviceRegistry,
    pub trainer: TrainerHandle,
    pub controller: crate::controller::ControllerHandle,
    pub heart_rate: HeartRateHandle,
    pub profile_view: Option<ProfileView>,
    /// Precomputed route geometry, kept Rust-side so the per-tick elevation and
    /// ascent-remaining lookups are a binary search rather than a scan.
    pub geometry: Option<ProfileGeometry>,
    pub deriver: Deriver,
    pub last_snapshot: Option<RideSnapshot>,
    pub last_derived: Option<Derived>,
    pub lap_index: u32,
    pub laps: Vec<LapSummary>,
    /// The most recently finished ride, kept so the summary screen survives a
    /// webview reload (FR-9.13). Cleared when the next ride starts.
    pub last_summary: Option<RideSummary>,
    /// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
    /// the webview asks for them.
    pub recovered: Vec<Recovered>,
    lap_start_ms: u64,
    lap_start_m: f64,
    lap_power_sum: f64,
    lap_power_n: u64,
}

/// Build the ride's data source.
///
/// **There is one, and it is the trainer.** A missing, sleeping or
/// uncontrollable trainer yields zeros, not invented numbers: the ride engine
/// reads `Telemetry::default()` and the screen shows a rider who is not
/// pedalling, which is the truth. There is deliberately no synthetic rider to
/// fall back to — a session a rider could finish and only then discover none of
/// it happened is worse than no session at all.
fn build_backend(
    inputs: &RideInputs,
    trainer: &TrainerHandle,
    heart_rate: &HeartRateHandle,
) -> Box<dyn RideBackend> {
    tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero");
    Box::new(SessionBackend::new(
        inputs,
        trainer.telemetry(),
        heart_rate.bpm(),
    ))
}

impl Inner {
    fn new(
        trainer: TrainerHandle,
        controller: crate::controller::ControllerHandle,
        heart_rate: HeartRateHandle,
    ) -> Self {
        let inputs = RideInputs::default();
        Self {
            backend: build_backend(&inputs, &trainer, &heart_rate),
            devices: DeviceRegistry::new(trainer.clone(), controller.clone(), heart_rate.clone()),
            inputs,
            trainer,
            controller,
            heart_rate,
            profile_view: None,
            geometry: None,
            deriver: Deriver::default(),
            last_snapshot: None,
            last_derived: None,
            lap_index: 1,
            laps: Vec::new(),
            last_summary: None,
            recovered: Vec::new(),
            lap_start_ms: 0,
            lap_start_m: 0.0,
            lap_power_sum: 0.0,
            lap_power_n: 0,
        }
    }

    pub fn ride_state(&self) -> RideState {
        RideState {
            status: self.inputs.status,
            mode: self.inputs.mode,
            target: self.last_snapshot.and_then(|s| s.target),
            gradient_offset_pct: self.inputs.gradient_offset_pct,
            manual_gradient_pct: self.inputs.manual_gradient_pct,
            resistance_level: self.inputs.resistance_level,
            power_target_w: self.inputs.power_target_w,
            gear: self.inputs.gear,
            gear_count: self.inputs.gear_count(),
            lap: self.lap_index,
            laps: self.laps.clone(),
            profile: self.profile_view.clone(),
            trainer: self.trainer.status(),
        }
    }

    pub fn set_profile(&mut self, profile: Profile, view: ProfileView, geom: ProfileGeometry) {
        self.inputs.profile = Some(Arc::new(profile));
        self.profile_view = Some(view);
        self.geometry = Some(geom);
        self.inputs.mode = bikecontrol_core::types::ControlMode::Profile;
    }

    pub fn clear_profile(&mut self) {
        self.inputs.profile = None;
        self.profile_view = None;
        self.geometry = None;
        if self.inputs.mode == bikecontrol_core::types::ControlMode::Profile {
            self.inputs.mode = bikecontrol_core::types::ControlMode::ManualGrade;
        }
    }

    /// Close the current lap and open the next (FR-3.19, FR-8.7).
    pub fn mark_lap(&mut self) -> LapSummary {
        let snapshot = self.last_snapshot;
        let elapsed_ms = snapshot.map(|s| s.elapsed_ms).unwrap_or(0);
        let distance_m = snapshot.map(|s| s.virtual_distance_m).unwrap_or(0.0);
        let lap = LapSummary {
            index: self.lap_index,
            elapsed_ms: elapsed_ms.saturating_sub(self.lap_start_ms),
            distance_m: distance_m - self.lap_start_m,
            avg_power_w: if self.lap_power_n == 0 {
                0.0
            } else {
                (self.lap_power_sum / self.lap_power_n as f64) as f32
            },
        };
        self.laps.push(lap);
        self.lap_index += 1;
        self.lap_start_ms = elapsed_ms;
        self.lap_start_m = distance_m;
        self.lap_power_sum = 0.0;
        self.lap_power_n = 0;
        lap
    }

    pub fn reset_ride(&mut self) {
        self.backend.reset();
        self.deriver.reset();
        self.last_derived = None;
        self.inputs.status = RideStatus::Idle;
        self.inputs.gradient_offset_pct = 0.0;
        self.last_snapshot = None;
        self.last_summary = None;
        self.lap_index = 1;
        self.laps.clear();
        self.lap_start_ms = 0;
        self.lap_start_m = 0.0;
        self.lap_power_sum = 0.0;
        self.lap_power_n = 0;
    }

    /// Fold a fresh snapshot into the lap accumulators and the rolling windows.
    fn absorb(&mut self, snapshot: &RideSnapshot) -> Derived {
        let running = self.inputs.status == RideStatus::Running;
        if running {
            if let Some(p) = snapshot.telemetry.power_w {
                self.lap_power_sum += p as f64;
                self.lap_power_n += 1;
            }
        }
        let profile = self.inputs.profile.clone();
        let derived = self.deriver.update(
            snapshot,
            running,
            self.inputs.rider.rider_kg,
            profile.as_deref(),
            self.geometry.as_ref(),
        );
        self.last_derived = Some(derived);
        derived
    }
}

#[derive(Clone)]
pub struct AppState {
    inner: Arc<Mutex<Inner>>,
    /// The live recording, behind its own lock so that journal `fsync`s never
    /// block a command waiting on `inner` — see [`crate::recording`].
    recorder: RecorderHandle,
}

impl Default for AppState {
    fn default() -> Self {
        Self::new()
    }
}

impl AppState {
    pub fn new() -> Self {
        let limits = RideInputs::default().limits;
        let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
        let controller = crate::controller::ControllerHandle::spawn();
        let heart_rate = HeartRateHandle::spawn();
        Self {
            inner: Arc::new(Mutex::new(Inner::new(trainer, controller, heart_rate))),
            recorder: RecorderHandle::default(),
        }
    }

    /// The ride recorder. Never call this while holding [`AppState::lock`].
    pub fn recorder(&self) -> RecorderHandle {
        self.recorder.clone()
    }

    /// The trainer supervisor handle, for callers outside the lock (SAF-2 at
    /// exit must not hold the mutex while it waits on the radio).
    pub fn trainer(&self) -> TrainerHandle {
        self.lock().trainer.clone()
    }

    /// The controller supervisor handle.
    pub fn controller(&self) -> crate::controller::ControllerHandle {
        self.lock().controller.clone()
    }

    /// The heart rate supervisor handle.
    pub fn heart_rate(&self) -> HeartRateHandle {
        self.lock().heart_rate.clone()
    }

    /// Panics are impossible to recover from here, and a poisoned lock means
    /// the ride loop already died — surface it rather than hide it.
    pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
        self.inner.lock().unwrap_or_else(|e| e.into_inner())
    }
}

/// Emit the low-frequency ride state. Call after anything that changes mode,
/// target, status, laps or the loaded profile.
pub fn emit_ride_state(app: &AppHandle) {
    let state = app.state::<AppState>();
    let payload = state.lock().ride_state();
    let _ = app.emit(events::RIDE_STATE, payload);
}

pub fn emit_devices(app: &AppHandle) {
    let state = app.state::<AppState>();
    let (scanning, devices) = {
        let inner = state.lock();
        (inner.devices.scanning, inner.devices.list())
    };
    let _ = app.emit(events::DEVICES_UPDATED, DeviceList { scanning, devices });
}

pub fn notify(app: &AppHandle, notice: Notice) {
    let _ = app.emit(events::APP_NOTICE, notice);
}

/// Confirm an input registered so the UI can flash the control (FR-9.9).
pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
    let _ = app.emit(
        events::INPUT_ACK,
        InputAck {
            action: action.into(),
            detail,
        },
    );
}

/// Forward controller button edges and link status to the webview.
///
/// The backend deliberately does **not** decide what a button means. It reports
/// "`plus` was pressed"; the webview routes that to the same intent as the
/// matching keypress. One input map, not two that can drift (§4.3).
pub fn spawn_controller_loop(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        let controller = app.state::<AppState>().controller();
        let mut inputs = controller.inputs();
        let mut status = controller.status_watch();
        let mut previous = controller.status();

        loop {
            tokio::select! {
                input = inputs.recv() => match input {
                    Ok(input) => {
                        // The paddles shift the virtual gear (FR-4.1, OQ-1).
                        // Done here rather than in the webview so gearing keeps
                        // working with the window unfocused or minimised.
                        //
                        // The webview must therefore NOT also act on these two
                        // buttons. It used to, and the result was that one
                        // paddle press both shifted a gear and tilted the road
                        // — the gear silently, the gradient visibly, so the
                        // paddles looked like a gradient trim and virtual
                        // shifting looked broken.
                        if input.pressed {
                            let delta = match input.button {
                                "plus" => 1i32,
                                "minus" => -1,
                                // The + pod's `Y`, as a second shift-down.
                                //
                                // Shifting down otherwise lives entirely on the
                                // `−` pod's paddle, which makes one pod a single
                                // point of failure for half the drivetrain — and
                                // a rider stuck in top gear mid-interval has no
                                // way out, because there is no on-screen gear
                                // control (FR-3.19 is unimplemented on Android).
                                // This is RISK-9's documented mitigation: put a
                                // shift on the right pod so the ride survives
                                // the left one misbehaving.
                                "y" => -1,
                                _ => 0,
                            };
                            if delta != 0 {
                                let state = app.state::<AppState>();
                                state.lock().inputs.shift_gear(delta);
                                emit_ride_state(&app);
                            }
                        }
                        let _ = app.emit(events::CONTROLLER_INPUT, input);
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                        // Dropping a *release* edge would latch a button on, so
                        // this is worth saying out loud rather than swallowing.
                        tracing::warn!("controller: webview missed {n} button edge(s)");
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
                },
                changed = status.changed() => {
                    if changed.is_err() {
                        return;
                    }
                    let payload = status.borrow_and_update().clone();
                    // Each pod speaks for itself (FR-1.4, FR-9.4): one pod
                    // arriving must not read as "the controller is connected"
                    // when the other is still missing.
                    for notice in pod_notices(&previous, &payload) {
                        notify(&app, notice);
                    }
                    previous = payload.clone();
                    let _ = app.emit(events::CONTROLLER_STATUS, payload);
                }
            }
        }
    });
}

/// Say what changed about each pod, in words (FR-1.8, FR-9.4).
///
/// Only *state* changes speak. A battery reading or a button count arriving
/// would otherwise raise a toast every few seconds, and the one message that
/// matters — a pod that has gone — would be buried in them.
fn pod_notices(before: &ControllerStatus, after: &ControllerStatus) -> Vec<Notice> {
    let mut out = Vec::new();
    for (was, now) in [(&before.minus, &after.minus), (&before.plus, &after.plus)] {
        if was.state == now.state {
            continue;
        }
        let pod = now.symbol;
        out.push(match now.state {
            PodState::Connected => Notice::info(format!("{pod} pod connected")),
            PodState::Reconnecting => Notice::warn(format!(
                "Lost the {pod} pod — reconnecting. Press a button on it to wake it."
            )),
            PodState::GaveUp => Notice::error(
                now.error
                    .clone()
                    .unwrap_or_else(|| format!("Stopped looking for the {pod} pod")),
            ),
            // Searching is visible on the card, and Idle after a deliberate
            // disconnect is already acknowledged by the command that did it.
            PodState::Searching | PodState::Idle => match &now.error {
                Some(error) => Notice::warn(error.clone()),
                None => continue,
            },
        });
    }
    out
}

/// The ride loop. One tick: advance the backend, publish the snapshot, and
/// transmit the (already clamped) target to the trainer.
pub fn spawn_ride_loop(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS));
        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        let dt_s = TICK_MS as f32 / 1000.0;
        let mut n: u64 = 0;
        loop {
            interval.tick().await;
            let (frame, command, altitude_m, status) = {
                let state = app.state::<AppState>();
                let mut inner = state.lock();
                let inputs = inner.inputs.clone();
                let tick = inner.backend.tick(dt_s, &inputs);
                inner.last_snapshot = Some(tick.snapshot);
                let derived = inner.absorb(&tick.snapshot);
                // A loaded route knows the rider's true altitude; without one
                // the recorder integrates elevation gain instead.
                let altitude_m = inner.geometry.as_ref().and_then(|g| {
                    let x = crate::profile_view::position_x(
                        g,
                        tick.snapshot.elapsed_ms as f64 / 1000.0,
                        tick.snapshot.virtual_distance_m,
                    );
                    g.elevation_at(x)
                });
                let status = inner.inputs.status;
                (
                    RideFrame {
                        snapshot: tick.snapshot,
                        derived,
                    },
                    tick.command,
                    altitude_m,
                    status,
                )
            };
            // NFR-11 — the screensaver follows the ride, not the window. Done
            // here rather than in the commands because a ride can also end on
            // its own (a profile running out), and this is the one place that
            // sees every status however it changed. Outside the lock: on a
            // transition it makes a D-Bus round trip.
            crate::wakelock::sync(status);
            // Journalling happens outside the ride-state lock, deliberately: the
            // recorder fsyncs periodically and holding `inner` across the disk
            // would stall every command behind it.
            if status == RideStatus::Running {
                app.state::<AppState>()
                    .recorder()
                    .record(&frame.snapshot, altitude_m);
            }
            n += 1;
            if n.is_multiple_of(TICK_HZ) {
                let s = &frame.snapshot;
                tracing::debug!(
                    elapsed_ms = s.elapsed_ms,
                    speed_kph = s.virtual_speed_kph,
                    distance_m = s.virtual_distance_m,
                    gradient = s.gradient_pct,
                    power = ?s.telemetry.power_w,
                    // The trainer's OWN speed, straight from Indoor Bike Data,
                    // alongside the speed our physics computed. Divergence
                    // between the two is the fastest way to tell a physics
                    // problem from a telemetry problem.
                    trainer_kph = ?s.telemetry.speed_kph,
                    cadence = ?s.telemetry.cadence_rpm,
                    // Which rule set the speed, the gear it used, and what that
                    // gear is worth. `NoCadence` here means the trainer's Zwift
                    // channel is not delivering and the ride is pinned at a
                    // stop — a fault, and the first thing to check when the
                    // speed looks wrong.
                    speed_source = ?s.speed_source,
                    gear = s.gear,
                    development_m = s.development_m,
                    ?command,
                    "ride tick"
                );
            }
            let _ = app.emit(events::RIDE_SNAPSHOT, frame);
            if let Some(target) = command {
                transmit(&app, target);
            }
        }
    });
}

/// Push a target to the trainer's FTMS control point.
///
/// Every target has already passed `SafetyLimits::clamp` in the ride engine,
/// and `bikecontrol_ble` clamps again against the trainer's own reported ranges
/// at the point of transmission (SAF-3, FR-2.6). Non-blocking: the BLE layer
/// rate-limits to 4 Hz and coalesces, so the ride loop never waits on a radio.
fn transmit(app: &AppHandle, target: ControlTarget) {
    let state = app.state::<AppState>();
    let trainer = state.lock().trainer.clone();
    trainer.set_target(target);
}

/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept so
/// the next ride does not have to reconnect.
pub fn release_trainer(app: &AppHandle) {
    let state = app.state::<AppState>();
    let (trainer, limits) = {
        let inner = state.lock();
        (inner.trainer.clone(), inner.inputs.limits)
    };
    tracing::info!("releasing trainer to zero load (SAF-2)");
    trainer.release(limits);
}

/// Close every BLE link the app owns, on the way out.
///
/// The trainer first and with the full SAF-2 reset — zero gradient, minimum
/// resistance, `Reset`, `Stop` — then the controller, which needs no reset but
/// does need disconnecting (SAF-9): a link the process merely abandons can
/// leave the peripheral held and unreachable on the next launch.
///
/// Blocks. That is the point: a fire-and-forget send races the process exit and
/// leaves the trainer loaded, which is exactly the failure SAF-2 exists to
/// prevent. Both supervisors abandon whatever connect or reconnect is in flight
/// rather than finish it, so the wait is bounded by their own budgets and not by
/// the radio (FR-1.10, NFR-9). The lock is released before waiting so the ride
/// loop can finish.
pub fn shutdown_devices(app: &AppHandle) {
    let Some(state) = app.try_state::<AppState>() else {
        return;
    };
    let (trainer, controller, heart_rate) =
        (state.trainer(), state.controller(), state.heart_rate());
    trainer.shutdown_blocking();
    controller.shutdown_blocking();
    heart_rate.shutdown_blocking();
}

/// The scan loop: refreshes the device list from the radio and pushes it when
/// it changes, and turns trainer state changes into notices the rider can act
/// on (FR-1.7, FR-1.8, FR-9.2).
pub fn spawn_device_loop(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS));
        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        loop {
            interval.tick().await;
            let result = {
                let state = app.state::<AppState>();
                let mut inner = state.lock();
                inner.devices.poll()
            };
            for device in &result.transitions {
                let _ = app.emit(
                    events::DEVICE_CONNECTION,
                    ConnectionEvent {
                        device_id: device.id.clone(),
                        state: device.state.clone(),
                        control_acquired: device.control_acquired,
                        error: device.error.clone(),
                    },
                );
            }
            if let Some(status) = result.trainer_changed {
                // FR-8.5: a dropout the app *knows* about is journalled with its
                // reason and its exact moment, rather than left to the
                // recorder's five-second silence detector to infer.
                if let Some(reason) = dropout_reason(&status.state) {
                    let state = app.state::<AppState>();
                    let at_ms = state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms);
                    state.recorder().mark_gap(at_ms, reason);
                }
                if let Some(notice) = trainer_notice(&status) {
                    notify(&app, notice);
                }
                emit_ride_state(&app);
            }
            if let Some(status) = result.hr_changed {
                if let Some(notice) = hr_notice(&status) {
                    notify(&app, notice);
                }
            }
            if result.changed {
                emit_devices(&app);
            }
        }
    });
}

/// Whether a trainer state change means telemetry has stopped arriving, and if
/// so what to write in the journal. `None` for states that still deliver data.
fn dropout_reason(state: &ConnectionState) -> Option<String> {
    match state {
        ConnectionState::Reconnecting => Some("trainer link lost — reconnecting".into()),
        ConnectionState::Lost { reason } => Some(format!("trainer unavailable: {reason}")),
        _ => None,
    }
}

/// Say what is wrong, in words, whenever the trainer link changes (FR-1.8,
/// FR-9.4). Silence is the one thing that is not allowed: a rider staring at
/// zeros must be told whether the trainer is missing, asleep or refusing
/// control.
fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
    let name = status.name.clone().unwrap_or_else(|| "Trainer".into());
    match &status.state {
        ConnectionState::Connecting => Some(Notice::info(format!("Connecting to {name}…"))),
        ConnectionState::Connected => Some(Notice::warn(format!(
            "{name} connected but control is not acquired — it will not respond to targets yet"
        ))),
        ConnectionState::Controlling if status.stale => Some(Notice::warn(format!(
            "{name} is connected but sending no data — turn the cranks to wake it"
        ))),
        ConnectionState::Controlling => match &status.error {
            Some(error) => Some(Notice::error(format!("{name}: {error}"))),
            None => Some(Notice::info(format!("{name} connected — control acquired"))),
        },
        ConnectionState::Reconnecting => Some(Notice::warn(format!(
            "Lost {name} — reconnecting. The ride continues; pedal to wake the trainer."
        ))),
        ConnectionState::Lost { reason } => Some(Notice::error(
            status
                .error
                .clone()
                .unwrap_or_else(|| format!("{name} unavailable: {reason}")),
        )),
        ConnectionState::Idle | ConnectionState::Scanning => None,
    }
}

/// The same, for the heart rate link (FR-1.8, FR-9.4). Quieter than the
/// trainer's: heart rate is garnish on the ride, not the ride, so only the
/// transitions the rider should act on speak.
fn hr_notice(status: &HeartRateStatus) -> Option<Notice> {
    let name = status
        .name
        .clone()
        .unwrap_or_else(|| "Heart rate monitor".into());
    match &status.state {
        ConnectionState::Connected => Some(Notice::info(format!("{name} connected"))),
        ConnectionState::Reconnecting => Some(Notice::warn(format!(
            "Lost {name} — reconnecting. The ride continues; heart rate pauses until it is back."
        ))),
        ConnectionState::Lost { reason } => Some(Notice::error(
            status
                .error
                .clone()
                .unwrap_or_else(|| format!("{name} unavailable: {reason}")),
        )),
        // Idle can also carry a failed connect's advice (the supervisor parks
        // there rather than in Lost, so the row stays clickable).
        ConnectionState::Idle => status.error.clone().map(Notice::warn),
        ConnectionState::Connecting
        | ConnectionState::Scanning
        | ConnectionState::Controlling => None,
    }
}
