//! The raw ride log: an append-only, line-delimited JSON journal (FR-8.4,
//! FR-8.6).
//!
//! The FIT file cannot be written incrementally in any useful sense — the
//! header carries a data size and the file ends with a CRC over everything, so
//! a partially written FIT is simply a broken FIT. The session is therefore
//! made crash-safe a level below: every sample is appended to this journal as a
//! complete line and flushed, and the FIT is assembled from the journal at the
//! end of the ride. If the app dies mid-ride the journal survives and
//! [`crate::build_fit_from_log`] regenerates the activity.
//!
//! JSON Lines was chosen over a packed binary format for one reason: torn
//! writes are recoverable. A crash during the final `write` leaves a truncated
//! last line, which the reader drops; every earlier line is intact and
//! self-describing. A binary format with a length prefix would need its own
//! framing and resync logic to reach the same place.

use std::path::PathBuf;

use bikecontrol_core::{ControlMode, RideSnapshot};
use serde::{Deserialize, Serialize};

/// Version of the on-disk log format, written into the session header so a
/// future reader can tell what it is looking at.
pub const LOG_FORMAT_VERSION: u16 = 1;

/// One line of the journal.
///
/// The tag is short because these are written at 1 Hz for hours; the field
/// names cost real bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t")]
pub enum LogEntry {
    /// Always the first line. Anchors elapsed time to the wall clock.
    #[serde(rename = "start")]
    Start(SessionStart),
    /// A 1 Hz telemetry sample.
    #[serde(rename = "s")]
    Sample(Sample),
    /// A stretch with no telemetry — a BLE dropout (FR-8.5). Recorded so the
    /// hole in the record stream is explained rather than mysterious. The
    /// timer keeps running across a gap: the rider was still pedalling, we just
    /// stopped hearing about it.
    #[serde(rename = "gap")]
    Gap {
        /// Elapsed time at which telemetry stopped, ms.
        at_ms: u64,
        /// Elapsed time at which it resumed, ms. `None` if the ride ended
        /// during the dropout.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        until_ms: Option<u64>,
        /// Human-readable cause, e.g. the disconnect reason.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        reason: String,
    },
    /// A lap marker (FR-8.7). Ends the lap in progress and starts a new one.
    #[serde(rename = "lap")]
    Lap {
        /// Elapsed time of the lap boundary, ms.
        at_ms: u64,
        /// True if triggered by the controller rather than the UI.
        #[serde(default, skip_serializing_if = "is_false")]
        from_controller: bool,
    },
    /// The rider paused. Time between a pause and the next resume is excluded
    /// from timer time but still counted in elapsed time.
    #[serde(rename = "pause")]
    Pause {
        /// Elapsed time at which the rider paused, ms.
        at_ms: u64,
    },
    /// The rider resumed.
    #[serde(rename = "resume")]
    Resume {
        /// Elapsed time at which the rider resumed, ms.
        at_ms: u64,
    },
    /// Clean end of ride. Its absence is how a recovered log is recognised as
    /// the product of a crash.
    #[serde(rename = "end")]
    End {
        /// Elapsed time at the end of the ride, ms.
        at_ms: u64,
    },
}

/// Session metadata, written as the first line of the journal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionStart {
    /// Wall-clock start of the ride, Unix milliseconds UTC. Every sample's
    /// `elapsed_ms` is an offset from this.
    pub start_unix_ms: i64,
    /// The rider's UTC offset in seconds at the start of the ride, used for
    /// `activity.local_timestamp`.
    #[serde(default)]
    pub utc_offset_secs: i32,
    /// FIT `sub_sport`. Defaults to `virtual_activity`, which is what makes
    /// Strava file the upload as a Virtual Ride.
    #[serde(default = "default_sub_sport")]
    pub sub_sport: u8,
    /// Name written into `file_id.product_name`.
    #[serde(default = "default_product_name")]
    pub product_name: String,
    /// Application version, scaled by 100 (1.20 is written as 120).
    #[serde(default = "default_software_version")]
    pub software_version: u16,
    /// Device serial. Zero means "unset" (the FIT base type is `uint32z`).
    #[serde(default)]
    pub serial_number: u32,
    /// Rider mass in kilograms, for the resting half of the calorie estimate
    /// (`bikecontrol_core::energy`). Zero means unknown — as it will be in any
    /// journal written before this field existed — and simply drops that term.
    #[serde(default)]
    pub rider_kg: f32,
    /// Format version of this log.
    #[serde(default)]
    pub log_format: u16,
}

#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires this signature
fn is_false(b: &bool) -> bool {
    !*b
}

fn default_sub_sport() -> u8 {
    crate::profile::enums::SUB_SPORT_VIRTUAL_ACTIVITY
}

fn default_product_name() -> String {
    "BikeControl".to_string()
}

fn default_software_version() -> u16 {
    100
}

impl Default for SessionStart {
    fn default() -> Self {
        Self {
            start_unix_ms: 0,
            utc_offset_secs: 0,
            sub_sport: default_sub_sport(),
            product_name: default_product_name(),
            software_version: default_software_version(),
            serial_number: 0,
            rider_kg: 0.0,
            log_format: LOG_FORMAT_VERSION,
        }
    }
}

/// One recorded sample (FR-8.1).
///
/// This is deliberately not [`RideSnapshot`] itself: the snapshot is a UI
/// contract that will keep changing, whereas a journal on disk has to stay
/// readable by a later version of the app. Fields are optional and skipped when
/// absent so that a log of a ride without a heart-rate strap does not carry
/// thousands of nulls.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Sample {
    /// Milliseconds since the start of the ride.
    #[serde(rename = "e")]
    pub elapsed_ms: u64,
    /// Trainer power, watts.
    #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
    pub power_w: Option<i16>,
    /// Cadence, rpm.
    #[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
    pub cadence_rpm: Option<f32>,
    /// Virtual speed from the physics engine, km/h.
    #[serde(rename = "v", default)]
    pub speed_kph: f32,
    /// Virtual distance, metres.
    #[serde(rename = "d", default)]
    pub distance_m: f64,
    /// Commanded gradient, percent.
    #[serde(rename = "g", default)]
    pub gradient_pct: f32,
    /// Cumulative elevation gain, metres.
    #[serde(rename = "eg", default)]
    pub elevation_gain_m: f32,
    /// Absolute altitude if a route supplies one; otherwise the encoder
    /// integrates gradient over distance to synthesise a profile.
    #[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
    pub altitude_m: Option<f32>,
    /// Heart rate, bpm, if a strap is paired.
    #[serde(rename = "h", default, skip_serializing_if = "Option::is_none")]
    pub heart_rate_bpm: Option<u8>,
    /// Trainer-reported cumulative energy, kcal.
    #[serde(rename = "k", default, skip_serializing_if = "Option::is_none")]
    pub energy_kcal: Option<u16>,
    /// Trainer resistance level, if the trainer reports one.
    #[serde(rename = "r", default, skip_serializing_if = "Option::is_none")]
    pub resistance: Option<i16>,
    /// Virtual gear (FR-8.1). No FIT record field carries this, so it lives in
    /// the journal only.
    #[serde(rename = "gear", default, skip_serializing_if = "Option::is_none")]
    pub gear: Option<u8>,
    /// Control mode in force at this sample (FR-8.1). Journal only.
    #[serde(rename = "m", default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<ControlMode>,
}

impl Sample {
    /// Build a sample from a ride snapshot.
    pub fn from_snapshot(snap: &RideSnapshot) -> Self {
        Self {
            elapsed_ms: snap.elapsed_ms,
            power_w: snap.telemetry.power_w,
            cadence_rpm: snap.telemetry.cadence_rpm,
            speed_kph: snap.virtual_speed_kph,
            distance_m: snap.virtual_distance_m,
            gradient_pct: snap.gradient_pct,
            elevation_gain_m: snap.elevation_gain_m,
            altitude_m: None,
            heart_rate_bpm: snap.telemetry.heart_rate_bpm,
            energy_kcal: snap.telemetry.total_energy_kcal,
            resistance: snap.telemetry.resistance_level,
            gear: None,
            mode: Some(snap.mode),
        }
    }

    /// Attach a virtual gear number.
    pub fn with_gear(mut self, gear: u8) -> Self {
        self.gear = Some(gear);
        self
    }

    /// Attach an absolute altitude, overriding the integrated profile.
    pub fn with_altitude(mut self, altitude_m: f32) -> Self {
        self.altitude_m = Some(altitude_m);
        self
    }
}

/// A parsed journal.
#[derive(Debug, Clone, PartialEq)]
pub struct RawLog {
    /// Session metadata from the `start` line.
    pub start: SessionStart,
    /// Every entry after the header, in file order.
    pub entries: Vec<LogEntry>,
    /// Lines that failed to parse and were skipped. A count of 1 on the final
    /// line is the normal signature of a crash mid-write; anything more
    /// suggests real corruption.
    pub skipped_lines: usize,
    /// Whether the log ended with an `end` entry. False means the ride was
    /// recovered from a crash.
    pub clean_shutdown: bool,
    /// Where the log came from, when it came from a file.
    pub path: Option<PathBuf>,
}

impl RawLog {
    /// Every sample, in order.
    pub fn samples(&self) -> impl Iterator<Item = &Sample> {
        self.entries.iter().filter_map(|e| match e {
            LogEntry::Sample(s) => Some(s),
            _ => None,
        })
    }

    /// Elapsed times at which laps were marked.
    pub fn lap_marks(&self) -> impl Iterator<Item = u64> + '_ {
        self.entries.iter().filter_map(|e| match e {
            LogEntry::Lap { at_ms, .. } => Some(*at_ms),
            _ => None,
        })
    }

    /// Recorded BLE dropouts as `(start_ms, end_ms)`, in order.
    ///
    /// A dropout is written twice: once unterminated the moment it is noticed,
    /// so it survives a crash during the dropout, and once with an end time
    /// when telemetry returns. The journal is append-only, so the earlier line
    /// cannot be rewritten — instead entries are keyed by their start time and
    /// a later, terminated entry supersedes the open one. An unterminated gap
    /// that is never closed runs to `fallback_end_ms`.
    pub fn gaps(&self, fallback_end_ms: u64) -> Vec<(u64, u64)> {
        let mut resolved: Vec<(u64, Option<u64>)> = Vec::new();
        for entry in &self.entries {
            if let LogEntry::Gap { at_ms, until_ms, .. } = entry {
                match resolved.iter_mut().find(|(start, _)| start == at_ms) {
                    // A concrete end time always supersedes an open one, and a
                    // later end time supersedes an earlier one.
                    Some(slot) => {
                        if let Some(end) = until_ms {
                            slot.1 = Some(slot.1.map_or(*end, |prev: u64| prev.max(*end)));
                        }
                    }
                    None => resolved.push((*at_ms, *until_ms)),
                }
            }
        }
        resolved.sort_unstable_by_key(|(start, _)| *start);
        resolved
            .into_iter()
            .map(|(start, end)| (start, end.unwrap_or(fallback_end_ms).max(start)))
            .collect()
    }

    /// Total time excluded from timer time by explicit pauses, in ms.
    ///
    /// A pause with no matching resume is closed at `fallback_end_ms`.
    /// Nested or repeated pauses are tolerated: only the outermost counts.
    pub fn paused_ms(&self, fallback_end_ms: u64) -> u64 {
        let mut total = 0u64;
        let mut paused_at: Option<u64> = None;
        for entry in &self.entries {
            match entry {
                LogEntry::Pause { at_ms } => {
                    if paused_at.is_none() {
                        paused_at = Some(*at_ms);
                    }
                }
                LogEntry::Resume { at_ms } => {
                    if let Some(start) = paused_at.take() {
                        total += at_ms.saturating_sub(start);
                    }
                }
                _ => {}
            }
        }
        if let Some(start) = paused_at {
            total += fallback_end_ms.saturating_sub(start);
        }
        total
    }
}

/// Parse a journal from anything line-oriented.
///
/// Malformed lines are skipped rather than fatal — the entire point of this
/// format is that a half-written tail costs one sample, not the ride.
pub fn parse_log(text: &str) -> Result<RawLog, crate::FitError> {
    let mut start: Option<SessionStart> = None;
    let mut entries = Vec::new();
    let mut skipped = 0usize;
    let mut clean = false;

    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        match serde_json::from_str::<LogEntry>(line) {
            Ok(LogEntry::Start(s)) => {
                if start.is_none() {
                    start = Some(s);
                } else {
                    // A second header means two rides in one file; ignore it
                    // rather than silently merging them.
                    skipped += 1;
                }
            }
            Ok(entry) => {
                if matches!(entry, LogEntry::End { .. }) {
                    clean = true;
                }
                entries.push(entry);
            }
            Err(_) => skipped += 1,
        }
    }

    let start = start.ok_or(crate::FitError::MissingSessionStart)?;
    Ok(RawLog {
        start,
        entries,
        skipped_lines: skipped,
        clean_shutdown: clean,
        path: None,
    })
}

/// Read and parse a journal from disk.
pub fn read_log(path: impl Into<PathBuf>) -> Result<RawLog, crate::FitError> {
    let path = path.into();
    let text = std::fs::read_to_string(&path).map_err(|source| crate::FitError::Io {
        path: path.clone(),
        source,
    })?;
    let mut log = parse_log(&text)?;
    log.path = Some(path);
    Ok(log)
}

/// Serialise one entry as a journal line, terminator included.
pub fn entry_to_line(entry: &LogEntry) -> Result<String, crate::FitError> {
    let mut s = serde_json::to_string(entry)?;
    s.push('\n');
    Ok(s)
}

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

    fn header_line() -> String {
        entry_to_line(&LogEntry::Start(SessionStart {
            start_unix_ms: 1_785_000_000_000,
            ..Default::default()
        }))
        .unwrap()
    }

    #[test]
    fn entries_round_trip_through_json() {
        let entries = vec![
            LogEntry::Start(SessionStart::default()),
            LogEntry::Sample(Sample {
                elapsed_ms: 1000,
                power_w: Some(250),
                cadence_rpm: Some(88.5),
                speed_kph: 32.4,
                distance_m: 9.0,
                gradient_pct: 2.5,
                elevation_gain_m: 0.2,
                heart_rate_bpm: Some(145),
                ..Default::default()
            }),
            LogEntry::Gap {
                at_ms: 5000,
                until_ms: Some(9000),
                reason: "peripheral disconnected".into(),
            },
            LogEntry::Lap {
                at_ms: 60_000,
                from_controller: true,
            },
            LogEntry::Pause { at_ms: 70_000 },
            LogEntry::Resume { at_ms: 80_000 },
            LogEntry::End { at_ms: 90_000 },
        ];
        for e in entries {
            let line = entry_to_line(&e).unwrap();
            assert!(line.ends_with('\n'));
            assert!(!line[..line.len() - 1].contains('\n'), "one entry, one line");
            let back: LogEntry = serde_json::from_str(&line).unwrap();
            assert_eq!(back, e);
        }
    }

    #[test]
    fn a_truncated_final_line_costs_one_sample_not_the_ride() {
        let mut text = header_line();
        for i in 1..=5u64 {
            text.push_str(
                &entry_to_line(&LogEntry::Sample(Sample {
                    elapsed_ms: i * 1000,
                    power_w: Some(200),
                    ..Default::default()
                }))
                .unwrap(),
            );
        }
        // Simulate a crash part-way through writing the sixth line.
        text.push_str("{\"t\":\"s\",\"e\":6000,\"p\":2");

        let log = parse_log(&text).unwrap();
        assert_eq!(log.samples().count(), 5);
        assert_eq!(log.skipped_lines, 1);
        assert!(!log.clean_shutdown, "no end entry means crash recovery");
    }

    #[test]
    fn a_log_with_no_header_is_an_error() {
        let text = entry_to_line(&LogEntry::Sample(Sample::default())).unwrap();
        assert!(matches!(
            parse_log(&text),
            Err(crate::FitError::MissingSessionStart)
        ));
    }

    #[test]
    fn clean_shutdown_is_detected() {
        let text = header_line() + &entry_to_line(&LogEntry::End { at_ms: 10 }).unwrap();
        assert!(parse_log(&text).unwrap().clean_shutdown);
    }

    #[test]
    fn paused_time_sums_intervals() {
        let text = header_line()
            + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
            + &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap()
            + &entry_to_line(&LogEntry::Pause { at_ms: 20_000 }).unwrap()
            + &entry_to_line(&LogEntry::Resume { at_ms: 23_000 }).unwrap();
        assert_eq!(parse_log(&text).unwrap().paused_ms(30_000), 8_000);
    }

    #[test]
    fn an_unclosed_pause_runs_to_the_end_of_the_ride() {
        let text = header_line() + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap();
        assert_eq!(parse_log(&text).unwrap().paused_ms(25_000), 15_000);
    }

    #[test]
    fn repeated_pauses_do_not_double_count() {
        let text = header_line()
            + &entry_to_line(&LogEntry::Pause { at_ms: 10_000 }).unwrap()
            + &entry_to_line(&LogEntry::Pause { at_ms: 12_000 }).unwrap()
            + &entry_to_line(&LogEntry::Resume { at_ms: 15_000 }).unwrap();
        assert_eq!(parse_log(&text).unwrap().paused_ms(20_000), 5_000);
    }

    #[test]
    fn gaps_are_reported_with_unterminated_ones_closed() {
        let text = header_line()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 1000,
                until_ms: Some(4000),
                reason: String::new(),
            })
            .unwrap()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 9000,
                until_ms: None,
                reason: "lost".into(),
            })
            .unwrap();
        assert_eq!(parse_log(&text).unwrap().gaps(12_000), vec![
            (1000, 4000),
            (9000, 12_000)
        ]);
    }

    #[test]
    fn a_closing_gap_entry_supersedes_the_open_one_rather_than_adding_a_second() {
        // A dropout is journalled twice — open, then closed — because the log
        // is append-only and must survive a crash during the dropout. It is
        // still one dropout.
        let text = header_line()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 5000,
                until_ms: None,
                reason: "peripheral disconnected".into(),
            })
            .unwrap()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 5000,
                until_ms: Some(12_000),
                reason: "telemetry resumed".into(),
            })
            .unwrap();
        let log = parse_log(&text).unwrap();
        assert_eq!(
            log.gaps(60_000),
            vec![(5000, 12_000)],
            "the closed entry must supersede the open one, not add to it"
        );
    }

    #[test]
    fn distinct_dropouts_stay_distinct() {
        let text = header_line()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 20_000,
                until_ms: Some(25_000),
                reason: String::new(),
            })
            .unwrap()
            + &entry_to_line(&LogEntry::Gap {
                at_ms: 5000,
                until_ms: Some(9000),
                reason: String::new(),
            })
            .unwrap();
        // Reported in time order regardless of write order.
        assert_eq!(parse_log(&text).unwrap().gaps(60_000), vec![
            (5000, 9000),
            (20_000, 25_000)
        ]);
    }

    #[test]
    fn blank_lines_and_whitespace_are_tolerated() {
        let text = format!("\n{}\n\n  \n", header_line().trim());
        let log = parse_log(&text).unwrap();
        assert_eq!(log.skipped_lines, 0);
        assert_eq!(log.entries.len(), 0);
    }

    #[test]
    fn a_second_header_is_skipped_not_merged() {
        let text = header_line() + &header_line();
        let log = parse_log(&text).unwrap();
        assert_eq!(log.skipped_lines, 1);
    }

    #[test]
    fn from_snapshot_carries_the_fields_the_contract_provides() {
        use bikecontrol_core::{ControlMode, Telemetry};
        let snap = RideSnapshot {
            elapsed_ms: 12_000,
            telemetry: Telemetry {
                elapsed_ms: 12_000,
                power_w: Some(233),
                cadence_rpm: Some(91.0),
                heart_rate_bpm: Some(150),
                total_energy_kcal: Some(42),
                resistance_level: Some(7),
                ..Default::default()
            },
            gear: 6,
            gear_count: 12,
            development_m: 5.7,
            target_cadence_rpm: 92.0,
            speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
            pedal_force_n: 120.0,
            virtual_speed_kph: 31.5,
            virtual_distance_m: 105.0,
            gradient_pct: 3.5,
            elevation_gain_m: 3.6,
            mode: ControlMode::Profile,
            target: None,
            profile_progress: Some(0.1),
        };
        let s = Sample::from_snapshot(&snap);
        assert_eq!(s.elapsed_ms, 12_000);
        assert_eq!(s.power_w, Some(233));
        assert_eq!(s.cadence_rpm, Some(91.0));
        assert_eq!(s.heart_rate_bpm, Some(150));
        assert_eq!(s.speed_kph, 31.5);
        assert_eq!(s.distance_m, 105.0);
        assert_eq!(s.gradient_pct, 3.5);
        assert_eq!(s.energy_kcal, Some(42));
        assert_eq!(s.resistance, Some(7));
        assert_eq!(s.mode, Some(ControlMode::Profile));
        // Speed comes from the physics engine, never from the trainer.
        assert_eq!(s.speed_kph, snap.virtual_speed_kph);
    }

    #[test]
    fn absent_optionals_do_not_appear_on_the_wire() {
        let line = entry_to_line(&LogEntry::Sample(Sample {
            elapsed_ms: 1000,
            ..Default::default()
        }))
        .unwrap();
        assert!(!line.contains("null"), "no null padding: {line}");
        assert!(!line.contains("\"h\""));
    }
}
