//! Loads, updates, and persists `StatsSnapshot` in response to game events,
//! and provides a toggleable full-window stats overlay (press `S`).
//!
//! The persistence path is configurable via `StatsPlugin::storage_path`.
//! In production, `StatsPlugin::default()` loads/saves from the platform
//! data dir. In tests, use `StatsPlugin::headless()` to disable all file
//! I/O so the user's real stats file is neither read nor overwritten.

use std::path::PathBuf;

use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::{
    load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot,
    WEEKLY_GOALS,
};

use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::challenge_progress_label;
use crate::events::{
    ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
    WinStreakMilestoneEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
    spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
    ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
    TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
    VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
};

/// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)]
pub struct StatsResource(pub StatsSnapshot);

/// Persistence path for `StatsResource`. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct StatsStoragePath(pub Option<PathBuf>);

/// System set for the stats-mutating systems. Downstream plugins that read
/// `StatsResource` after a win/abandon should run `.after(StatsUpdate)`.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct StatsUpdate;

/// Marker component on the stats overlay root node.
#[derive(Component, Debug)]
pub struct StatsScreen;

/// Marker component on an individual stat cell inside the stats overlay.
///
/// Each cell contains a large value label and a small descriptor label below it.
#[derive(Component, Debug)]
pub struct StatsCell;

/// Registers stats resources, update systems, and the UI toggle.
pub struct StatsPlugin {
    /// Where to persist stats. `None` disables all file I/O (for tests).
    pub storage_path: Option<PathBuf>,
}

impl Default for StatsPlugin {
    fn default() -> Self {
        Self {
            storage_path: stats_file_path(),
        }
    }
}

impl StatsPlugin {
    /// Plugin configured with no persistence. Use in tests and headless apps
    /// where touching `~/.local/share/solitaire_quest/stats.json` would be
    /// incorrect.
    pub fn headless() -> Self {
        Self { storage_path: None }
    }
}

impl Plugin for StatsPlugin {
    fn build(&self, app: &mut App) {
        let loaded = match &self.storage_path {
            Some(path) => load_stats_from(path),
            None => StatsSnapshot::default(),
        };
        app.insert_resource(StatsResource(loaded))
            .insert_resource(StatsStoragePath(self.storage_path.clone()))
            .add_message::<GameWonEvent>()
            .add_message::<NewGameRequestEvent>()
            .add_message::<ForfeitEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<ToggleStatsRequestEvent>()
            .add_message::<WinStreakMilestoneEvent>()
            // record_abandoned must read `move_count` BEFORE handle_new_game
            // clobbers it with a fresh game. These are NOT in StatsUpdate because
            // StatsUpdate (as a set) is ordered after GameMutation by external
            // constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
            // and a system cannot be both inside a set and individually before a
            // set-level ordering constraint.
            .add_systems(
                Update,
                update_stats_on_new_game.before(GameMutation),
            )
            .add_systems(
                Update,
                update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
            )
            .add_systems(
                Update,
                handle_forfeit.before(GameMutation),
            )
            .add_systems(Update, toggle_stats_screen.after(GameMutation))
            .add_systems(Update, handle_stats_close_button);
    }
}

fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
    let Some(target) = &path.0 else {
        return;
    };
    if let Err(e) = save_stats_to(target, stats) {
        warn!("failed to save stats after {context}: {e}");
    }
}

fn update_stats_on_win(
    mut events: MessageReader<GameWonEvent>,
    game: Res<GameStateResource>,
    mut stats: ResMut<StatsResource>,
    path: Res<StatsStoragePath>,
    mut milestone: MessageWriter<WinStreakMilestoneEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    for ev in events.read() {
        let prev_streak = stats.0.win_streak_current;
        stats
            .0
            .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
        let new_streak = stats.0.win_streak_current;
        // Fire the streak-milestone event only on the threshold
        // crossing — `prev < threshold && new >= threshold`. This
        // guarantees the flourish never retriggers at every win past
        // the highest milestone.
        if let Some(crossed) = streak_milestone_crossed(prev_streak, new_streak) {
            milestone.write(WinStreakMilestoneEvent { streak: crossed });
            toast.write(InfoToastEvent(format!(
                "Win streak: {crossed}! \u{1F525}"
            )));
        }
        persist(&path, &stats.0, "win");
    }
}

/// Returns the milestone value that the player just crossed, if any.
///
/// A milestone is "crossed" when `prev < threshold && new >= threshold`
/// for some `threshold` in [`STREAK_MILESTONES`]. Returns the largest
/// such threshold (so a single win that vaults the player from a
/// streak of 0 directly to 5 — implausible, but defensive — fires the
/// most-celebrated milestone, not the smallest).
///
/// Returns `None` when no threshold was crossed, i.e. either:
/// - the streak did not change,
/// - the streak rose but stayed below every threshold, or
/// - the streak rose past a threshold that `prev` was already at or
///   above.
///
/// Pure function exposed for unit testing without Bevy.
pub fn streak_milestone_crossed(prev: u32, new: u32) -> Option<u32> {
    if new <= prev {
        return None;
    }
    STREAK_MILESTONES
        .iter()
        .copied()
        .filter(|&t| prev < t && new >= t)
        .max()
}

fn update_stats_on_new_game(
    mut events: MessageReader<NewGameRequestEvent>,
    game: Res<GameStateResource>,
    mut stats: ResMut<StatsResource>,
    path: Res<StatsStoragePath>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    for _ in events.read() {
        if game.0.move_count > 0 && !game.0.is_won {
            let streak = stats.0.win_streak_current;
            stats.0.record_abandoned();
            persist(&path, &stats.0, "abandoned game");
            if streak > 1 {
                toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
            }
        }
    }
}

/// When the player presses G to forfeit, record the game as abandoned, save
/// stats, fire an informational toast, and start a new game.
///
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
/// into the new deal (task #41).
fn handle_forfeit(
    mut events: MessageReader<ForfeitEvent>,
    game: Res<GameStateResource>,
    mut stats: ResMut<StatsResource>,
    path: Res<StatsStoragePath>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
    mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
    for _ in events.read() {
        if game.0.move_count > 0 && !game.0.is_won {
            let streak = stats.0.win_streak_current;
            stats.0.record_abandoned();
            persist(&path, &stats.0, "forfeit");
            if streak > 1 {
                toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
            }
        }
        // Reset auto-complete so the badge and chime don't carry over to the
        // new game that is about to start.
        if let Some(ref mut ac) = auto_complete {
            **ac = AutoCompleteState::default();
        }
        toast.write(InfoToastEvent("Game forfeited".to_string()));
        new_game.write(NewGameRequestEvent::default());
    }
}

/// Marker on the "Done" button inside the Stats modal. Click despawns
/// the overlay; `S` keyboard shortcut toggles it the same way.
#[derive(Component, Debug)]
pub struct StatsCloseButton;

#[allow(clippy::too_many_arguments)]
fn toggle_stats_screen(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    mut requests: MessageReader<ToggleStatsRequestEvent>,
    stats: Res<StatsResource>,
    progress: Option<Res<ProgressResource>>,
    time_attack: Option<Res<TimeAttackResource>>,
    font_res: Option<Res<FontResource>>,
    screens: Query<Entity, With<StatsScreen>>,
) {
    let button_clicked = requests.read().count() > 0;
    if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
        return;
    }
    if let Ok(entity) = screens.single() {
        commands.entity(entity).despawn();
    } else {
        spawn_stats_screen(
            &mut commands,
            &stats.0,
            progress.as_deref().map(|p| &p.0),
            time_attack.as_deref(),
            font_res.as_deref(),
        );
    }
}

/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
    mut commands: Commands,
    close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
    screens: Query<Entity, With<StatsScreen>>,
) {
    if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    for entity in &screens {
        commands.entity(entity).despawn();
    }
}

fn spawn_stats_screen(
    commands: &mut Commands,
    stats: &StatsSnapshot,
    progress: Option<&PlayerProgress>,
    time_attack: Option<&TimeAttackResource>,
    font_res: Option<&FontResource>,
) {
    // --- primary stat cells ---
    // First-launch zero-state: when no games have been played yet, render
    // every top-level cell as an em-dash so the panel doesn't read as a
    // mix of "0" counters and "—" sentinels (which feels buggy).
    let is_first_launch = stats.games_played == 0;
    let dash = "\u{2014}".to_string();
    let win_rate_str    = if is_first_launch { dash.clone() } else { format_win_rate(stats) };
    let played_str      = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_played) };
    let won_str         = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_won) };
    let lost_str        = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_lost) };
    let fastest_str     = if is_first_launch { dash.clone() } else { format_fastest_win(stats.fastest_win_seconds) };
    let avg_time_str    = if is_first_launch { dash.clone() } else { format_avg_time(stats) };
    let best_score_str  = if is_first_launch { dash.clone() } else { format_optional_u32(stats.best_single_score) };
    let best_streak_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.win_streak_best) };

    let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
    let font_section = TextFont {
        font: font_handle.clone(),
        font_size: TYPE_BODY_LG,
        ..default()
    };
    let font_row = TextFont {
        font: font_handle,
        font_size: TYPE_BODY,
        ..default()
    };

    spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
        spawn_modal_header(card, "Statistics", font_res);

        // First-launch caption — sits above the grid as gentle nudge so
        // the wall of em-dashes reads as "nothing to track yet" rather
        // than as broken state.
        if is_first_launch {
            card.spawn((
                Text::new("Play a game to start tracking stats."),
                TextFont {
                    font_size: TYPE_CAPTION,
                    ..default()
                },
                TextColor(TEXT_SECONDARY),
                Node {
                    margin: UiRect {
                        bottom: VAL_SPACE_2,
                        ..default()
                    },
                    ..default()
                },
            ));
        }

        // --- primary stat cells grid ---
        card.spawn(Node {
            flex_direction: FlexDirection::Row,
            flex_wrap: FlexWrap::Wrap,
            justify_content: JustifyContent::Center,
            align_items: AlignItems::FlexStart,
            column_gap: VAL_SPACE_4,
            row_gap: VAL_SPACE_3,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|grid| {
            spawn_stat_cell(grid, &win_rate_str,    "Win Rate");
            spawn_stat_cell(grid, &played_str,      "Games Played");
            spawn_stat_cell(grid, &won_str,         "Games Won");
            spawn_stat_cell(grid, &lost_str,        "Games Lost");
            spawn_stat_cell(grid, &fastest_str,     "Fastest Win");
            spawn_stat_cell(grid, &avg_time_str,    "Avg Time");
            spawn_stat_cell(grid, &best_score_str,  "Best Score");
            spawn_stat_cell(grid, &best_streak_str, "Best Streak");
        });

        // --- progression section ---
        if let Some(p) = progress {
            card.spawn((
                Text::new("Progression"),
                font_section.clone(),
                TextColor(STATE_INFO),
            ));

            let level_str     = format_stat_value(p.level);
            let xp_str        = format_stat_value(p.total_xp as u32);
            let next_label    = xp_to_next_level_label(p.total_xp, p.level);
            let daily_str     = format_stat_value(p.daily_challenge_streak);
            let challenge_str = challenge_progress_label(p.challenge_index);

            card.spawn(Node {
                flex_direction: FlexDirection::Row,
                flex_wrap: FlexWrap::Wrap,
                justify_content: JustifyContent::Center,
                align_items: AlignItems::FlexStart,
                column_gap: VAL_SPACE_4,
                row_gap: VAL_SPACE_3,
                width: Val::Percent(100.0),
                ..default()
            })
            .with_children(|grid| {
                spawn_stat_cell(grid, &level_str,     "Level");
                spawn_stat_cell(grid, &xp_str,        "Total XP");
                spawn_stat_cell(grid, &next_label,    "Next Level");
                spawn_stat_cell(grid, &daily_str,     "Daily Streak");
                spawn_stat_cell(grid, &challenge_str, "Challenge");
            });

            // Weekly goals
            card.spawn((
                Text::new("Weekly Goals"),
                font_section.clone(),
                TextColor(TEXT_SECONDARY),
            ));
            for goal in WEEKLY_GOALS {
                let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
                card.spawn((
                    Text::new(format!("  {}: {}/{}", goal.description, pv, goal.target)),
                    font_row.clone(),
                    TextColor(TEXT_PRIMARY),
                ));
            }

            // Unlocks line
            card.spawn((
                Text::new(format!(
                    "Card Backs: {}  |  Backgrounds: {}",
                    format_id_list(&p.unlocked_card_backs),
                    format_id_list(&p.unlocked_backgrounds),
                )),
                font_row.clone(),
                TextColor(TEXT_SECONDARY),
            ));
        }

        // --- Time Attack section ---
        if let Some(ta) = time_attack
            && ta.active {
                let mins = (ta.remaining_secs / 60.0).floor() as u64;
                let secs = (ta.remaining_secs % 60.0).floor() as u64;
                card.spawn((
                    Text::new(format!(
                        "Time Attack \u{2014} {mins}m {secs:02}s left  |  Wins: {}",
                        ta.wins
                    )),
                    font_section.clone(),
                    TextColor(STATE_WARNING),
                ));
            }

        spawn_modal_actions(card, |actions| {
            spawn_modal_button(
                actions,
                StatsCloseButton,
                "Done",
                Some("S"),
                ButtonVariant::Primary,
                font_res,
            );
        });
    });
}

/// Spawn a single stat cell: a large value label on top and a small
/// descriptor below, inside a fixed-min-width column with a subtle
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
/// fill clashed against the new midnight-purple modal surface.
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
    parent
        .spawn((
            StatsCell,
            Node {
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::Center,
                justify_content: JustifyContent::Center,
                min_width: Val::Px(110.0),
                padding: UiRect::all(VAL_SPACE_2),
                border: UiRect::all(Val::Px(1.0)),
                border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                ..default()
            },
            BorderColor::all(BORDER_SUBTLE),
        ))
        .with_children(|cell| {
            // Large value label — accent yellow makes the number sing
            // against the dark card surface.
            cell.spawn((
                Text::new(value.to_string()),
                TextFont {
                    font_size: TYPE_HEADLINE,
                    ..default()
                },
                TextColor(ACCENT_PRIMARY),
            ));
            // Small descriptor below the value.
            cell.spawn((
                Text::new(label.to_string()),
                TextFont {
                    font_size: TYPE_BODY,
                    ..default()
                },
                TextColor(TEXT_SECONDARY),
            ));
        });
}

/// Format a win-rate value for display.
///
/// Returns `"—"` when no games have been played, otherwise `"N%"`.
pub fn format_win_rate(stats: &StatsSnapshot) -> String {
    match stats.win_rate() {
        None => "\u{2014}".to_string(),
        Some(r) => format!("{}%", (r) as u32),
    }
}

/// Format `fastest_win_seconds` for display.
///
/// Returns `"—"` when the value is `u64::MAX` (sentinel for "no wins yet") or
/// zero. Otherwise delegates to [`format_duration`].
pub fn format_fastest_win(fastest_win_seconds: u64) -> String {
    if fastest_win_seconds == u64::MAX || fastest_win_seconds == 0 {
        "\u{2014}".to_string()
    } else {
        format_duration(fastest_win_seconds)
    }
}

/// Format `avg_time_seconds` for display.
///
/// Returns `"—"` when no games have been won yet (`games_won == 0`), otherwise
/// delegates to [`format_duration`].
pub fn format_avg_time(stats: &StatsSnapshot) -> String {
    if stats.games_won == 0 {
        "\u{2014}".to_string()
    } else {
        format_duration(stats.avg_time_seconds)
    }
}

/// Format an optional `u32` statistic.
///
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
pub fn format_optional_u32(value: u32) -> String {
    if value == 0 {
        "\u{2014}".to_string()
    } else {
        value.to_string()
    }
}

/// Format any `u32`-like stat value as a decimal string.
///
/// Unlike [`format_optional_u32`], this always shows the number (even if zero).
pub fn format_stat_value<T: std::fmt::Display>(value: T) -> String {
    format!("{value}")
}

/// Returns XP remaining until next level, formatted as "N XP (P%)".
fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
    let xp_current = if level < 10 {
        level as u64 * 500
    } else {
        5_000 + (level as u64 - 10) * 1_000
    };
    let xp_next = if level < 10 {
        (level as u64 + 1) * 500
    } else {
        5_000 + (level as u64 - 9) * 1_000
    };
    let span = xp_next - xp_current;
    let done = total_xp.saturating_sub(xp_current).min(span);
    let pct = if span == 0 { 100 } else { done.saturating_mul(100).checked_div(span).unwrap_or(100) };
    let remaining = span - done;
    format!("{remaining} XP ({pct}%)")
}

/// Format a duration given in whole seconds as `"M:SS"`.
///
/// Example: `90` → `"1:30"`.
pub fn format_duration(secs: u64) -> String {
    let m = secs / 60;
    let s = secs % 60;
    format!("{m}:{s:02}")
}

/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
/// Empty list shows as "None".
fn format_id_list(ids: &[usize]) -> String {
    if ids.is_empty() {
        return "None".to_string();
    }
    let mut sorted: Vec<usize> = ids.to_vec();
    sorted.sort_unstable();
    sorted.dedup();
    sorted
        .iter()
        .map(|i| format!("#{i}"))
        .collect::<Vec<_>>()
        .join(", ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::game_plugin::GamePlugin;
    use crate::table_plugin::TablePlugin;

    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(GamePlugin)
            .add_plugins(TablePlugin)
            .add_plugins(StatsPlugin::headless());
        // MinimalPlugins doesn't register keyboard input — add it so the
        // toggle system can read ButtonInput<KeyCode> in tests.
        app.init_resource::<ButtonInput<KeyCode>>();
        // ProgressResource is an optional dependency for the stats screen;
        // include it so toggle tests exercise the progression panel.
        app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
        app.update();
        app
    }

    #[test]
    fn stats_resource_exists_after_startup() {
        let app = headless_app();
        assert!(app.world().get_resource::<StatsResource>().is_some());
    }

    #[test]
    fn headless_plugin_starts_with_default_stats() {
        let app = headless_app();
        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats, &StatsSnapshot::default());
    }

    #[test]
    fn win_event_increments_games_won() {
        let mut app = headless_app();
        app.world_mut().write_message(GameWonEvent {
            score: 1000,
            time_seconds: 120,
        });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_won, 1);
        assert_eq!(stats.games_played, 1);
    }

    #[test]
    fn draw_three_win_increments_draw_three_wins_only() {
        let mut app = headless_app();
        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .draw_mode = solitaire_core::game_state::DrawMode::DrawThree;

        app.world_mut().write_message(GameWonEvent {
            score: 500,
            time_seconds: 200,
        });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.draw_three_wins, 1, "draw_three_wins must increment for DrawThree mode");
        assert_eq!(stats.draw_one_wins, 0, "draw_one_wins must not increment for DrawThree mode");
    }

    #[test]
    fn new_game_after_moves_records_abandoned() {
        let mut app = headless_app();

        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .move_count = 3;

        app.world_mut()
            .write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 1);
        assert_eq!(stats.games_lost, 1);
        assert_eq!(stats.win_streak_current, 0);
    }

    #[test]
    fn new_game_without_moves_does_not_record_abandoned() {
        let mut app = headless_app();
        app.world_mut()
            .write_message(NewGameRequestEvent { seed: Some(42), mode: None, confirmed: false });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.games_played, 0);
    }

    #[test]
    fn pressing_s_spawns_stats_screen() {
        let mut app = headless_app();
        assert_eq!(
            app.world_mut()
                .query::<&StatsScreen>()
                .iter(app.world())
                .count(),
            0
        );

        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        assert_eq!(
            app.world_mut()
                .query::<&StatsScreen>()
                .iter(app.world())
                .count(),
            1
        );
    }

    #[test]
    fn pressing_s_twice_closes_stats_screen() {
        let mut app = headless_app();

        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        // Release + clear + press: `press()` is a no-op if the key is already
        // in `pressed`, and MinimalPlugins doesn't include bevy_input's
        // per-frame updater to drain `just_pressed`, so we cycle manually.
        {
            let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
            input.release(KeyCode::KeyS);
            input.clear();
            input.press(KeyCode::KeyS);
        }
        app.update();

        assert_eq!(
            app.world_mut()
                .query::<&StatsScreen>()
                .iter(app.world())
                .count(),
            0
        );
    }

    #[test]
    fn format_id_list_renders_empty_as_none() {
        assert_eq!(format_id_list(&[]), "None");
    }

    #[test]
    fn format_id_list_sorts_dedups_and_prefixes() {
        assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
    }

    #[test]
    fn xp_to_next_level_label_at_zero_xp() {
        // Level 0, 0 XP: 500 needed, 0% done.
        assert_eq!(xp_to_next_level_label(0, 0), "500 XP (0%)");
    }

    #[test]
    fn xp_to_next_level_label_halfway_through_level_1() {
        // Level 1 starts at 500 XP, level 2 at 1000 XP.
        // At 750 XP: 250 done of 500, 50%, 250 remaining.
        assert_eq!(xp_to_next_level_label(750, 1), "250 XP (50%)");
    }

    #[test]
    fn xp_to_next_level_label_at_level_10_boundary() {
        // Level 10 starts at 5000 XP, level 11 at 6000 XP.
        // At 5000 XP: 0 done, 0%, 1000 remaining.
        assert_eq!(xp_to_next_level_label(5_000, 10), "1000 XP (0%)");
    }

    // -----------------------------------------------------------------------
    // format_duration
    // -----------------------------------------------------------------------

    #[test]
    fn format_duration_zero_seconds() {
        assert_eq!(format_duration(0), "0:00");
    }

    #[test]
    fn format_duration_pads_seconds_to_two_digits() {
        assert_eq!(format_duration(65), "1:05");
    }

    #[test]
    fn format_duration_exactly_one_hour() {
        assert_eq!(format_duration(3600), "60:00");
    }

    #[test]
    fn format_duration_handles_sub_minute() {
        assert_eq!(format_duration(59), "0:59");
    }

    // -----------------------------------------------------------------------
    // Task #65 — win rate and stat cell pure-function tests
    // -----------------------------------------------------------------------

    #[test]
    fn format_win_rate_zero() {
        // 0 wins, 0 played → "—"
        let s = StatsSnapshot::default();
        assert_eq!(format_win_rate(&s), "\u{2014}");
    }

    #[test]
    fn format_win_rate_half() {
        // 5 wins out of 10 played → "50%"
        let s = StatsSnapshot {
            games_played: 10,
            games_won: 5,
            ..StatsSnapshot::default()
        };
        assert_eq!(format_win_rate(&s), "50%");
    }

    #[test]
    fn format_stat_value_zero_returns_zero() {
        assert_eq!(format_stat_value(0u32), "0");
    }

    // -----------------------------------------------------------------------
    // Task #66 — fastest win, best score, streak pure-function tests
    // -----------------------------------------------------------------------

    #[test]
    fn format_fastest_win_unset() {
        // fastest_win_seconds == u64::MAX → "—"
        assert_eq!(format_fastest_win(u64::MAX), "\u{2014}");
    }

    #[test]
    fn format_fastest_win_90s() {
        // 90 seconds → "1:30"
        assert_eq!(format_fastest_win(90), "1:30");
    }

    #[test]
    fn best_score_display_zero() {
        // best_single_score == 0 → "—"
        assert_eq!(format_optional_u32(0), "\u{2014}");
    }

    // -----------------------------------------------------------------------
    // Task #38 — avg time pure-function tests
    // -----------------------------------------------------------------------

    #[test]
    fn format_avg_time_no_wins_shows_dash() {
        // games_won == 0 → "—"
        let s = StatsSnapshot::default();
        assert_eq!(format_avg_time(&s), "\u{2014}");
    }

    #[test]
    fn format_avg_time_after_single_win() {
        // After one win of 90 s avg should be "1:30"
        let s = StatsSnapshot {
            games_won: 1,
            avg_time_seconds: 90,
            ..StatsSnapshot::default()
        };
        assert_eq!(format_avg_time(&s), "1:30");
    }

    #[test]
    fn format_avg_time_after_multiple_wins() {
        // avg_time_seconds = 200 s → "3:20"
        let s = StatsSnapshot {
            games_won: 3,
            avg_time_seconds: 200,
            ..StatsSnapshot::default()
        };
        assert_eq!(format_avg_time(&s), "3:20");
    }

    // -----------------------------------------------------------------------
    // Task #49 — streak-broken toast on forfeit
    // -----------------------------------------------------------------------

    #[test]
    fn forfeit_with_streak_fires_streak_broken_toast() {
        let mut app = headless_app();

        // Set up a streak of 3 and at least one move so forfeit counts.
        {
            let mut stats = app.world_mut().resource_mut::<StatsResource>();
            stats.0.win_streak_current = 3;
        }
        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .move_count = 1;

        app.world_mut().write_message(ForfeitEvent);
        app.update();

        let events = app.world().resource::<Messages<InfoToastEvent>>();
        let mut reader = events.get_cursor();
        let messages: Vec<&str> = reader
            .read(events)
            .map(|e| e.0.as_str())
            .collect();

        assert!(
            messages.contains(&"Streak of 3 broken!"),
            "expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
        );
    }

    #[test]
    fn forfeit_with_streak_of_one_does_not_fire_streak_broken_toast() {
        let mut app = headless_app();

        {
            let mut stats = app.world_mut().resource_mut::<StatsResource>();
            stats.0.win_streak_current = 1;
        }
        app.world_mut()
            .resource_mut::<crate::resources::GameStateResource>()
            .0
            .move_count = 1;

        app.world_mut().write_message(ForfeitEvent);
        app.update();

        let events = app.world().resource::<Messages<InfoToastEvent>>();
        let mut reader = events.get_cursor();
        let messages: Vec<&str> = reader
            .read(events)
            .map(|e| e.0.as_str())
            .collect();

        assert!(
            !messages.iter().any(|m| m.contains("broken")),
            "expected no streak-broken toast for streak of 1, got: {messages:?}"
        );
    }

    // -----------------------------------------------------------------------
    // Streak-milestone flourish — pure helper + event-firing tests
    // -----------------------------------------------------------------------

    /// Pure helper: every threshold in `STREAK_MILESTONES` (3, 5, 10) must
    /// fire when the streak crosses it from below.
    #[test]
    fn streak_milestone_helper_fires_at_each_threshold() {
        for &threshold in STREAK_MILESTONES {
            assert_eq!(
                streak_milestone_crossed(threshold - 1, threshold),
                Some(threshold),
                "expected milestone {threshold} to fire when crossed from below",
            );
        }
    }

    /// Pure helper: rising past 10 to 11, 12, … must NOT fire — the
    /// flourish is a threshold-crossing event, not a "every win past 10"
    /// event.
    #[test]
    fn streak_milestone_helper_does_not_fire_past_highest() {
        // prev=10 → new=11: above the highest threshold, no crossing.
        assert_eq!(streak_milestone_crossed(10, 11), None);
        // prev=15 → new=16: well past every threshold, no crossing.
        assert_eq!(streak_milestone_crossed(15, 16), None);
        // prev=2 → new=2: no change → no crossing.
        assert_eq!(streak_milestone_crossed(2, 2), None);
    }

    /// Pure helper: rising 1 → 2 stays below the lowest threshold (3),
    /// must NOT fire.
    #[test]
    fn streak_milestone_helper_does_not_fire_below_threshold() {
        assert_eq!(streak_milestone_crossed(1, 2), None);
        assert_eq!(streak_milestone_crossed(0, 1), None);
    }

    /// Integration: pre-set streak to 2, fire a win that bumps it to 3,
    /// assert exactly one `WinStreakMilestoneEvent { streak: 3 }` is
    /// written by the win handler.
    #[test]
    fn streak_milestone_event_fires_at_threshold_crossing() {
        let mut app = headless_app();
        {
            let mut stats = app.world_mut().resource_mut::<StatsResource>();
            stats.0.win_streak_current = 2;
        }
        app.world_mut().write_message(GameWonEvent {
            score: 500,
            time_seconds: 90,
        });
        app.update();

        let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
        let mut reader = events.get_cursor();
        let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();

        assert_eq!(
            collected,
            vec![3],
            "expected one WinStreakMilestoneEvent {{ streak: 3 }} after crossing 2 → 3",
        );
    }

    /// Integration: pre-set streak to 1, fire a win that bumps it to 2 —
    /// no threshold is crossed, no event must be fired.
    #[test]
    fn streak_milestone_event_does_not_fire_at_non_threshold() {
        let mut app = headless_app();
        {
            let mut stats = app.world_mut().resource_mut::<StatsResource>();
            stats.0.win_streak_current = 1;
        }
        app.world_mut().write_message(GameWonEvent {
            score: 500,
            time_seconds: 90,
        });
        app.update();

        let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
        let mut reader = events.get_cursor();
        let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();

        assert!(
            collected.is_empty(),
            "expected no WinStreakMilestoneEvent for non-threshold streak crossing 1 → 2, got {collected:?}",
        );
    }

    /// Integration: pre-set streak to 10, fire a win that bumps it to 11.
    /// Past the highest threshold, no event must fire — the flourish
    /// is reserved for the threshold crossing itself.
    #[test]
    fn streak_milestone_event_does_not_fire_past_10() {
        let mut app = headless_app();
        {
            let mut stats = app.world_mut().resource_mut::<StatsResource>();
            stats.0.win_streak_current = 10;
        }
        app.world_mut().write_message(GameWonEvent {
            score: 500,
            time_seconds: 90,
        });
        app.update();

        let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
        let mut reader = events.get_cursor();
        let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();

        assert!(
            collected.is_empty(),
            "expected no WinStreakMilestoneEvent past the highest threshold, got {collected:?}",
        );
    }
}
