//! 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::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_data::{
    PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
    load_replay_history_from, load_stats_from, replay_history_path, save_stats_to, stats_file_path,
};

use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::challenge_progress_label;
use crate::events::{
    ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
    WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::platform::ClipboardBackendResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
    ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
    spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
    ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
    STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
    TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, 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;

/// Resource holding the rolling [`ReplayHistory`] of recent winning
/// replays.
///
/// Populated from `<data_dir>/ferrous_solitaire/replays.json` at startup
/// and refreshed in-place whenever the engine writes a new winning
/// replay so the Stats overlay's selector always reflects the current
/// on-disk history.
///
/// `replays[0]` is the most recent win — the Stats overlay's selector
/// defaults to that entry and lets the player step backwards through
/// up to [`solitaire_data::REPLAY_HISTORY_CAP`] older entries.
#[derive(Resource, Debug, Default, Clone)]
pub struct ReplayHistoryResource(pub ReplayHistory);

/// Marker on the "Copy share link" button inside the Stats modal.
/// Click reads the share URL from the currently-selected replay
/// (`history.0.replays[selected.0].share_url`) and writes it through the
/// active platform clipboard backend, surfacing a confirmation toast. The
/// share URL is populated by `sync_plugin::poll_replay_upload_result`
/// when the corresponding win's upload completes and is persisted to
/// `replays.json` so it survives a restart.
#[derive(Component, Debug)]
pub struct CopyShareLinkButton;

/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
///
/// `0` is the most recent win and is the default on every modal open.
/// The Prev / Next chips wrap-around within the bounds of the current
/// history so the selector is always sat on a valid replay (or on `0`
/// when the history is empty — the chips paint disabled in that case).
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct SelectedReplayIndex(pub usize);

/// Persistence path for the rolling replay history file
/// (`replays.json`). `None` disables I/O — used by tests and by
/// `StatsPlugin::headless`.
#[derive(Resource, Debug, Clone)]
pub struct LatestReplayPath(pub Option<PathBuf>);

/// Marker on the "Watch replay" button inside the Stats modal. Clicking
/// it starts in-engine playback of the selected replay — see
/// [`handle_watch_replay_button`].
#[derive(Component, Debug)]
pub struct WatchReplayButton;

/// Marker on the selector's "Previous replay" chip — steps the
/// selection backwards (toward older replays) within
/// [`ReplayHistoryResource`].
#[derive(Component, Debug)]
pub struct ReplayPrevButton;

/// Marker on the selector's "Next replay" chip — steps the selection
/// forwards (toward more recent replays).
#[derive(Component, Debug)]
pub struct ReplayNextButton;

/// Marker on the selector's `"Replay N / M"` caption text node so the
/// repaint system can rewrite the label as the selection changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorCaption;

/// Marker on the detail text node that shows the selected replay's
/// `"{duration} win on {date}"` + optional `"· Shareable"` badge.
/// Repainted by `repaint_replay_selector_detail` whenever the
/// selection or history changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorDetail;

/// Marker component on each per-mode bests row in the stats overlay.
///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
/// Zen, Challenge — Time Attack and Daily are intentionally excluded; see
/// `StatsSnapshot` doc comments). Tests query by this marker to assert the
/// per-mode section rendered.
#[derive(Component, Debug)]
pub struct PerModeBestsRow;

/// Marker on the scrollable body Node inside the Stats modal.
///
/// The Stats panel renders an 8-cell primary grid, three per-mode bests
/// rows, a five-cell progression grid, weekly goals, an unlocks line,
/// optional Time Attack readout, and the latest replay caption — enough
/// content to overflow the modal on the 800x600 minimum window. This
/// marker tags the inner container that carries `Overflow::scroll_y()`
/// plus a `max_height` constraint. Mirrors the `SettingsPanelScrollable`
/// pattern.
#[derive(Component, Debug)]
pub struct StatsScrollable;

/// 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/ferrous_solitaire/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(),
        };
        // Replay file lives next to stats.json — when the StatsPlugin
        // is in headless mode (storage_path = None), we mirror that
        // policy and disable replay I/O too. Otherwise resolve the
        // platform-default path via `replay_history_path()`.
        let replay_path = self.storage_path.as_ref().and(replay_history_path());
        let initial_history = replay_path
            .as_deref()
            .and_then(load_replay_history_from)
            .unwrap_or_default();
        app.insert_resource(StatsResource(loaded))
            .insert_resource(StatsStoragePath(self.storage_path.clone()))
            .insert_resource(ReplayHistoryResource(initial_history))
            .init_resource::<SelectedReplayIndex>()
            .insert_resource(LatestReplayPath(replay_path))
            .add_message::<GameWonEvent>()
            .add_message::<NewGameRequestEvent>()
            .add_message::<ForfeitEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<ToggleStatsRequestEvent>()
            .add_message::<WinStreakMilestoneEvent>()
            // `MouseWheel` is emitted by Bevy's input plugin under
            // `DefaultPlugins`; register it explicitly so the stats-scroll
            // system also runs cleanly under `MinimalPlugins` in tests.
            .add_message::<MouseWheel>()
            .add_message::<TouchInput>()
            // 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 must run before update_stats_on_new_game so
                // the NewGameRequestEvent it emits is not visible to
                // update_stats_on_new_game in the same frame — otherwise
                // record_abandoned() fires twice on every forfeit (#21).
                handle_forfeit
                    .before(GameMutation)
                    .before(update_stats_on_new_game),
            )
            .add_systems(Update, toggle_stats_screen.after(GameMutation))
            .add_systems(Update, handle_stats_close_button)
            .add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
            .add_systems(Update, handle_watch_replay_button)
            .add_systems(Update, handle_copy_share_link_button)
            .add_systems(
                Update,
                (
                    handle_replay_selector_buttons,
                    repaint_replay_selector_caption,
                    repaint_replay_selector_detail,
                )
                    .chain(),
            )
            .add_systems(Update, scroll_stats_panel)
            .add_systems(
                Update,
                crate::ui_modal::touch_scroll_panel::<StatsScrollable>,
            );
    }
}

/// Routes mouse-wheel events into the Stats modal's scrollable body
/// while the panel is open. No-op when no `StatsScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_stats_panel(
    mut scroll_evr: MessageReader<MouseWheel>,
    mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
) {
    if scrollables.is_empty() {
        scroll_evr.clear();
        return;
    }
    let delta_y: f32 = scroll_evr
        .read()
        .map(|ev| match ev.unit {
            MouseScrollUnit::Line => ev.y * 50.0,
            MouseScrollUnit::Pixel => ev.y,
        })
        .sum();
    if delta_y == 0.0 {
        return;
    }
    for mut sp in scrollables.iter_mut() {
        sp.0.y = (sp.0.y - delta_y).max(0.0);
    }
}

/// After a win, the engine has just appended a fresh winning replay to
/// the rolling history file. Re-load it so the next time the player
/// opens the Stats overlay the selector reflects the new entry, and
/// reset [`SelectedReplayIndex`] to `0` so the default selection is the
/// just-recorded win.
fn refresh_replay_history_on_win(
    mut wins: MessageReader<GameWonEvent>,
    mut history: ResMut<ReplayHistoryResource>,
    mut selected: ResMut<SelectedReplayIndex>,
    path: Res<LatestReplayPath>,
) {
    // Only re-load when at least one win actually fired.
    let mut win_events = wins.read();
    if win_events.next().is_none() {
        return;
    }
    win_events.for_each(|_| {});
    let Some(p) = path.0.as_deref() else {
        return;
    };
    history.0 = load_replay_history_from(p).unwrap_or_default();
    // Snap the selector back to the most recent win — that's the one
    // the player just earned.
    selected.0 = 0;
}

/// Click handler for the "Watch replay" button.
///
/// Starts in-engine replay playback for the currently-selected entry in
/// [`ReplayHistoryResource`] (per [`SelectedReplayIndex`]). If the
/// history is empty or the selector points past the end (defensive
/// guard), surfaces an [`InfoToastEvent`] instead. The playback path
/// resets the live game to the recorded deal and ticks through the
/// move list via [`crate::replay_playback`]; the
/// [`crate::replay_overlay`] banner surfaces while playback runs.
/// Copies the currently-selected replay's `share_url` through the
/// active platform clipboard backend and surfaces a confirmation toast.
/// When no URL is in hand on the selected entry (replay never uploaded
/// — the player won on a local-only backend, the upload failed, or the
/// replay pre-dates v0.19.0 share-link persistence) the button still
/// acknowledges the click but explains why the clipboard wasn't
/// written. Backend failures are logged and fall back to surfacing the
/// share URL directly in a toast.
fn handle_copy_share_link_button(
    buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
    history: Res<ReplayHistoryResource>,
    selected: Res<SelectedReplayIndex>,
    clipboard: Option<Res<ClipboardBackendResource>>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if !buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    let Some(url) = history
        .0
        .replays
        .get(selected.0)
        .and_then(|r| r.share_url.as_ref())
    else {
        toast.write(InfoToastEvent(
            "No share link for this replay \u{2014} win a game on a server-backed sync to upload one.".to_string(),
        ));
        return;
    };

    let Some(clipboard) = clipboard else {
        toast.write(InfoToastEvent(format!("Share link: {url}")));
        return;
    };

    match clipboard.0.set_text(url) {
        Ok(()) => {
            toast.write(InfoToastEvent(format!("Copied: {url}")));
        }
        Err(e) => {
            warn!("clipboard write failed: {e}");
            toast.write(InfoToastEvent(format!("Share link: {url}")));
        }
    }
}

fn handle_watch_replay_button(
    mut commands: Commands,
    buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
    history: Res<ReplayHistoryResource>,
    selected: Res<SelectedReplayIndex>,
    playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if !buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    let chosen = history.0.replays.get(selected.0);
    match (chosen, playback) {
        (Some(replay), Some(mut playback)) => {
            crate::replay_playback::start_replay_playback(
                &mut commands,
                &mut playback,
                replay.clone(),
            );
        }
        (Some(replay), None) => {
            // ReplayPlaybackPlugin not registered (headless test
            // fixtures); fall back to a descriptive toast.
            toast.write(InfoToastEvent(format!(
                "Replay ready ({})",
                format_replay_caption(replay)
            )));
        }
        (None, _) => {
            toast.write(InfoToastEvent(
                "No replay recorded yet \u{2014} win a game first.".to_string(),
            ));
        }
    }
}

/// Click handler for the Prev / Next chips on the Stats overlay's
/// replay selector. Steps [`SelectedReplayIndex`] within the bounds of
/// the current [`ReplayHistoryResource`]; selection wraps so the
/// chooser is always sat on a valid replay.
///
/// No-op when the history is empty — the selector chips paint disabled
/// in that case but a defensive bounds check here keeps things tidy if
/// the click somehow lands.
fn handle_replay_selector_buttons(
    prev: Query<&Interaction, (With<ReplayPrevButton>, Changed<Interaction>)>,
    next: Query<&Interaction, (With<ReplayNextButton>, Changed<Interaction>)>,
    history: Res<ReplayHistoryResource>,
    mut selected: ResMut<SelectedReplayIndex>,
) {
    let len = history.0.replays.len();
    if len == 0 {
        return;
    }
    let prev_pressed = prev.iter().any(|i| *i == Interaction::Pressed);
    let next_pressed = next.iter().any(|i| *i == Interaction::Pressed);
    if prev_pressed {
        // Step toward older replays — wrap to the oldest when at the
        // newest (index 0).
        selected.0 = if selected.0 == 0 {
            len - 1
        } else {
            selected.0 - 1
        };
    }
    if next_pressed {
        // Step toward more recent replays — wrap to the newest when at
        // the oldest.
        selected.0 = (selected.0 + 1) % len;
    }
}

/// Live-update the `"Replay N / M"` caption text as the selector
/// changes. The caption sits next to the Prev / Next chips above the
/// Watch button so the player can see at a glance which replay they're
/// about to watch.
fn repaint_replay_selector_caption(
    history: Res<ReplayHistoryResource>,
    selected: Res<SelectedReplayIndex>,
    mut q: Query<&mut Text, With<ReplaySelectorCaption>>,
) {
    if !history.is_changed() && !selected.is_changed() {
        return;
    }
    for mut text in &mut q {
        **text = replay_selector_caption(selected.0, history.0.replays.len());
    }
}

/// Repaints the `ReplaySelectorDetail` text node whenever the
/// selection or history changes. Shows `"{duration} win on {date}"` for
/// the selected replay, with a `"· Shareable"` badge when the replay
/// carries a sync-uploaded share URL. Empty when the history is empty.
fn repaint_replay_selector_detail(
    history: Res<ReplayHistoryResource>,
    selected: Res<SelectedReplayIndex>,
    mut q: Query<&mut Text, With<ReplaySelectorDetail>>,
) {
    if !history.is_changed() && !selected.is_changed() {
        return;
    }
    let label = replay_selector_detail(&history.0.replays, selected.0);
    for mut text in &mut q {
        **text = label.clone();
    }
}

/// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
    let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
        return String::new();
    };
    let base = format_replay_caption(r);
    if r.share_url.is_some() {
        format!("{base} \u{2022} Shareable") // ·
    } else {
        base
    }
}

/// Pure helper: render the selector caption shown next to the Prev /
/// Next chips. Returns `"No replays"` when the history is empty,
/// otherwise `"Replay {1-based index} / {total}"`.
///
/// `index` is zero-based as it's stored in [`SelectedReplayIndex`].
/// The display flips it to a one-based ordinal so "Replay 1" reads as
/// "the most recent win" — matching the mental model the chooser
/// surfaces.
pub fn replay_selector_caption(index: usize, total: usize) -> String {
    if total == 0 {
        return "No replays".to_string();
    }
    // Defensive clamp — the caller is supposed to keep `index` in
    // range, but a stale selector after a cap-driven truncation
    // shouldn't crash the renderer.
    let one_based = index.min(total.saturating_sub(1)) + 1;
    format!("Replay {one_based} / {total}")
}

/// Pure helper: render a one-line caption for a [`Replay`] suitable
/// for the Stats overlay button label and the "Replay loaded" toast.
///
/// Format: `"M:SS win on YYYY-MM-DD"`. For a 134-second win recorded
/// on 2026-05-02, returns `"2:14 win on 2026-05-02"`.
pub fn format_replay_caption(replay: &Replay) -> String {
    format!(
        "{} win on {}",
        format_duration(replay.time_seconds),
        replay.recorded_at,
    )
}

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>,
) {
    let mut win_events = events.read();
    let Some(ev) = win_events.next() else {
        return;
    };
    win_events.for_each(|_| {});

    let prev_streak = stats.0.win_streak_current;
    stats
        .0
        .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
    // Per-mode best score / fastest win — additive on top of the
    // lifetime totals tracked by `update_on_win`. TimeAttack is a
    // no-op inside the helper because it has its own session-level
    // scoring model.
    stats
        .0
        .update_per_mode_bests(ev.score, ev.time_seconds, game.0.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>>,
    latest_replay: Res<ReplayHistoryResource>,
    selected_index: Res<SelectedReplayIndex>,
    screens: Query<Entity, With<StatsScreen>>,
    other_modal_scrims: Query<(), (With<ModalScrim>, Without<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 {
        if !other_modal_scrims.is_empty() {
            return;
        }
        spawn_stats_screen(
            &mut commands,
            &stats.0,
            progress.as_deref().map(|p| &p.0),
            time_attack.as_deref(),
            font_res.as_deref(),
            &latest_replay.0.replays,
            selected_index.0,
        );
    }
}

/// 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>,
    replays: &[Replay],
    selected_index: usize,
) {
    // --- 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()
    };

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

        // Scrollable body — the Stats panel renders an 8-cell grid plus
        // multiple sections (per-mode bests, progression, weekly goals,
        // unlocks, optional Time Attack, latest replay caption) and
        // overflows the modal on the 800x600 minimum window. Wrapping
        // in an `Overflow::scroll_y()` Node with a constrained
        // `max_height` keeps every cell reachable; the Watch Replay /
        // Done action row stays fixed outside the scroll.
        card.spawn((
            StatsScrollable,
            ScrollPosition::default(),
            Node {
                flex_direction: FlexDirection::Column,
                row_gap: VAL_SPACE_3,
                max_height: Val::Vh(70.0),
                overflow: Overflow::scroll_y(),
                ..default()
            },
        ))
        .with_children(|body| {
            // 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 {
                body.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 ---
            body.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");
            });

            // --- per-mode bests section ---
            // Three rows, one per supported mode. Time Attack uses session-level
            // scoring (count of wins inside a 10-minute window) so a per-game
            // best wouldn't compose; Daily uses Classic scoring and so already
            // contributes to the Classic row.
            body.spawn((
                Text::new("Per-mode bests"),
                font_section.clone(),
                TextColor(STATE_INFO),
            ));
            body.spawn(Node {
                flex_direction: FlexDirection::Column,
                width: Val::Percent(100.0),
                row_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|column| {
                spawn_per_mode_bests_row(
                    column,
                    "Classic",
                    stats.classic_best_score,
                    stats.classic_fastest_win_seconds,
                    &font_row,
                );
                spawn_per_mode_bests_row(
                    column,
                    "Zen",
                    stats.zen_best_score,
                    stats.zen_fastest_win_seconds,
                    &font_row,
                );
                spawn_per_mode_bests_row(
                    column,
                    "Challenge",
                    stats.challenge_best_score,
                    stats.challenge_fastest_win_seconds,
                    &font_row,
                );
            });

            // --- progression section ---
            if let Some(p) = progress {
                body.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);

                body.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
                body.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);
                    body.spawn((
                        Text::new(format!("  {}: {}/{}", goal.description, pv, goal.target)),
                        font_row.clone(),
                        TextColor(TEXT_PRIMARY),
                    ));
                }

                // Unlocks line
                body.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;
                body.spawn((
                    Text::new(format!(
                        "Time Attack \u{2014} {mins}m {secs:02}s left  |  Wins: {}",
                        ta.wins
                    )),
                    font_section.clone(),
                    TextColor(STATE_WARNING),
                ));
            }

            // --- Replay selector ---
            // Prev / Next chips step through the full replay history;
            // `repaint_replay_selector_caption` and
            // `repaint_replay_selector_detail` keep both text nodes
            // live as the selection changes. Using `ModalButton` on
            // the chips plugs them into the existing modal-button
            // hover/press paint loop at no extra cost.
            body.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_3,
                ..default()
            })
            .with_children(|row| {
                // ← Prev chip
                row.spawn((
                    ReplayPrevButton,
                    ModalButton(ButtonVariant::Secondary),
                    Button,
                    Node {
                        padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        border: UiRect::all(Val::Px(1.0)),
                        border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                        ..default()
                    },
                    BackgroundColor(BG_ELEVATED_HI),
                    BorderColor::all(BORDER_SUBTLE),
                    HighContrastBorder::with_default(BORDER_SUBTLE),
                ))
                .with_children(|b| {
                    b.spawn((
                        Text::new("\u{2190}"),
                        font_row.clone(),
                        TextColor(TEXT_PRIMARY),
                    ));
                });

                // "Replay N / M" caption — rewritten live by
                // `repaint_replay_selector_caption`.
                row.spawn((
                    ReplaySelectorCaption,
                    Text::new(replay_selector_caption(selected_index, replays.len())),
                    font_row.clone(),
                    TextColor(TEXT_SECONDARY),
                ));

                // → Next chip
                row.spawn((
                    ReplayNextButton,
                    ModalButton(ButtonVariant::Secondary),
                    Button,
                    Node {
                        padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        border: UiRect::all(Val::Px(1.0)),
                        border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                        ..default()
                    },
                    BackgroundColor(BG_ELEVATED_HI),
                    BorderColor::all(BORDER_SUBTLE),
                    HighContrastBorder::with_default(BORDER_SUBTLE),
                ))
                .with_children(|b| {
                    b.spawn((
                        Text::new("\u{2192}"),
                        font_row.clone(),
                        TextColor(TEXT_PRIMARY),
                    ));
                });
            });

            // Detail line: rewritten live by `repaint_replay_selector_detail`.
            body.spawn((
                ReplaySelectorDetail,
                Text::new(replay_selector_detail(replays, selected_index)),
                font_row.clone(),
                TextColor(TEXT_SECONDARY),
            ));
        });

        spawn_modal_actions(card, |actions| {
            // The Watch Replay button is always rendered so the
            // affordance is discoverable from a fresh install. When no
            // replay exists, the click handler surfaces a clear
            // "No replay recorded yet" toast rather than silently
            // doing nothing.
            spawn_modal_button(
                actions,
                WatchReplayButton,
                "Watch replay",
                None,
                ButtonVariant::Secondary,
                font_res,
            );
            // Copy share link only renders when a sharable URL is in
            // hand. The button is intentionally absent (rather than
            // disabled) when no upload has happened yet — keeps the
            // action bar free of dead controls in the local-only and
            // first-launch cases.
            spawn_modal_button(
                actions,
                CopyShareLinkButton,
                "Copy share link",
                None,
                ButtonVariant::Secondary,
                font_res,
            );
            spawn_modal_button(
                actions,
                StatsCloseButton,
                "Done",
                Some("S"),
                ButtonVariant::Primary,
                font_res,
            );
        });
    });
    // Stats is read-only — opt into click-outside-to-dismiss.
    commands.entity(scrim).insert(ScrimDismissible);
}

/// Spawn one row of the "Per-mode bests" section: the mode label on the
/// left, then the best-score and best-time readouts right-aligned. Each
/// row is tagged with [`PerModeBestsRow`] so tests can count them.
///
/// `best_score == 0` and `fastest_win_seconds == 0` both render as an
/// em-dash, consistent with the first-launch zero-state treatment used
/// by the primary cells above.
fn spawn_per_mode_bests_row(
    parent: &mut ChildSpawnerCommands,
    mode_label: &str,
    best_score: u32,
    fastest_win_seconds: u64,
    font_row: &TextFont,
) {
    let dash = "\u{2014}".to_string();
    let score_str = if best_score == 0 {
        format!("Best {dash}")
    } else {
        format!("Best {best_score}")
    };
    let time_str = if fastest_win_seconds == 0 {
        format!("Best time {dash}")
    } else {
        format!("Best time {}", format_duration(fastest_win_seconds))
    };

    parent
        .spawn((
            PerModeBestsRow,
            Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                justify_content: JustifyContent::SpaceBetween,
                width: Val::Percent(100.0),
                column_gap: VAL_SPACE_3,
                ..default()
            },
        ))
        .with_children(|row| {
            // Mode label on the left.
            row.spawn((
                Text::new(mode_label.to_string()),
                font_row.clone(),
                TextColor(TEXT_PRIMARY),
            ));
            // Right-aligned readouts grouped together.
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                justify_content: JustifyContent::FlexEnd,
                column_gap: VAL_SPACE_3,
                ..default()
            })
            .with_children(|readouts| {
                readouts.spawn((
                    Text::new(score_str),
                    font_row.clone(),
                    TextColor(ACCENT_PRIMARY),
                ));
                readouts.spawn((
                    Text::new(time_str),
                    font_row.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            });
        });
}

/// 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),
            HighContrastBorder::with_default(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::<GameStateResource>()
            .0
            .set_test_draw_mode(solitaire_core::DrawStockConfig::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 multiple_win_events_in_one_frame_increment_once() {
        let mut app = headless_app();
        app.world_mut().write_message(GameWonEvent {
            score: 1000,
            time_seconds: 120,
        });
        app.world_mut().write_message(GameWonEvent {
            score: 1500,
            time_seconds: 90,
        });
        app.update();

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

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

        app.world_mut()
            .resource_mut::<GameStateResource>()
            .0
            .set_test_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 stats_modal_body_is_scrollable() {
        let mut app = headless_app();
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        let count = app
            .world_mut()
            .query::<&StatsScrollable>()
            .iter(app.world())
            .count();
        assert_eq!(
            count, 1,
            "Stats modal must spawn exactly one StatsScrollable body"
        );

        let mut q = app
            .world_mut()
            .query_filtered::<&Node, With<StatsScrollable>>();
        let nodes: Vec<&Node> = q.iter(app.world()).collect();
        assert_ne!(
            nodes[0].max_height,
            Val::Auto,
            "scrollable body must set a non-default max_height"
        );
        assert_eq!(nodes[0].overflow, Overflow::scroll_y());
    }

    #[test]
    fn stats_screen_renders_three_per_mode_bests_rows() {
        // Open the Stats overlay and assert three [`PerModeBestsRow`]
        // entities exist — one per supported [`GameMode`] (Classic, Zen,
        // Challenge — Time Attack and Daily are excluded by design).
        let mut app = headless_app();
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        let row_count = app
            .world_mut()
            .query::<&PerModeBestsRow>()
            .iter(app.world())
            .count();
        assert_eq!(
            row_count, 3,
            "expected three per-mode bests rows (Classic, Zen, Challenge), got {row_count}"
        );
    }

    #[test]
    fn classic_win_event_updates_classic_best_score() {
        // Default mode is Classic — a win event should populate the
        // Classic per-mode bests but leave Zen and Challenge at zero.
        let mut app = headless_app();
        app.world_mut().write_message(GameWonEvent {
            score: 1500,
            time_seconds: 180,
        });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.classic_best_score, 1500);
        assert_eq!(stats.classic_fastest_win_seconds, 180);
        assert_eq!(stats.zen_best_score, 0);
        assert_eq!(stats.challenge_best_score, 0);
    }

    #[test]
    fn zen_win_event_updates_zen_best_score_only() {
        let mut app = headless_app();
        app.world_mut()
            .resource_mut::<GameStateResource>()
            .0
            .mode = solitaire_core::game_state::GameMode::Zen;

        app.world_mut().write_message(GameWonEvent {
            score: 1800,
            time_seconds: 600,
        });
        app.update();

        let stats = &app.world().resource::<StatsResource>().0;
        assert_eq!(stats.zen_best_score, 1800);
        assert_eq!(stats.zen_fastest_win_seconds, 600);
        assert_eq!(stats.classic_best_score, 0);
        assert_eq!(stats.challenge_best_score, 0);
    }

    #[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::<GameStateResource>()
            .0
            .set_test_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::<GameStateResource>()
            .0
            .set_test_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:?}",
        );
    }

    // -----------------------------------------------------------------------
    // Prev/Next replay selector spawn-site tests
    // -----------------------------------------------------------------------

    #[test]
    fn selector_row_spawns_when_stats_screen_opens() {
        let mut app = headless_app();
        // Pre-populate a replay so the selector has something to show.
        {
            let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
            hist.0.replays.push(make_test_replay(90, None));
        }
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        let prev = app
            .world_mut()
            .query::<&ReplayPrevButton>()
            .iter(app.world())
            .count();
        let next = app
            .world_mut()
            .query::<&ReplayNextButton>()
            .iter(app.world())
            .count();
        let caption = app
            .world_mut()
            .query::<&ReplaySelectorCaption>()
            .iter(app.world())
            .count();
        let detail = app
            .world_mut()
            .query::<&ReplaySelectorDetail>()
            .iter(app.world())
            .count();
        assert_eq!(prev, 1, "expected one ReplayPrevButton");
        assert_eq!(next, 1, "expected one ReplayNextButton");
        assert_eq!(caption, 1, "expected one ReplaySelectorCaption");
        assert_eq!(detail, 1, "expected one ReplaySelectorDetail");
    }

    #[test]
    fn selector_caption_initial_text_is_replay_one_of_one() {
        let mut app = headless_app();
        {
            let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
            hist.0.replays.push(make_test_replay(120, None));
        }
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        let mut q = app
            .world_mut()
            .query_filtered::<&Text, With<ReplaySelectorCaption>>();
        let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
        assert_eq!(texts.len(), 1);
        assert_eq!(
            texts[0], "Replay 1 / 1",
            "caption must show '1 / 1' for a single-replay history"
        );
    }

    #[test]
    fn selector_detail_initial_text_matches_replay_caption() {
        let mut app = headless_app();
        {
            let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
            hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
        }
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::KeyS);
        app.update();

        let mut q = app
            .world_mut()
            .query_filtered::<&Text, With<ReplaySelectorDetail>>();
        let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
        assert_eq!(texts.len(), 1);
        assert_eq!(
            texts[0], "1:05 win on 2026-05-08",
            "detail must show formatted replay caption for the selected replay"
        );
    }

    #[test]
    fn selector_detail_appends_shareable_badge_when_url_present() {
        // `replay_selector_detail` is pure — no app setup needed.
        let replays = vec![make_test_replay(
            90,
            Some("https://example.com/r/abc".to_string()),
        )];
        let label = replay_selector_detail(&replays, 0);
        assert!(
            label.contains("Shareable"),
            "detail must include 'Shareable' badge when share_url is set, got: {label:?}"
        );
    }

    #[test]
    fn selector_caption_shows_no_replays_when_history_is_empty() {
        assert_eq!(replay_selector_caption(0, 0), "No replays");
    }

    #[test]
    fn selector_caption_wraps_ordinal_correctly() {
        // index 2 (0-based) in a 3-replay history → "Replay 3 / 3"
        assert_eq!(replay_selector_caption(2, 3), "Replay 3 / 3");
    }

    /// Build a minimal [`Replay`] for use in stats-plugin unit tests.
    ///
    /// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
    /// `time_seconds` and `share_url` are the only varying fields across tests.
    fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
        let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
        let mut r = Replay::new(
            1,
            solitaire_core::DrawStockConfig::DrawOne,
            solitaire_core::game_state::GameMode::Classic,
            time_seconds,
            0,
            date,
            vec![],
        );
        r.share_url = share_url;
        r
    }

    /// 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:?}",
        );
    }
}
