use crate::error::MoveError;
use crate::klondike_adapter::{
    KlondikeAdapter, foundation_from_slot as adapter_foundation_from_slot,
    skip_cards_from_count as adapter_skip_cards_from_count,
    tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Card, Game as _, Session, SessionConfig, SolveError};
use klondike::{
    DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
    KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// Save-file schema version for `GameState`. Increment when the on-disk
/// representation changes incompatibly so `load_game_state_from` can refuse
/// older formats and start the player on a fresh game.
///
/// History:
/// - v1: `Foundation(Suit)` keys.
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
/// - v3 (rejected): session-backed save files using local mirror types with u8
///   indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
///   variants (e.g. `"Foundation1"` instead of `0`).
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
///   persisted. They are derived from the upstream `card_game`/`klondike` session
///   stats, which are rebuilt by replaying `saved_moves` on load. Older files that
///   still carry those keys load fine — the extra fields are ignored.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;

/// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solvability check.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;

/// Outcome of a solvability check ([`GameState::solve_first_move`]):
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first useful move
///   on a winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or the
///   game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded before
///   a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;

/// Default value for `GameState::schema_version` when deserialising older
/// save files that pre-date the field.
fn schema_v1() -> u32 {
    1
}

/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum DifficultyLevel {
    #[default]
    Easy,
    Medium,
    Hard,
    Expert,
    Grandmaster,
    /// Unverified system-time seed — may or may not be winnable.
    Random,
}

impl DifficultyLevel {
    /// Short human-readable label shown in the HUD and win summary.
    pub fn label(self) -> &'static str {
        match self {
            Self::Easy => "Easy",
            Self::Medium => "Medium",
            Self::Hard => "Hard",
            Self::Expert => "Expert",
            Self::Grandmaster => "Grandmaster",
            Self::Random => "Random",
        }
    }
}

/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
    #[default]
    /// Standard Klondike rules with score and timer.
    Classic,
    /// No timer, no score display, ambient audio only.
    Zen,
    /// Fixed hard seeds, no undo, must win to advance.
    Challenge,
    /// Play as many games as possible within 10 minutes.
    TimeAttack,
    /// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
    Difficulty(DifficultyLevel),
}

/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)]
struct PersistedGameState {
    pub draw_mode: DrawStockConfig,
    pub mode: GameMode,
    pub elapsed_seconds: u64,
    pub seed: u64,
    pub take_from_foundation: bool,
    pub schema_version: u32,
    pub saved_moves: Vec<KlondikeInstruction>,
}

/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
///
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// intentionally absent: all three are rebuilt by replaying the instruction
/// history through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn {
    pub draw_mode: DrawStockConfig,
    #[serde(default)]
    pub mode: GameMode,
    pub elapsed_seconds: u64,
    pub seed: u64,
    #[serde(default)]
    pub take_from_foundation: bool,
    #[serde(default = "schema_v1")]
    pub schema_version: u32,
    pub saved_moves: Vec<KlondikeInstruction>,
}

#[cfg(feature = "test-support")]
/// Test-only override state that shadows the real session pile data.
///
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
/// first checks for an override here before falling back to the session.
/// This lets unit tests in `solitaire_engine` construct arbitrary board
/// configurations without needing to drive the full klondike session.
#[derive(Clone, Debug, Default)]
pub struct TestPileState {
    /// Override for face-down stock cards.  `None` means "use session".
    pub stock: Option<Vec<Card>>,
    /// Override for face-up waste cards.  `None` means "use session".
    pub waste: Option<Vec<Card>>,
    /// Per-tableau overrides.  Missing keys fall back to the session.
    /// Each entry carries its own face-up flag so tests can place face-down
    /// cards (e.g. an un-flipped tableau card).
    pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
    /// Per-foundation overrides.  Missing keys fall back to the session.
    pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
    /// Override for the derived `move_count()`.  `None` means "use session
    /// history length".
    pub move_count: Option<u32>,
    /// Override for the derived `is_won()`.  `None` means "use session win
    /// state".
    pub won: Option<bool>,
    /// Override for the derived `is_auto_completable()`.  `None` means "derive
    /// from session state".
    pub auto_completable: Option<bool>,
}

/// Full state of an in-progress Klondike Solitaire game.
///
/// Score, undo count, and recycle count are **not** stored here. They are
/// derived on demand from the upstream `card_game`/`klondike` session stats via
/// [`GameState::score`], [`GameState::undo_count`], and
/// [`GameState::recycle_count`]. The session is the single source of truth; the
/// −15 undo penalty is configured on the session ([`Self::session_config`]) and
/// applied by the upstream score formula.
#[derive(Debug, Clone)]
pub struct GameState {
    /// Top-level mode (Classic / Zen).
    pub mode: GameMode,
    /// Seconds elapsed since the game started, used for time-bonus scoring.
    pub elapsed_seconds: u64,
    /// RNG seed used to deal this game. Same seed always produces the same layout.
    pub seed: u64,
    /// When `true`, the player may move the top card of a foundation pile back
    /// onto a compatible tableau column.
    pub take_from_foundation: bool,
    pub(crate) session: Session<Klondike>,
    #[cfg(feature = "test-support")]
    /// Test pile overrides. Always `None` in production runtime code.
    pub test_pile_state: Option<TestPileState>,
}

impl PartialEq for GameState {
    fn eq(&self, other: &Self) -> bool {
        self.draw_mode() == other.draw_mode()
            && self.mode == other.mode
            && self.score() == other.score()
            && self.move_count() == other.move_count()
            && self.elapsed_seconds == other.elapsed_seconds
            && self.seed == other.seed
            && self.is_won() == other.is_won()
            && self.is_auto_completable() == other.is_auto_completable()
            && self.undo_count() == other.undo_count()
            && self.recycle_count() == other.recycle_count()
            && self.take_from_foundation == other.take_from_foundation
            && self.stock_cards() == other.stock_cards()
            && self.waste_cards() == other.waste_cards()
            && (0..4_u8)
                .all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
            && (0..7_usize).all(|index| {
                let Ok(tableau) = Self::tableau_from_index(index) else {
                    return false;
                };
                self.pile(KlondikePile::Tableau(tableau))
                    == other.pile(KlondikePile::Tableau(tableau))
            })
    }
}

impl Eq for GameState {}

impl Serialize for GameState {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        PersistedGameState {
            draw_mode: self.draw_mode(),
            mode: self.mode,
            elapsed_seconds: self.elapsed_seconds,
            seed: self.seed,
            take_from_foundation: self.take_from_foundation,
            schema_version: GAME_STATE_SCHEMA_VERSION,
            saved_moves: self.saved_moves(),
        }
        .serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for GameState {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let persisted = PersistedGameStateIn::deserialize(deserializer)?;

        // Accept v4 (upstream named-variant serde) and v5 (current, derived
        // stats). v3 (legacy u8-index format) and all others are rejected.
        match persisted.schema_version {
            4 | 5 => {}
            v => {
                return Err(serde::de::Error::custom(format!(
                    "unsupported GameState schema version {v}"
                )));
            }
        }

        let mut game = Self {
            mode: persisted.mode,
            elapsed_seconds: persisted.elapsed_seconds,
            seed: persisted.seed,
            take_from_foundation: persisted.take_from_foundation,
            session: Self::new_session(persisted.seed, persisted.draw_mode),
            #[cfg(feature = "test-support")]
            test_pile_state: None,
        };

        // Replay the saved instruction history. The upstream session tracks
        // score components and recycle_count as it processes each move, so the
        // derived stats are correct once replay completes. `undo_count()` resets
        // to 0 across save/load because undone moves are not part of the saved
        // forward history.
        let replay_config = Self::replay_config(persisted.draw_mode);
        for instruction in persisted.saved_moves {
            if !game
                .session
                .state()
                .state()
                .is_instruction_valid(&replay_config, instruction)
            {
                return Err(serde::de::Error::custom(
                    "saved instruction history is invalid for reconstructed session",
                ));
            }
            game.session.process_instruction(instruction);
        }

        Ok(game)
    }
}

impl GameState {
    /// Creates a new Classic-mode game dealt from the given seed and draw mode.
    pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
        Self::new_with_mode(seed, draw_mode, GameMode::Classic)
    }

    /// Creates a new game with an explicit `GameMode`.
    pub fn new_with_mode(seed: u64, draw_mode: DrawStockConfig, mode: GameMode) -> Self {
        Self {
            mode,
            elapsed_seconds: 0,
            seed,
            take_from_foundation: true,
            session: Self::new_session(seed, draw_mode),
            #[cfg(feature = "test-support")]
            test_pile_state: None,
        }
    }

    /// Whether the player draws one or three cards from the stock per turn.
    /// Derived from the underlying session config (set once at deal time).
    pub fn draw_mode(&self) -> DrawStockConfig {
        self.session.config().inner.draw_stock
    }

    /// Current game score, derived from the upstream session stats.
    ///
    /// The upstream score is a linear sum of move-type counts (foundation/
    /// tableau/flip deltas) plus `undos * undo_penalty` (−15 each). Floored at 0
    /// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`],
    /// where scoring is suppressed entirely.
    ///
    /// Note: the win-time bonus (`compute_time_bonus`) is layered on by the
    /// engine's win-summary, not included here — this is the in-play base score.
    pub fn score(&self) -> i32 {
        if self.mode == GameMode::Zen {
            return 0;
        }
        self.session
            .state()
            .score(self.session.stats(), self.session.config())
            .max(0)
    }

    /// Number of times `undo()` has been successfully invoked this game, read
    /// from the upstream session stats.
    ///
    /// Resets to 0 across a save/load cycle: only the forward instruction
    /// history is persisted, so undone moves leave no trace to replay.
    pub fn undo_count(&self) -> u32 {
        self.session.stats().undos()
    }

    /// Number of times the waste pile has been recycled back to stock this game,
    /// read from the upstream session stats.
    ///
    /// This is a **cumulative** count — the upstream stat is not rolled back when
    /// a recycle is undone, so it reflects total recycles ever performed.
    pub fn recycle_count(&self) -> u32 {
        self.session.stats().stats().recycle_count()
    }

    /// Total moves made this game (draws, recycles, and card moves), derived
    /// from the session's instruction history length.
    pub fn move_count(&self) -> u32 {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state
            && let Some(count) = state.move_count
        {
            return count;
        }
        Self::u32_from_len(self.session.history().len())
    }

    /// True once all 52 cards are on the foundations. No further moves are
    /// accepted. Derived from the session win state.
    pub fn is_won(&self) -> bool {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state
            && let Some(won) = state.won
        {
            return won;
        }
        self.check_win()
    }

    /// True when the game can be completed without further player input
    /// (and is not already won). Derived from the session state.
    pub fn is_auto_completable(&self) -> bool {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state
            && let Some(auto) = state.auto_completable
        {
            return auto;
        }
        !self.check_win() && self.check_auto_complete()
    }

    fn new_session(seed: u64, draw_mode: DrawStockConfig) -> Session<Klondike> {
        Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
    }

    fn session_config(draw_mode: DrawStockConfig) -> SessionConfig<KlondikeConfig> {
        SessionConfig {
            inner: Self::replay_config(draw_mode),
            // The −15 WXP undo penalty is now applied by the upstream score
            // formula (`undos * undo_penalty`) rather than by hand in `undo()`.
            undo_penalty: -15,
            ..SessionConfig::default()
        }
    }

    fn replay_config(draw_mode: DrawStockConfig) -> KlondikeConfig {
        // Always allow foundation returns during replay, regardless of the
        // player's current `take_from_foundation` setting. A move recorded
        // when the rule was enabled must replay correctly even if the player
        // later disables it; a restrictive replay config would reject it and
        // corrupt the save.
        KlondikeAdapter::config_for(draw_mode, true)
    }

    fn validation_config(&self) -> KlondikeConfig {
        KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
    }

    /// Collects the session instruction history as upstream types for schema v4
    /// serialisation.
    fn saved_moves(&self) -> Vec<KlondikeInstruction> {
        self.session
            .history()
            .iter()
            .map(|snapshot| *snapshot.instruction())
            .collect()
    }

    /// Returns the deterministic instruction history for the current deal as
    /// upstream [`KlondikeInstruction`] values.
    ///
    /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
    /// sequence is sufficient to replay the game state exactly. Consumers
    /// record these directly (they serialise via `KlondikeInstruction`'s
    /// compact serde) and play them back via [`GameState::apply_instruction`].
    pub fn instruction_history(&self) -> Vec<KlondikeInstruction> {
        self.session
            .history()
            .iter()
            .map(|snapshot| *snapshot.instruction())
            .collect()
    }

    fn u32_from_len(len: usize) -> u32 {
        if len > u32::MAX as usize {
            u32::MAX
        } else {
            len as u32
        }
    }

    pub fn undo_stack_len(&self) -> usize {
        self.session.history().len()
    }

    fn cards_with_face(
        cards: impl IntoIterator<Item = Card>,
        face_up: bool,
    ) -> Vec<(Card, bool)> {
        cards.into_iter().map(|card| (card, face_up)).collect()
    }

    pub fn stock_cards(&self) -> Vec<(Card, bool)> {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state
            && let Some(ref cards) = state.stock
        {
            return cards.iter().map(|c| (c.clone(), false)).collect();
        }
        let state = self.session.state().state().state();
        Self::cards_with_face(state.stock().face_down().iter().cloned(), false)
    }

    pub fn waste_cards(&self) -> Vec<(Card, bool)> {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state
            && let Some(ref cards) = state.waste
        {
            return cards.iter().map(|c| (c.clone(), true)).collect();
        }
        let state = self.session.state().state().state();
        Self::cards_with_face(state.stock().face_up().iter().cloned(), true)
    }

    /// Returns the cards in the requested pile as `(card, face_up)` tuples.
    ///
    /// **Note on `KlondikePile::Stock`:** this variant returns the face-up
    /// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
    /// to read the face-down draw cards.
    pub fn pile(&self, pile: KlondikePile) -> Vec<(Card, bool)> {
        #[cfg(feature = "test-support")]
        if let Some(ref state) = self.test_pile_state {
            match pile {
                KlondikePile::Stock => {
                    if let Some(ref cards) = state.waste {
                        return cards.iter().map(|c| (c.clone(), true)).collect();
                    }
                }
                KlondikePile::Foundation(f) => {
                    if let Some(cards) = state.foundation.get(&f) {
                        return cards.iter().map(|c| (c.clone(), true)).collect();
                    }
                }
                KlondikePile::Tableau(t) => {
                    if let Some(cards) = state.tableau.get(&t) {
                        return cards.clone();
                    }
                }
            }
        }
        let state = self.session.state().state().state();
        match pile {
            KlondikePile::Stock => self.waste_cards(),
            KlondikePile::Foundation(foundation) => {
                let cards = match foundation {
                    Foundation::Foundation1 => state.foundation1(),
                    Foundation::Foundation2 => state.foundation2(),
                    Foundation::Foundation3 => state.foundation3(),
                    Foundation::Foundation4 => state.foundation4(),
                };
                Self::cards_with_face(cards.iter().cloned(), true)
            }
            KlondikePile::Tableau(tableau) => {
                let mut cards =
                    Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
                cards.extend(Self::cards_with_face(
                    state.tableau_face_up_cards(tableau).iter().cloned(),
                    true,
                ));
                cards
            }
        }
    }

    pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
        adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
    }

    pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
        adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
    }

    pub fn foundation_cards(&self, slot: u8) -> Result<Vec<(Card, bool)>, MoveError> {
        let foundation = Self::foundation_from_slot(slot)?;
        Ok(self.pile(KlondikePile::Foundation(foundation)))
    }

    /// Returns `true` when test-only pile overrides are active.
    #[cfg(feature = "test-support")]
    pub fn has_test_pile_overrides(&self) -> bool {
        self.test_pile_state.is_some()
    }

    /// Returns `false` in production builds where test pile overrides are absent.
    #[cfg(not(feature = "test-support"))]
    pub const fn has_test_pile_overrides(&self) -> bool {
        false
    }

    /// Test-support helper: clear all pile overrides so reads come from the
    /// underlying klondike session again.
    #[cfg(feature = "test-support")]
    pub fn clear_test_pile_overrides(&mut self) {
        self.test_pile_state = None;
    }

    /// Test-support helper: re-deal the current seed under a different draw
    /// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
    /// a specific mode use this instead of mutating a field.
    #[cfg(feature = "test-support")]
    pub fn set_test_draw_mode(&mut self, draw_mode: DrawStockConfig) {
        self.session = Self::new_session(self.seed, draw_mode);
    }

    /// Test-support helper: override the value returned by [`Self::is_won`]
    /// without driving the session to a genuine win.
    #[cfg(feature = "test-support")]
    pub fn set_test_won(&mut self, won: bool) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.won = Some(won);
    }

    /// Test-support helper: override the value returned by
    /// [`Self::is_auto_completable`].
    #[cfg(feature = "test-support")]
    pub fn set_test_auto_completable(&mut self, auto_completable: bool) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.auto_completable = Some(auto_completable);
    }

    /// Test-support helper: override the value returned by
    /// [`Self::move_count`] without applying real moves.
    #[cfg(feature = "test-support")]
    pub fn set_test_move_count(&mut self, move_count: u32) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.move_count = Some(move_count);
    }

    /// Test-support helper: perform `n` real undos so [`Self::undo_count`]
    /// reports `n`. Each iteration draws a card then immediately undoes it,
    /// leaving the board unchanged but advancing the upstream `undos` counter.
    ///
    /// Since `score`/`undo_count`/`recycle_count` are now derived from the
    /// session stats rather than stored fields, tests drive the real session to
    /// reach a desired stat instead of assigning the value directly.
    #[cfg(feature = "test-support")]
    pub fn force_test_undos(&mut self, n: u32) {
        for _ in 0..n {
            if self.draw().is_ok() {
                let _ = self.undo();
            }
        }
    }

    /// Test-support helper: perform `n` real stock recycles so
    /// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then
    /// draws once more to recycle, repeated `n` times.
    #[cfg(feature = "test-support")]
    pub fn force_test_recycles(&mut self, n: u32) {
        for _ in 0..n {
            let mut guard = 0;
            while !self.stock_cards().is_empty() && guard < 200 {
                guard += 1;
                if self.draw().is_err() {
                    break;
                }
            }
            // Stock now empty (waste full) — this draw recycles waste → stock.
            let _ = self.draw();
        }
    }

    /// Test-support helper: drive real moves until [`Self::score`] reaches at
    /// least `target`, returning the resulting score. Prefers foundation moves
    /// (+10 each) and falls back to the solver-priority move, so a modest target
    /// is reached within a handful of moves on a typical deal.
    #[cfg(feature = "test-support")]
    pub fn force_test_score(&mut self, target: i32) -> i32 {
        let mut guard = 0;
        while self.score() < target && !self.is_won() && guard < 4000 {
            guard += 1;
            let instructions = self.possible_instructions();
            let next = instructions
                .iter()
                .copied()
                .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
                .or_else(|| instructions.into_iter().next());
            match next {
                Some(instruction) => {
                    if self.apply_instruction(instruction).is_err() {
                        break;
                    }
                }
                None => break,
            }
        }
        self.score()
    }

    /// Test-support helper: override face-down stock cards returned by
    /// [`Self::stock_cards`].
    #[cfg(feature = "test-support")]
    pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.stock = Some(cards);
    }

    /// Test-support helper: override face-up waste cards returned by
    /// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
    #[cfg(feature = "test-support")]
    pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.waste = Some(cards);
    }

    /// Test-support helper: override cards for a specific tableau column.
    ///
    /// All provided cards are treated as face-up. Use
    /// [`Self::set_test_tableau_cards_with_face`] when a test needs to place
    /// face-down cards.
    #[cfg(feature = "test-support")]
    pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
        let with_face = cards.into_iter().map(|c| (c, true)).collect();
        self.set_test_tableau_cards_with_face(tableau, with_face);
    }

    /// Test-support helper: override cards for a specific tableau column,
    /// specifying each card's face-up flag (`true` = face-up).
    #[cfg(feature = "test-support")]
    pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.tableau.insert(tableau, cards);
    }

    /// Test-support helper: override cards for a specific foundation pile.
    #[cfg(feature = "test-support")]
    pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
        let state = self
            .test_pile_state
            .get_or_insert_with(TestPileState::default);
        state.foundation.insert(foundation, cards);
    }

    /// Test-support helper: override cards for a specific pile.
    ///
    /// For `KlondikePile::Stock`, all provided cards go to the face-down stock
    /// override. Use [`Self::set_test_waste_cards`] to override the waste pile
    /// separately.
    #[cfg(feature = "test-support")]
    pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
        match pile {
            KlondikePile::Stock => {
                self.set_test_stock_cards(cards);
            }
            KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
            KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
        }
    }

    fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
        adapter_skip_cards_from_count(skip)
            .ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
    }

    fn instruction_for_move(
        &self,
        from: KlondikePile,
        to: KlondikePile,
        count: usize,
    ) -> Result<KlondikeInstruction, MoveError> {
        match (from, to) {
            (_, KlondikePile::Stock) => Err(MoveError::InvalidDestination),
            (KlondikePile::Stock, KlondikePile::Foundation(foundation)) => {
                if count != 1 {
                    return Err(MoveError::RuleViolation(
                        "only one card can move to foundation at a time".into(),
                    ));
                }
                Ok(KlondikeInstruction::DstFoundation(DstFoundation {
                    src: KlondikePile::Stock,
                    foundation,
                }))
            }
            (KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => {
                if count != 1 {
                    return Err(MoveError::RuleViolation(
                        "only one card can move to foundation at a time".into(),
                    ));
                }
                Ok(KlondikeInstruction::DstFoundation(DstFoundation {
                    src: KlondikePile::Tableau(src),
                    foundation,
                }))
            }
            (KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
                MoveError::RuleViolation("cannot move between foundation slots".into()),
            ),
            (KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
                if count != 1 {
                    return Err(MoveError::RuleViolation(
                        "only the top waste card may be moved".into(),
                    ));
                }
                Ok(KlondikeInstruction::DstTableau(DstTableau {
                    src: KlondikePileStack::Stock,
                    tableau: dst,
                }))
            }
            (KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => {
                if count != 1 {
                    return Err(MoveError::RuleViolation(
                        "only one card can return from foundation at a time".into(),
                    ));
                }
                Ok(KlondikeInstruction::DstTableau(DstTableau {
                    src: KlondikePileStack::Foundation(foundation),
                    tableau: dst,
                }))
            }
            (KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => {
                let face_up_count = self
                    .session
                    .state()
                    .state()
                    .state()
                    .tableau_face_up_cards(src)
                    .len();
                if count > face_up_count {
                    return Err(MoveError::RuleViolation(
                        "cannot move face-down card".into(),
                    ));
                }
                let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?;
                Ok(KlondikeInstruction::DstTableau(DstTableau {
                    src: KlondikePileStack::Tableau(TableauStack {
                        tableau: src,
                        skip_cards,
                    }),
                    tableau: dst,
                }))
            }
        }
    }

    /// Decodes an upstream [`KlondikeInstruction`] into the `(from, to, count)`
    /// pile coordinates of `solitaire_core`'s own pile model, against the live
    /// board. Returns `None` for no-op instructions (foundation→foundation, or a
    /// tableau move of zero cards).
    ///
    /// This is the single, canonical translation from the upstream instruction
    /// move-currency to core's [`KlondikePile`] vocabulary. It lives in core
    /// because decoding a tableau-run length requires upstream pile-stack types
    /// (`KlondikePileStack`/`SkipCards`) that the engine and wasm crates do not
    /// see — relocating it would duplicate this logic across both crates. The
    /// two edges that genuinely need on-screen positions call it: the engine's
    /// hint highlight (which pile to glow) and the wasm debug move list (pile
    /// names + run length serialized to the browser harness).
    ///
    /// Game logic — auto-complete, move application, the property tests — stays
    /// in instruction space and never calls this; applying a move uses
    /// [`Self::apply_instruction`].
    pub fn instruction_to_piles(
        &self,
        instruction: KlondikeInstruction,
    ) -> Option<(KlondikePile, KlondikePile, usize)> {
        let state = self.session.state().state().state();
        match instruction {
            KlondikeInstruction::RotateStock => {
                Some((KlondikePile::Stock, KlondikePile::Stock, 1))
            }
            KlondikeInstruction::DstFoundation(dst_foundation) => {
                if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
                    return None;
                }
                let source = match dst_foundation.src {
                    KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
                    KlondikePile::Stock => KlondikePile::Stock,
                    KlondikePile::Foundation(_) => return None,
                };
                Some((
                    source,
                    KlondikePile::Foundation(dst_foundation.foundation),
                    1,
                ))
            }
            KlondikeInstruction::DstTableau(dst_tableau) => {
                let (source, count) = match dst_tableau.src {
                    KlondikePileStack::Tableau(tableau_stack) => {
                        let face_up_count =
                            state.tableau_face_up_cards(tableau_stack.tableau).len();
                        let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
                        if count == 0 {
                            return None;
                        }
                        (KlondikePile::Tableau(tableau_stack.tableau), count)
                    }
                    KlondikePileStack::Stock => (KlondikePile::Stock, 1),
                    KlondikePileStack::Foundation(foundation) => {
                        (KlondikePile::Foundation(foundation), 1)
                    }
                };
                Some((source, KlondikePile::Tableau(dst_tableau.tableau), count))
            }
        }
    }

    /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
    ///
    /// Recycling is deliberately unlimited in every draw mode: extra passes
    /// are discouraged through the upstream score penalty, never rejected
    /// with a [`MoveError`]. The difficulty seed catalog and the
    /// winnable-deal solver are verified under this rule — see the
    /// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
    /// section) before considering a hard pass limit.
    pub fn draw(&mut self) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }

        let stock_empty = self.stock_cards().is_empty();
        let waste_empty = self.waste_cards().is_empty();
        if stock_empty && waste_empty {
            return Err(MoveError::StockEmpty);
        }

        // The session tracks score components and recycle_count as it processes
        // the instruction; no local bookkeeping required.
        self.session
            .process_instruction(KlondikeInstruction::RotateStock);
        Ok(())
    }

    /// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids.
    pub fn move_cards(
        &mut self,
        from: KlondikePile,
        to: KlondikePile,
        count: usize,
    ) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }
        if from == to {
            return Err(MoveError::RuleViolation(
                "source and destination must differ".into(),
            ));
        }

        let from_pile = self.pile(from);
        if from_pile.is_empty() {
            return Err(MoveError::EmptySource);
        }
        if count == 0 || count > from_pile.len() {
            return Err(MoveError::RuleViolation("invalid card count".into()));
        }

        let instruction = self.instruction_for_move(from, to, count)?;
        self.apply_instruction(instruction)
    }

    /// Apply an upstream [`KlondikeInstruction`] directly to the live session.
    ///
    /// This is the native apply path for moves that already exist in
    /// instruction form — solver hints, auto-complete, replay, and the property
    /// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
    /// thin adapter that converts pile coordinates to an instruction and
    /// delegates here, so the move bookkeeping (rule validation, the undo
    /// snapshot, and the session's score/recycle stats) lives in exactly one
    /// place.
    ///
    /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
    /// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
    pub fn apply_instruction(
        &mut self,
        instruction: KlondikeInstruction,
    ) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }

        let config = self.validation_config();
        if !self
            .session
            .state()
            .state()
            .is_instruction_valid(&config, instruction)
        {
            return Err(MoveError::RuleViolation("move violates rules".into()));
        }

        // The session records the move snapshot and updates score/recycle stats.
        self.session.process_instruction(instruction);
        Ok(())
    }

    /// Restore the most recent undo snapshot.
    ///
    /// The −15 undo penalty is applied by the upstream score formula
    /// (`undos * undo_penalty`), and the session increments its `undos` counter,
    /// so this method only has to delegate to [`Session::undo`] after the mode
    /// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values.
    pub fn undo(&mut self) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }
        if self.mode == GameMode::Challenge {
            return Err(MoveError::RuleViolation(
                "undo is disabled in Challenge mode".into(),
            ));
        }
        if self.session.history().is_empty() {
            return Err(MoveError::UndoStackEmpty);
        }

        self.session.undo();
        Ok(())
    }

    /// Returns `true` when all four foundation slots each contain a complete A→K sequence.
    pub fn check_win(&self) -> bool {
        self.session.is_win()
    }

    /// Returns `true` when the game can be completed without further player input
    /// (stock empty, waste empty, all tableau cards face-up).
    pub fn check_auto_complete(&self) -> bool {
        self.session.state().state().is_win_trivial()
    }

    /// Returns all currently valid moves as upstream [`KlondikeInstruction`]s,
    /// ordered by the `klondike` solver's move priority.
    ///
    /// This is the engine's move currency. Callers that need on-screen pile
    /// positions — hint highlighting and the wasm debug move list — decode each
    /// instruction with [`Self::instruction_to_piles`] at their UI edge.
    pub fn possible_instructions(&self) -> Vec<KlondikeInstruction> {
        if self.is_won() {
            return Vec::new();
        }

        let config = self.validation_config();
        self.session
            .state()
            .state()
            .get_sorted_moves(&config)
            .into_iter()
            .collect()
    }

    /// Returns `true` when `move_cards(from, to, count)` would currently succeed.
    pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
        if self.is_won() || from == to {
            return false;
        }
        let from_pile = self.pile(*from);
        if count == 0 || count > from_pile.len() {
            return false;
        }
        let Ok(instruction) = self.instruction_for_move(*from, *to, count) else {
            return false;
        };
        let config = self.validation_config();
        self.session
            .state()
            .state()
            .is_instruction_valid(&config, instruction)
    }

    /// Returns the current pile containing `card`, if any.
    pub fn pile_containing_card(&self, card: Card) -> Option<KlondikePile> {
        if self.stock_cards().iter().any(|(c, _)| *c == card)
            || self.waste_cards().iter().any(|(c, _)| *c == card)
        {
            return Some(KlondikePile::Stock);
        }
        for slot in 0..4_u8 {
            let foundation = Self::foundation_from_slot(slot).ok()?;
            let pile = self.pile(KlondikePile::Foundation(foundation));
            if pile.iter().any(|(c, _)| *c == card) {
                return Some(KlondikePile::Foundation(foundation));
            }
        }
        for index in 0..7_usize {
            let tableau = Self::tableau_from_index(index).ok()?;
            let pile = self.pile(KlondikePile::Tableau(tableau));
            if pile.iter().any(|(c, _)| *c == card) {
                return Some(KlondikePile::Tableau(tableau));
            }
        }
        None
    }

    /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
    pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> {
        if !self.is_auto_completable() || self.is_won() {
            return None;
        }

        // A foundation-bound single-card move is exactly a `DstFoundation`
        // instruction whose source is not itself a foundation. Match the
        // instruction variant directly rather than projecting every candidate
        // to `(from, to, count)` pile coordinates — auto-complete is pure game
        // logic and never needs on-screen positions.
        self.possible_instructions()
            .into_iter()
            .find_map(|instruction| match instruction {
                KlondikeInstruction::DstFoundation(dst)
                    if !matches!(dst.src, KlondikePile::Foundation(_)) =>
                {
                    Some((dst.src, KlondikePile::Foundation(dst.foundation)))
                }
                _ => None,
            })
    }

    /// Read-only access to the underlying [`card_game::Session`] for this deal.
    ///
    /// Exposes `session.history()` (deterministic replay) and `session.solve()`
    /// (DFS solver) to crates outside `solitaire_core` without surfacing the
    /// mutable field. Internal code that needs to mutate the session accesses
    /// the `pub(crate)` field directly.
    pub fn session(&self) -> &Session<Klondike> {
        &self.session
    }

    /// Solvability of the current position: the first useful move on a winning
    /// path, `Ok(None)` if unwinnable (or already won), or `Err` if the solver
    /// hit its budget before reaching a verdict. See [`SolveOutcome`].
    ///
    /// Delegates the search to upstream [`card_game::Session::solve`] on a
    /// solve-budgeted copy of the current board, then extracts the first
    /// non-useless instruction from the returned solution. Backs the hint system
    /// and the Play-by-seed verdict badge.
    pub fn solve_first_move(&self, moves_budget: u64, states_budget: u64) -> SolveOutcome {
        // An already-won game has no "next move"; report it as unwinnable so the
        // winnable contract (`Some(_)` ⇒ a real move exists) holds.
        if self.is_won() {
            return Ok(None);
        }

        let config = SessionConfig {
            inner: KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation),
            undo_penalty: 0,
            solve_moves_budget: moves_budget,
            solve_states_budget: states_budget,
        };
        let session = Session::new(self.session.state().state().clone(), config);

        session.solve().map(|solution| {
            solution.and_then(|solution| {
                solution
                    .raw_solution()
                    .iter()
                    .map(|snapshot| *snapshot.instruction())
                    .find(|instruction| !instruction.is_useless())
            })
        })
    }

    /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
    ///
    /// Fresh-deal solving models standard Klondike rules, so the non-standard
    /// take-from-foundation house rule stays disabled. Backs the
    /// "Winnable deals only" retry loop.
    pub fn solve_fresh_deal(
        seed: u64,
        draw_mode: DrawStockConfig,
        moves_budget: u64,
        states_budget: u64,
    ) -> SolveOutcome {
        let mut game = Self::new(seed, draw_mode);
        game.take_from_foundation = false;
        game.solve_first_move(moves_budget, states_budget)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Resolve every legal instruction to its `(from, to, count)` piles for
    /// tests that assert against pile positions. Mirrors what a UI edge does
    /// via [`GameState::instruction_to_piles`].
    fn legal_pile_moves(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
        game.possible_instructions()
            .into_iter()
            .filter_map(|instruction| game.instruction_to_piles(instruction))
            .collect()
    }

    fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> {
        const MAX_SEED: u64 = 512;
        const MAX_STEPS: usize = 160;

        for seed in 1..=MAX_SEED {
            let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
            game.take_from_foundation = true;

            for _ in 0..MAX_STEPS {
                let moves = legal_pile_moves(&game);
                if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
                    *count == 1
                        && matches!(from, KlondikePile::Foundation(_))
                        && matches!(to, KlondikePile::Tableau(_))
                }) {
                    return Some((game, from, to));
                }

                if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| {
                    *count == 1
                        && !matches!(from, KlondikePile::Foundation(_))
                        && matches!(to, KlondikePile::Foundation(_))
                }) && game.move_cards(from, to, count).is_ok()
                {
                    continue;
                }

                if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty())
                    && game.draw().is_ok()
                {
                    continue;
                }

                if let Some((from, to, count)) = moves
                    .iter()
                    .copied()
                    .find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_)))
                    && game.move_cards(from, to, count).is_ok()
                {
                    continue;
                }

                break;
            }
        }

        None
    }

    /// Drive a DrawOne game until a recycle is available, perform it, and return
    /// the game.  Returns `None` if no recycle position is found within the
    /// iteration limit (shouldn't happen in practice).
    fn game_at_first_recycle() -> Option<GameState> {
        for seed in 1..=256_u64 {
            let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
            for _ in 0..200 {
                if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
                    // This draw will recycle.
                    game.draw().ok()?;
                    return Some(game);
                }
                let _ = game.draw();
            }
        }
        None
    }

    #[test]
    fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() {
        // Upstream `KlondikeStats::recycle_count` counts every recycle ever
        // performed; it is intentionally NOT decremented when a recycle is
        // undone (the session restores the board but leaves the stat). This is
        // the post-migration semantics: a cumulative count, not a net count.
        let mut game = game_at_first_recycle().expect("could not reach recycle");
        assert_eq!(game.recycle_count(), 1, "first recycle should give count=1");
        game.undo().expect("undo should succeed");
        assert_eq!(
            game.recycle_count(),
            1,
            "recycle_count is cumulative: undoing a recycle does not roll it back",
        );
    }

    #[test]
    fn draw_one_recycling_is_unlimited_by_design() {
        // Rules decision (Gitea #117): stock recycling is unlimited in every
        // draw mode. Extra passes are discouraged via the upstream score
        // penalty, never blocked with a MoveError. The difficulty seed
        // catalog and the winnable-deal solver are verified under this rule,
        // so a hard pass limit must not be introduced casually — see the
        // "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
        let mut game = game_at_first_recycle().expect("could not reach recycle");
        // ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
        for _ in 0..2_000 {
            if game.recycle_count() >= 10 {
                break;
            }
            game.draw()
                .expect("unlimited recycling: draw must never fail on pass count");
        }
        assert!(
            game.recycle_count() >= 10,
            "expected at least 10 recycles within the draw budget, got {}",
            game.recycle_count(),
        );
    }

    #[test]
    fn undo_applies_minus_15_penalty_via_upstream_score() {
        // A foundation move scores +10 upstream; undoing it nets the move score
        // back to 0 and adds the −15 undo penalty, which `score()` floors at 0.
        let mut game = GameState::new(1, DrawStockConfig::DrawOne);
        // Find and play any scoring move, then undo it.
        let scoring_move = game
            .possible_instructions()
            .into_iter()
            .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)));
        if let Some(instruction) = scoring_move {
            game.apply_instruction(instruction)
                .expect("scoring move should apply");
            assert!(game.score() > 0, "a foundation move should raise the score");
            game.undo().expect("undo should succeed");
            assert_eq!(game.undo_count(), 1, "undo increments the upstream counter");
            // base score returns to 0, minus 15 undo penalty, floored at 0.
            assert_eq!(game.score(), 0, "score floors at 0 after the undo penalty");
        }
    }

    #[test]
    fn take_from_foundation_allows_legal_return_move() {
        let (mut game, from, to) = find_foundation_return_position()
            .expect("expected to find a deterministic foundation->tableau return move");

        game.take_from_foundation = true;
        assert!(game.can_move_cards(&from, &to, 1));
        assert!(
            legal_pile_moves(&game)
                .iter()
                .any(|(f, t, c)| *f == from && *t == to && *c == 1)
        );
        assert!(game.move_cards(from, to, 1).is_ok());
    }

    #[test]
    fn take_from_foundation_disabled_blocks_return_move_everywhere() {
        let (mut game, from, to) = find_foundation_return_position()
            .expect("expected to find a deterministic foundation->tableau return move");

        game.take_from_foundation = false;
        assert!(!game.can_move_cards(&from, &to, 1));
        assert!(
            legal_pile_moves(&game)
                .iter()
                .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
                    || !matches!(t, KlondikePile::Tableau(_)))
        );
        assert!(game.move_cards(from, to, 1).is_err());
    }

    // ── Solvability check (solve_first_move / solve_fresh_deal) ──────────────

    /// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
    /// extracted first move (both `Eq`) rather than the whole `Result`.
    fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
        (outcome.is_err(), outcome.clone().ok().flatten())
    }

    #[test]
    fn solve_fresh_deal_is_deterministic() {
        let a = GameState::solve_fresh_deal(
            7,
            DrawStockConfig::DrawOne,
            DEFAULT_SOLVE_MOVES_BUDGET,
            DEFAULT_SOLVE_STATES_BUDGET,
        );
        let b = GameState::solve_fresh_deal(
            7,
            DrawStockConfig::DrawOne,
            DEFAULT_SOLVE_MOVES_BUDGET,
            DEFAULT_SOLVE_STATES_BUDGET,
        );
        assert_eq!(verdict_key(&a), verdict_key(&b));
    }

    #[test]
    fn winnable_verdict_carries_a_first_move() {
        // Contract: a first move is present iff the verdict is winnable.
        let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 5_000);
        let winnable = matches!(outcome, Ok(Some(_)));
        let has_move = outcome.ok().flatten().is_some();
        assert_eq!(winnable, has_move);
    }

    #[test]
    fn solve_first_move_uses_live_game_state() {
        let mut game = GameState::new(42, DrawStockConfig::DrawOne);
        game.draw().expect("draw must succeed");

        let outcome = game.solve_first_move(5_000, 5_000);
        let winnable = matches!(outcome, Ok(Some(_)));
        let has_move = outcome.ok().flatten().is_some();
        assert_eq!(winnable, has_move);
    }

    #[test]
    fn zero_state_budget_is_inconclusive() {
        let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 0);
        assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
    }

    #[test]
    fn budget_is_passed_through_not_clamped() {
        // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
        // budget reaches the solver unchanged.
        let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
        let medium =
            GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
        assert!(easy.is_err());
        assert!(matches!(medium, Ok(Some(_))));
    }
}
