use super::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};

/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
fn test_app(seed: u64) -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
    // Disable I/O — tests must not touch the real game state file or
    // the real replay file. Both default to dirs::data_dir() in the
    // plugin's build path; clearing them keeps tests self-contained.
    app.insert_resource(GameStatePath(None));
    app.insert_resource(ReplayPath(None));
    // Force `PendingRestoredGame` empty so production saved-game
    // state on the dev machine's disk (loaded by `GamePlugin::build`)
    // can't leak into per-test world state and trip the
    // `pending.0.is_some()` guard in `auto_save_game_state` /
    // `save_game_state_on_exit`. Without this clear, an
    // unrelated `~/.local/share/ferrous_solitaire/game_state.json`
    // would silently disable the auto-save path under test.
    app.insert_resource(PendingRestoredGame(None));
    // Override the system-time seed with a known value.
    app.world_mut().resource_mut::<GameStateResource>().0 =
        GameState::new(seed, DrawStockConfig::DrawOne);
    app
}

#[test]
fn plugin_inserts_game_state_resource() {
    let app = test_app(1);
    assert!(app.world().get_resource::<GameStateResource>().is_some());
    assert!(app.world().get_resource::<GameStatePath>().is_some());
    assert!(app.world().get_resource::<DragState>().is_some());
    assert!(app.world().get_resource::<SyncStatusResource>().is_some());
}

#[test]
fn draw_request_advances_game_state() {
    let mut app = test_app(42);
    let stock_before = app
        .world()
        .resource::<GameStateResource>()
        .0
        .stock_cards()
        .len();

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

    let stock_after = app
        .world()
        .resource::<GameStateResource>()
        .0
        .stock_cards()
        .len();
    let waste_after = app
        .world()
        .resource::<GameStateResource>()
        .0
        .waste_cards()
        .len();
    assert_eq!(stock_after, stock_before - 1);
    assert_eq!(waste_after, 1);
}

#[test]
fn draw_request_fires_state_changed_event() {
    let mut app = test_app(42);
    app.world_mut().write_message(DrawRequestEvent);
    app.update();
    let events = app.world().resource::<Messages<StateChangedEvent>>();
    let mut reader = events.get_cursor();
    assert!(reader.read(events).next().is_some());
}

#[test]
fn undo_after_draw_restores_state() {
    let mut app = test_app(42);
    app.world_mut().write_message(DrawRequestEvent);
    app.update();
    app.world_mut().write_message(UndoRequestEvent);
    app.update();
    let g = &app.world().resource::<GameStateResource>().0;
    assert_eq!(g.stock_cards().len(), 24);
    assert_eq!(g.waste_cards().len(), 0);
}

#[test]
fn new_game_request_reseeds() {
    let mut app = test_app(1);
    let before: Vec<solitaire_core::Card> = app
        .world()
        .resource::<GameStateResource>()
        .0
        .pile(KlondikePile::Tableau(Tableau::Tableau1))
        .iter()
        .map(|c| c.0.clone())
        .collect();

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

    let after: Vec<solitaire_core::Card> = app
        .world()
        .resource::<GameStateResource>()
        .0
        .pile(KlondikePile::Tableau(Tableau::Tableau1))
        .iter()
        .map(|c| c.0.clone())
        .collect();
    assert_ne!(before, after);
}

#[test]
fn settings_changed_updates_take_from_foundation_flag() {
    let mut app = test_app(1);
    assert!(
        app.world()
            .resource::<GameStateResource>()
            .0
            .take_from_foundation,
        "fresh game should inherit default take_from_foundation=true",
    );

    let mut settings = solitaire_data::Settings {
        take_from_foundation: false,
        ..Default::default()
    };
    app.world_mut()
        .write_message(crate::settings_plugin::SettingsChangedEvent(
            settings.clone(),
        ));
    app.update();
    assert!(
        !app.world()
            .resource::<GameStateResource>()
            .0
            .take_from_foundation,
        "settings event must forward take_from_foundation=false into live game state",
    );

    settings.take_from_foundation = true;
    app.world_mut()
        .write_message(crate::settings_plugin::SettingsChangedEvent(settings));
    app.update();
    assert!(
        app.world()
            .resource::<GameStateResource>()
            .0
            .take_from_foundation,
        "settings event must forward take_from_foundation=true into live game state",
    );
}

#[test]
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
    let mut elapsed = 0;
    let mut acc = 0.0;
    advance_elapsed(&mut elapsed, &mut acc, 2.5, false);
    assert_eq!(elapsed, 2);
    // Remaining 0.5 should still be in the accumulator.
    advance_elapsed(&mut elapsed, &mut acc, 0.5, false);
    assert_eq!(elapsed, 3);
}

#[test]
fn advance_elapsed_is_noop_when_won() {
    let mut elapsed = 100;
    let mut acc = 0.0;
    advance_elapsed(&mut elapsed, &mut acc, 5.0, true);
    assert_eq!(elapsed, 100);
    assert_eq!(acc, 0.0);
}

#[test]
fn advance_elapsed_saturates_at_u64_max() {
    let mut elapsed = u64::MAX;
    let mut acc = 0.0;
    advance_elapsed(&mut elapsed, &mut acc, 5.0, false);
    assert_eq!(elapsed, u64::MAX, "elapsed must not overflow past u64::MAX");
}

#[test]
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
    let mut elapsed = 0;
    let mut acc = 0.0;
    // 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
    for _ in 0..4 {
        advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
    }
    assert_eq!(elapsed, 1);
    // Repeat once more for a total of 2 seconds.
    for _ in 0..4 {
        advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
    }
    assert_eq!(elapsed, 2);
}

#[test]
fn invalid_move_does_not_fire_state_changed() {
    let mut app = test_app(42);
    // Stock -> Waste is InvalidDestination; no state change expected.
    app.world_mut().write_message(MoveRequestEvent {
        from: KlondikePile::Stock,
        to: KlondikePile::Stock,
        count: 1,
    });
    app.update();
    let events = app.world().resource::<Messages<StateChangedEvent>>();
    let mut reader = events.get_cursor();
    assert!(reader.read(events).next().is_none());
}

// -----------------------------------------------------------------------
// Persistence tests
// -----------------------------------------------------------------------

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

/// save_game_state_on_exit writes to disk when AppExit fires.
#[test]
fn exit_saves_game_state() {
    use solitaire_data::load_game_state_from;

    let path = tmp_gs_path("exit_save");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(7);
    // Point persistence at our temp file.
    app.insert_resource(GameStatePath(Some(path.clone())));
    // Override the seed so we can verify it was written.
    app.world_mut().resource_mut::<GameStateResource>().0 =
        GameState::new(7654, DrawStockConfig::DrawOne);

    app.world_mut().write_message(AppExit::Success);
    app.update();

    let loaded = load_game_state_from(&path).expect("file should exist after exit");
    assert_eq!(loaded.seed, 7654);

    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);
}

/// new_game_request deletes any previously saved state file.
#[test]
fn new_game_deletes_saved_state() {
    use solitaire_data::save_game_state_to;

    let path = tmp_gs_path("new_game_delete");
    // Pre-create a saved file.
    save_game_state_to(&path, &GameState::new(1, DrawStockConfig::DrawOne)).unwrap();
    assert!(path.exists());

    let mut app = test_app(1);
    app.insert_resource(GameStatePath(Some(path.clone())));
    app.world_mut().write_message(NewGameRequestEvent {
        seed: Some(2),
        mode: None,
        confirmed: false,
    });
    app.update();

    assert!(
        !path.exists(),
        "saved file should be deleted after new game"
    );
}

/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
/// can be 0.0 (or, under heavy parallel cargo-test load, large
/// enough that the pre-seeded margin is consumed by it), so a
/// single-frame check is fragile. Looping until the file appears
/// (or hitting the bound) makes the test robust against
/// first-frame Time variance without changing the underlying
/// behaviour contract.
#[test]
fn auto_save_writes_after_30_seconds() {
    use solitaire_data::load_game_state_from;

    let path = tmp_gs_path("auto_save_30s");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(42);
    app.insert_resource(GameStatePath(Some(path.clone())));
    // Give the game one move so move_count > 0 (auto-save guard).
    app.world_mut()
        .resource_mut::<GameStateResource>()
        .0
        .set_test_move_count(1);

    // Re-arm the timer past the threshold every frame and pump
    // updates until the save fires. Caps at 16 iterations — a
    // healthy run hits it on the first or second frame; the cap
    // prevents an infinite loop if a future regression skips
    // the save unconditionally.
    for _ in 0..16 {
        app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
        app.update();
        if path.exists() {
            break;
        }
    }

    assert!(
        path.exists(),
        "auto-save file must exist after timer crosses threshold"
    );
    let loaded = load_game_state_from(&path).expect("file must be loadable");
    assert_eq!(loaded.seed, 42);

    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);
}

/// auto_save_game_state does NOT write to disk when no moves have been made.
#[test]
fn auto_save_skips_when_no_moves() {
    let path = tmp_gs_path("auto_save_skip");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(99);
    app.insert_resource(GameStatePath(Some(path.clone())));
    // move_count stays at 0 (fresh game); timer is past threshold.
    app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
    app.update();

    assert!(
        !path.exists(),
        "auto-save must not fire when move_count == 0"
    );
}

#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
    use solitaire_core::{Card, Deck, Rank, Suit};
    let mut app = test_app(1);
    // Build a tableau with two face-up cards.
    {
        let mut gs = app.world_mut().resource_mut::<GameStateResource>();
        gs.0.set_test_tableau_cards(
            Tableau::Tableau1,
            vec![
                Card::new(Deck::Deck1, Suit::Clubs, Rank::King),
                Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen),
            ],
        );
        gs.0.set_test_tableau_cards(
            Tableau::Tableau2,
            vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
        );
    }

    app.world_mut().write_message(MoveRequestEvent {
        from: KlondikePile::Tableau(Tableau::Tableau1),
        to: KlondikePile::Tableau(Tableau::Tableau2),
        count: 1,
    });
    app.update();

    let events = app
        .world()
        .resource::<Messages<CardFlippedEvent>>();
    let mut cursor = events.get_cursor();
    let fired: Vec<_> = cursor.read(events).collect();
    assert!(
        fired.is_empty(),
        "no flip event when exposed card was already face-up"
    );
}

// -----------------------------------------------------------------------
// Task #29 — has_legal_moves pure-function tests
// -----------------------------------------------------------------------

#[test]
fn has_legal_moves_returns_true_for_fresh_game() {
    // A fresh deal always has a non-empty stock (24 cards), so drawing
    // is always a legal move regardless of the initial face-up tableau cards.
    let game = GameState::new(42, DrawStockConfig::DrawOne);
    assert!(
        has_legal_moves(&game),
        "fresh deal must contain at least one legal move"
    );
}

#[test]
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
    // Drawing from a non-empty stock is always a legal move in standard
    // Klondike (unlimited recycles), even if the drawn card cannot be
    // immediately placed. The game is only stuck when both stock AND waste
    // are exhausted and no visible card can be moved.
    use solitaire_core::{Card, Deck, Rank, Suit};
    let mut game = GameState::new(1, DrawStockConfig::DrawOne);
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        game.set_test_foundation_cards(foundation, Vec::new());
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        game.set_test_tableau_cards(tableau, Vec::new());
    }
    game.set_test_waste_cards(Vec::new());
    let mut stock = Vec::new();
    for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
        stock.push(Card::new(Deck::Deck1, Suit::Hearts, r));
    }
    game.set_test_stock_cards(stock);
    // Stock is non-empty, so drawing is always a valid move.
    assert!(
        has_legal_moves(&game),
        "non-empty stock means drawing is a legal move regardless of placement options",
    );
}

#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
    use solitaire_core::{Card, Deck, Rank, Suit};
    let mut game = GameState::new(1, DrawStockConfig::DrawOne);

    // Empty stock and waste so draw is NOT available.
    game.set_test_stock_cards(Vec::new());
    game.set_test_waste_cards(Vec::new());

    // Clear all tableau and foundations, put Ace of Clubs on tableau 0.
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        game.set_test_foundation_cards(foundation, Vec::new());
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        game.set_test_tableau_cards(tableau, Vec::new());
    }
    game.set_test_tableau_cards(
        Tableau::Tableau1,
        vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
    );

    assert!(
        has_legal_moves(&game),
        "Ace can always go to an empty foundation"
    );
}

#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
    // Regression: the bug only checked t.cards.last() (top face-up card).
    // If the only legal move involves a face-up card that is NOT the top
    // card of its column the previous code would return false (softlock)
    // even though the player can still move that run.
    use solitaire_core::{Card, Deck, Rank, Suit};
    let mut game = GameState::new(1, DrawStockConfig::DrawOne);

    game.set_test_stock_cards(Vec::new());
    game.set_test_waste_cards(Vec::new());
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        game.set_test_foundation_cards(foundation, Vec::new());
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        game.set_test_tableau_cards(tableau, Vec::new());
    }

    // Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
    // King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
    // only legal tableau move, and that move targets the Queen which is non-top.
    game.set_test_tableau_cards(
        Tableau::Tableau1,
        vec![
            Card::new(Deck::Deck1, Suit::Spades, Rank::Queen),
            Card::new(Deck::Deck1, Suit::Hearts, Rank::Jack),
        ],
    );
    game.set_test_tableau_cards(
        Tableau::Tableau2,
        vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
    );

    assert!(
        has_legal_moves(&game),
        "Queen (non-top face-up) should be detected as a valid move source onto King",
    );
}

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

/// Helper that also initialises `ButtonInput<KeyCode>` so the keyboard
/// systems do not panic in MinimalPlugins environments.
fn test_app_with_input(seed: u64) -> App {
    let mut app = test_app(seed);
    app.init_resource::<ButtonInput<KeyCode>>();
    app
}

#[test]
fn new_game_request_with_moves_spawns_confirm_dialog() {
    let mut app = test_app_with_input(42);
    // Simulate an active game with moves made.
    app.world_mut()
        .resource_mut::<GameStateResource>()
        .0
        .set_test_move_count(5);
    app.world_mut().write_message(NewGameRequestEvent {
        seed: None,
        mode: None,
        confirmed: false,
    });
    app.update();

    let count = app
        .world_mut()
        .query::<&ConfirmNewGameScreen>()
        .iter(app.world())
        .count();
    assert_eq!(
        count, 1,
        "ConfirmNewGameScreen must be spawned when move_count > 0"
    );
}

#[test]
fn new_game_request_on_fresh_game_skips_confirm() {
    let mut app = test_app_with_input(42);
    // move_count stays at 0 (fresh game).
    assert_eq!(
        app.world().resource::<GameStateResource>().0.move_count(),
        0,
        "test assumes a fresh game with no moves"
    );
    app.world_mut().write_message(NewGameRequestEvent {
        seed: None,
        mode: None,
        confirmed: false,
    });
    app.update();

    let count = app
        .world_mut()
        .query::<&ConfirmNewGameScreen>()
        .iter(app.world())
        .count();
    assert_eq!(
        count, 0,
        "ConfirmNewGameScreen must NOT appear for a fresh game"
    );
}

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

#[test]
fn game_over_screen_absent_when_moves_available() {
    // A fresh game always has moves (stock is non-empty).
    let mut app = test_app_with_input(42);
    app.world_mut().write_message(StateChangedEvent);
    app.update();

    let count = app
        .world_mut()
        .query::<&GameOverScreen>()
        .iter(app.world())
        .count();
    assert_eq!(
        count, 0,
        "GameOverScreen must not appear when moves are available"
    );
}

// Verify that the game-over overlay contains the expected header text and
// action-hint strings so players understand why the overlay appeared and
// what keys to press.
// -----------------------------------------------------------------------
// Task #56 — Escape dismisses GameOverScreen and starts new game
// -----------------------------------------------------------------------

// Pressing Escape while `GameOverScreen` is visible must fire
// `NewGameRequestEvent` — identical behaviour to pressing N.
// -----------------------------------------------------------------------
// Task #48 — Undo with empty stack fires InfoToastEvent
// -----------------------------------------------------------------------

/// Sending `UndoRequestEvent` on a fresh game (empty undo stack) must fire
/// exactly one `InfoToastEvent` with the message "Nothing to undo".
#[test]
fn undo_on_empty_stack_fires_info_toast() {
    let mut app = test_app(42);
    // Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
    app.world_mut().write_message(UndoRequestEvent);
    app.update();

    let events = app.world().resource::<Messages<InfoToastEvent>>();
    let mut reader = events.get_cursor();
    let fired: Vec<_> = reader.read(events).collect();
    assert_eq!(
        fired.len(),
        1,
        "exactly one InfoToastEvent must fire on empty-stack undo"
    );
    assert_eq!(
        fired[0].0, "Nothing to undo",
        "toast message must be 'Nothing to undo'"
    );
}

// -----------------------------------------------------------------------
// Foundation-completion flourish — FoundationCompletedEvent firing logic
// -----------------------------------------------------------------------

/// Reading helper: collect every `FoundationCompletedEvent` written
/// during the most recent `update()` so the test body can assert
/// against count, slot, and suit.
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
    let events = app.world().resource::<Messages<FoundationCompletedEvent>>();
    let mut cursor = events.get_cursor();
    cursor.read(events).copied().collect()
}

/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
    use solitaire_core::{Card, Deck, Rank, Suit};

    let mut app = test_app(1);
    // Reset the world: clear stock + waste so a draw isn't possible,
    // empty all tableaux + foundations, then place a face-up King of
    // Spades on Tableau(0). Tableau(1) is empty, so the King can move
    // there legally.
    {
        let mut gs = app.world_mut().resource_mut::<GameStateResource>();
        gs.0.set_test_stock_cards(Vec::new());
        gs.0.set_test_waste_cards(Vec::new());
        for foundation in [
            Foundation::Foundation1,
            Foundation::Foundation2,
            Foundation::Foundation3,
            Foundation::Foundation4,
        ] {
            gs.0.set_test_foundation_cards(foundation, Vec::new());
        }
        for tableau in [
            Tableau::Tableau1,
            Tableau::Tableau2,
            Tableau::Tableau3,
            Tableau::Tableau4,
            Tableau::Tableau5,
            Tableau::Tableau6,
            Tableau::Tableau7,
        ] {
            gs.0.set_test_tableau_cards(tableau, Vec::new());
        }
        gs.0.set_test_tableau_cards(
            Tableau::Tableau1,
            vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
        );
    }

    app.world_mut().write_message(MoveRequestEvent {
        from: KlondikePile::Tableau(Tableau::Tableau1),
        to: KlondikePile::Tableau(Tableau::Tableau2),
        count: 1,
    });
    app.update();

    let fired = drain_foundation_events(&app);
    assert!(
        fired.is_empty(),
        "FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
    );
}

/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
    let mut app = test_app(42);
    // Make a move so the undo stack is non-empty.
    app.world_mut().write_message(DrawRequestEvent);
    app.update();
    // Clear events from the draw so we start with a clean slate.
    app.world_mut()
        .resource_mut::<Messages<InfoToastEvent>>()
        .clear();

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

    let events = app.world().resource::<Messages<InfoToastEvent>>();
    let mut reader = events.get_cursor();
    let fired: Vec<_> = reader.read(events).collect();
    assert!(
        fired.is_empty(),
        "no InfoToastEvent must fire on a successful undo"
    );
}

// -----------------------------------------------------------------------
// Win-game replay recording
//
// The recording resource captures exactly the player-driven actions
// that successfully advanced GameState. On GameWonEvent it freezes
// into a Replay (with seed/mode/time/score metadata) and persists.
// -----------------------------------------------------------------------

/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
    let mut app = test_app(42);
    // Stock → Waste is InvalidDestination; the live engine rejects it.
    app.world_mut().write_message(MoveRequestEvent {
        from: KlondikePile::Stock,
        to: KlondikePile::Stock,
        count: 1,
    });
    app.update();

    let recording = app.world().resource::<RecordingReplay>();
    assert!(
        recording.moves.is_empty(),
        "rejected moves must not enter the recording, got {:?}",
        recording.moves,
    );
}

/// Undo intentionally does NOT enter the recording. The replay
/// represents the canonical path the player took to win, not the
/// missteps that were rolled back.
#[test]
fn replay_recording_skips_undo() {
    let mut app = test_app(42);
    app.world_mut().write_message(DrawRequestEvent);
    app.update();
    app.world_mut().write_message(UndoRequestEvent);
    app.update();

    let recording = app.world().resource::<RecordingReplay>();
    assert_eq!(
        recording.moves.len(),
        1,
        "only the draw is recorded; the undo does not erase it nor add a new entry",
    );
    assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
}

/// Starting a new game wipes the recording so the next deal begins
/// with a clean buffer.
#[test]
fn replay_recording_clears_on_new_game() {
    let mut app = test_app(1);
    app.world_mut().write_message(DrawRequestEvent);
    app.update();
    assert_eq!(
        app.world().resource::<RecordingReplay>().moves.len(),
        1,
        "draw should have been recorded",
    );

    // Use `confirmed: true` so the request bypasses the
    // abandon-current-game modal (which fires when move_count > 0)
    // and goes straight to the new-game branch that clears the
    // recording. The modal-spawn path is exercised by other tests
    // in this module.
    app.world_mut().write_message(NewGameRequestEvent {
        seed: Some(2),
        mode: None,
        confirmed: true,
    });
    app.update();

    let recording = app.world().resource::<RecordingReplay>();
    assert!(
        recording.moves.is_empty(),
        "recording must be cleared on new-game start; got {:?}",
        recording.moves,
    );
}

/// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test]
fn replay_recording_freezes_into_replay_on_game_won() {
    use solitaire_data::load_replay_history_from;

    let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(7654);
    app.insert_resource(ReplayPath(Some(path.clone())));

    // Push two recorded instructions manually so we can verify they
    // survive the freeze/save round-trip without having to drive a
    // real win. Both are `RotateStock` — the only instruction
    // constructible without the runtime-only `klondike` pile-stack
    // types (which the engine intentionally does not depend on); the
    // round-trip shape is identical for any instruction variant.
    {
        let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
        recording.moves.push(KlondikeInstruction::RotateStock);
        recording.moves.push(KlondikeInstruction::RotateStock);
    }

    // Fire the win event the engine emits when the last foundation
    // completes — `record_replay_on_win` listens for it.
    app.world_mut().write_message(GameWonEvent {
        score: 4321,
        time_seconds: 250,
    });
    app.update();

    let history = load_replay_history_from(&path)
        .expect("a winning replay must be persisted to ReplayPath");
    assert_eq!(
        history.replays.len(),
        1,
        "fresh history must contain exactly the just-recorded win",
    );
    let loaded = &history.replays[0];
    assert_eq!(loaded.seed, 7654, "seed must match the live game state");
    assert_eq!(
        loaded.draw_mode,
        DrawStockConfig::DrawOne,
        "draw_mode must be captured"
    );
    assert_eq!(
        loaded.final_score, 4321,
        "final_score must come from the win event"
    );
    assert_eq!(
        loaded.time_seconds, 250,
        "time_seconds must come from the win event"
    );
    assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
    assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
    assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));

    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);
}

/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
    use solitaire_data::load_replay_history_from;

    let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(11);
    app.insert_resource(ReplayPath(Some(path.clone())));

    // First win.
    {
        let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
        recording.moves.clear();
        recording.moves.push(KlondikeInstruction::RotateStock);
    }
    app.world_mut().write_message(GameWonEvent {
        score: 100,
        time_seconds: 60,
    });
    app.update();

    // Second win — different score so we can distinguish.
    {
        let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
        recording.moves.clear();
        recording.moves.push(KlondikeInstruction::RotateStock);
        recording.moves.push(KlondikeInstruction::RotateStock);
    }
    app.world_mut().write_message(GameWonEvent {
        score: 200,
        time_seconds: 120,
    });
    app.update();

    let history = load_replay_history_from(&path).expect("history must exist");
    assert_eq!(history.replays.len(), 2, "both wins must be retained");
    // Newest first — second win lands at index 0.
    assert_eq!(history.replays[0].final_score, 200);
    assert_eq!(history.replays[1].final_score, 100);

    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);
}

/// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without
/// driving any actual moves) would clobber the developer's real
/// replay file every time `cargo test` ran.
#[test]
fn replay_with_empty_recording_skips_save() {
    let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json");
    #[cfg(not(target_arch = "wasm32"))]
    let _ = std::fs::remove_file(&path);

    let mut app = test_app(1);
    app.insert_resource(ReplayPath(Some(path.clone())));
    // Recording is empty by default — fire a win event anyway.
    app.world_mut().write_message(GameWonEvent {
        score: 100,
        time_seconds: 30,
    });
    app.update();

    assert!(
        !path.exists(),
        "no replay must be written when recording is empty",
    );
}

// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------

/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
    let settings = solitaire_data::Settings {
        winnable_deals_only,
        ..solitaire_data::Settings::default()
    };
    app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}

#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
    // Toggle off — the engine must use the seed it was handed and
    // never invoke the solver. Seed 999 is just an arbitrary
    // deterministic seed; the test asserts the resulting deal
    // matches `GameState::new(999, DrawOne)`.
    let mut app = test_app(1);
    insert_settings(&mut app, false);

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

    let actual_seed = app.world().resource::<GameStateResource>().0.seed;
    assert_eq!(
        actual_seed, 999,
        "with solver toggle off, the requested seed must be honoured exactly"
    );
    // Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
    let expected = GameState::new(999, DrawStockConfig::DrawOne);
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        assert_eq!(
            app.world()
                .resource::<GameStateResource>()
                .0
                .pile(KlondikePile::Tableau(tableau)),
            expected.pile(KlondikePile::Tableau(tableau)),
            "tableau column {tableau:?} must match the unfiltered seed",
        );
    }
}

#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
    // When seed is None and toggle is off, the engine uses a
    // system-time seed and skips the solver. We can't pin the
    // exact seed, but we can assert the seed is *not* the
    // sentinel zero (which would only happen if SystemTime is
    // before the epoch — practically impossible), AND that no
    // resource has been mutated to suggest the solver ran.
    // The strongest assertion is "the move runs to completion
    // without panicking", which the .update() call covers.
    let mut app = test_app(1);
    insert_settings(&mut app, false);

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

    // Game state was reseeded — move_count is 0 on the new game.
    assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
}

#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
    // Even with the toggle on, an *explicit* seed must be honoured:
    // daily challenges, replay seeding, and challenge-mode all
    // pass `Some(seed)` and must never be retried.
    let mut app = test_app(1);
    insert_settings(&mut app, true);

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

    assert_eq!(
        app.world().resource::<GameStateResource>().0.seed,
        123,
        "explicit-seed requests must skip the solver retry loop",
    );
}

#[test]
fn choose_winnable_seed_accepts_inconclusive_seed() {
    // With the upstream session solver (card_game v0.4.0) no seeds in 0..500
    // are proven Unwinnable — they are either Winnable or Inconclusive.
    // `choose_winnable_seed` must accept Inconclusive as "probably winnable",
    // so calling it with any seed in this range must return quickly (at most
    // the retry cap) rather than looping forever.
    //
    // Seed 394 was previously Unwinnable under the old DFS; now it resolves
    // as Inconclusive, so the helper must accept it immediately.
    let chosen = choose_winnable_seed(394, DrawStockConfig::DrawOne);
    assert_eq!(
        chosen, 394,
        "seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
    );
}

#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
    // End-to-end: with the toggle on, fire a NewGameRequestEvent
    // with seed=None and *manually pre-seed* the system-time
    // path by clearing the GameStateResource so handle_new_game
    // takes the random branch. We can't easily inject the
    // system-time seed here, so we exercise the helper via a
    // separate call and assert the *resource* receives the
    // post-retry seed when the helper would have rejected.
    //
    // We test the integration by setting up an alternative
    // scenario: pass `seed: Some(394)` with toggle on. Our
    // implementation already documents that explicit seeds skip
    // the retry, so this *won't* trigger retry. The cleaner
    // integration is captured in `choose_winnable_seed_skips_*`.
    // Here we verify the default-seed path doesn't crash when
    // toggle is on — exercising the live solver call inside
    // handle_new_game without depending on the solver picking
    // a specific seed.
    let mut app = test_app(1);
    insert_settings(&mut app, true);

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

    // The chosen seed is non-deterministic (system time),
    // but the new game must have been started cleanly:
    // move_count back to 0, undo stack empty.
    assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
    assert_eq!(
        app.world()
            .resource::<GameStateResource>()
            .0
            .undo_stack_len(),
        0
    );
}

/// Async-solver flow: a winnable-only request with no explicit
/// seed must populate `PendingNewGameSeed` on the same frame the
/// request fires (no main-thread stall waiting on the solver),
/// and subsequent updates must clear the pending state and
/// produce a new GameState.
///
/// Drives multiple `app.update()` calls because the polling
/// system needs at least one tick after spawn to observe the
/// task as ready and re-emit the synthetic event.
#[test]
fn winnable_seed_search_runs_async_and_completes_eventually() {
    let mut app = test_app(394);
    insert_settings(&mut app, true);

    app.world_mut().write_message(NewGameRequestEvent {
        seed: None,
        mode: None,
        confirmed: false,
    });
    // First update: handle_new_game spawns the solver task and
    // returns. The GameStateResource is unchanged on this tick —
    // the player's previous game is still on screen, so the UI
    // doesn't visually stall.
    app.update();
    assert!(
        app.world().resource::<PendingNewGameSeed>().inner.is_some(),
        "first frame should have an in-flight solver task",
    );

    // Pump frames until the polling system observes the task as
    // ready and re-emits the synthetic event. AsyncComputeTaskPool
    // is a shared pool across the whole `cargo test` run — when
    // dozens of tests execute in parallel the pool can take a
    // while to actually schedule our future. The yield_now() lets
    // the pool's worker threads make progress between our polls
    // without burning wall-clock time.
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
    while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
        app.update();
        std::thread::yield_now();
        if std::time::Instant::now() >= deadline {
            break;
        }
    }
    assert!(
        app.world().resource::<PendingNewGameSeed>().inner.is_none(),
        "solver task should have completed within 15 s wall-clock",
    );
    // New game completed: a fresh deal carries 0 moves.
    assert_eq!(
        app.world().resource::<GameStateResource>().0.move_count(),
        0,
        "completed new game must be in fresh-deal state",
    );
}

/// Cancel-on-replace: a winnable-only request that arrives while
/// a previous solver task is in flight must drop the previous
/// task and queue the new one. The most recently-fired request
/// is the one whose seed wins, regardless of which task started
/// first.
#[test]
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
    let mut app = test_app(394);
    insert_settings(&mut app, true);

    // Fire the first request; first update spawns the task.
    app.world_mut().write_message(NewGameRequestEvent {
        seed: None,
        mode: None,
        confirmed: false,
    });
    app.update();
    assert!(
        app.world().resource::<PendingNewGameSeed>().inner.is_some(),
        "first request should be in flight",
    );

    // Fire a SECOND request with an explicit seed before the
    // first task can complete. handle_new_game's `pending.inner =
    // None` line must drop the in-flight task; the explicit-seed
    // branch then bypasses the solver entirely. After this tick
    // the GameStateResource carries seed 12345, not whatever the
    // solver would have picked for the first request.
    app.world_mut().write_message(NewGameRequestEvent {
        seed: Some(12345),
        mode: None,
        confirmed: true,
    });
    app.update();

    // Drive a few more ticks to drain any stragglers.
    for _ in 0..5 {
        app.update();
    }

    assert!(
        app.world().resource::<PendingNewGameSeed>().inner.is_none(),
        "explicit-seed request must have cancelled the in-flight task",
    );
    assert_eq!(
        app.world().resource::<GameStateResource>().0.seed,
        12345,
        "explicit-seed request takes precedence over the dropped solver task",
    );
}
