//! WebAssembly bindings for browser-side replay playback and interactive gameplay.
//!
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
//! step applies one [`KlondikeInstruction`] to the underlying `GameState`
//! and returns the resulting pile snapshot as JSON for the JS layer to
//! render.
//!
//! The state machine is the same Rust [`solitaire_core::GameState`]
//! the desktop client uses, so the two implementations cannot drift —
//! same seed + same input list = same pile state at every step,
//! regardless of which platform replays the game.
//!
//! The crate intentionally does **not** depend on `solitaire_data`
//! (which pulls `dirs`, `keyring`, `reqwest`, and other non-wasm
//! crates) — instead it defines a minimal `Replay` mirror with the
//! same serde shape as `solitaire_data::Replay`. The JSON wire format
//! is the contract.

use chrono::NaiveDate;
use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use wasm_bindgen::prelude::*;

/// Mirrors `solitaire_data::Replay` v3.
///
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
/// move-currency `solitaire_core` persists. A stock click is
/// `KlondikeInstruction::RotateStock`; a card move is a
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
/// runtime-only and intentionally not part of the wire format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
    #[serde(default)]
    pub schema_version: u32,
    pub seed: u64,
    pub draw_mode: DrawStockConfig,
    pub mode: GameMode,
    pub time_seconds: u64,
    pub final_score: i32,
    pub recorded_at: NaiveDate,
    pub moves: Vec<KlondikeInstruction>,
}

/// JS-friendly snapshot of a `GameState` at a particular replay step.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StateSnapshot {
    pub step_idx: usize,
    pub total_steps: usize,
    pub score: i32,
    pub move_count: u32,
    pub is_won: bool,
    pub stock: Vec<CardSnapshot>,
    pub waste: Vec<CardSnapshot>,
    /// Length 4 — one per foundation slot, in slot order (0..=3). The
    /// claimed suit (if any) is the bottom card's suit.
    pub foundations: [Vec<CardSnapshot>; 4],
    /// Length 7 — one per tableau column (0..=6).
    pub tableaus: [Vec<CardSnapshot>; 7],
}

/// One card, projected for the JS card renderer. `face_up = false`
/// means the card back is drawn; in that case `suit` and `rank` are
/// still set (so the renderer doesn't need separate "unknown" data),
/// just hidden visually.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CardSnapshot {
    /// Stable per-card identity for the JS renderer (an opaque key). Serialises
    /// as the upstream `Card`'s transparent integer value.
    pub id: Card,
    /// `"clubs" | "diamonds" | "hearts" | "spades"`.
    pub suit: &'static str,
    /// 1-13, where 1 is Ace and 13 is King.
    pub rank: u8,
    pub face_up: bool,
}

impl From<&(Card, bool)> for CardSnapshot {
    fn from((card, face_up): &(Card, bool)) -> Self {
        Self {
            id: card.clone(),
            suit: match card.suit() {
                Suit::Clubs => "clubs",
                Suit::Diamonds => "diamonds",
                Suit::Hearts => "hearts",
                Suit::Spades => "spades",
            },
            rank: card.rank() as u8,
            face_up: *face_up,
        }
    }
}

/// Browser-side replay state machine. Owns a live `GameState` and the
/// replay's move list; each `step()` applies the next move.
#[wasm_bindgen]
pub struct ReplayPlayer {
    game: GameState,
    moves: Vec<KlondikeInstruction>,
    step_idx: usize,
}

fn log_replay_move_error(err: &MoveError) {
    #[cfg(target_arch = "wasm32")]
    web_sys::console::error_1(&format!("Replay move failed: {:?}", err).into());

    #[cfg(not(target_arch = "wasm32"))]
    eprintln!("Replay move failed: {:?}", err);
}

// Native-callable methods. Used by both the wasm-bindgen interface
// below and by unit tests, which can't go through `serde_wasm_bindgen`
// (it panics on non-wasm targets).
impl ReplayPlayer {
    /// Construct from a raw replay JSON string. Returns the parsing
    /// error as a `String` so the wasm-bindgen wrapper can convert
    /// it to a `JsValue` and tests can assert on it directly.
    pub fn from_json(replay_json: &str) -> Result<Self, String> {
        let replay: Replay =
            serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
        let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
        Ok(Self {
            game,
            moves: replay.moves,
            step_idx: 0,
        })
    }

    /// Apply the next move. Returns `Ok(None)` once the list is exhausted.
    pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
        if self.step_idx >= self.moves.len() {
            return Ok(None);
        }
        let instruction = self.moves[self.step_idx];
        self.game.apply_instruction(instruction)?;
        self.step_idx += 1;
        Ok(Some(self.snapshot()))
    }

    fn snapshot(&self) -> StateSnapshot {
        let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
            self.game.pile(t).iter().map(CardSnapshot::from).collect()
        };
        let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
            .map(|f| pile_cards(KlondikePile::Foundation(f)));
        let tableaus: [Vec<CardSnapshot>; 7] =
            solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
        StateSnapshot {
            step_idx: self.step_idx,
            total_steps: self.moves.len(),
            score: self.game.score(),
            move_count: self.game.move_count(),
            is_won: self.game.is_won(),
            stock: self
                .game
                .stock_cards()
                .iter()
                .map(CardSnapshot::from)
                .collect(),
            waste: self
                .game
                .waste_cards()
                .iter()
                .map(CardSnapshot::from)
                .collect(),
            foundations,
            tableaus,
        }
    }
}

// JS-facing surface. Thin wrapper around the native API: serialises
// `StateSnapshot` to `JsValue` via `serde_wasm_bindgen` and converts
// `String` errors to `JsValue` strings. Native unit tests bypass this
// layer because `serde_wasm_bindgen::to_value` panics off-target.
#[wasm_bindgen]
impl ReplayPlayer {
    /// Construct from a raw replay JSON string.
    #[wasm_bindgen(constructor)]
    pub fn new(replay_json: &str) -> Result<ReplayPlayer, JsValue> {
        #[cfg(feature = "console_error_panic_hook")]
        console_error_panic_hook::set_once();
        Self::from_json(replay_json).map_err(|e| JsValue::from_str(&e))
    }

    /// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
    ///
    /// Throws a JS string exception on serialisation failure (should never
    /// occur in practice — `StateSnapshot` contains only primitive types).
    pub fn state(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self.snapshot())
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Apply the next move; returns the post-step snapshot, or `null`
    /// once the move list is exhausted.
    ///
    /// Returns `null` (not an exception) when the replay is finished.
    /// Throws `"replay_desync"` when the next recorded move is illegal for
    /// the current state, and logs the underlying core error to the JS console.
    /// Throws a JS string exception on serialisation failure.
    pub fn step(&mut self) -> Result<JsValue, JsValue> {
        match self.step_native() {
            Ok(Some(snap)) => {
                serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
            }
            Ok(None) => Ok(JsValue::NULL),
            Err(e) => {
                log_replay_move_error(&e);
                Err(JsValue::from_str("replay_desync"))
            }
        }
    }

    /// Total number of moves the replay contains.
    pub fn total_steps(&self) -> usize {
        self.moves.len()
    }

    /// 0-indexed position of the next move to apply.
    pub fn step_idx(&self) -> usize {
        self.step_idx
    }

    /// Returns `true` once every move has been applied.
    pub fn is_finished(&self) -> bool {
        self.step_idx >= self.moves.len()
    }
}

// ---------------------------------------------------------------------------
// Interactive game surface
// ---------------------------------------------------------------------------

/// Full snapshot of a live `SolitaireGame` for the JS renderer.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GameSnapshot {
    pub score: i32,
    pub move_count: u32,
    pub is_won: bool,
    pub is_auto_completable: bool,
    /// `false` when stock, waste, and all pile-to-pile moves are exhausted.
    pub has_moves: bool,
    pub undo_count: u32,
    /// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
    pub undo_stack_len: usize,
    pub stock: Vec<CardSnapshot>,
    pub waste: Vec<CardSnapshot>,
    pub foundations: [Vec<CardSnapshot>; 4],
    pub tableaus: [Vec<CardSnapshot>; 7],
}

/// Result returned to JS from every mutating game action.
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub snapshot: Option<GameSnapshot>,
}

/// Debug action understood by the automation-oriented debug API.
///
/// This mirrors legal player inputs and is intentionally independent from DOM
/// or pointer coordinates so test runners can drive the engine directly.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DebugMove {
    Move {
        from: String,
        to: String,
        count: usize,
    },
    StockClick,
}

/// Invariant report returned by the debug API after each step.
///
/// `state_ok` is `true` when no structural violations were detected.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugInvariantReport {
    pub state_ok: bool,
    pub total_cards_seen: usize,
    /// Cards that appeared more than once across all piles.
    pub duplicate_cards: Vec<Card>,
    /// Cards from the full single-deck set that are absent from the board.
    pub missing_cards: Vec<Card>,
    pub stock_has_face_up_cards: bool,
    pub waste_has_face_down_cards: bool,
    pub foundation_has_face_down_cards: bool,
    pub tableau_visibility_violation: bool,
    pub soft_lock: bool,
}

/// Full debug snapshot for engine-integration and browser automation tests.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugSnapshot {
    pub seed: u64,
    pub draw_mode: DrawStockConfig,
    pub mode: GameMode,
    pub state: GameSnapshot,
    pub legal_moves: Vec<DebugMove>,
    pub move_history: Vec<KlondikeInstruction>,
    pub invariants: DebugInvariantReport,
    pub state_json: String,
}

fn pile_name(pile: KlondikePile) -> String {
    match pile {
        KlondikePile::Stock => "stock".to_string(),
        KlondikePile::Foundation(f) => format!("foundation-{}", f as u8),
        KlondikePile::Tableau(t) => format!("tableau-{}", t as u8),
    }
}

fn can_stock_click(game: &GameState) -> bool {
    !(game.is_won() || game.stock_cards().is_empty() && game.waste_cards().is_empty())
}

fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
    let mut moves: Vec<DebugMove> = game
        .possible_instructions()
        .into_iter()
        .filter_map(|instruction| game.instruction_to_piles(instruction))
        .map(|(from, to, count)| DebugMove::Move {
            from: pile_name(from),
            to: pile_name(to),
            count,
        })
        .collect();
    if can_stock_click(game) {
        moves.push(DebugMove::StockClick);
    }
    moves
}

fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
    let stock = game.stock_cards();
    let waste = game.waste_cards();
    let foundations =
        solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
    let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));

    let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
    let mut duplicate_cards = Vec::new();
    let mut total_cards_seen = 0_usize;

    let mut feed = |cards: &[(Card, bool)]| {
        for (card, _) in cards {
            total_cards_seen += 1;
            if !seen.insert(card.clone()) {
                duplicate_cards.push(card.clone());
            }
        }
    };

    feed(&stock);
    feed(&waste);
    for pile in &foundations {
        feed(pile);
    }
    for pile in &tableaus {
        feed(pile);
    }

    // Reference set: the full 52-card single deck, using whichever deck id the
    // dealt cards carry. Any of those 52 not on the board is missing.
    let deck = seen
        .iter()
        .next()
        .map(|c| c.deck())
        .unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid"));
    let mut missing_cards = Vec::new();
    for suit in Suit::SUITS {
        for rank in Rank::RANKS {
            let card = Card::new(deck, suit, rank);
            if !seen.contains(&card) {
                missing_cards.push(card);
            }
        }
    }

    let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
    let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
    let foundation_has_face_down_cards = foundations
        .iter()
        .any(|pile| pile.iter().any(|(_, face_up)| !*face_up));

    let tableau_visibility_violation = tableaus.iter().any(|pile| {
        let mut seen_face_up = false;
        for (_, face_up) in pile {
            if *face_up {
                seen_face_up = true;
            } else if seen_face_up {
                return true;
            }
        }
        false
    });

    let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();

    let state_ok = duplicate_cards.is_empty()
        && missing_cards.is_empty()
        && !stock_has_face_up_cards
        && !waste_has_face_down_cards
        && !foundation_has_face_down_cards
        && !tableau_visibility_violation;

    DebugInvariantReport {
        state_ok,
        total_cards_seen,
        duplicate_cards,
        missing_cards,
        stock_has_face_up_cards,
        waste_has_face_down_cards,
        foundation_has_face_down_cards,
        tableau_visibility_violation,
        soft_lock,
    }
}

/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
///
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
/// `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
/// full pile snapshot at any time without mutating state.
#[wasm_bindgen]
pub struct SolitaireGame {
    game: GameState,
}

impl SolitaireGame {
    fn snap(&self) -> GameSnapshot {
        let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
            self.game.pile(t).iter().map(CardSnapshot::from).collect()
        };
        let has_moves = {
            let stock_empty = self.game.stock_cards().is_empty();
            let waste_empty = self.game.waste_cards().is_empty();
            !stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
        };
        GameSnapshot {
            score: self.game.score(),
            move_count: self.game.move_count(),
            is_won: self.game.is_won(),
            is_auto_completable: self.game.is_auto_completable(),
            has_moves,
            undo_count: self.game.undo_count(),
            undo_stack_len: self.game.undo_stack_len(),
            stock: self
                .game
                .stock_cards()
                .iter()
                .map(CardSnapshot::from)
                .collect(),
            waste: self
                .game
                .waste_cards()
                .iter()
                .map(CardSnapshot::from)
                .collect(),
            foundations: solitaire_core::FOUNDATIONS
                .map(|f| cards(KlondikePile::Foundation(f))),
            tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
        }
    }

    fn pile_from_str(s: &str) -> Result<KlondikePile, String> {
        match s {
            "stock" | "waste" => Ok(KlondikePile::Stock),
            _ if s.starts_with("foundation-") => {
                let slot: u8 = s["foundation-".len()..]
                    .parse()
                    .map_err(|_| format!("bad pile: {s}"))?;
                let foundation = solitaire_core::FOUNDATIONS
                    .get(slot as usize)
                    .copied()
                    .ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
                Ok(KlondikePile::Foundation(foundation))
            }
            _ if s.starts_with("tableau-") => {
                let col: usize = s["tableau-".len()..]
                    .parse()
                    .map_err(|_| format!("bad pile: {s}"))?;
                let tableau = solitaire_core::TABLEAUS
                    .get(col)
                    .copied()
                    .ok_or_else(|| format!("tableau col out of range: {col}"))?;
                Ok(KlondikePile::Tableau(tableau))
            }
            _ => Err(format!("unknown pile: {s}")),
        }
    }

    fn legal_moves_native(&self) -> Vec<DebugMove> {
        legal_moves_for_game(&self.game)
    }

    fn move_history_native(&self) -> Vec<KlondikeInstruction> {
        self.game.instruction_history()
    }

    fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
        // The session's forward instruction history *is* the replayable
        // move list: each entry replays cleanly via `apply_instruction`
        // against a fresh game with the same seed/draw mode/mode.
        self.game.instruction_history()
    }

    fn debug_snapshot_native(&self) -> DebugSnapshot {
        let legal_moves = self.legal_moves_native();
        let invariants = invariant_report_for_game(&self.game, &legal_moves);
        let state_json = serde_json::to_string(&self.game).unwrap_or_default();
        DebugSnapshot {
            seed: self.game.seed,
            draw_mode: self.game.draw_mode(),
            mode: self.game.mode,
            state: self.snap(),
            legal_moves,
            move_history: self.move_history_native(),
            invariants,
            state_json,
        }
    }

    fn apply_debug_move_native(&mut self, mv: &DebugMove) -> Result<(), String> {
        match mv {
            DebugMove::StockClick => self.game.draw().map_err(|e| e.to_string()),
            DebugMove::Move { from, to, count } => {
                let from_pile = Self::pile_from_str(from)?;
                let to_pile = Self::pile_from_str(to)?;
                if from_pile == KlondikePile::Stock && to_pile == KlondikePile::Stock {
                    self.game.draw().map_err(|e| e.to_string())
                } else {
                    self.game
                        .move_cards(from_pile, to_pile, *count)
                        .map_err(|e| e.to_string())
                }
            }
        }
    }

    fn apply_legal_move_native(&mut self, index: usize) -> Result<(), String> {
        let legal_moves = self.legal_moves_native();
        let mv = legal_moves
            .get(index)
            .ok_or_else(|| format!("legal move index out of range: {index}"))?
            .clone();
        self.apply_debug_move_native(&mv)
    }

    fn ok_js(&self) -> JsValue {
        serde_wasm_bindgen::to_value(&ActionResult {
            ok: true,
            error: None,
            snapshot: Some(self.snap()),
        })
        .unwrap_or(JsValue::NULL)
    }

    fn err_js(msg: impl std::fmt::Display) -> JsValue {
        serde_wasm_bindgen::to_value(&ActionResult {
            ok: false,
            error: Some(msg.to_string()),
            snapshot: None,
        })
        .unwrap_or(JsValue::NULL)
    }
}

#[wasm_bindgen]
impl SolitaireGame {
    /// Create a new DrawOne or DrawThree Classic game from the given seed.
    ///
    /// `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
    /// Pass `Date.now()` or a random integer from JS for variety.
    #[wasm_bindgen(constructor)]
    pub fn new(seed: f64, draw_three: bool) -> SolitaireGame {
        #[cfg(feature = "console_error_panic_hook")]
        console_error_panic_hook::set_once();
        let dm = if draw_three {
            DrawStockConfig::DrawThree
        } else {
            DrawStockConfig::DrawOne
        };
        SolitaireGame {
            game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
        }
    }

    /// Full pile snapshot as a JS object.
    ///
    /// Throws a JS string exception on serialisation failure.
    pub fn state(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self.snap()).map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// The seed used to deal this game.
    pub fn seed(&self) -> f64 {
        self.game.seed as f64
    }

    /// Draw from stock to waste (or recycle waste → stock when stock is empty).
    /// Returns `{ok, error?, snapshot?}`.
    pub fn draw(&mut self) -> JsValue {
        match self.game.draw() {
            Ok(()) => self.ok_js(),
            Err(e) => Self::err_js(e),
        }
    }

    /// Move `count` cards from pile `from` to pile `to`.
    ///
    /// Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
    /// `"tableau-0"` .. `"tableau-6"`.
    ///
    /// Returns `{ok, error?, snapshot?}`.
    pub fn move_cards(&mut self, from: &str, to: &str, count: usize) -> JsValue {
        let from_pile = match Self::pile_from_str(from) {
            Ok(p) => p,
            Err(e) => return Self::err_js(e),
        };
        let to_pile = match Self::pile_from_str(to) {
            Ok(p) => p,
            Err(e) => return Self::err_js(e),
        };
        match self.game.move_cards(from_pile, to_pile, count) {
            Ok(()) => self.ok_js(),
            Err(e) => Self::err_js(e),
        }
    }

    /// Undo the last move. Returns `{ok, error?, snapshot?}`.
    pub fn undo(&mut self) -> JsValue {
        match self.game.undo() {
            Ok(()) => self.ok_js(),
            Err(e) => Self::err_js(e),
        }
    }

    /// Serialise the full game state as a JSON string for `localStorage`.
    ///
    /// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
    /// opaque — callers should treat it as a blob and store/restore it verbatim.
    pub fn serialize(&self) -> Result<String, JsValue> {
        serde_json::to_string(&self.game).map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
    ///
    /// Returns an error string if the JSON is malformed or describes a state
    /// that can't be deserialised (e.g. from a future schema version).
    pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
        serde_json::from_str::<GameState>(json)
            .map(|mut game| {
                // Older saves serialised with take_from_foundation=false (the core default).
                // The web client has no settings layer, so enforce the standard rule here.
                game.take_from_foundation = true;
                SolitaireGame { game }
            })
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Apply one auto-complete move (only valid when `is_auto_completable`).
    ///
    /// If no card can go directly to a foundation this step, advances the
    /// waste by calling `draw()` so the next step can try again. Returns the
    /// post-move snapshot, or `null` when no progress is possible.
    pub fn auto_complete_step(&mut self) -> JsValue {
        if !self.game.is_auto_completable() {
            return JsValue::NULL;
        }
        if let Some((from, to)) = self.game.next_auto_complete_move() {
            let _ = self.game.move_cards(from, to, 1);
            return self.ok_js();
        }
        // No direct foundation move — advance through the waste.
        match self.game.draw() {
            Ok(()) => self.ok_js(),
            Err(_) => JsValue::NULL,
        }
    }

    /// Returns replay moves encoded in the `solitaire_data::Replay` wire format
    /// — a list of upstream [`KlondikeInstruction`]s.
    ///
    /// This is the deterministic instruction history; together with `seed()`
    /// and the draw mode it replays cleanly via `apply_instruction`.
    pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
        let moves = self.replay_moves_native();
        serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Returns all currently-legal debug moves as a JS array.
    ///
    /// Includes [`DebugMove::StockClick`] when stock interaction is legal.
    pub fn debug_legal_moves(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self.legal_moves_native())
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Returns deterministic instruction history for the current game.
    ///
    /// Together with `seed()` and `draw_mode`, this history is replayable.
    pub fn debug_move_history(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self.move_history_native())
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Returns a comprehensive debug snapshot for automated verification.
    pub fn debug_snapshot(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self.debug_snapshot_native())
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// Applies the legal move currently at `index` from `debug_legal_moves()`.
    pub fn debug_apply_legal_move(&mut self, index: usize) -> JsValue {
        match self.apply_legal_move_native(index) {
            Ok(()) => self.ok_js(),
            Err(e) => Self::err_js(e),
        }
    }

    /// Applies one debug move encoded as JSON.
    ///
    /// JSON must match [`DebugMove`], for example:
    /// `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
    /// `{"kind":"stock_click"}`.
    pub fn debug_apply_move_json(&mut self, move_json: &str) -> JsValue {
        let parsed = match serde_json::from_str::<DebugMove>(move_json) {
            Ok(value) => value,
            Err(e) => return Self::err_js(format!("invalid debug move JSON: {e}")),
        };
        match self.apply_debug_move_native(&parsed) {
            Ok(()) => self.ok_js(),
            Err(e) => Self::err_js(e),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;
    use std::fmt::Write;

    /// The JS card renderer reads `card.id` as an opaque identity key. After the
    /// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is
    /// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a
    /// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses).
    #[test]
    fn card_snapshot_id_serialises_as_a_plain_number() {
        let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]);
        let snap = CardSnapshot::from(&(card, true));
        let json = serde_json::to_value(&snap).expect("serialise CardSnapshot");
        assert!(
            json["id"].is_number(),
            "card.id must serialise as a JSON number for the JS opaque key, got {:?}",
            json["id"]
        );
        assert_eq!(json["suit"], "hearts");
        assert_eq!(json["rank"], 1);
        assert_eq!(json["face_up"], true);
    }

    fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
        if moves.is_empty() {
            return None;
        }
        if let Some((idx, _)) = moves.iter().enumerate().find(|(_, m)| {
            matches!(
                m,
                DebugMove::Move {
                    to,
                    count: 1,
                    ..
                } if to.starts_with("foundation-")
            )
        }) {
            return Some(idx);
        }
        if let Some((idx, _)) = moves
            .iter()
            .enumerate()
            .find(|(_, m)| matches!(m, DebugMove::Move { .. }))
        {
            return Some(idx);
        }
        Some(0)
    }

    fn assert_invariants(snapshot: &DebugSnapshot, seed: u64) {
        assert!(
            snapshot.invariants.state_ok,
            "state invariant failure (seed={seed}): {:?}",
            snapshot.invariants
        );
    }

    fn board_key(state: &GameSnapshot) -> String {
        let mut key = String::new();
        let mut push_cards = |cards: &[CardSnapshot]| {
            for card in cards {
                let _ = write!(
                    key,
                    "{:?}:{}:{},",
                    card.id,
                    card.rank,
                    if card.face_up { 1 } else { 0 }
                );
            }
            key.push('|');
        };
        push_cards(&state.stock);
        push_cards(&state.waste);
        for pile in &state.foundations {
            push_cards(pile);
        }
        for pile in &state.tableaus {
            push_cards(pile);
        }
        key
    }

    fn run_autonomous(seed: u64, draw_mode: DrawStockConfig, max_steps: usize) -> DebugSnapshot {
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
        };
        let mut last_snapshot = game.debug_snapshot_native();
        let mut seen_states = HashSet::new();
        seen_states.insert(board_key(&last_snapshot.state));
        assert_invariants(&last_snapshot, seed);

        for step in 0..max_steps {
            if last_snapshot.state.is_won || last_snapshot.legal_moves.is_empty() {
                return last_snapshot;
            }
            let idx = pick_move_index(&last_snapshot.legal_moves).unwrap_or_default();
            if let Err(e) = game.apply_legal_move_native(idx) {
                panic!("failed to apply legal move (seed={seed}, step={step}, idx={idx}): {e}");
            }
            last_snapshot = game.debug_snapshot_native();
            if !seen_states.insert(board_key(&last_snapshot.state)) {
                // Deterministic autoplay returned to an earlier state.
                // Treat as a terminal non-winning run, not a harness failure.
                return last_snapshot;
            }
            assert_invariants(&last_snapshot, seed);
        }
        panic!("autonomous run exceeded step budget (seed={seed}, max_steps={max_steps})");
    }

    #[test]
    fn debug_snapshot_exposes_replayable_seed_and_history() {
        let seed = 42_u64;
        let final_snapshot = run_autonomous(seed, DrawStockConfig::DrawOne, 1500);
        assert_eq!(final_snapshot.seed, seed);
        assert!(
            !final_snapshot.state_json.is_empty(),
            "debug snapshot must include serialised current state"
        );
        let restored = match SolitaireGame::from_saved(&final_snapshot.state_json) {
            Ok(game) => game,
            Err(err) => panic!("failed to restore debug snapshot state: {err:?}"),
        };
        let restored_snapshot = restored.debug_snapshot_native();
        assert_eq!(restored_snapshot.state, final_snapshot.state);
    }

    #[test]
    fn replay_moves_export_is_json_compatible_and_replayable() {
        let seed = 7_u64;
        let draw_mode = DrawStockConfig::DrawThree;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
        };

        for step in 0..64 {
            let legal_moves = game.legal_moves_native();
            if legal_moves.is_empty() {
                break;
            }
            let idx = pick_move_index(&legal_moves).unwrap_or_default();
            if let Err(e) = game.apply_legal_move_native(idx) {
                panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
            }
        }

        let exported_moves = game.replay_moves_native();
        assert!(
            !exported_moves.is_empty(),
            "progressed game must export a non-empty replay move list"
        );

        let moves_json = match serde_json::to_value(&exported_moves) {
            Ok(value) => value,
            Err(err) => panic!("failed to serialise exported replay moves: {err}"),
        };
        assert!(
            moves_json.is_array(),
            "exported replay moves must serialise as a JSON array"
        );

        let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
            Ok(parsed) => parsed,
            Err(err) => {
                panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
            }
        };
        assert_eq!(
            parsed_back, exported_moves,
            "replay move JSON must round-trip through KlondikeInstruction"
        );

        let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
            Some(date) => date,
            None => panic!("invalid recorded_at date in test"),
        };
        let replay = Replay {
            schema_version: 3,
            seed,
            draw_mode,
            mode: GameMode::Classic,
            time_seconds: 120,
            final_score: game.game.score(),
            recorded_at,
            moves: exported_moves,
        };
        let replay_json = match serde_json::to_string(&replay) {
            Ok(json) => json,
            Err(err) => panic!("failed to serialise replay JSON: {err}"),
        };

        let mut player = match ReplayPlayer::from_json(&replay_json) {
            Ok(value) => value,
            Err(err) => panic!("failed to construct replay player: {err}"),
        };
        loop {
            match player.step_native() {
                Ok(Some(_)) => {}
                Ok(None) => break,
                Err(err) => panic!("replay player desynced while applying exported moves: {err}"),
            }
        }

        let original_state = match serde_json::to_string(&game.game) {
            Ok(json) => json,
            Err(err) => panic!("failed to serialise original game state: {err}"),
        };
        let replayed_state = match serde_json::to_string(&player.game) {
            Ok(json) => json,
            Err(err) => panic!("failed to serialise replayed game state: {err}"),
        };
        assert_eq!(
            replayed_state, original_state,
            "replayed state must match the live state the moves were exported from"
        );
    }

    #[test]
    fn debug_api_autonomous_seed_batch_smoke() {
        for seed in 0_u64..128_u64 {
            let draw_mode = if seed % 2 == 0 {
                DrawStockConfig::DrawOne
            } else {
                DrawStockConfig::DrawThree
            };
            let snapshot = run_autonomous(seed, draw_mode, 2000);
            assert_invariants(&snapshot, seed);
        }
    }

    #[test]
    #[ignore = "long-running soak for unattended CI pipelines"]
    fn debug_api_autonomous_thousands_seed_soak() {
        for seed in 10_000_u64..12_000_u64 {
            let draw_mode = if seed % 2 == 0 {
                DrawStockConfig::DrawOne
            } else {
                DrawStockConfig::DrawThree
            };
            let snapshot = run_autonomous(seed, draw_mode, 3000);
            assert_invariants(&snapshot, seed);
        }
    }

    #[test]
    fn serialize_from_saved_round_trip() {
        let seed = 55_u64;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
        };
        // Advance a few moves so there is non-trivial state to round-trip.
        for _ in 0..20 {
            let moves = game.legal_moves_native();
            if moves.is_empty() {
                break;
            }
            let idx = pick_move_index(&moves).unwrap_or_default();
            let _ = game.apply_legal_move_native(idx);
        }

        let json = game
            .serialize()
            .expect("serialize must succeed for a valid game");
        assert!(!json.is_empty(), "serialized JSON must be non-empty");

        let restored =
            SolitaireGame::from_saved(&json).expect("from_saved must accept its own output");

        assert_eq!(
            board_key(&game.debug_snapshot_native().state),
            board_key(&restored.debug_snapshot_native().state),
            "restored game board must match original after round-trip"
        );
        assert_eq!(
            game.game.seed, restored.game.seed,
            "seed must survive serialize/from_saved"
        );
    }

    #[test]
    fn undo_reverts_to_prior_state() {
        let seed = 99_u64;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
        };

        let before_key = board_key(&game.debug_snapshot_native().state);
        let before_history_len = game.game.instruction_history().len();

        let moves = game.legal_moves_native();
        assert!(!moves.is_empty(), "seed {seed}: no legal moves at start");
        let idx = pick_move_index(&moves).unwrap_or_default();
        game.apply_legal_move_native(idx)
            .unwrap_or_else(|e| panic!("apply_legal_move failed: {e}"));

        // State should have changed.
        assert_ne!(
            board_key(&game.debug_snapshot_native().state),
            before_key,
            "board state must change after applying a legal move"
        );

        // Undo must restore the prior state.
        game.game.undo().expect("undo must succeed after one move");

        assert_eq!(
            board_key(&game.debug_snapshot_native().state),
            before_key,
            "board state must match pre-move state after undo"
        );
        assert_eq!(
            game.game.instruction_history().len(),
            before_history_len,
            "history length must return to pre-move value after undo"
        );
    }

    #[test]
    fn draw_one_advances_waste_by_one() {
        let seed = 1_u64;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
        };

        let stock_before = game.game.stock_cards().len();
        let waste_before = game.game.waste_cards().len();

        assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");

        game.game.draw().expect("draw must succeed when stock is non-empty");

        assert_eq!(
            game.game.stock_cards().len(),
            stock_before - 1,
            "DrawOne: stock must decrease by 1"
        );
        assert_eq!(
            game.game.waste_cards().len(),
            waste_before + 1,
            "DrawOne: waste must increase by 1"
        );
    }

    #[test]
    fn draw_three_advances_waste_by_three() {
        let seed = 1_u64;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, DrawStockConfig::DrawThree, GameMode::Classic),
        };

        let stock_before = game.game.stock_cards().len();
        let waste_before = game.game.waste_cards().len();

        assert!(
            stock_before >= 3,
            "seed {seed}: stock must have at least 3 cards for this test"
        );

        game.game.draw().expect("draw must succeed when stock has cards");

        let expected_drawn = stock_before.min(3);
        assert_eq!(
            game.game.stock_cards().len(),
            stock_before - expected_drawn,
            "DrawThree: stock must decrease by {expected_drawn}"
        );
        assert_eq!(
            game.game.waste_cards().len(),
            waste_before + expected_drawn,
            "DrawThree: waste must increase by {expected_drawn}"
        );
    }

    #[test]
    fn debug_apply_move_json_stock_click_advances_waste() {
        let seed = 3_u64;
        let mut game = SolitaireGame {
            game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
        };

        let waste_before = game.game.waste_cards().len();
        assert!(
            !game.game.stock_cards().is_empty(),
            "seed {seed}: stock must be non-empty at start"
        );

        // Use the native path: parse the JSON ourselves and apply via the
        // native method (debug_apply_move_json wraps this but touches js-sys
        // on non-wasm targets).
        let mv: DebugMove = serde_json::from_str(r#"{"kind":"stock_click"}"#)
            .expect("stock_click JSON must parse to DebugMove");
        game.apply_debug_move_native(&mv)
            .unwrap_or_else(|e| panic!("apply_debug_move_native failed: {e}"));

        assert!(
            game.game.waste_cards().len() > waste_before,
            "after stock_click move waste must have grown"
        );
    }
}
