//! Ride recording: the journal, the FIT file, and where both live.
//!
//! Two files per ride, in the app's data directory:
//!
//! ```text
//! rides/2026-08-05T18-42-10.jsonl   the raw journal (FR-8.4)
//! rides/2026-08-05T18-42-10.fit     the activity, written at stop
//! ```
//!
//! Neither path is chosen by the rider. The journal is crash-safety scaffolding
//! and the FIT written next to it is the automatic copy that exists so a ride
//! can never be lost to a cancelled dialog — see [`finish`]. Saving *somewhere
//! the rider picked* (FR-9.14) is a separate copy, made afterwards, from a file
//! that is already safely on disk.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};

use bikecontrol_core::types::RideSnapshot;
use bikecontrol_fit::profile::enums;
use bikecontrol_fit::{FitSummary, Recorder, RecorderOptions, Sample};
use serde::Serialize;
use tauri::{AppHandle, Manager};

/// The recording of one ride, plus where its output landed.
struct Active {
    recorder: Recorder,
    fit_path: PathBuf,
}

/// The live recorder, behind its own lock.
///
/// Deliberately *not* a field of [`crate::state::Inner`]. `RecorderOptions`
/// defaults to an `fsync` every ten samples, and holding the ride-state mutex
/// across a blocking `sync_data` would stall every command and the device loop
/// behind the disk. The ride loop takes this lock only after it has dropped the
/// other one.
#[derive(Clone, Default)]
pub struct RecorderHandle(Arc<Mutex<Option<Active>>>);

impl RecorderHandle {
    fn lock(&self) -> MutexGuard<'_, Option<Active>> {
        self.0.lock().unwrap_or_else(|e| e.into_inner())
    }

    pub fn is_recording(&self) -> bool {
        self.lock().is_some()
    }

    /// Begin recording. Any recorder still open is abandoned first — its
    /// journal stays on disk and can be rebuilt, but it is not this ride.
    ///
    /// Returns the journal path, or the reason recording could not start. A
    /// failure here is not fatal to the ride: the caller reports it and the
    /// rider carries on unrecorded, which is worse than recording but far
    /// better than refusing to ride.
    /// `dir` is passed in rather than resolved from the `AppHandle` so that the
    /// whole start/record/finish path can be exercised without a Tauri app.
    pub fn start(&self, dir: &Path, opts: RideRecordingSetup) -> Result<PathBuf, String> {
        let stamp = opts.stamp;
        let log_path = dir.join(format!("{stamp}.jsonl"));
        let fit_path = dir.join(format!("{stamp}.fit"));

        let mut slot = self.lock();
        if let Some(prev) = slot.take() {
            let orphan = prev.recorder.abandon();
            tracing::warn!(path = %orphan.display(), "a recording was still open; abandoning it");
        }

        let recorder = Recorder::create(
            &log_path,
            RecorderOptions {
                // A ride following a loaded route is a Virtual Ride; a plain
                // trainer session with no course is indoor cycling. Getting
                // this wrong files every ERG workout as a virtual ride.
                sub_sport: if opts.has_profile {
                    enums::SUB_SPORT_VIRTUAL_ACTIVITY
                } else {
                    enums::SUB_SPORT_INDOOR_CYCLING
                },
                rider_kg: opts.rider_kg,
                software_version: software_version(),
                ..Default::default()
            },
        )
        .map_err(|e| format!("could not start recording: {e}"))?;

        tracing::info!(journal = %log_path.display(), "recording started");
        *slot = Some(Active { recorder, fit_path });
        Ok(log_path)
    }

    /// Record one tick. Throttled to 1 Hz inside the recorder, so this is safe
    /// and cheap to call on every engine tick.
    pub fn record(&self, snapshot: &RideSnapshot, altitude_m: Option<f32>) {
        let mut slot = self.lock();
        let Some(active) = slot.as_mut() else { return };
        // `Sample::from_snapshot` leaves `gear` unset even though the snapshot
        // carries one, hence `record_sample` rather than `record`.
        // Gears are one-based and there are a dozen or so; the cast cannot
        // realistically saturate, but clamping beats a panic in the ride loop.
        let gear = u8::try_from(snapshot.gear).unwrap_or(u8::MAX);
        let mut sample = Sample::from_snapshot(snapshot).with_gear(gear);
        if let Some(altitude) = altitude_m {
            sample = sample.with_altitude(altitude);
        }
        if let Err(e) = active.recorder.record_sample(sample) {
            tracing::warn!(%e, "dropped a sample");
        }
    }

    /// Run something against the live recorder, if there is one. Errors are
    /// logged rather than propagated: a journal write that fails must not turn
    /// a lap press or a pause into a failed command.
    fn with(
        &self,
        what: &'static str,
        f: impl FnOnce(&mut Recorder) -> Result<(), bikecontrol_fit::FitError>,
    ) {
        let mut slot = self.lock();
        let Some(active) = slot.as_mut() else { return };
        if let Err(e) = f(&mut active.recorder) {
            tracing::warn!(%e, "could not journal {what}");
        }
    }

    pub fn mark_lap(&self, at_ms: u64, from_controller: bool) {
        self.with("lap", |r| r.mark_lap(at_ms, from_controller));
    }

    pub fn pause(&self, at_ms: u64) {
        self.with("pause", |r| r.pause(at_ms));
    }

    pub fn resume(&self, at_ms: u64) {
        self.with("resume", |r| r.resume(at_ms));
    }

    /// Note a telemetry dropout (FR-8.5). Repeated calls inside one dropout are
    /// a no-op in the recorder, so the device loop can call this freely.
    pub fn mark_gap(&self, at_ms: u64, reason: impl Into<String>) {
        let reason = reason.into();
        self.with("dropout", move |r| r.mark_gap(at_ms, reason));
    }

    /// Close the journal and write the FIT beside it.
    ///
    /// `Ok(None)` means there was nothing recording — stopping a ride that
    /// never started is not an error.
    pub fn finish(&self) -> Result<Option<(FitSummary, PathBuf)>, String> {
        let Some(active) = self.lock().take() else {
            return Ok(None);
        };
        let Active { recorder, fit_path } = active;
        let journal = recorder.log_path().to_path_buf();
        match recorder.finish(&fit_path) {
            Ok(summary) => {
                tracing::info!(
                    path = %fit_path.display(),
                    records = summary.records,
                    "activity written"
                );
                Ok(Some((summary, fit_path)))
            }
            // The journal survives, so say where it is. "Failed to save" with no
            // path would leave a recoverable ride looking like a lost one.
            Err(e) => Err(format!(
                "could not write the FIT file ({e}) — the ride is still recorded at {}",
                journal.display()
            )),
        }
    }
}

/// What [`RecorderHandle::start`] needs from the ride state, read under the
/// other lock and handed over so the two are never held at once.
pub struct RideRecordingSetup {
    pub stamp: String,
    pub rider_kg: f32,
    pub has_profile: bool,
}

/// The finished ride, as the summary screen shows it (FR-9.13, FR-9.14).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideSummary {
    pub duration_s: f64,
    /// Excludes time spent paused.
    pub moving_s: f64,
    pub distance_m: f64,
    pub ascent_m: u16,
    pub avg_power_w: Option<u16>,
    pub max_power_w: Option<u16>,
    pub avg_cadence_rpm: Option<u8>,
    pub calories: Option<u16>,
    pub records: usize,
    pub laps: usize,
    /// BLE dropouts spanned (FR-8.5).
    pub gaps: usize,
    /// True when the activity was rebuilt from a journal with no end marker.
    pub recovered_from_crash: bool,
    /// Journal lines that could not be parsed. Non-zero means data was lost.
    pub skipped_log_lines: usize,
    /// The automatic copy, in the app's data directory.
    pub fit_path: String,
    /// Where the rider chose to save it, once they have (FR-9.14).
    pub saved_path: Option<String>,
}

impl RideSummary {
    pub fn new(summary: &FitSummary, fit_path: &Path) -> Self {
        Self {
            duration_s: summary.total_elapsed_s,
            moving_s: summary.total_timer_s,
            distance_m: summary.total_distance_m,
            ascent_m: summary.total_ascent_m,
            avg_power_w: summary.avg_power_w,
            max_power_w: summary.max_power_w,
            avg_cadence_rpm: summary.avg_cadence_rpm,
            calories: summary.total_calories,
            records: summary.records,
            laps: summary.laps,
            gaps: summary.gaps,
            recovered_from_crash: summary.recovered_from_crash,
            skipped_log_lines: summary.skipped_log_lines,
            fit_path: fit_path.display().to_string(),
            saved_path: None,
        }
    }
}

/// `rides/` inside the app's data directory, created if absent.
pub fn rides_dir(app: &AppHandle) -> Result<PathBuf, String> {
    let dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("no app data directory: {e}"))?
        .join("rides");
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("could not create {}: {e}", dir.display()))?;
    Ok(dir)
}

/// A filename stem for a ride starting now: local time, sortable, no colons
/// (Windows will not have them).
pub fn stamp_now() -> String {
    chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string()
}

/// The app version as FIT wants it: scaled by 100, so 0.1.0 is `10`.
fn software_version() -> u16 {
    let v = env!("CARGO_PKG_VERSION");
    let mut parts = v.split('.');
    let major: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
    let minor: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
    major.saturating_mul(100).saturating_add(minor)
}

/// Copy the finished activity to a path the rider chose (FR-9.14).
///
/// A copy, never a move: the automatic file stays where it is so that saving
/// twice, or saving to a disk that then fills up, cannot lose the ride.
pub fn save_copy(from: &Path, to: &Path) -> Result<(), String> {
    if !from.exists() {
        return Err(format!("{} is gone — nothing to save", from.display()));
    }
    if let Some(parent) = to.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("could not create {}: {e}", parent.display()))?;
        }
    }
    std::fs::copy(from, to)
        .map(|_| ())
        .map_err(|e| format!("could not save to {}: {e}", to.display()))
}

// ---------------------------------------------------------------------------
// Crash recovery (FR-8.4)
// ---------------------------------------------------------------------------

/// A journal with no activity beside it: a ride that ended when the process did.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Recovered {
    pub fit_path: String,
    pub summary: RideSummary,
}

/// Rebuild an activity for every journal that has no FIT next to it.
///
/// The encoder guarantees a file rebuilt from a journal is byte-identical to
/// one written by a clean shutdown of the same ride, so this is not a degraded
/// path — it is the same path, run late.
///
/// Journals that produce nothing useful (a ride that recorded no samples before
/// the crash) are deleted rather than left to be retried on every launch.
pub fn recover_orphans(app: &AppHandle) -> Vec<Recovered> {
    match rides_dir(app) {
        Ok(dir) => recover_in(&dir),
        Err(e) => {
            tracing::warn!(%e, "cannot reach the rides directory; skipping recovery");
            Vec::new()
        }
    }
}

/// [`recover_orphans`] against a directory, so it can be tested without a Tauri
/// app.
pub fn recover_in(dir: &Path) -> Vec<Recovered> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };

    let mut recovered = Vec::new();
    for entry in entries.flatten() {
        let log_path = entry.path();
        if log_path.extension().is_none_or(|e| e != "jsonl") {
            continue;
        }
        let fit_path = log_path.with_extension("fit");
        if fit_path.exists() {
            continue;
        }
        match bikecontrol_fit::build_fit_from_log(&log_path, &fit_path) {
            Ok(summary) => {
                tracing::info!(
                    journal = %log_path.display(),
                    records = summary.records,
                    "recovered an interrupted ride"
                );
                recovered.push(Recovered {
                    fit_path: fit_path.display().to_string(),
                    summary: RideSummary::new(&summary, &fit_path),
                });
            }
            Err(e) => {
                tracing::warn!(
                    journal = %log_path.display(),
                    %e,
                    "journal holds no usable ride; removing it"
                );
                let _ = std::fs::remove_file(&log_path);
            }
        }
    }
    recovered
}

/// Delete the oldest rides once there are more than `keep`.
///
/// §5.8 puts in-app ride history out of scope for v1 — the FIT the rider saved
/// is the artifact, and what is left here is a safety net, not a library. Left
/// unbounded it would grow forever in a directory nobody opens. A ride is only
/// ever removed once its FIT exists, so nothing is deleted before it has been
/// through the encoder.
pub fn prune(app: &AppHandle, keep: usize) {
    let Ok(dir) = rides_dir(app) else { return };
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return;
    };

    // Stems are timestamps, so lexical order is chronological.
    let mut stems: Vec<String> = entries
        .flatten()
        .filter(|e| e.path().extension().is_some_and(|x| x == "fit"))
        .filter_map(|e| e.path().file_stem()?.to_str().map(str::to_owned))
        .collect();
    if stems.len() <= keep {
        return;
    }
    stems.sort();
    let doomed = stems.len() - keep;
    for stem in stems.into_iter().take(doomed) {
        let _ = std::fs::remove_file(dir.join(format!("{stem}.fit")));
        let _ = std::fs::remove_file(dir.join(format!("{stem}.jsonl")));
        tracing::info!(ride = %stem, "pruned an old recording");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bikecontrol_core::types::{ControlMode, RideSnapshot, Telemetry};

    fn tmpdir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("bc-rec-{name}-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn setup() -> RideRecordingSetup {
        RideRecordingSetup {
            stamp: "2026-08-05T18-42-10".into(),
            rider_kg: 78.0,
            has_profile: true,
        }
    }

    fn snapshot(elapsed_ms: u64) -> RideSnapshot {
        RideSnapshot {
            elapsed_ms,
            telemetry: Telemetry {
                elapsed_ms,
                power_w: Some(230),
                cadence_rpm: Some(91.0),
                ..Default::default()
            },
            virtual_speed_kph: 30.0,
            virtual_distance_m: elapsed_ms as f64 * 0.008_333,
            gradient_pct: 2.0,
            elevation_gain_m: elapsed_ms as f32 * 0.000_15,
            gear: 7,
            gear_count: 12,
            development_m: 6.3,
            target_cadence_rpm: 90.0,
            pedal_force_n: 120.0,
            speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
            mode: ControlMode::Profile,
            target: None,
            profile_progress: None,
        }
    }

    /// The whole app-side recording path, without a Tauri app: start, record a
    /// ride's worth of ticks at the real 4 Hz rate, mark a lap and a pause, and
    /// finish. The FIT that comes out must be one an uploader would accept.
    #[test]
    fn a_ride_records_end_to_end_and_produces_a_verifiable_fit() {
        let dir = tmpdir("e2e");
        let rec = RecorderHandle::default();
        assert!(!rec.is_recording());

        let journal = rec.start(&dir, setup()).unwrap();
        assert!(rec.is_recording());
        assert_eq!(journal, dir.join("2026-08-05T18-42-10.jsonl"));

        // 60 s of ride at the ride loop's 4 Hz. The recorder throttles to 1 Hz
        // internally, so this also proves the throttle survives the wiring.
        for tick in 0..240u64 {
            rec.record(&snapshot(tick * 250), Some(120.0 + tick as f32 * 0.01));
        }
        rec.mark_lap(30_000, false);
        rec.pause(40_000);
        rec.resume(50_000);
        rec.mark_gap(55_000, "trainer went quiet");

        let (summary, fit_path) = rec.finish().unwrap().expect("a ride was recording");
        assert!(!rec.is_recording(), "finish must release the recorder");
        assert_eq!(fit_path, dir.join("2026-08-05T18-42-10.fit"));

        assert_eq!(summary.records, 60, "60 s at 1 Hz after throttling");
        assert_eq!(summary.laps, 2, "one marker splits the ride in two");
        assert!(!summary.recovered_from_crash);
        assert_eq!(summary.skipped_log_lines, 0);
        assert_eq!(summary.avg_power_w, Some(230));
        assert_eq!(summary.avg_cadence_rpm, Some(91), "FR-9.13 needs this");

        // The file itself, not just what the encoder claimed about it.
        let bytes = std::fs::read(&fit_path).unwrap();
        assert_eq!(bytes.len(), summary.bytes);
        bikecontrol_fit::verify(&bytes).expect("a FIT an uploader would reject");

        // And the summary the screen renders agrees with it.
        let view = RideSummary::new(&summary, &fit_path);
        assert_eq!(view.avg_cadence_rpm, Some(91));
        assert_eq!(view.saved_path, None, "not saved anywhere yet");
        // The ten seconds of pause are elapsed time but not moving time, which
        // is the distinction the summary screen shows as "N paused".
        assert!(
            view.moving_s < view.duration_s,
            "moving {} vs duration {}",
            view.moving_s,
            view.duration_s
        );
        let _ = std::fs::remove_dir_all(dir);
    }

    /// Stopping a ride that never started is not an error.
    #[test]
    fn finishing_without_recording_is_not_a_failure() {
        assert!(RecorderHandle::default().finish().unwrap().is_none());
    }

    /// FR-8.4: a journal left behind by a crash becomes an activity on the next
    /// launch, and one that already has its FIT is left alone.
    #[test]
    fn an_orphaned_journal_is_rebuilt_and_a_finished_one_is_not() {
        let dir = tmpdir("orphan");
        let rec = RecorderHandle::default();
        rec.start(&dir, setup()).unwrap();
        for tick in 0..40u64 {
            rec.record(&snapshot(tick * 250), None);
        }
        // Drop the handle's contents without finishing — the crash case.
        let orphan = dir.join("2026-08-05T18-42-10.jsonl");
        drop(rec);
        assert!(orphan.exists());
        assert!(!dir.join("2026-08-05T18-42-10.fit").exists());

        let rebuilt = recover_in(&dir);
        assert_eq!(rebuilt.len(), 1);
        assert!(rebuilt[0].summary.recovered_from_crash);
        bikecontrol_fit::verify(&std::fs::read(&rebuilt[0].fit_path).unwrap()).unwrap();

        // Second pass: the FIT now exists, so there is nothing left to recover.
        assert!(
            recover_in(&dir).is_empty(),
            "recovery must not repeat itself"
        );
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn the_software_version_is_scaled_the_way_fit_wants() {
        // Whatever the crate version is, the encoding must not panic or
        // overflow; the shape is what matters.
        let v = software_version();
        assert!(v < 10_000);
    }

    #[test]
    fn a_stamp_is_a_sortable_filename() {
        let s = stamp_now();
        assert_eq!(s.len(), 19, "YYYY-MM-DDTHH-MM-SS");
        assert!(!s.contains(':'), "colons are illegal in Windows filenames");
    }

    #[test]
    fn saving_a_copy_leaves_the_original() {
        let dir = std::env::temp_dir().join(format!("bc-save-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let from = dir.join("ride.fit");
        std::fs::write(&from, b"activity").unwrap();
        let to = dir.join("nested").join("saved.fit");

        save_copy(&from, &to).unwrap();
        assert_eq!(std::fs::read(&to).unwrap(), b"activity");
        assert!(from.exists(), "the automatic copy must survive the save");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn saving_from_a_missing_file_says_so() {
        let missing = std::env::temp_dir().join("bc-definitely-not-here.fit");
        let err = save_copy(&missing, &std::env::temp_dir().join("out.fit")).unwrap_err();
        assert!(err.contains("nothing to save"), "{err}");
    }
}
