//! End-to-end crash safety (FR-8.4, FR-8.5, FR-8.6).
//!
//! These tests do to the journal what a crash does — truncate it at an
//! arbitrary byte — and then check that a valid activity still comes out the
//! other side.

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

use bikecontrol_core::{ControlMode, RideSnapshot, Telemetry};
use bikecontrol_fit::{build_fit_from_log, read_log, verify, Recorder, RecorderOptions};
use chrono::{TimeZone, Utc};
use fitparser::profile::MesgNum;

fn workdir(name: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "bikecontrol-fit-it-{name}-{}-{:?}",
        std::process::id(),
        std::thread::current().id()
    ));
    let _ = fs::remove_dir_all(&dir);
    fs::create_dir_all(&dir).unwrap();
    dir
}

fn snapshot(second: u64) -> RideSnapshot {
    let elapsed_ms = second * 1000;
    RideSnapshot {
        elapsed_ms,
        telemetry: Telemetry {
            elapsed_ms,
            power_w: Some(180 + (second % 60) as i16),
            cadence_rpm: Some(84.0 + (second % 12) as f32),
            heart_rate_bpm: Some(138 + (second % 25) as u8),
            ..Default::default()
        },
        virtual_speed_kph: 29.0 + (second % 7) as f32,
        virtual_distance_m: second as f64 * 8.2,
        gradient_pct: ((second % 20) as f32 - 10.0) / 2.0,
        elevation_gain_m: second as f32 * 0.15,
        gear: 6,
        gear_count: 12,
        development_m: 5.7,
        target_cadence_rpm: 90.0,
        speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
        pedal_force_n: 120.0,
        mode: ControlMode::Profile,
        target: None,
        profile_progress: Some(second as f32 / 600.0),
    }
}

/// Record `seconds` of riding and leave the journal behind, as a crash would.
fn crashed_journal(dir: &Path, seconds: u64) -> PathBuf {
    let log = dir.join("ride.jsonl");
    let mut rec = Recorder::create_at(
        &log,
        RecorderOptions::default(),
        Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
        7200,
    )
    .unwrap();
    for s in 0..seconds {
        rec.record(&snapshot(s)).unwrap();
    }
    // No finish(): the process "dies" here.
    rec.abandon()
}

#[test]
fn a_journal_truncated_mid_line_still_yields_a_valid_activity() {
    let dir = workdir("torn");
    let log = crashed_journal(&dir, 120);

    // Chop the file part-way through the final line, exactly as a power cut
    // during a write would.
    let text = fs::read_to_string(&log).unwrap();
    let final_terminator = text.rfind('\n').unwrap();
    let start_of_final_line = text[..final_terminator].rfind('\n').unwrap() + 1;
    let torn = &text[..start_of_final_line + 12];
    assert!(!torn.ends_with('\n'), "the cut must land inside a line");
    fs::write(&log, torn).unwrap();

    let parsed = read_log(&log).unwrap();
    assert_eq!(parsed.skipped_lines, 1, "exactly the torn line was dropped");
    assert_eq!(parsed.samples().count(), 119, "one sample lost, not the ride");
    assert!(!parsed.clean_shutdown);

    let fit = dir.join("recovered.fit");
    let summary = build_fit_from_log(&log, &fit).unwrap();
    assert_eq!(summary.records, 119);
    assert!(summary.recovered_from_crash);

    let bytes = fs::read(&fit).unwrap();
    assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes));
    let records = fitparser::from_bytes(&bytes).unwrap();
    assert_eq!(
        records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
        119
    );
    assert_eq!(
        records.iter().filter(|r| r.kind() == MesgNum::Session).count(),
        1
    );
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn a_journal_truncated_at_any_byte_never_produces_a_broken_fit() {
    // The general statement. For every truncation point past the header, the
    // recovery path must either refuse cleanly or produce a file that verifies
    // — never a file that passes our checks and fails someone else's.
    let dir = workdir("everytrunc");
    let log = crashed_journal(&dir, 40);
    let full = fs::read(&log).unwrap();

    let truncated_log = dir.join("truncated.jsonl");
    let mut produced = 0;
    for cut in (16..full.len()).step_by(7) {
        fs::write(&truncated_log, &full[..cut]).unwrap();
        let fit = dir.join("out.fit");
        // Refusing is fine: a journal with no header, or with no samples yet,
        // has no activity in it. Producing a *broken* file is not.
        if build_fit_from_log(&truncated_log, &fit).is_ok() {
            let bytes = fs::read(&fit).unwrap();
            assert!(
                verify(&bytes).is_ok(),
                "truncation at {cut} produced an invalid FIT: {:?}",
                verify(&bytes)
            );
            fitparser::from_bytes(&bytes).unwrap_or_else(|e| {
                panic!("truncation at {cut} produced a file the decoder rejected: {e}")
            });
            produced += 1;
        }
    }
    assert!(produced > 10, "expected most truncations to be recoverable");
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn recovery_reproduces_the_file_a_clean_finish_would_have_written() {
    let dir = workdir("identical");
    let log = dir.join("ride.jsonl");
    let mut rec = Recorder::create_at(
        &log,
        RecorderOptions::default(),
        Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
        7200,
    )
    .unwrap();
    for s in 0..90 {
        rec.record(&snapshot(s)).unwrap();
    }
    rec.mark_lap(30_000, true).unwrap();
    rec.mark_gap(45_000, "trainer dropped").unwrap();
    rec.record(&snapshot(60)).unwrap();

    let clean = dir.join("clean.fit");
    let clean_summary = rec.finish(&clean).unwrap();

    let rebuilt = dir.join("rebuilt.fit");
    let rebuilt_summary = build_fit_from_log(&log, &rebuilt).unwrap();

    assert_eq!(clean_summary, rebuilt_summary);
    assert_eq!(fs::read(&clean).unwrap(), fs::read(&rebuilt).unwrap());
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn a_ride_survives_repeated_dropouts_and_laps() {
    // FR-8.5 plus FR-8.7 together, over a ride that keeps losing the trainer.
    let dir = workdir("messy");
    let log = dir.join("ride.jsonl");
    let mut rec = Recorder::create_at(
        &log,
        RecorderOptions::default(),
        Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
        0,
    )
    .unwrap();

    let mut second = 0u64;
    for round in 0..4 {
        for _ in 0..30 {
            rec.record(&snapshot(second)).unwrap();
            second += 1;
        }
        rec.mark_lap(second * 1000, round % 2 == 0).unwrap();
        rec.mark_gap(second * 1000, "peripheral disconnected").unwrap();
        second += 15; // fifteen seconds of silence
    }
    for _ in 0..30 {
        rec.record(&snapshot(second)).unwrap();
        second += 1;
    }

    let fit = dir.join("ride.fit");
    let summary = rec.finish(&fit).unwrap();

    assert_eq!(summary.records, 150, "every sample we actually saw");
    assert_eq!(summary.laps, 5, "four markers split the ride into five laps");
    assert_eq!(summary.gaps, 4);
    assert!(!summary.recovered_from_crash);

    let bytes = fs::read(&fit).unwrap();
    assert!(verify(&bytes).is_ok());
    let records = fitparser::from_bytes(&bytes).unwrap();
    assert_eq!(records.iter().filter(|r| r.kind() == MesgNum::Lap).count(), 5);
    assert_eq!(
        records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
        150
    );
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn a_journal_with_only_a_header_is_refused_rather_than_written_empty() {
    let dir = workdir("empty");
    let log = crashed_journal(&dir, 0);
    let fit = dir.join("out.fit");
    assert!(
        build_fit_from_log(&log, &fit).is_err(),
        "an activity with no records is rejected by every uploader"
    );
    assert!(!fit.exists(), "no file should be left behind");
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn a_long_ride_records_and_recovers() {
    // Two hours at 1 Hz — the realistic worst case for a trainer session.
    let dir = workdir("long");
    let log = dir.join("ride.jsonl");
    let mut rec = Recorder::create_at(
        &log,
        RecorderOptions {
            // fsync per sample would dominate the runtime and proves nothing
            // extra here; durability is covered by its own test.
            fsync_every: None,
            ..Default::default()
        },
        Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
        7200,
    )
    .unwrap();
    for s in 0..7200 {
        rec.record(&snapshot(s)).unwrap();
    }
    let fit = dir.join("ride.fit");
    let summary = rec.finish(&fit).unwrap();

    assert_eq!(summary.records, 7200);
    assert_eq!(summary.total_elapsed_s, 7199.0);
    let bytes = fs::read(&fit).unwrap();
    assert!(verify(&bytes).is_ok());
    assert!(
        bytes.len() < 200_000,
        "two hours came to {} bytes",
        bytes.len()
    );
    let records = fitparser::from_bytes(&bytes).unwrap();
    assert_eq!(
        records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
        7200
    );
    let _ = fs::remove_dir_all(dir);
}
