//! Rider setup, remembered across launches (FR-7.4).
//!
//! Two things were wrong before this module existed, and they were the same
//! thing twice.
//!
//! `RiderConfig` lived only in [`crate::backend::RideInputs`], which meant it
//! lived exactly as long as the process — and nothing in the UI ever called
//! `set_rider_config`, so in practice every ride was ridden as a **105 kg
//! rider on an 8 kg bike**, the struct's own defaults. Mass is not a cosmetic
//! setting: it sets the speed a given power produces, the ETA that follows from
//! it, the calorie estimate, and how a 6% ramp feels. A 62 kg rider was being
//! shown somebody else's ride.
//!
//! ```text
//!   app_data_dir()/settings.json
//!   { "version": 1, "rider": {…}, "limits": {…}, "prefs": {…} }
//! ```
//!
//! Written whole on every change — it is three small structs, and settings are
//! changed by hand at human speed, so this never lands in the ride loop's path.
//! Like [`crate::known`] it is *advisory*: an unreadable file costs the rider
//! their setup, never their ride, so every failure is logged and swallowed.
//!
//! ## Why `prefs` is here and not in `RiderConfig`
//!
//! FTP, maximum heart rate and the unit system change nothing about the
//! physics — they decide how a number is *drawn*. `bikecontrol_core::types` is
//! the frozen contract the engine and the FIT writer share, and a display
//! preference has no business in it. The file keeps them side by side because
//! that is where the rider expects to find them; the types stay apart.

use std::path::{Path, PathBuf};

use bikecontrol_core::types::{RiderConfig, SafetyLimits};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};

/// Bumped only if the shape changes incompatibly. An older file with a version
/// we do not know is discarded rather than guessed at.
const VERSION: u32 = 1;
const FILE: &str = "settings.json";

/// Which units the rider reads. Everything is *stored* and *recorded* in SI
/// regardless — this is the last conversion before the glass, so a FIT file
/// never depends on what the screen was set to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Units {
    #[default]
    Metric,
    Imperial,
}

/// Display preferences. Not physics — see the module note.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Preferences {
    /// Functional threshold power, watts. The reference every power zone is a
    /// fraction of; zero means the rider has not set one and zones are not
    /// drawn at all, which is honest — a zone against a guessed FTP is worse
    /// than no zone.
    pub ftp_w: u16,
    /// Maximum heart rate, bpm. Same contract: zero means no HR zones.
    pub max_hr_bpm: u16,
    pub units: Units,
}

impl Default for Preferences {
    fn default() -> Self {
        Self {
            // Deliberately no default FTP: an invented threshold would colour
            // every ride wrong and look authoritative doing it.
            ftp_w: 0,
            max_hr_bpm: 0,
            units: Units::Metric,
        }
    }
}

/// The file on disk.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Stored {
    version: u32,
    rider: RiderConfig,
    limits: SafetyLimits,
    prefs: Preferences,
}

/// The rider's setup, plus where to write it.
#[derive(Debug, Default)]
pub struct Settings {
    pub prefs: Preferences,
    /// `None` before [`Settings::attach`] — `AppState::new` runs before there
    /// is an `AppHandle` to ask for a data directory, so the first moments of
    /// the process are in-memory only.
    path: Option<PathBuf>,
}

impl Settings {
    /// Point at the file and read it back over the defaults already in
    /// `rider` and `limits`.
    ///
    /// Applied by `&mut` rather than returned because a partial application is
    /// the one outcome that must not be possible: the rider's mass and the
    /// safety clamps that bound what can be sent to the trainer come from the
    /// same file and are adopted in the same breath.
    pub fn attach(&mut self, path: PathBuf, rider: &mut RiderConfig, limits: &mut SafetyLimits) {
        self.path = Some(path.clone());
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
            Err(e) => {
                tracing::warn!(path = %path.display(), error = %e, "could not read settings");
                return;
            }
        };
        let stored: Stored = match serde_json::from_str(&text) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(path = %path.display(), error = %e, "settings are unreadable; using defaults");
                return;
            }
        };
        if stored.version != VERSION {
            tracing::warn!(
                found = stored.version,
                expected = VERSION,
                "settings are from another version; using defaults"
            );
            return;
        }
        // A stored file that fails the same checks the commands apply is not
        // trusted just because it is on disk — it may predate a tightened
        // bound, or have been edited by hand.
        if validate_rider(&stored.rider).is_err() || validate_limits(&stored.limits).is_err() {
            tracing::warn!("stored settings are out of range; using defaults");
            return;
        }
        *rider = stored.rider;
        *limits = stored.limits;
        self.prefs = stored.prefs;
        tracing::info!(rider_kg = stored.rider.rider_kg, "settings restored");
    }

    /// Write the file, atomically. A half-written `settings.json` is discarded
    /// whole on the next launch, which would silently put the rider back on a
    /// 105 kg default — the exact failure this module exists to prevent.
    pub fn save(&self, rider: &RiderConfig, limits: &SafetyLimits) {
        let Some(path) = &self.path else {
            return;
        };
        let stored = Stored {
            version: VERSION,
            rider: *rider,
            limits: *limits,
            prefs: self.prefs,
        };
        if let Err(e) = write_atomic(path, &stored) {
            tracing::warn!(path = %path.display(), error = %e, "could not save settings");
        }
    }
}

/// Bounds worth refusing, with the reason a rider can act on.
///
/// These are not taste. Each one is a value that makes the ride engine produce
/// nonsense rather than merely something unusual — a zero mass divides, a zero
/// wheel circumference divides, an efficiency above 1 invents power.
pub fn validate_rider(c: &RiderConfig) -> Result<(), String> {
    let check = |ok: bool, msg: &str| if ok { Ok(()) } else { Err(msg.to_string()) };
    check(
        (20.0..=250.0).contains(&c.rider_kg),
        "Rider mass must be between 20 and 250 kg.",
    )?;
    check(
        (1.0..=50.0).contains(&c.bike_kg),
        "Bike mass must be between 1 and 50 kg.",
    )?;
    check(
        (0.0005..=0.05).contains(&c.crr),
        "Rolling resistance is typically 0.002–0.010 for road tyres.",
    )?;
    check(
        (0.1..=1.5).contains(&c.cda),
        "CdA must be between 0.1 and 1.5 m² — a road position is about 0.32.",
    )?;
    check(
        (0.5..=1.0).contains(&c.drivetrain_efficiency),
        "Drivetrain efficiency is a fraction between 0.5 and 1.0 — about 0.97 for a clean chain.",
    )?;
    check(
        (0.5..=1.6).contains(&c.air_density),
        "Air density must be between 0.5 and 1.6 kg/m³ — sea level is 1.225.",
    )?;
    check(
        (0.5..=3.5).contains(&c.wheel_circumference_m),
        "Wheel circumference must be between 0.5 and 3.5 m — a 700×25 is about 2.1.",
    )?;
    check(
        (0.1..=0.25).contains(&c.crank_length_m),
        "Crank length must be between 0.10 and 0.25 m — road cranks are 0.170–0.175.",
    )?;
    check(
        (0.5..=20.0).contains(&c.physical_development_m),
        "Physical development must be between 0.5 and 20 m per crank revolution.",
    )?;
    Ok(())
}

pub fn validate_limits(l: &SafetyLimits) -> Result<(), String> {
    if l.min_gradient_pct >= l.max_gradient_pct {
        return Err("Gradient limits are inverted.".into());
    }
    if l.min_resistance >= l.max_resistance {
        return Err("Resistance limits are inverted.".into());
    }
    if l.min_power_w >= l.max_power_w {
        return Err("Power limits are inverted.".into());
    }
    if l.max_power_w > 2000 {
        return Err("Maximum power above 2000 W is not a limit, it is a hazard.".into());
    }
    Ok(())
}

pub fn validate_prefs(p: &Preferences) -> Result<(), String> {
    // Zero is the "not set" case for both, and must stay reachable: a rider who
    // does not know their FTP is better served by no zones than by a guess.
    if p.ftp_w != 0 && !(50..=600).contains(&p.ftp_w) {
        return Err("FTP must be between 50 and 600 W, or 0 for no zones.".into());
    }
    if p.max_hr_bpm != 0 && !(100..=230).contains(&p.max_hr_bpm) {
        return Err("Maximum heart rate must be between 100 and 230 bpm, or 0 for no zones.".into());
    }
    Ok(())
}

fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
    let text = serde_json::to_string_pretty(stored)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir)?;
    }
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, text)?;
    std::fs::rename(&tmp, path)
}

/// Beside the remembered devices and the recorded rides.
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
    let dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("no app data directory: {e}"))?;
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("could not create {}: {e}", dir.display()))?;
    Ok(dir.join(FILE))
}

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

    fn temp(name: &str) -> PathBuf {
        std::env::temp_dir().join(format!("bikecontrol-settings-test-{name}.json"))
    }

    #[test]
    fn a_rider_survives_the_process() {
        let path = temp("roundtrip");
        let _ = std::fs::remove_file(&path);

        let mut settings = Settings::default();
        let mut rider = RiderConfig::default();
        let mut limits = SafetyLimits::default();
        settings.attach(path.clone(), &mut rider, &mut limits);
        rider.rider_kg = 62.0;
        settings.prefs.ftp_w = 240;
        settings.save(&rider, &limits);

        let mut again = Settings::default();
        let mut rider2 = RiderConfig::default();
        let mut limits2 = SafetyLimits::default();
        again.attach(path.clone(), &mut rider2, &mut limits2);
        assert_eq!(rider2.rider_kg, 62.0);
        assert_eq!(again.prefs.ftp_w, 240);

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn a_missing_file_leaves_the_defaults_alone() {
        let path = temp("missing");
        let _ = std::fs::remove_file(&path);
        let mut settings = Settings::default();
        let mut rider = RiderConfig::default();
        let mut limits = SafetyLimits::default();
        settings.attach(path, &mut rider, &mut limits);
        assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
        assert_eq!(settings.prefs, Preferences::default());
    }

    /// A file someone edited by hand must not be able to put a zero mass into
    /// the engine, which would divide by it on the next tick.
    #[test]
    fn an_out_of_range_file_is_refused_whole() {
        let path = temp("nonsense");
        let stored = Stored {
            version: VERSION,
            rider: RiderConfig {
                rider_kg: 0.0,
                ..RiderConfig::default()
            },
            limits: SafetyLimits::default(),
            prefs: Preferences {
                ftp_w: 300,
                ..Preferences::default()
            },
        };
        write_atomic(&path, &stored).unwrap();

        let mut settings = Settings::default();
        let mut rider = RiderConfig::default();
        let mut limits = SafetyLimits::default();
        settings.attach(path.clone(), &mut rider, &mut limits);
        assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
        // Refused whole: the preferences in the same file do not sneak through.
        assert_eq!(settings.prefs.ftp_w, 0);

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn zero_means_no_zones_rather_than_an_invalid_ftp() {
        assert!(validate_prefs(&Preferences::default()).is_ok());
        assert!(validate_prefs(&Preferences {
            ftp_w: 20,
            ..Preferences::default()
        })
        .is_err());
    }
}
