//! Routes game-request events to `solitaire_core::GameState` and emits
//! state-change notifications.
//!
//! Game state persistence: on startup the plugin attempts to restore an
//! in-progress game from `game_state.json`. On app exit the current state is
//! written back (unless the game is won). On a win or new-game request the
//! file is deleted so the next launch starts fresh.

use std::path::PathBuf;

use chrono::Utc;

use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
    Replay, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
    game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path,
    save_game_state_to,
};

use crate::events::{
    CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
    MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};

#[cfg(target_os = "android")]
const NO_MOVES_MSG: &str = "No moves available — tap the stock to draw or start a new game";
#[cfg(not(target_os = "android"))]
const NO_MOVES_MSG: &str = "No moves available — press D to draw or N for a new game";
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
    ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
    spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme;

// ---------------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog
// ---------------------------------------------------------------------------

/// Marker on the confirm-new-game modal root node.
#[derive(Component, Debug)]
pub struct ConfirmNewGameScreen;

// ---------------------------------------------------------------------------
// Task #58 — Game-over overlay
// ---------------------------------------------------------------------------

/// Marker on the game-over overlay root node.
#[derive(Component, Debug)]
pub struct GameOverScreen;

/// System set for `GamePlugin`'s state-mutating systems. Downstream plugins
/// that read the resulting `StateChangedEvent` should schedule themselves
/// `.after(GameMutation)` so updates propagate within a single frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct GameMutation;

/// System set for every writer of [`crate::events::NewGameRequestEvent`].
///
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
/// mode pickers). Their relative append order within a frame is
/// meaningless — consumers drain the whole queue — so members are
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
/// set; do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct NewGameRequestWriters;

/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters;

/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters;

/// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);

/// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
/// file (`replays.json`). `None` disables I/O — used by tests and on
/// minimal Linux containers without `dirs::data_dir()`.
///
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
/// history at this path via
/// [`solitaire_data::append_replay_to_history`], capping at
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
/// unbounded.
#[derive(Resource, Debug, Clone)]
pub struct ReplayPath(pub Option<PathBuf>);

/// Holds the saved-on-disk in-progress game between plugin build and
/// the player's answer to the "Continue or start a new game?" prompt.
///
/// Some(game) at startup means a previously-saved game existed and had
/// real moves on it. The restore-prompt modal swaps it into
/// `GameStateResource` if the player picks Continue, or drops it (and
/// lets `handle_new_game` clean up the disk file) on New Game. None for
/// first-launch installs and for save files that contain a fresh deal
/// with no moves yet — there's nothing meaningful to "continue" there.
#[derive(Resource, Debug, Default)]
pub struct PendingRestoredGame(pub Option<GameState>);

/// Marker on the "Welcome back — Continue or start a new game?" modal
/// scrim. Despawning the scrim cascades to the card and children, so a
/// single `commands.entity(scrim).despawn()` tears the modal down.
#[derive(Component, Debug)]
pub struct RestorePromptScreen;

/// Marker on the modal's primary "Continue" button.
#[derive(Component, Debug)]
pub struct RestoreContinueButton;

/// Marker on the modal's secondary "New game" button.
#[derive(Component, Debug)]
pub struct RestoreNewGameButton;

/// In-memory accumulator for [`KlondikeInstruction`] entries during the
/// current game. Cleared on every new-game start; frozen into a [`Replay`]
/// and flushed to disk by [`record_replay_on_win`] when the player wins.
///
/// Recording captures only successful state-mutating events the player
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
/// intentionally not recorded — see [`solitaire_data::replay`] for the
/// design rationale. Each entry is the atomic player input as a
/// [`KlondikeInstruction`] (a stock click is
/// [`KlondikeInstruction::RotateStock`]); pile-position types are
/// runtime-only and never persisted.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
    /// Ordered list of instructions applied so far this game.
    pub moves: Vec<KlondikeInstruction>,
}

impl RecordingReplay {
    /// Reset the recording. Called on every `NewGameRequestEvent` so a
    /// fresh deal starts with an empty move list.
    pub fn clear(&mut self) {
        self.moves.clear();
    }
}

/// Registers game resources, events, and the systems that route user intent
/// (events) into mutations on `GameState`.
pub struct GamePlugin;

impl GamePlugin {
    /// Plugin with no persistence. Use in headless tests to avoid touching the
    /// real `game_state.json` on disk.
    pub fn headless() -> Self {
        Self
    }
}

impl Plugin for GamePlugin {
    fn build(&self, app: &mut App) {
        let path = game_state_file_path();
        // Try to load any saved in-progress game. We don't want to
        // silently restore a half-played game on launch — the player
        // should get to decide between continuing and starting fresh.
        // So: if there IS a saved game with progress and it isn't
        // already won, hold it in `PendingRestoredGame` and let the
        // restore-prompt modal swap it into `GameStateResource` if
        // the player picks Continue. Otherwise put it directly into
        // `GameStateResource` (existing behaviour for un-played /
        // won deals which there's nothing to ask about).
        let saved = path.as_deref().and_then(load_game_state_from);
        let prompt_worthy = saved
            .as_ref()
            .is_some_and(|g| g.move_count() > 0 && !g.is_won());
        let (initial_state, pending_restore) = if prompt_worthy {
            (
                GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne),
                saved,
            )
        } else {
            (
                saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
                None,
            )
        };

        // One-shot migration from the legacy single-slot
        // `latest_replay.json` to the rolling history at `replays.json`.
        // Runs at plugin construction so the player's last winning
        // replay from a pre-history build is the first entry of the
        // new history file. The legacy file is intentionally left in
        // place for one release as a safety net (see
        // `migrate_legacy_latest_replay` doc comment).
        let history_path = replay_history_path();
        if let (Some(legacy), Some(history)) = (
            #[allow(deprecated)]
            latest_replay_path(),
            history_path.as_ref(),
        ) {
            migrate_legacy_latest_replay(&legacy, history);
        }

        app.insert_resource(GameStateResource(initial_state))
            .insert_resource(GameStatePath(path))
            .insert_resource(ReplayPath(history_path))
            .insert_resource(PendingRestoredGame(pending_restore))
            .init_resource::<RecordingReplay>()
            .init_resource::<PendingNewGameSeed>()
            .init_resource::<DragState>()
            .init_resource::<SyncStatusResource>()
            .add_message::<MoveRequestEvent>()
            .add_message::<DrawRequestEvent>()
            .add_message::<UndoRequestEvent>()
            .add_message::<NewGameRequestEvent>()
            .add_message::<StateChangedEvent>()
            .add_message::<crate::events::MoveRejectedEvent>()
            .add_message::<GameWonEvent>()
            .add_message::<CardFlippedEvent>()
            .add_message::<crate::events::AchievementUnlockedEvent>()
            .add_message::<FoundationCompletedEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<AppLifecycle>()
            // add_message is idempotent; SettingsPlugin also registers this.
            .add_message::<crate::settings_plugin::SettingsChangedEvent>()
            .add_systems(
                Update,
                poll_pending_new_game_seed
                    .before(GameMutation)
                    .in_set(NewGameRequestWriters)
                    .ambiguous_with(NewGameRequestWriters),
            )
            .add_systems(
                Update,
                (handle_new_game, handle_draw, handle_move, handle_undo)
                    .chain()
                    .in_set(GameMutation),
            )
            .add_systems(
                Update,
                check_no_moves
                    .after(GameMutation)
                    .before(crate::card_plugin::BoardVisuals)
                    .in_set(InfoToastWriters)
                    .ambiguous_with(InfoToastWriters),
            )
            .add_systems(Update, record_replay_on_win.after(GameMutation))
            .add_systems(
                Update,
                (
                    handle_confirm_input,
                    handle_confirm_button_input,
                    handle_game_over_input,
                    handle_game_over_button_input,
                )
                    .after(GameMutation)
                    .before(crate::ui_focus::FocusKeys)
                    .in_set(NewGameRequestWriters)
                    .ambiguous_with(NewGameRequestWriters)
                    .in_set(UndoRequestWriters)
                    .ambiguous_with(UndoRequestWriters),
            )
            // Restore prompt: spawn the modal once the splash is gone,
            // route Continue / New Game intents back into the existing
            // GameMutation flow.
            // All pre-mutation game-state writers are chained: elapsed
            // time ticks first, settings sync next, then the restore prompt —
            // a deterministic spine instead of three unordered ResMut holders
            // (ambiguity burn-down, #143).
            .add_systems(
                Update,
                (
                    tick_elapsed_time,
                    sync_settings_to_game,
                    spawn_restore_prompt_if_pending,
                    handle_restore_prompt
                        .in_set(NewGameRequestWriters)
                        .ambiguous_with(NewGameRequestWriters),
                )
                    .chain()
                    .after(crate::settings_plugin::SettingsMutation)
                    .before(GameMutation),
            )
            .init_resource::<AutoSaveTimer>()
            .add_systems(Update, auto_save_game_state.after(GameMutation))
            .add_systems(Last, save_game_state_on_exit);
    }
}

/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
///
/// This covers two cases that the new-game path misses:
/// 1. The initial settings load at startup: saves on disk default to `false`
///    but `Settings` defaults to `true`; the event fires once when the
///    settings file is first read.
/// 2. A user toggling the setting mid-session in the Settings panel.
fn sync_settings_to_game(
    mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
    mut game: ResMut<GameStateResource>,
) {
    for ev in events.read() {
        game.0.take_from_foundation = ev.0.take_from_foundation;
    }
}

/// Pure, testable helper. Updates `elapsed_seconds` and drains the
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
pub fn advance_elapsed(
    elapsed_seconds: &mut u64,
    accumulator: &mut f32,
    delta_secs: f32,
    is_won: bool,
) {
    if is_won {
        return;
    }
    *accumulator += delta_secs;
    while *accumulator >= 1.0 {
        *elapsed_seconds = elapsed_seconds.saturating_add(1);
        *accumulator -= 1.0;
    }
}

/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won), not paused, and no blocking modal
/// (Home picker or first-run onboarding) is covering the board. Stops
/// counting on win so the final time reflects how long the player took;
/// stops while the pause overlay is open; stops while Home is up so the
/// timer doesn't tick before the player commits to a deal; stops while
/// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial.
///
/// On Android the first frame after the app is resumed from background
/// can carry a very large `delta_secs` equal to the entire suspension
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
/// `Suspended` so that frame's delta is dropped instead of applied.
#[allow(clippy::too_many_arguments)]
fn tick_elapsed_time(
    time: Res<Time>,
    mut game: ResMut<GameStateResource>,
    mut accumulator: Local<f32>,
    mut skip_next_delta: Local<bool>,
    paused: Option<Res<crate::pause_plugin::PausedResource>>,
    home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
    onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
    mut lifecycle: MessageReader<AppLifecycle>,
) {
    for event in lifecycle.read() {
        if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
            *skip_next_delta = true;
        }
    }
    if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !onboarding_screens.is_empty() {
        return;
    }
    if *skip_next_delta {
        *skip_next_delta = false;
        return;
    }
    let is_won = game.0.is_won();
    advance_elapsed(
        &mut game.0.elapsed_seconds,
        &mut accumulator,
        time.delta_secs(),
        is_won,
    );
}

fn seed_from_system_time() -> u64 {
    Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
}

/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
///   - `Ok(Some(_))` — winnable (provably solvable); accept.
///   - `Err(_)` — inconclusive (budget exceeded, no proof either way);
///     accept (we treat "we don't know" as winnable so the toggle never
///     silently drops a player into the retry cap).
///   - `Ok(None)` — provably dead; try the next seed.
///
/// If every seed in the retry window is provably dead (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
///
/// In-flight async work for "Winnable deals only" seed selection.
///
/// `handle_new_game` writes here when it needs the solver to vet a deal;
/// `poll_pending_new_game_seed` reads from here, polls the task, and
/// re-emits a `NewGameRequestEvent` with the chosen seed once the task
/// completes. The desktop client's UI never blocks on the worst-case
/// 50 × ~120 ms solver runs that can pile up on pathological deals.
///
/// At most one task is ever in flight: a fresh new-game request while
/// a previous task is still running drops the previous task (Bevy's
/// `Task` `Drop` cancels it cooperatively at the next await point) and
/// queues the new one.
#[derive(Resource, Default)]
pub struct PendingNewGameSeed {
    /// `Some` while a solver-vetted seed is being computed.
    inner: Option<PendingSeedTask>,
}

/// One in-flight winnable-seed search plus the request fields that
/// would have flowed through `handle_new_game` synchronously. The
/// poll system replays them on a synthetic `NewGameRequestEvent` once
/// the task completes — `seed: Some(...)` skips the solver branch on
/// the second pass so we don't loop.
struct PendingSeedTask {
    handle: Task<u64>,
    mode: Option<GameMode>,
    confirmed: bool,
}

/// Update system: poll the in-flight winnable-seed search. When the
/// task resolves, emit a synthetic `NewGameRequestEvent` carrying the
/// chosen seed. Ordered `.before(GameMutation)` so `handle_new_game`
/// picks up the synthetic event on the same frame, completing the
/// new-game flow without a one-frame visual lag.
fn poll_pending_new_game_seed(
    mut pending: ResMut<PendingNewGameSeed>,
    mut new_game_writer: MessageWriter<NewGameRequestEvent>,
) {
    let Some(p) = pending.inner.as_mut() else {
        return;
    };
    let Some(seed) = future::block_on(future::poll_once(&mut p.handle)) else {
        return;
    };
    let mode = p.mode;
    let confirmed = p.confirmed;
    pending.inner = None;
    new_game_writer.write(NewGameRequestEvent {
        seed: Some(seed),
        mode,
        confirmed,
    });
}

/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawStockConfig) -> u64 {
    let mut seed = initial_seed;
    for _ in 0..SOLVER_DEAL_RETRY_CAP {
        match GameState::solve_fresh_deal(
            seed,
            draw_mode,
            DEFAULT_SOLVE_MOVES_BUDGET,
            DEFAULT_SOLVE_STATES_BUDGET,
        ) {
            // Winnable (`Ok(Some)`) or inconclusive (`Err`) → accept as
            // "probably winnable"; only a proven dead deal (`Ok(None)`) retries.
            Ok(Some(_)) | Err(_) => return seed,
            Ok(None) => {
                seed = seed.wrapping_add(1);
            }
        }
    }
    // Retry cap exhausted — accept the latest tried seed rather than
    // recurring forever.
    seed
}

#[allow(clippy::too_many_arguments)]
fn handle_new_game(
    mut commands: Commands,
    mut new_game: MessageReader<NewGameRequestEvent>,
    mut game: ResMut<GameStateResource>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut recording: ResMut<RecordingReplay>,
    mut pending_seed: ResMut<PendingNewGameSeed>,
    settings: Option<Res<crate::settings_plugin::SettingsResource>>,
    path: Option<Res<GameStatePath>>,
    font_res: Option<Res<FontResource>>,
    confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
    game_over_screens: Query<Entity, With<GameOverScreen>>,
    layout: Option<Res<crate::layout::LayoutResource>>,
    mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
    scrims: Query<(), With<ModalScrim>>,
) {
    for ev in new_game.read() {
        // If an active game is in progress, intercept and show a confirm dialog.
        // A game is "active" when moves have been made and it is not yet won.
        let needs_confirm = game.0.move_count() > 0 && !game.0.is_won();
        // Skip confirmation if a ConfirmNewGameScreen already exists (prevents
        // duplicates) or if the event itself was already confirmed by the
        // player pressing Y on the modal — without the `confirmed` check the
        // modal would be respawned the frame after the despawn flushes.
        // Also skip if any other modal scrim is currently open (global guard).
        let confirm_already_open = !confirm_screens.is_empty();
        if needs_confirm && !confirm_already_open && !ev.confirmed {
            if !scrims.is_empty() {
                return;
            }
            // Despawn any stale game-over overlay before showing confirm dialog.
            for entity in &game_over_screens {
                commands.entity(entity).despawn();
            }
            spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
            continue;
        }

        // Despawn confirm and game-over overlays before starting the new game.
        for entity in &confirm_screens {
            commands.entity(entity).despawn();
        }
        for entity in &game_over_screens {
            commands.entity(entity).despawn();
        }

        // Drop any in-flight winnable-seed search now that we've
        // committed to acting on a new request. Its result was for
        // the previous user intent — the new request supersedes it
        // regardless of which branch we take below (synchronous
        // explicit-seed deal vs. another async solver search).
        pending_seed.inner = None;

        let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
        // Prefer the draw mode from Settings when starting a fresh game.
        // Fall back to the current game's draw mode in headless/test contexts
        // where SettingsPlugin is not installed.
        let draw_mode = settings
            .as_ref()
            .map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode);
        let mode = ev.mode.unwrap_or(game.0.mode);

        // Solver-backed retry: when the player has opted in to
        // "Winnable deals only" AND this is a random Classic deal
        // (no caller-supplied seed), reject deals the solver can
        // prove unwinnable and try the next seed. Capped at
        // [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
        // hang the main thread — if every attempt is rejected we
        // fall through to the latest tried seed.
        //
        // **Scope** — the retry deliberately skips:
        // - Daily challenges and challenge-mode seeds (caller passes
        //   `ev.seed = Some(...)` so the player gets the same deal as
        //   everyone else).
        // - Replays (the replay's own seed is authoritative).
        // - Any other explicit seed request — the player asked for
        //   that seed; honour it.
        let winnable_only = settings.as_ref().is_some_and(|s| s.0.winnable_deals_only);
        if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
            let task = AsyncComputeTaskPool::get()
                .spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
            pending_seed.inner = Some(PendingSeedTask {
                handle: task,
                mode: ev.mode,
                confirmed: ev.confirmed,
            });
            // Skip the rest of the new-game flow; the polling system
            // will re-emit a synthetic event with a chosen seed once
            // the task resolves.
            continue;
        }

        let chosen_seed = initial_seed;

        game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
        if let Some(s) = settings.as_ref() {
            game.0.take_from_foundation = s.0.take_from_foundation;
        }
        // Reset the in-flight replay buffer — a fresh deal starts with
        // an empty move list. The previously saved replay on disk
        // (latest_replay.json) is preserved until the player wins again.
        recording.clear();
        // Delete any previously saved in-progress state — this is a fresh game.
        if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
            && let Err(e) = delete_game_state_at(p)
        {
            warn!("game_state: failed to delete saved game: {e}");
        }
        // Snap every existing card sprite to the stock position before the
        // deal animation starts. Without this the per-card slide tween reads
        // each card's previous-game Transform as its source, which lets a
        // careful observer track origin points to deduce where face-down
        // cards came from. Funnelling all sprites through the deck position
        // hides that information and reads naturally as "dealt from the
        // deck." Skipped when LayoutResource isn't present (headless tests).
        if let Some(layout) = layout.as_ref()
            && let Some(stock) = layout.0.pile_positions.get(&KlondikePile::Stock)
        {
            for mut tx in &mut card_transforms {
                tx.translation.x = stock.x;
                tx.translation.y = stock.y;
            }
        }
        changed.write(StateChangedEvent);
    }
}

/// Marker on the primary "New game" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmYesButton;

/// Marker on the secondary "Cancel" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmNoButton;

/// Spawns the confirm-new-game modal using the standard `ui_modal`
/// primitive — uniform scrim, centred card, real buttons with hover /
/// press states.
///
/// Shown when the player requests a new game while moves have been made
/// and the game is not yet won. The original `NewGameRequestEvent` is
/// stored on the scrim entity so `handle_confirm_input` /
/// `handle_confirm_button` can replay it with the same seed / mode on
/// confirmation.
///
/// Replaces a bespoke layout that used plain `Text` labels for "Yes (Y)"
/// and "No (N)" — those were not real Button entities, so the player
/// had no hover / press feedback and the modal felt like a debug panel
/// (the user's smoke-test "#2 complaint").
/// Update-schedule system: once the splash overlay is gone and there's
/// a pending restored game waiting for the player's answer, spawn the
/// "Welcome back — Continue or start a new game?" modal. Idempotent —
/// the existing `RestorePromptScreen` query gates against duplicate
/// spawns if Update fires before the player clicks.
fn spawn_restore_prompt_if_pending(
    mut commands: Commands,
    pending: Res<PendingRestoredGame>,
    splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
    existing: Query<(), With<RestorePromptScreen>>,
    font_res: Option<Res<FontResource>>,
    scrims: Query<(), With<ModalScrim>>,
) {
    if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
        return;
    }
    if !scrims.is_empty() {
        return;
    }
    spawn_modal(
        &mut commands,
        RestorePromptScreen,
        ui_theme::Z_MODAL_PANEL,
        |card| {
            spawn_modal_header(card, "Welcome back", font_res.as_deref());
            spawn_modal_body_text(
                card,
                "You have an in-progress game. Continue where you left off, or start a new one?",
                ui_theme::TEXT_SECONDARY,
                font_res.as_deref(),
            );
            spawn_modal_actions(card, |actions| {
                spawn_modal_button(
                    actions,
                    RestoreNewGameButton,
                    "New game",
                    Some("N"),
                    ButtonVariant::Secondary,
                    font_res.as_deref(),
                );
                spawn_modal_button(
                    actions,
                    RestoreContinueButton,
                    "Continue",
                    Some("Enter"),
                    ButtonVariant::Primary,
                    font_res.as_deref(),
                );
            });
        },
    );
}

/// Click handlers + keyboard shortcuts for the restore prompt.
///
/// Continue (Enter / C) — swaps the saved game into `GameStateResource`
/// and writes a `StateChangedEvent` so card sprites resync to the
/// restored layout.
/// New game (N) — drops the saved game and writes
/// `NewGameRequestEvent { confirmed: true }`. The existing
/// `handle_new_game` flow takes over: deletes `game_state.json`, deals
/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips
/// the abandon-current-game confirm dialog (the player has already
/// confirmed by clicking New game here).
#[allow(clippy::too_many_arguments)]
fn handle_restore_prompt(
    mut commands: Commands,
    keys: Option<Res<ButtonInput<KeyCode>>>,
    screens: Query<Entity, With<RestorePromptScreen>>,
    continue_buttons: Query<&Interaction, (With<RestoreContinueButton>, Changed<Interaction>)>,
    new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
    mut pending: ResMut<PendingRestoredGame>,
    mut game: ResMut<GameStateResource>,
    settings: Option<Res<crate::settings_plugin::SettingsResource>>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
    mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
) {
    if screens.is_empty() {
        return;
    }
    // Esc maps to Continue rather than New Game so a stray dismiss
    // press preserves the saved game — the data-preserving default is
    // the safer fallback when a player hits Esc reflexively to "close
    // this dialog" without reading it.
    let key_continue = keys.as_ref().is_some_and(|k| {
        k.just_pressed(KeyCode::Enter)
            || k.just_pressed(KeyCode::KeyC)
            || k.just_pressed(KeyCode::Escape)
    });
    let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN));
    let click_continue = continue_buttons.iter().any(|i| *i == Interaction::Pressed);
    let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed);

    let resolved = if key_continue || click_continue {
        if let Some(restored) = pending.0.take() {
            game.0 = restored;
            // Patch setting that serialized with the old core default of `false`.
            if let Some(s) = settings.as_ref() {
                game.0.take_from_foundation = s.0.take_from_foundation;
            }
            changed.write(StateChangedEvent);
        }
        for entity in &screens {
            commands.entity(entity).despawn();
        }
        true
    } else if key_new || click_new {
        pending.0 = None;
        for entity in &screens {
            commands.entity(entity).despawn();
        }
        new_game.write(NewGameRequestEvent {
            seed: None,
            mode: None,
            confirmed: true,
        });
        true
    } else {
        false
    };

    // The player has just made an explicit launch-time choice (continue
    // saved game, or start a fresh deal). Suppress the launch-time Home
    // auto-show so it doesn't pop on top of the resolution they picked.
    // `M` still re-opens the picker on demand.
    if resolved && let Some(ref mut shown) = launch_home_shown {
        shown.0 = true;
    }
}

fn spawn_confirm_dialog(
    commands: &mut Commands,
    original_request: NewGameRequestEvent,
    font_res: Option<&FontResource>,
) {
    let scrim = spawn_modal(
        commands,
        ConfirmNewGameScreen,
        ui_theme::Z_MODAL_PANEL,
        |card| {
            spawn_modal_header(card, "Abandon current game?", font_res);
            spawn_modal_body_text(
                card,
                "Your progress will be lost.",
                ui_theme::TEXT_SECONDARY,
                font_res,
            );
            spawn_modal_actions(card, |actions| {
                spawn_modal_button(
                    actions,
                    ConfirmNoButton,
                    "Cancel",
                    Some("Esc"),
                    ButtonVariant::Secondary,
                    font_res,
                );
                spawn_modal_button(
                    actions,
                    ConfirmYesButton,
                    "New game",
                    Some("Y"),
                    ButtonVariant::Primary,
                    font_res,
                );
            });
        },
    );
    // Attach the original request to the scrim so handle_confirm_input
    // and handle_confirm_button can read it on confirmation.
    commands
        .entity(scrim)
        .insert(OriginalNewGameRequest(original_request));
}

/// Carries the original `NewGameRequestEvent` on the confirm overlay so
/// `handle_confirm_input` can replay it with the same seed / mode.
#[derive(Component, Debug, Clone, Copy)]
struct OriginalNewGameRequest(NewGameRequestEvent);

/// Handles keyboard input while `ConfirmNewGameScreen` is open.
///
/// `Y` or `Enter` confirms: despawns the overlay and fires `NewGameRequestEvent`.
/// `N` or `Escape` cancels: despawns the overlay without starting a new game.
fn handle_confirm_input(
    mut commands: Commands,
    keys: Option<Res<ButtonInput<KeyCode>>>,
    screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
) {
    let Ok((entity, original)) = screens.single() else {
        return;
    };
    let Some(keys) = keys else {
        return;
    };

    let confirmed = keys.just_pressed(KeyCode::KeyY) || keys.just_pressed(KeyCode::Enter);
    let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);

    if confirmed {
        commands.entity(entity).despawn();
        // Set `confirmed: true` so handle_new_game skips the dialog spawn
        // and goes straight to the start-game branch. Without this flag the
        // modal would respawn the frame after the despawn flushes (because
        // confirm_screens is empty by then) and the new game would never
        // actually start.
        new_game.write(NewGameRequestEvent {
            seed: original.0.seed,
            mode: original.0.mode,
            confirmed: true,
        });
    } else if cancelled {
        commands.entity(entity).despawn();
    }
}

/// Mouse / touch counterpart to `handle_confirm_input`. Reads
/// `Changed<Interaction>` on the modal's `ConfirmYesButton` /
/// `ConfirmNoButton` so the modal closes and (on confirm) starts a new
/// game whether the player uses the keyboard accelerator or clicks.
///
/// This is the system that closes the user's #2 smoke-test complaint:
/// previously the dialog had only `Text::new("Yes (Y)")` labels — not
/// real button entities — so clicks did nothing and the only path
/// through the modal was the keyboard.
#[allow(clippy::too_many_arguments)]
fn handle_confirm_button_input(
    mut commands: Commands,
    yes_buttons: Query<&Interaction, (With<ConfirmYesButton>, Changed<Interaction>)>,
    no_buttons: Query<&Interaction, (With<ConfirmNoButton>, Changed<Interaction>)>,
    screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
) {
    let Ok((entity, original)) = screens.single() else {
        return;
    };
    let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
    let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);

    if confirmed {
        commands.entity(entity).despawn();
        new_game.write(NewGameRequestEvent {
            seed: original.0.seed,
            mode: original.0.mode,
            confirmed: true,
        });
    } else if cancelled {
        commands.entity(entity).despawn();
    }
}

fn handle_draw(
    mut draws: MessageReader<DrawRequestEvent>,
    mut game: ResMut<GameStateResource>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut flipped: MessageWriter<CardFlippedEvent>,
    mut recording: ResMut<RecordingReplay>,
) {
    for _ in draws.read() {
        // Capture which cards are about to be drawn (top of the stock pile)
        // so we can fire flip events after they land face-up in the waste.
        // Only relevant when stock is non-empty; a recycle moves waste back to
        // stock face-down, so no flip events are needed in that case.
        let drawn_cards: Vec<solitaire_core::Card> = {
            let stock = game.0.stock_cards();
            if stock.is_empty() {
                Vec::new()
            } else {
                let draw_count = match game.0.draw_mode() {
                    DrawStockConfig::DrawOne => 1_usize,
                    DrawStockConfig::DrawThree => 3_usize,
                };
                let n = stock.len();
                let take = n.min(draw_count);
                stock[n - take..].iter().map(|c| c.0.clone()).collect()
            }
        };

        match game.0.draw() {
            Ok(()) => {
                // Fire a flip event for each card that moved from stock to waste.
                for card in drawn_cards {
                    flipped.write(CardFlippedEvent(card));
                }
                // Record the atomic player input. Whether the engine
                // resolves this to a draw or a waste→stock recycle is
                // a deterministic function of stock state at the time
                // the click happens — re-executing on the same starting
                // deal produces the same effect, so the input alone is
                // sufficient to recover the move on playback.
                recording.moves.push(KlondikeInstruction::RotateStock);
                changed.write(StateChangedEvent);
            }
            Err(e) => warn!("draw rejected: {e}"),
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn handle_move(
    mut moves: MessageReader<MoveRequestEvent>,
    mut game: ResMut<GameStateResource>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut won: MessageWriter<GameWonEvent>,
    mut flipped: MessageWriter<CardFlippedEvent>,
    mut foundation_done: MessageWriter<FoundationCompletedEvent>,
    mut recording: ResMut<RecordingReplay>,
    path: Option<Res<GameStatePath>>,
) {
    for ev in moves.read() {
        let was_won = game.0.is_won();
        // Identify the card that will be exposed (and may flip face-up) by the move.
        // It's the card just below the bottom of the moving stack in the source pile.
        let source_cards = pile_cards(&game.0, &ev.from);
        let flip_candidate = {
            let n = source_cards.len();
            if n > ev.count {
                let c = &source_cards[n - ev.count - 1];
                if !c.1 { Some(c.0.clone()) } else { None }
            } else {
                None
            }
        };
        match game.0.move_cards(ev.from, ev.to, ev.count) {
            Ok(()) => {
                // Record the move in the in-flight replay buffer. Done
                // first so the entry is captured even if a subsequent
                // event-write or pile-lookup happens to bail out below.
                // `move_cards` resolved the pile coordinates to a
                // `KlondikeInstruction` and pushed it onto the session
                // history; recover that exact instruction from the tail
                // (no clone — the instruction is `Copy`). Pile-position
                // types are runtime-only, so we persist the instruction
                // rather than the (from, to, count) triple.
                if let Some(instruction) =
                    game.0.session().history().last().map(|s| *s.instruction())
                {
                    recording.moves.push(instruction);
                }
                // Fire flip event if the candidate card is now face-up.
                if let Some(fcard) = flip_candidate
                    && pile_cards(&game.0, &ev.from)
                        .last()
                        .is_some_and(|c| c.0 == fcard && c.1)
                {
                    flipped.write(CardFlippedEvent(fcard));
                }
                // If this move landed on a foundation pile and that pile is
                // now complete (Ace → King, 13 cards), fire the per-suit
                // flourish event. Drives a brief decorative scale-pulse on
                // the King + a golden tint on the foundation marker plus a
                // short audio ping. Purely a UI / audio cue — does not
                // cross `solitaire_sync` and is not persisted.
                if let KlondikePile::Foundation(slot) = ev.to
                    && let Some(slot) = foundation_slot(slot)
                    && game.0.pile(ev.to).len() == 13
                    && let Some(suit) = game.0.pile(ev.to).first().map(|c| c.0.suit())
                {
                    foundation_done.write(FoundationCompletedEvent { slot, suit });
                }
                changed.write(StateChangedEvent);
                if !was_won && game.0.is_won() {
                    won.write(GameWonEvent {
                        score: game.0.score(),
                        time_seconds: game.0.elapsed_seconds,
                    });
                    // Delete the saved state — a won game should not be resumed.
                    if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
                        && let Err(e) = delete_game_state_at(p)
                    {
                        warn!("game_state: failed to delete on win: {e}");
                    }
                }
            }
            Err(e) => warn!(
                "move rejected {:?} -> {:?} x{}: {e}",
                ev.from, ev.to, ev.count
            ),
        }
    }
}

fn handle_undo(
    mut undos: MessageReader<UndoRequestEvent>,
    mut game: ResMut<GameStateResource>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    use solitaire_core::error::MoveError;

    for _ in undos.read() {
        match game.0.undo() {
            Ok(()) => {
                changed.write(StateChangedEvent);
            }
            Err(MoveError::UndoStackEmpty) => {
                toast.write(InfoToastEvent("Nothing to undo".to_string()));
            }
            Err(e) => warn!("undo rejected: {e}"),
        }
    }
}

/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then append it to the rolling
/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
/// (tests inject a temp path).
///
/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
/// entries; older wins age out automatically when the cap is hit. The
/// recording buffer is left intact after the win so a subsequent
/// state-change does not erase the move list before the save completes;
/// it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win(
    mut wins: MessageReader<GameWonEvent>,
    game: Res<GameStateResource>,
    recording: Res<RecordingReplay>,
    path: Option<Res<ReplayPath>>,
) {
    for ev in wins.read() {
        // Skip persistence when the recording is empty. This guards
        // against unrelated tests in other plugins that synthesise a
        // `GameWonEvent` (e.g. to exercise XP / streak / weekly goal
        // logic) without driving any actual moves — those wins should
        // not silently overwrite the developer's real replay file.
        // A real win always has at least one recorded `Move`.
        if recording.moves.is_empty() {
            continue;
        }
        // Recording freezes on win, so the move that triggered the
        // win condition is the last one in the list. Storing the
        // index explicitly lets the playback UI read the WIN MOVE
        // position directly instead of re-deriving it on every render.
        let win_move_index = recording.moves.len().checked_sub(1);
        let replay = Replay::new(
            game.0.seed,
            game.0.draw_mode(),
            game.0.mode,
            ev.time_seconds,
            ev.score,
            Utc::now().date_naive(),
            recording.moves.clone(),
        )
        .with_win_move_index(win_move_index);
        let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
            // No persistence path configured (e.g. tests / minimal Linux
            // containers without dirs::data_dir). The in-memory replay
            // is still available via the resource for callers that want
            // to inspect it without going through the disk.
            continue;
        };
        if let Err(e) = append_replay_to_history(p, replay) {
            warn!("replay: failed to append winning replay to history: {e}");
        }
    }
}

fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> {
    match pile {
        KlondikePile::Stock => game.waste_cards(),
        _ => game.pile(*pile),
    }
}

fn foundation_slot(foundation: solitaire_core::Foundation) -> Option<u8> {
    match foundation {
        solitaire_core::Foundation::Foundation1 => Some(0),
        solitaire_core::Foundation::Foundation2 => Some(1),
        solitaire_core::Foundation::Foundation3 => Some(2),
        solitaire_core::Foundation::Foundation4 => Some(3),
    }
}

// ---------------------------------------------------------------------------
// Task #29 — No-moves detection
// ---------------------------------------------------------------------------

/// Returns `true` if the current game state has at least one legal move
/// that could ever lead to progress.
///
/// Considers a card "playable" if it's currently face-up on the top of
/// the Waste or any Tableau, OR if it lives in the Stock or Waste pile
/// at all (every card in those piles eventually rotates through the
/// Waste's top in both Draw-One and Draw-Three over the course of
/// recycling). For each such candidate, checks whether it can land on
/// any Foundation or any Tableau in the current state.
///
/// Returns `false` only when *no* card anywhere can land anywhere —
/// the player can keep drawing through the stock forever and nothing
/// will ever come of it. This treats "draw cycle with no useful drop"
/// as a softlock rather than as "legal moves remain", which the
/// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool {
    // Drawing from a non-empty stock, and recycling a non-empty waste back to
    // stock, are always legal moves in standard Klondike (unlimited recycles).
    // A game can only be genuinely stuck when both stock AND waste are exhausted.
    let stock_empty = game.stock_cards().is_empty();
    let waste_empty = game.waste_cards().is_empty();
    if !stock_empty || !waste_empty {
        return true;
    }

    // Stock and waste both exhausted — delegate to the authoritative move
    // enumeration in core, which validates tableau sequence structure and
    // foundation placement correctly. The previous hand-rolled loop only
    // checked can_place_on_tableau(card, dest) for individual face-up cards
    // without verifying that the cards above them form a valid alternating run,
    // causing false positives when a useful-looking card was buried under an
    // invalid sequence.
    !game.possible_instructions().is_empty()
}

/// After each `StateChangedEvent`, check if the game has no legal moves.
///
/// When stuck (no legal moves and game not won), fires `InfoToastEvent` and
/// spawns a `GameOverScreen` overlay. The overlay is despawned automatically
/// when `has_legal_moves` returns true again (e.g. after undo) or when the
/// game is won.
#[allow(clippy::too_many_arguments)]
fn check_no_moves(
    mut commands: Commands,
    mut events: MessageReader<StateChangedEvent>,
    game: Res<GameStateResource>,
    mut toast: MessageWriter<InfoToastEvent>,
    mut already_fired: Local<bool>,
    game_over_screens: Query<Entity, With<GameOverScreen>>,
    font_res: Option<Res<FontResource>>,
    scrims: Query<(), With<ModalScrim>>,
) {
    // Reset the debounce flag on every state change so if something changes
    // we re-evaluate on the next state change.
    let had_event = events.read().count() > 0;

    if !had_event {
        return;
    }

    // Reset debounce whenever the state changes.
    *already_fired = false;

    // Despawn game-over overlay whenever moves become available again or game is won.
    let moves_ok = has_legal_moves(&game.0);
    if moves_ok || game.0.is_won() {
        for entity in &game_over_screens {
            commands.entity(entity).despawn();
        }
    }

    if game.0.is_won() {
        return;
    }

    if !moves_ok && !*already_fired {
        toast.write(InfoToastEvent(NO_MOVES_MSG.to_string()));
        *already_fired = true;
        // Only spawn the overlay if one does not already exist, and no other
        // modal scrim is currently open (global ModalScrim guard).
        if game_over_screens.is_empty() && scrims.is_empty() {
            spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
        }
    }
}

/// Marker on the "Undo" secondary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverUndoButton;

/// Marker on the "New Game" primary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverNewGameButton;

/// Spawns the game-over modal using the standard `ui_modal` primitive.
///
/// Replaces a bespoke layout that listed action hints as plain text
/// ("Press N for a new game", "Press G to forfeit") — the audit
/// flagged this as the same class of "feels like a debug panel"
/// problem the confirm modal had. Now there are real buttons with
/// hover/press feedback; the keyboard accelerators stay as optional
/// shortcuts displayed inside the buttons' caption chips.
fn spawn_game_over_screen(commands: &mut Commands, score: i32, font_res: Option<&FontResource>) {
    spawn_modal(commands, GameOverScreen, ui_theme::Z_MODAL_PANEL, |card| {
        spawn_modal_header(card, "No more moves available", font_res);
        spawn_modal_body_text(
            card,
            format!("Final score: {score}"),
            ui_theme::TEXT_PRIMARY,
            font_res,
        );
        spawn_modal_actions(card, |actions| {
            spawn_modal_button(
                actions,
                GameOverUndoButton,
                "Undo",
                Some("U"),
                ButtonVariant::Secondary,
                font_res,
            );
            spawn_modal_button(
                actions,
                GameOverNewGameButton,
                "New Game",
                Some("N"),
                ButtonVariant::Primary,
                font_res,
            );
        });
    });
}

/// Handles keyboard input while `GameOverScreen` is open.
///
/// `N` or `Escape` fires `NewGameRequestEvent` (which will trigger the confirm
/// dialog if moves have been made). `U` fires `UndoRequestEvent` and despawns
/// the overlay — the `check_no_moves` system will re-show it on the next
/// `StateChangedEvent` if the undo did not restore any legal moves.
fn handle_game_over_input(
    mut commands: Commands,
    keys: Option<Res<ButtonInput<KeyCode>>>,
    screens: Query<Entity, With<GameOverScreen>>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
    mut undo: MessageWriter<UndoRequestEvent>,
) {
    if screens.is_empty() {
        return;
    }
    let Some(keys) = keys else {
        return;
    };

    if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
        // confirmed: true — the game is already stuck; no abandon-confirmation needed.
        new_game.write(NewGameRequestEvent {
            confirmed: true,
            ..default()
        });
    } else if keys.just_pressed(KeyCode::KeyU) {
        for entity in &screens {
            commands.entity(entity).despawn();
        }
        undo.write(UndoRequestEvent);
    }
}

/// Mouse / touch counterpart to `handle_game_over_input`. Click on the
/// modal's Undo button → fire `UndoRequestEvent` and despawn so
/// `check_no_moves` can re-evaluate. Click on New Game → fire
/// `NewGameRequestEvent` (the abandon-current-game guard does not apply
/// here because the game is unwinnable).
#[allow(clippy::type_complexity)]
fn handle_game_over_button_input(
    mut commands: Commands,
    new_game_buttons: Query<&Interaction, (With<GameOverNewGameButton>, Changed<Interaction>)>,
    undo_buttons: Query<&Interaction, (With<GameOverUndoButton>, Changed<Interaction>)>,
    screens: Query<Entity, With<GameOverScreen>>,
    mut new_game: MessageWriter<NewGameRequestEvent>,
    mut undo: MessageWriter<UndoRequestEvent>,
) {
    if screens.is_empty() {
        return;
    }
    if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
        // confirmed: true — the game is already stuck; no abandon-confirmation needed.
        new_game.write(NewGameRequestEvent {
            confirmed: true,
            ..default()
        });
    } else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
        for entity in &screens {
            commands.entity(entity).despawn();
        }
        undo.write(UndoRequestEvent);
    }
}

const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;

/// Accumulated real-world seconds since the last auto-save. Exposed as a
/// `Resource` so tests can pre-seed it past the threshold without needing to
/// control `Time::delta_secs()`.
#[derive(Resource, Default)]
pub struct AutoSaveTimer(pub f32);

/// Periodically saves game state every 30 real-world seconds while a game is
/// in progress. The timer uses real delta time (not game elapsed_seconds) so
/// it keeps ticking even if the game clock is paused.
fn auto_save_game_state(
    time: Res<Time>,
    game: Res<GameStateResource>,
    path: Option<Res<GameStatePath>>,
    mut timer: ResMut<AutoSaveTimer>,
    paused: Option<Res<crate::pause_plugin::PausedResource>>,
    pending: Res<PendingRestoredGame>,
) {
    // Don't save if paused, game is won, no moves have been made yet,
    // or there's a pending restore the player hasn't answered — saving
    // the fresh-deal placeholder we seeded GameStateResource with at
    // startup would clobber the real saved game on disk.
    if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
    {
        return;
    }
    timer.0 += time.delta_secs();
    if timer.0 >= AUTO_SAVE_INTERVAL_SECS {
        timer.0 -= AUTO_SAVE_INTERVAL_SECS;
        let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
            return;
        };
        if let Err(e) = save_game_state_to(p, &game.0) {
            warn!("game_state: auto-save failed: {e}");
        }
    }
}

/// Last-schedule system: persists the current game state on `AppExit` so the
/// player can resume where they left off. Won games are not saved (the
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
/// because the game loop is already shutting down.
///
/// Special case: when `PendingRestoredGame` still holds a saved game the
/// player never answered the restore prompt for, write THAT to disk
/// instead of the live `GameStateResource`. Otherwise we'd clobber a
/// real saved game with the fresh-deal placeholder we seeded
/// `GameStateResource` with at startup.
fn save_game_state_on_exit(
    mut exit_events: MessageReader<AppExit>,
    game: Res<GameStateResource>,
    path: Res<GameStatePath>,
    pending: Res<PendingRestoredGame>,
) {
    if exit_events.is_empty() {
        return;
    }
    exit_events.clear();
    let Some(p) = path.0.as_deref() else { return };
    let to_save = pending.0.as_ref().unwrap_or(&game.0);
    if let Err(e) = save_game_state_to(p, to_save) {
        warn!("game_state: failed to save on exit: {e}");
    }
}

#[cfg(test)]
mod tests;
