//! Atomic file I/O for persisted game data.
//!
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data.

use chrono::Utc;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use solitaire_core::game_state::GameState;

use crate::stats::StatsSnapshot;

const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";

/// Returns the platform-specific path to `stats.json`, or `None` if
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> {
    crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
}

/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
/// the file is missing or cannot be deserialized (corrupt/truncated).
pub fn load_stats_from(path: &Path) -> StatsSnapshot {
    let Ok(data) = fs::read(path) else {
        return StatsSnapshot::default();
    };
    serde_json::from_slice(&data).unwrap_or_default()
}

/// Save stats to an explicit path using an atomic write (`.tmp` → rename).
pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let json = serde_json::to_string_pretty(stats).map_err(io::Error::other)?;

    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, json.as_bytes())?;
    fs::rename(&tmp, path)?;
    Ok(())
}

/// Load stats from the platform default path. Returns default if the path
/// is unavailable or the file is missing/corrupt.
pub fn load_stats() -> StatsSnapshot {
    stats_file_path()
        .map(|p| load_stats_from(&p))
        .unwrap_or_default()
}

/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
    let path = stats_file_path()
        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
    save_stats_to(&path, stats)
}

// ---------------------------------------------------------------------------
// In-progress game state
// ---------------------------------------------------------------------------

/// Returns the platform-specific path to `game_state.json`, or `None` if
/// `crate::data_dir()` is unavailable.
pub fn game_state_file_path() -> Option<PathBuf> {
    crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
}

/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
/// missing, corrupt, represents a finished game, or carries a save-schema
/// version other than [`GAME_STATE_SCHEMA_VERSION`].
///
/// Schema mismatch is treated as "no save" so a player upgrading across an
/// incompatible game-state format change starts fresh instead of seeing a
/// half-loaded game (or a deserialiser error). v1 saves with the old
/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves
/// that happen to round-trip but report `schema_version: 1` are also rejected
/// here.
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
    let data = fs::read(path).ok()?;
    let gs: GameState = serde_json::from_slice(&data).ok()?;
    if gs.is_won() { None } else { Some(gs) }
}

/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
/// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
    if gs.is_won() {
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(gs).map_err(io::Error::other)?;
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, json.as_bytes())?;
    fs::rename(&tmp, path)?;
    Ok(())
}

/// Delete the game state file (called on win, loss, or new-game start).
/// Silently ignores `NotFound` errors.
pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// Remove any leftover `*.tmp` files in the app data directory.
///
/// These can be left behind if the process crashes between the write and rename
/// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
    let dir = match crate::data_dir() {
        Some(d) => d.join(crate::APP_DIR_NAME),
        None => return Ok(()),
    };

    if !dir.exists() {
        return Ok(());
    }

    cleanup_tmp_files_in(&dir);
    Ok(())
}

// ---------------------------------------------------------------------------
// Time Attack session (mode-specific sibling of game_state.json)
// ---------------------------------------------------------------------------
//
// `GameState` carries `mode: GameMode`, so an in-progress Zen / Challenge /
// Classic / TimeAttack deal is already round-tripped through `game_state.json`
// — closing the window mid-deal in any of those modes restores the deal on
// next launch. Time Attack adds a 10-minute session window and a per-session
// win counter that live OUTSIDE `GameState` (in `TimeAttackResource` on the
// engine side), so they are NOT covered by the game-state save/load. This
// sibling file persists just that extra session-level state.
//
// The Bevy plugin layer (`solitaire_engine::time_attack_plugin`) is the only
// caller. The file lives next to `game_state.json` in the same data dir and
// is written using the same `.tmp` → rename atomic-write contract that the
// rest of `storage.rs` uses.

/// Persisted state for an in-progress Time Attack session.
///
/// Fields mirror the live `TimeAttackResource` minus the `active` flag (the
/// presence of the file *is* the active flag — a missing file means no
/// session in progress).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimeAttackSession {
    /// Seconds remaining in the 10-minute window when the save was written.
    pub remaining_secs: f32,
    /// Wins accumulated during the session so far.
    pub wins: u32,
    /// Wall-clock instant the save was written, as unix seconds. Used at
    /// load time to detect whether the session window expired in real
    /// time while the app was closed and to decrement `remaining_secs`
    /// by the real elapsed time so the resumed session reflects how
    /// long the window has actually been running.
    pub saved_at_unix_secs: u64,
}

/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
    crate::data_dir().map(|d| {
        d.join(crate::APP_DIR_NAME)
            .join(TIME_ATTACK_SESSION_FILE_NAME)
    })
}

/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
/// `.tmp` → rename contract.
pub fn save_time_attack_session_to(path: &Path, session: &TimeAttackSession) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(session).map_err(io::Error::other)?;
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, json.as_bytes())?;
    fs::rename(&tmp, path)?;
    Ok(())
}

/// Load a Time Attack session from `path`, decrementing `remaining_secs`
/// by the wall-clock time elapsed between the save and now.
///
/// Returns `None` when:
/// - the file is missing or unreadable,
/// - the JSON is corrupt / malformed, or
/// - the session window expired during the time the app was closed
///   (`saved_at_unix_secs + remaining_secs <= now_unix_secs`).
///
/// The `now_unix_secs` parameter is injectable so unit tests can simulate
/// arbitrary wall-clock gaps without touching the real system clock. The
/// public companion [`load_time_attack_session_from`] resolves "now" from
/// `SystemTime::now()`.
pub fn load_time_attack_session_from_at(
    path: &Path,
    now_unix_secs: u64,
) -> Option<TimeAttackSession> {
    let data = fs::read(path).ok()?;
    let session: TimeAttackSession = serde_json::from_slice(&data).ok()?;
    // Compute wall-clock elapsed seconds since the save was written.
    // Saturating subtraction guards against a clock that moved backwards
    // (rare, but possible across NTP corrections or VM clock drift).
    let elapsed = now_unix_secs.saturating_sub(session.saved_at_unix_secs);
    let remaining = session.remaining_secs - elapsed as f32;
    if remaining <= 0.0 {
        return None;
    }
    Some(TimeAttackSession {
        remaining_secs: remaining,
        wins: session.wins,
        saved_at_unix_secs: session.saved_at_unix_secs,
    })
}

/// Load a Time Attack session from `path`, using `SystemTime::now()` as
/// the reference for the wall-clock-elapsed adjustment.
///
/// See [`load_time_attack_session_from_at`] for the rules under which
/// the call returns `None` (missing file, corrupt JSON, expired window).
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
    let now = Utc::now().timestamp().max(0) as u64;
    load_time_attack_session_from_at(path, now)
}

/// Delete the Time Attack session file (called on session end, on session
/// start, or on game completion). Silently ignores `NotFound` errors.
pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// Convenience helper for callers that want to stamp a session with the
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
    let now = Utc::now().timestamp().max(0) as u64;
    TimeAttackSession {
        remaining_secs,
        wins,
        saved_at_unix_secs: now,
    }
}

/// Inner helper: delete `*.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
fn cleanup_tmp_files_in(dir: &Path) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path
                .file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.ends_with(".tmp"))
            {
                let _ = fs::remove_file(&path);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stats::{StatsExt, StatsSnapshot};
    use solitaire_core::DrawStockConfig;
    use std::env;

    fn tmp_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!("solitaire_test_{name}.json"))
    }

    #[test]
    fn round_trip_save_and_load() {
        let path = tmp_path("round_trip");
        let _ = fs::remove_file(&path);

        let mut stats = StatsSnapshot::default();
        stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
        save_stats_to(&path, &stats).expect("save");

        let loaded = load_stats_from(&path);
        assert_eq!(loaded.games_won, 1);
        assert_eq!(loaded.best_single_score, 1000);
        assert_eq!(loaded.fastest_win_seconds, 180);
    }

    #[test]
    fn load_from_missing_file_returns_default() {
        let path = tmp_path("missing_file_abc123");
        let _ = fs::remove_file(&path);
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }

    #[test]
    fn save_is_atomic_no_half_written_file() {
        let path = tmp_path("atomic_write");
        let stats = StatsSnapshot::default();
        save_stats_to(&path, &stats).expect("save");

        let tmp = path.with_extension("json.tmp");
        assert!(!tmp.exists(), ".tmp file must be cleaned up after rename");
    }

    #[test]
    fn load_from_corrupt_file_returns_default() {
        let path = tmp_path("corrupt");
        fs::write(&path, b"not valid json!!!").expect("write corrupt");
        let stats = load_stats_from(&path);
        assert_eq!(stats, StatsSnapshot::default());
    }

    /// Test the core cleanup logic by creating `.json.tmp` files in a temporary
    /// directory, running the cleanup loop manually, and verifying removal.
    #[test]
    fn cleanup_removes_tmp_files() {
        let dir = env::temp_dir().join("solitaire_cleanup_test");
        fs::create_dir_all(&dir).expect("create test dir");

        // Create a pair of .json.tmp files and one regular file that must survive.
        let tmp1 = dir.join("stats.json.tmp");
        let tmp2 = dir.join("progress.json.tmp");
        let keep = dir.join("settings.json");
        fs::write(&tmp1, b"orphan1").expect("write tmp1");
        fs::write(&tmp2, b"orphan2").expect("write tmp2");
        fs::write(&keep, b"{}").expect("write keep");

        // Run the cleanup logic directly against our test directory.
        cleanup_tmp_files_in(&dir);

        assert!(!tmp1.exists(), "stats.json.tmp should have been removed");
        assert!(!tmp2.exists(), "progress.json.tmp should have been removed");
        assert!(keep.exists(), "settings.json must not be removed");

        // Tidy up.
        let _ = fs::remove_file(&keep);
        let _ = fs::remove_dir(&dir);
    }

    /// Calling `cleanup_orphaned_tmp_files` on a box with no app data dir is a
    /// no-op and must not return an error.
    #[test]
    fn cleanup_on_nonexistent_dir_is_ok() {
        // We can't control whether the real app dir exists in the test
        // environment, but the public function must at least not panic or
        // return an Err when the directory is absent.
        // The real implementation returns Ok(()) for missing dirs.
        let result = cleanup_orphaned_tmp_files();
        // The function is allowed to succeed whether or not the dir exists.
        assert!(result.is_ok());
    }

    // -----------------------------------------------------------------------
    // game_state persistence tests
    // -----------------------------------------------------------------------

    fn gs_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!("solitaire_test_gs_{name}.json"))
    }

    #[test]
    fn game_state_round_trip() {
        use solitaire_core::game_state::GameState;
        let path = gs_path("round_trip");
        let _ = fs::remove_file(&path);

        let gs = GameState::new(12345, DrawStockConfig::DrawOne);
        save_game_state_to(&path, &gs).expect("save");

        let loaded = load_game_state_from(&path).expect("load");
        assert_eq!(loaded.seed, gs.seed);
        assert_eq!(loaded.draw_mode(), gs.draw_mode());
        assert!(!loaded.is_won());
    }

    #[test]
    fn load_game_state_missing_file_returns_none() {
        let path = gs_path("missing_xyz");
        let _ = fs::remove_file(&path);
        assert!(load_game_state_from(&path).is_none());
    }

    #[test]
    fn load_game_state_corrupt_file_returns_none() {
        let path = gs_path("corrupt");
        fs::write(&path, b"not valid json!!!").expect("write");
        assert!(load_game_state_from(&path).is_none());
    }

    #[test]
    fn save_game_state_skips_won_games() {
        use solitaire_core::game_state::GameState;
        let path = gs_path("won_skip");
        let _ = fs::remove_file(&path);

        let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
        gs.set_test_won(true);
        save_game_state_to(&path, &gs).expect("save should be no-op, not error");
        assert!(
            !path.exists(),
            "should not have written a file for a won game"
        );
    }

    #[test]
    fn delete_game_state_removes_file() {
        use solitaire_core::game_state::GameState;
        let path = gs_path("delete");
        let gs = GameState::new(1, DrawStockConfig::DrawOne);
        save_game_state_to(&path, &gs).expect("save");
        assert!(path.exists());
        delete_game_state_at(&path).expect("delete");
        assert!(!path.exists());
    }

    #[test]
    fn delete_game_state_missing_file_is_ok() {
        let path = gs_path("delete_missing");
        let _ = fs::remove_file(&path);
        assert!(delete_game_state_at(&path).is_ok());
    }

    #[test]
    fn save_game_state_is_atomic() {
        use solitaire_core::game_state::GameState;
        let path = gs_path("atomic");
        let gs = GameState::new(55, DrawStockConfig::DrawThree);
        save_game_state_to(&path, &gs).expect("save");
        let tmp = path.with_extension("json.tmp");
        assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
    }

    /// Pre-v2 save files used `Foundation(Suit)` keys and either fail to
    /// parse outright or surface a `schema_version: 1`. Either path must
    /// produce `None` so the player launches into a fresh game.
    ///
    /// Sibling assertion: the stats round-trip path is unaffected — only
    /// the game-state schema bumped.
    #[test]
    fn save_format_v1_is_rejected() {
        let path = gs_path("schema_v1");
        let _ = fs::remove_file(&path);

        // A pared-down v1 JSON literal: foundation pile keys use the old
        // suit-tagged form and the file omits `schema_version` (so it
        // deserialises with the default of 1). Even if a future change
        // makes `Foundation(Suit)` parse-compatible, the schema-version
        // gate keeps this case rejected.
        let v1_json = r#"{
            "piles": [
                [{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}]
            ],
            "draw_mode": "DrawOne",
            "score": 0,
            "move_count": 0,
            "elapsed_seconds": 0,
            "seed": 42,
            "is_won": false,
            "is_auto_completable": false,
            "undo_count": 0,
            "undo_stack": []
        }"#;
        fs::write(&path, v1_json).expect("write v1 fixture");

        assert!(
            load_game_state_from(&path).is_none(),
            "v1 game_state.json must be rejected (parse failure or schema bump)",
        );

        // Sibling sanity: stats files are independent and still round-trip.
        let stats_path = tmp_path("schema_unrelated_stats");
        let _ = fs::remove_file(&stats_path);
        save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats");
        let loaded = load_stats_from(&stats_path);
        assert_eq!(loaded, StatsSnapshot::default());
    }

    /// Schema v4 serialises the instruction history using upstream
    /// `KlondikeInstruction` serde (named enum variants). The deserialiser
    /// replays all `saved_moves` to reconstruct every pile.
    ///
    /// A fresh-game test (zero moves) never exercises that replay path, so this
    /// test plays several real moves — including an undo — before saving.
    ///
    /// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
    /// (they are derived from the replayed session stats), round-trip fidelity is
    /// verified by **re-save idempotency**: reloading the save and serialising it
    /// again must reproduce byte-identical JSON. `undo_count` deliberately resets
    /// to 0 on load because only the forward instruction history is persisted.
    #[test]
    fn game_state_v5_mid_game_round_trip() {
        use solitaire_core::KlondikeInstruction;
        use solitaire_core::game_state::GameState;

        let path = gs_path("v4_mid_game");
        let _ = fs::remove_file(&path);

        let mut gs = GameState::new(42, DrawStockConfig::DrawOne);

        // Draw several times to populate the instruction history with
        // RotateStock entries and expose waste cards for further moves.
        for _ in 0..6 {
            if gs.draw().is_err() {
                break;
            }
        }

        // Execute the first available DstTableau or DstFoundation move so the
        // instruction history contains a type other than RotateStock.
        if let Some(instruction) = gs.possible_instructions().into_iter().find(|i| {
            matches!(
                i,
                KlondikeInstruction::DstTableau(_) | KlondikeInstruction::DstFoundation(_)
            )
        }) {
            let _ = gs.apply_instruction(instruction);
        }

        // Undo once: verifies that `undo_count` is persisted and that the
        // truncated history (post-undo) replays back to the correct state.
        if gs.undo_stack_len() > 0 {
            let _ = gs.undo();
        }

        assert!(
            gs.undo_stack_len() > 0,
            "instruction history must be non-empty (seed 42 always produces draws)",
        );

        save_game_state_to(&path, &gs).expect("save");

        // Verify the file carries the v5 schema marker.
        let json = fs::read_to_string(&path).expect("read json");
        assert!(
            json.contains("\"schema_version\"") && json.contains('5'),
            "saved file must use schema version 5",
        );

        let loaded = load_game_state_from(&path)
            .expect("a valid in-progress game must load without error");

        // The forward instruction history round-trips, so the reconstructed board
        // re-serialises to byte-identical JSON.
        let path_reload = gs_path("v5_mid_game_reload");
        let _ = fs::remove_file(&path_reload);
        save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
        assert_eq!(
            fs::read_to_string(&path).expect("read original save"),
            fs::read_to_string(&path_reload).expect("read re-saved"),
            "re-saving the loaded game must reproduce the original save exactly",
        );

        // Derived board reads match the live game (move count + recycle count are
        // both rebuilt from the replayed forward history).
        assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
        assert_eq!(
            loaded.recycle_count(),
            gs.recycle_count(),
            "recycle_count round-trips",
        );
        // undo_count is intentionally not persisted: it resets to 0 on load.
        assert_eq!(
            loaded.undo_count(),
            0,
            "undo_count resets across save/load under schema v5",
        );
    }

    /// A schema v3 save (instruction history using the old u8-index mirror
    /// types) is no longer loadable. The legacy migration path was dropped,
    /// so any file claiming `schema_version: 3` must be rejected and the
    /// player started on a fresh game.
    #[test]
    fn game_state_v3_is_rejected() {
        let path = gs_path("v3_reject");
        let _ = fs::remove_file(&path);

        // Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
        let v3_json = r#"{
            "draw_mode": "DrawOne",
            "mode": "Classic",
            "score": 0,
            "elapsed_seconds": 0,
            "seed": 42,
            "undo_count": 0,
            "recycle_count": 0,
            "take_from_foundation": true,
            "schema_version": 3,
            "saved_moves": ["RotateStock"]
        }"#;
        fs::write(&path, v3_json).expect("write v3 fixture");

        assert!(
            load_game_state_from(&path).is_none(),
            "schema v3 must be rejected (no migration path)",
        );

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

    /// Schema v2 stored raw pile arrays and undo snapshots (no instruction
    /// history). Any file claiming `schema_version: 2` must be rejected so
    /// players upgrading from an older build start with a fresh game rather
    /// than a half-reconstructed state.
    #[test]
    fn save_format_v2_is_rejected() {
        let path = gs_path("schema_v2");
        let _ = fs::remove_file(&path);

        // Structurally valid JSON for `PersistedGameState` but with
        // `schema_version: 2`. The schema-version gate in
        // `GameState::deserialize` must reject this before replay starts.
        let v2_json = r#"{
            "draw_mode": "DrawOne",
            "mode": "Classic",
            "score": 0,
            "elapsed_seconds": 0,
            "seed": 42,
            "undo_count": 0,
            "recycle_count": 0,
            "take_from_foundation": true,
            "schema_version": 2,
            "saved_moves": []
        }"#;
        fs::write(&path, v2_json).expect("write v2 fixture");

        assert!(
            load_game_state_from(&path).is_none(),
            "schema v2 game_state.json must be rejected — player must start a fresh game",
        );
    }

    // -----------------------------------------------------------------------
    // Time Attack session persistence
    //
    // Documents the contract that closing the window mid-Time-Attack does
    // NOT lose the 10-minute window or the running win count. Classic /
    // Zen / Challenge are covered by `game_state.json` because their entire
    // mid-deal state lives in `GameState.mode` + `GameState.piles`; Time
    // Attack additionally needs the session timer + wins counter, both of
    // which live in `TimeAttackResource` on the engine side and are NOT
    // part of `GameState`. This sibling file persists exactly that.
    // -----------------------------------------------------------------------

    fn ta_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!("solitaire_test_ta_{name}.json"))
    }

    /// Round-trip a session that was saved "just now" (zero wall-clock
    /// elapsed). All three persisted fields must come back unchanged.
    #[test]
    fn time_attack_session_round_trips_through_save_and_load() {
        let path = ta_path("round_trip");
        let _ = fs::remove_file(&path);

        // Use a fixed unix timestamp so the load step (which receives the
        // SAME timestamp as "now") sees zero wall-clock elapsed.
        let saved_at: u64 = 1_800_000_000;
        let session = TimeAttackSession {
            remaining_secs: 240.0,
            wins: 3,
            saved_at_unix_secs: saved_at,
        };
        save_time_attack_session_to(&path, &session).expect("save");

        let loaded = load_time_attack_session_from_at(&path, saved_at)
            .expect("session must load when not yet expired");
        assert!(
            (loaded.remaining_secs - 240.0).abs() < 0.01,
            "remaining_secs must be unchanged when no wall-clock time has passed; got {}",
            loaded.remaining_secs,
        );
        assert_eq!(loaded.wins, 3, "wins must round-trip");
        assert_eq!(
            loaded.saved_at_unix_secs, saved_at,
            "timestamp must round-trip"
        );

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

    /// A session whose window expired entirely between launches must be
    /// discarded on load — the caller starts fresh rather than resuming a
    /// dead session.
    #[test]
    fn time_attack_session_discarded_when_expired_between_launches() {
        let path = ta_path("expired");
        let _ = fs::remove_file(&path);

        // Saved 20 minutes ago with 240 s remaining — long expired.
        let saved_at: u64 = 1_800_000_000;
        let session = TimeAttackSession {
            remaining_secs: 240.0,
            wins: 5,
            saved_at_unix_secs: saved_at,
        };
        save_time_attack_session_to(&path, &session).expect("save");

        // 20 minutes (1200 s) later → 240 - 1200 = -960 s remaining.
        let now = saved_at + 1200;
        assert!(
            load_time_attack_session_from_at(&path, now).is_none(),
            "an expired session must return None so the player starts fresh",
        );

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

    /// The `remaining_secs` returned at load time must be the persisted
    /// value minus the wall-clock seconds that elapsed while the app was
    /// closed.
    #[test]
    fn time_attack_session_remaining_secs_decremented_by_real_elapsed() {
        let path = ta_path("decremented");
        let _ = fs::remove_file(&path);

        let saved_at: u64 = 1_800_000_000;
        let session = TimeAttackSession {
            remaining_secs: 240.0,
            wins: 2,
            saved_at_unix_secs: saved_at,
        };
        save_time_attack_session_to(&path, &session).expect("save");

        // 60 s elapsed in real time → expect 180 s remaining.
        let now = saved_at + 60;
        let loaded = load_time_attack_session_from_at(&path, now)
            .expect("session must still load — 180 s left");
        assert!(
            (loaded.remaining_secs - 180.0).abs() < 5.0,
            "remaining_secs ≈ 180 ± 5 s after a 60 s wall-clock gap; got {}",
            loaded.remaining_secs,
        );
        assert_eq!(loaded.wins, 2, "wins must survive the elapsed adjustment");

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

    /// Atomic-write contract — `.tmp` must not be left behind after
    /// `save_time_attack_session_to` returns.
    #[test]
    fn time_attack_session_save_is_atomic() {
        let path = ta_path("atomic");
        let session = TimeAttackSession {
            remaining_secs: 100.0,
            wins: 0,
            saved_at_unix_secs: 1_800_000_000,
        };
        save_time_attack_session_to(&path, &session).expect("save");
        let tmp = path.with_extension("json.tmp");
        assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
        let _ = fs::remove_file(&path);
    }

    /// Loading from a path that does not exist must return `None`, not
    /// panic.
    #[test]
    fn time_attack_session_missing_file_returns_none() {
        let path = ta_path("missing_xyz");
        let _ = fs::remove_file(&path);
        assert!(load_time_attack_session_from_at(&path, 0).is_none());
    }

    /// Loading from a corrupt / partially-written file must return `None`,
    /// not surface a deserialiser error.
    #[test]
    fn time_attack_session_corrupt_file_returns_none() {
        let path = ta_path("corrupt");
        fs::write(&path, b"not valid json!!!").expect("write");
        assert!(load_time_attack_session_from_at(&path, 0).is_none());
        let _ = fs::remove_file(&path);
    }

    /// `delete_time_attack_session_at` removes the file when it exists
    /// and returns `Ok(())` when it does not.
    #[test]
    fn time_attack_session_delete_handles_present_and_absent() {
        let path = ta_path("delete");
        let session = TimeAttackSession {
            remaining_secs: 50.0,
            wins: 0,
            saved_at_unix_secs: 1_800_000_000,
        };
        save_time_attack_session_to(&path, &session).expect("save");
        assert!(path.exists());
        delete_time_attack_session_at(&path).expect("delete");
        assert!(!path.exists());
        // Second delete on the now-absent file must succeed.
        delete_time_attack_session_at(&path).expect("missing-file delete is ok");
    }

    /// A session whose `saved_at_unix_secs` is in the future (e.g. the
    /// system clock moved backward across NTP correction) must NOT be
    /// rejected as expired. Saturating subtraction must clamp the
    /// "elapsed" value to zero.
    #[test]
    fn time_attack_session_handles_clock_running_backwards() {
        let path = ta_path("clock_backwards");
        let _ = fs::remove_file(&path);

        let saved_at: u64 = 1_800_000_000;
        let session = TimeAttackSession {
            remaining_secs: 60.0,
            wins: 1,
            saved_at_unix_secs: saved_at,
        };
        save_time_attack_session_to(&path, &session).expect("save");

        // "now" is BEFORE the saved time — should not crash, should not expire.
        let now_in_past = saved_at - 100;
        let loaded = load_time_attack_session_from_at(&path, now_in_past)
            .expect("clock-backwards must not discard the session");
        assert!(
            (loaded.remaining_secs - 60.0).abs() < 0.01,
            "remaining_secs must clamp elapsed to 0 when clock ran backwards; got {}",
            loaded.remaining_secs,
        );

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