//! Spider solitaire rules — a second [`card_game::Game`] implementation
//! alongside the upstream Klondike.
//!
//! Built directly on the upstream `card_game` containers: two decks
//! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten
//! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the
//! `Stack`/`Pile` public API that `klondike` uses internally.
//!
//! ## Rules implemented
//!
//! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive
//!   5 (top card face-up) — 54 dealt, 50 in stock.
//! - Build down regardless of suit; only same-suit descending runs may
//!   be picked up and moved.
//! - Empty piles accept any card or movable run.
//! - The stock deals one card to every pile (10 total), only while no
//!   pile is empty.
//! - A completed K→A same-suit run is removed automatically; the game
//!   is won when all 8 runs are removed.
//! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits
//!   spread over the same 104 cards.
//!
//! ## Determinism
//!
//! Deals are seeded with an inline SplitMix64 + Fisher–Yates shuffle:
//! `solitaire_core` has no `rand` dependency (and adding one needs
//! explicit approval), so Spider's seed space is deliberately
//! self-contained rather than shared with Klondike's `StdRng` seeds.
//! The same seed + suit count always produces the same deal.
//!
//! ## Card identity caveat (engine integration, later phase)
//!
//! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit
//! games need more than four copies of a suit, so *identical* `Card`
//! values legitimately coexist (e.g. eight `♠A` in a 1-suit game,
//! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so
//! core is unaffected, but the engine's `Card → Entity` mapping is
//! keyed by card value and will need positional keys before a Spider
//! UI lands.

use card_game::{Card, Deck, Game, Pile, Rank, Session, SessionConfig, Stack, Suit};
use serde::{Deserialize, Serialize};

use crate::error::MoveError;

/// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks).
pub const SPIDER_DECK_SIZE: usize = 104;
/// Cards left in the stock after the opening deal (5 deals × 10).
const STOCK_SIZE: usize = 50;
/// Cards in one completed run (K → A).
const RUN_LEN: usize = 13;
/// Runs required to win.
const TOTAL_RUNS: u8 = 8;
/// Face-down cards in the deepest opening pile.
const MAX_FACE_DOWN: usize = 5;

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

/// Spider difficulty: how many distinct suits the 104 cards span.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum SpiderSuits {
    /// All spades — the beginner layout (default).
    #[default]
    One,
    /// Spades + hearts.
    Two,
    /// Full two-deck spread, the classic four-suit game.
    Four,
}

impl SpiderSuits {
    /// Suits used at this difficulty.
    fn suits(self) -> &'static [Suit] {
        match self {
            Self::One => &[Suit::Spades],
            Self::Two => &[Suit::Spades, Suit::Hearts],
            Self::Four => &[Suit::Spades, Suit::Hearts, Suit::Clubs, Suit::Diamonds],
        }
    }
}

/// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`.
///
/// Defaults follow the familiar Microsoft formula: start at 500, −1
/// per move, +100 per completed run (the upstream session adds
/// `undos × undo_penalty` on top).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpiderScoring {
    /// Score a fresh deal starts from.
    pub base: i32,
    /// Added per move (conventionally negative).
    pub move_penalty: i32,
    /// Added per completed K→A run.
    pub run_bonus: i32,
}

impl Default for SpiderScoring {
    fn default() -> Self {
        Self {
            base: 500,
            move_penalty: -1,
            run_bonus: 100,
        }
    }
}

/// Full Spider rules configuration (the `Game::Config` type).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpiderConfig {
    /// Suit-count difficulty.
    pub suits: SpiderSuits,
    /// Scoring knobs.
    pub scoring: SpiderScoring,
}

// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------

/// Per-game counters (the `Game::Stats` type). Cumulative — they are
/// not rolled back by undo, matching upstream `KlondikeStats`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpiderStats {
    moves: u32,
    deals: u32,
    runs_completed: u32,
}

impl SpiderStats {
    /// Total instructions processed (moves + deals).
    pub const fn moves(&self) -> u32 {
        self.moves
    }
    /// Stock deals performed.
    pub const fn deals(&self) -> u32 {
        self.deals
    }
    /// K→A runs completed over the whole game (not undone-adjusted).
    pub const fn runs_completed(&self) -> u32 {
        self.runs_completed
    }
}

// ---------------------------------------------------------------------------
// Instruction
// ---------------------------------------------------------------------------

/// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction {
    /// Deal one card from the stock onto every tableau pile.
    Deal,
    /// Move the top `count` face-up cards (a same-suit descending run)
    /// from pile `from` to pile `to`. Pile indices are `0..10`.
    Move {
        /// Source pile index.
        from: u8,
        /// Destination pile index.
        to: u8,
        /// Number of cards in the moved run (≥ 1).
        count: u8,
    },
}

// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------

/// Pure Spider game position: ten tableaus, the stock, and the count
/// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame {
    tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
    stock: Stack<STOCK_SIZE>,
    completed_runs: u8,
}

/// SplitMix64 step — small, well-known, and dependency-free. Spider
/// only needs a deterministic shuffle, not cryptographic quality.
const fn splitmix64(state: u64) -> (u64, u64) {
    let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
    let mut z = state;
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    (state, z ^ (z >> 31))
}

/// In-place Fisher–Yates driven by SplitMix64. The modulo bias is
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
fn shuffle(cards: &mut [Card], seed: u64) {
    let mut state = seed;
    for i in (1..cards.len()).rev() {
        let (next_state, r) = splitmix64(state);
        state = next_state;
        #[allow(clippy::cast_possible_truncation)]
        let j = (r % (i as u64 + 1)) as usize;
        cards.swap(i, j);
    }
}

/// Builds the 104-card Spider deck for a suit-count difficulty.
///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
    let suit_set = suits.suits();
    let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
    let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
    let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
    for copy in 0..copies {
        for &suit in suit_set {
            for rank in Rank::RANKS {
                cards.push(Card::new(decks[copy % decks.len()], suit, rank));
            }
        }
    }
    cards
}

impl SpiderGame {
    /// Deals a new seeded game at the given suit difficulty.
    pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
        let mut deck = build_deck(suits);
        shuffle(&mut deck, seed);
        let mut cards = deck.into_iter();

        let tableaus = core::array::from_fn(|index| {
            // Piles 0–3 open with 5 face-down cards, piles 4–9 with 4;
            // one face-up card lands on each afterwards.
            let down_count = if index < 4 { 5 } else { 4 };
            let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
            let mut pile = Pile::new_face_down(stack);
            if let Some(card) = cards.next() {
                pile.push(card);
            }
            pile
        });
        let stock: Stack<STOCK_SIZE> = cards.collect();

        Self {
            tableaus,
            stock,
            completed_runs: 0,
        }
    }

    /// Face-up cards of pile `index` (bottom → top). Empty slice for
    /// out-of-range indices.
    pub fn tableau_face_up(&self, index: usize) -> &[Card] {
        self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
    }

    /// Face-down cards of pile `index` (bottom → top).
    pub fn tableau_face_down(&self, index: usize) -> &[Card] {
        self.tableaus
            .get(index)
            .map_or(&[], |pile| pile.face_down())
    }

    /// Cards remaining in the stock.
    pub fn stock_len(&self) -> usize {
        self.stock.len()
    }

    /// Completed K→A runs removed from play so far.
    pub const fn completed_runs(&self) -> u8 {
        self.completed_runs
    }

    /// Length of the longest movable run on top of pile `index`: the
    /// maximal same-suit, strictly-descending face-up suffix.
    fn movable_run_len(&self, index: usize) -> usize {
        let Some(pile) = self.tableaus.get(index) else {
            return 0;
        };
        let up = pile.face_up();
        let mut len = usize::from(!up.is_empty());
        while len < up.len() {
            let above = &up[up.len() - len];
            let below = &up[up.len() - len - 1];
            let descends =
                below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
            if !descends {
                break;
            }
            len += 1;
        }
        len
    }

    /// Whether `Move { from, to, count }` is legal in this position.
    fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
        let (from, to, count) = (from as usize, to as usize, count as usize);
        if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
            return false;
        }
        if count > self.movable_run_len(from) {
            return false;
        }
        let src_up = self.tableaus[from].face_up();
        // Bottom card of the moved run; `count <= movable_run_len <=
        // src_up.len()` guarantees the index is in range.
        let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
            return false;
        };
        match self.tableaus[to].face_up().last() {
            // Build down regardless of suit.
            Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
            // Empty pile accepts anything (face-down remnant can't
            // exist without a face-up card — Pile flips eagerly).
            None => self.tableaus[to].is_empty(),
        }
    }

    /// Whether the stock may deal: cards remain and no pile is empty.
    fn is_deal_valid(&self) -> bool {
        !self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
    }

    /// Removes a completed K→A same-suit run from the top of pile
    /// `index`, if one is present. Returns `true` when a run was
    /// removed (and the next face-down card, if any, was flipped).
    fn sweep_completed_run(&mut self, index: usize) -> bool {
        let Some(pile) = self.tableaus.get_mut(index) else {
            return false;
        };
        let up = pile.face_up();
        if up.len() < RUN_LEN {
            return false;
        }
        let run = &up[up.len() - RUN_LEN..];
        let suit = run[0].suit();
        let is_complete = run.iter().enumerate().all(|(offset, card)| {
            card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
        });
        if !is_complete {
            return false;
        }
        let start = up.len() - RUN_LEN;
        let (_removed, _flipped) = pile.take_range_flip_up(start..);
        true
    }
}

impl Game for SpiderGame {
    type Score = i32;
    type Stats = SpiderStats;
    type Config = SpiderConfig;
    type Instruction = SpiderInstruction;

    fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score {
        let scoring = &config.scoring;
        let moves = i32::try_from(stats.moves).unwrap_or(i32::MAX);
        let runs = i32::from(self.completed_runs);
        scoring.base + moves.saturating_mul(scoring.move_penalty) + runs * scoring.run_bonus
    }

    fn possible_instructions(
        &self,
        config: &Self::Config,
    ) -> impl Iterator<Item = Self::Instruction> + use<> {
        let mut out = Vec::new();
        if self.is_deal_valid() {
            out.push(SpiderInstruction::Deal);
        }
        for from in 0..SPIDER_TABLEAUS {
            let max_run = self.movable_run_len(from);
            for count in 1..=max_run {
                for to in 0..SPIDER_TABLEAUS {
                    #[allow(clippy::cast_possible_truncation)]
                    let instruction = SpiderInstruction::Move {
                        from: from as u8,
                        to: to as u8,
                        count: count as u8,
                    };
                    if self.is_move_valid(from as u8, to as u8, count as u8) {
                        out.push(instruction);
                    }
                }
            }
        }
        let _ = config;
        out.into_iter()
    }

    fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
        match instruction {
            SpiderInstruction::Deal => self.is_deal_valid(),
            SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
        }
    }

    fn process_instruction(
        &mut self,
        stats: &mut Self::Stats,
        config: &Self::Config,
        instruction: Self::Instruction,
    ) {
        // The trait offers no error channel; an invalid instruction is
        // a no-op rather than a panic (error policy: no panics in game
        // logic). Callers route through `SpiderGameState`, which
        // validates first and surfaces `MoveError`.
        if !self.is_instruction_valid(config, instruction) {
            return;
        }
        stats.moves += 1;
        match instruction {
            SpiderInstruction::Deal => {
                stats.deals += 1;
                for index in 0..SPIDER_TABLEAUS {
                    match self.stock.pop() {
                        Some(card) => self.tableaus[index].push(card),
                        None => break,
                    }
                }
                for index in 0..SPIDER_TABLEAUS {
                    if self.sweep_completed_run(index) {
                        self.completed_runs += 1;
                        stats.runs_completed += 1;
                    }
                }
            }
            SpiderInstruction::Move { from, to, count } => {
                let (from, to, count) = (from as usize, to as usize, count as usize);
                let src_len = self.tableaus[from].face_up().len();
                let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
                self.tableaus[to].extend(cards);
                if self.sweep_completed_run(to) {
                    self.completed_runs += 1;
                    stats.runs_completed += 1;
                }
            }
        }
    }

    fn is_win(&self) -> bool {
        self.completed_runs >= TOTAL_RUNS
    }
}

// ---------------------------------------------------------------------------
// Session wrapper
// ---------------------------------------------------------------------------

/// Spider counterpart of [`crate::game_state::GameState`]: owns the
/// upstream [`Session`] (undo history + score/undo bookkeeping) and
/// exposes `Result<_, MoveError>` mutations, mirroring the Klondike
/// wrapper's conventions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState {
    seed: u64,
    session: Session<SpiderGame>,
}

impl SpiderGameState {
    /// New seeded game with the default (1-suit) difficulty.
    pub fn new(seed: u64) -> Self {
        Self::new_with_suits(seed, SpiderSuits::default())
    }

    /// New seeded game at an explicit suit difficulty.
    pub fn new_with_suits(seed: u64, suits: SpiderSuits) -> Self {
        let config = SessionConfig {
            inner: SpiderConfig {
                suits,
                scoring: SpiderScoring::default(),
            },
            // Undoing costs one move, matching the familiar Spider
            // scoring; the upstream formula applies `undos × penalty`.
            undo_penalty: -1,
            ..SessionConfig::default()
        };
        Self {
            seed,
            session: Session::new(SpiderGame::with_seed(seed, suits), config),
        }
    }

    /// The deal seed this game was created from.
    pub const fn seed(&self) -> u64 {
        self.seed
    }

    /// Suit difficulty of this game.
    pub fn suits(&self) -> SpiderSuits {
        self.session.config().inner.suits
    }

    /// Current position (read-only).
    pub fn game(&self) -> &SpiderGame {
        self.session.state().state()
    }

    /// In-play score, clamped at 0 like the Klondike wrapper.
    pub fn score(&self) -> i32 {
        self.session
            .state()
            .score(self.session.stats(), self.session.config())
            .max(0)
    }

    /// Whether all 8 runs are complete.
    pub fn is_won(&self) -> bool {
        self.session.is_win()
    }

    /// Total instructions applied (deals + moves), from history.
    pub fn move_count(&self) -> u32 {
        u32::try_from(self.session.history().len()).unwrap_or(u32::MAX)
    }

    /// Successful undos this session.
    pub fn undo_count(&self) -> u32 {
        self.session.stats().undos()
    }

    /// Legal instructions in the current position.
    pub fn possible_instructions(&self) -> Vec<SpiderInstruction> {
        self.session.possible_instructions().collect()
    }

    /// Applies one instruction. `Err` on illegal instructions and
    /// finished games; the session records the undo snapshot.
    pub fn apply_instruction(&mut self, instruction: SpiderInstruction) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }
        let config = &self.session.config().inner;
        if !self
            .session
            .state()
            .state()
            .is_instruction_valid(config, instruction)
        {
            return Err(MoveError::RuleViolation("move violates rules".into()));
        }
        self.session.process_instruction(instruction);
        Ok(())
    }

    /// Restores the previous snapshot. Mirrors the Klondike wrapper's
    /// guards; the upstream session counts the undo for scoring.
    pub fn undo(&mut self) -> Result<(), MoveError> {
        if self.is_won() {
            return Err(MoveError::GameAlreadyWon);
        }
        if self.session.history().is_empty() {
            return Err(MoveError::UndoStackEmpty);
        }
        self.session.undo();
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Test support
// ---------------------------------------------------------------------------

#[cfg(any(test, feature = "test-support"))]
impl SpiderGame {
    /// Builds an arbitrary position for tests: per-pile
    /// `(face_down, face_up)` card lists plus stock and completed-run
    /// count. No card-count invariants are enforced — stacked
    /// positions for rule tests routinely use partial layouts.
    pub fn from_test_layout(
        piles: [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS],
        stock: Vec<Card>,
        completed_runs: u8,
    ) -> Self {
        let mut iter = piles.into_iter();
        let tableaus = core::array::from_fn(|_| {
            // `piles` has exactly SPIDER_TABLEAUS entries.
            let (down, up) = iter.next().unwrap_or_default();
            let mut pile = Pile::new_face_down(down.into_iter().collect());
            pile.extend(up);
            pile
        });
        Self {
            tableaus,
            stock: stock.into_iter().collect(),
            completed_runs,
        }
    }
}

#[cfg(any(test, feature = "test-support"))]
impl SpiderGameState {
    /// Wraps an arbitrary position in a fresh session (empty history).
    pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
        let config = SessionConfig {
            inner: SpiderConfig {
                suits,
                scoring: SpiderScoring::default(),
            },
            undo_penalty: -1,
            ..SessionConfig::default()
        };
        Self {
            seed: 0,
            session: Session::new(game, config),
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    /// `Card::new` shorthand for stacked positions.
    fn card(suit: Suit, rank: Rank) -> Card {
        Card::new(Deck::Deck1, suit, rank)
    }

    /// K→A same-suit run, bottom (King) first — the face-up order a
    /// completed run occupies on a pile.
    fn full_run(suit: Suit) -> Vec<Card> {
        let mut ranks: Vec<Rank> = Rank::RANKS.to_vec();
        ranks.reverse();
        ranks.into_iter().map(|rank| card(suit, rank)).collect()
    }

    fn empty_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
        core::array::from_fn(|_| (Vec::new(), Vec::new()))
    }

    /// A layout where every pile is non-empty (single junk card), so
    /// deal-validity tests can toggle exactly one condition.
    fn junk_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
        core::array::from_fn(|_| (Vec::new(), vec![card(Suit::Clubs, Rank::King)]))
    }

    // -- dealing ----------------------------------------------------------

    #[test]
    fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
        let game = SpiderGame::with_seed(42, SpiderSuits::Four);
        for index in 0..SPIDER_TABLEAUS {
            let expected_down = if index < 4 { 5 } else { 4 };
            assert_eq!(game.tableau_face_down(index).len(), expected_down);
            assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
        }
        assert_eq!(game.stock_len(), STOCK_SIZE);
        assert_eq!(game.completed_runs(), 0);
    }

    #[test]
    fn same_seed_same_deal_different_seed_different_deal() {
        let a = SpiderGame::with_seed(7, SpiderSuits::Four);
        let b = SpiderGame::with_seed(7, SpiderSuits::Four);
        let c = SpiderGame::with_seed(8, SpiderSuits::Four);
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn suit_difficulty_controls_suit_spread() {
        let one = build_deck(SpiderSuits::One);
        let two = build_deck(SpiderSuits::Two);
        let four = build_deck(SpiderSuits::Four);
        for deck in [&one, &two, &four] {
            assert_eq!(deck.len(), SPIDER_DECK_SIZE);
        }
        assert!(one.iter().all(|c| c.suit() == Suit::Spades));
        assert!(
            two.iter()
                .all(|c| matches!(c.suit(), Suit::Spades | Suit::Hearts))
        );
        for suit in Suit::SUITS {
            assert_eq!(
                four.iter().filter(|c| c.suit() == suit).count(),
                2 * RUN_LEN,
                "four-suit deck has exactly two copies per suit"
            );
        }
    }

    // -- move rules -------------------------------------------------------

    #[test]
    fn single_card_builds_down_regardless_of_suit() {
        let mut layout = empty_layout();
        layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
        layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
        assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
        assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
    }

    #[test]
    fn only_same_suit_runs_are_movable_as_a_group() {
        let mut layout = empty_layout();
        // Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
        layout[0].1 = vec![
            card(Suit::Spades, Rank::Seven),
            card(Suit::Spades, Rank::Six),
        ];
        layout[1].1 = vec![
            card(Suit::Hearts, Rank::Seven),
            card(Suit::Spades, Rank::Six),
        ];
        // Destinations: 8♣ (for the pair), 7♦ (for a lone six).
        layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
        layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
        assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
        assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
        assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
    }

    #[test]
    fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold() {
        let mut layout = empty_layout();
        layout[0].1 = vec![
            card(Suit::Spades, Rank::Nine),
            card(Suit::Spades, Rank::Eight),
        ];
        // Pile 1 deliberately left empty.
        layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
        assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
        assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
        assert!(
            !game.is_move_valid(0, 2, 2),
            "9♠8♠ cannot land on 9♥ (needs a 10)"
        );
    }

    #[test]
    fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
        let mut layout = empty_layout();
        layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
        assert!(!game.is_move_valid(0, 0, 1), "self-move");
        assert!(!game.is_move_valid(0, 1, 0), "zero count");
        assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
        assert!(!game.is_move_valid(10, 0, 1), "source out of range");
        assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
    }

    // -- dealing from stock -------------------------------------------------

    #[test]
    fn deal_requires_stock_and_no_empty_pile() {
        let stock = vec![card(Suit::Spades, Rank::Ace); 10];
        let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
        assert!(game.is_deal_valid());

        let mut with_gap = junk_layout();
        with_gap[3].1.clear();
        let game = SpiderGame::from_test_layout(with_gap, stock, 0);
        assert!(!game.is_deal_valid(), "empty pile blocks the deal");

        let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
        assert!(!game.is_deal_valid(), "empty stock blocks the deal");
    }

    #[test]
    fn deal_puts_one_card_on_every_pile() {
        let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
        let before: Vec<usize> = (0..SPIDER_TABLEAUS)
            .map(|i| state.game().tableau_face_up(i).len())
            .collect();
        state
            .apply_instruction(SpiderInstruction::Deal)
            .expect("deal is legal on a fresh game");
        for (index, previous) in before.iter().enumerate() {
            assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
        }
        assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
    }

    #[test]
    fn stock_supports_exactly_five_deals() {
        let mut state = SpiderGameState::new_with_suits(11, SpiderSuits::One);
        for _ in 0..5 {
            state
                .apply_instruction(SpiderInstruction::Deal)
                .expect("five deals must all be legal on untouched piles");
        }
        assert_eq!(state.game().stock_len(), 0);
        assert!(matches!(
            state.apply_instruction(SpiderInstruction::Deal),
            Err(MoveError::RuleViolation(_))
        ));
    }

    // -- run completion & win ----------------------------------------------

    #[test]
    fn completing_a_run_removes_it_and_flips_the_card_beneath() {
        let mut layout = empty_layout();
        // Pile 0: one face-down card under K..2 of spades; the ace
        // arrives from pile 1.
        let mut run = full_run(Suit::Spades);
        let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
        layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
        layout[1].1 = vec![ace];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
        let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);

        state
            .apply_instruction(SpiderInstruction::Move {
                from: 1,
                to: 0,
                count: 1,
            })
            .expect("ace onto two completes the run");

        assert_eq!(state.game().completed_runs(), 1);
        assert_eq!(
            state.game().tableau_face_up(0),
            &[card(Suit::Hearts, Rank::Nine)],
            "run removed and the buried card flipped face-up"
        );
        assert!(state.game().tableau_face_up(1).is_empty());
    }

    #[test]
    fn eighth_run_wins_the_game() {
        let mut layout = empty_layout();
        let mut run = full_run(Suit::Spades);
        let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
        layout[0].1 = run;
        layout[1].1 = vec![ace];
        let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
        let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);

        assert!(!state.is_won());
        state
            .apply_instruction(SpiderInstruction::Move {
                from: 1,
                to: 0,
                count: 1,
            })
            .expect("winning move is legal");
        assert!(state.is_won());
        assert!(matches!(
            state.apply_instruction(SpiderInstruction::Deal),
            Err(MoveError::GameAlreadyWon)
        ));
    }

    // -- session wrapper -----------------------------------------------------

    #[test]
    fn undo_restores_position_and_counts() {
        let mut state = SpiderGameState::new_with_suits(21, SpiderSuits::Two);
        let fresh = state.game().clone();

        assert!(matches!(state.undo(), Err(MoveError::UndoStackEmpty)));
        state
            .apply_instruction(SpiderInstruction::Deal)
            .expect("deal");
        assert_ne!(*state.game(), fresh);

        state.undo().expect("one snapshot to restore");
        assert_eq!(*state.game(), fresh);
        assert_eq!(state.undo_count(), 1);
        assert_eq!(state.move_count(), 0, "undo pops the history entry");
    }

    #[test]
    fn score_follows_base_move_penalty_and_run_bonus() {
        let mut state = SpiderGameState::new_with_suits(5, SpiderSuits::One);
        assert_eq!(state.score(), 500, "fresh game starts at base");
        state
            .apply_instruction(SpiderInstruction::Deal)
            .expect("deal");
        assert_eq!(state.score(), 499, "one move costs one point");
    }

    #[test]
    fn rule_violation_surfaces_move_error() {
        let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
        let result = state.apply_instruction(SpiderInstruction::Move {
            from: 0,
            to: 0,
            count: 1,
        });
        assert!(matches!(result, Err(MoveError::RuleViolation(_))));
    }

    // -- generated instructions ----------------------------------------------

    #[test]
    fn possible_instructions_are_all_valid_and_include_deal() {
        let state = SpiderGameState::new_with_suits(13, SpiderSuits::Four);
        let config = SpiderConfig::default();
        let instructions = state.possible_instructions();
        assert!(
            instructions.contains(&SpiderInstruction::Deal),
            "fresh game has no empty pile, so Deal must be offered"
        );
        for instruction in instructions {
            assert!(state.game().is_instruction_valid(&config, instruction));
        }
    }
}

#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    /// Total cards across tableaus + stock + removed runs must always
    /// equal 104, and every generated instruction must validate — for
    /// any seed, difficulty, and random walk through legal moves.
    fn card_conservation(game: &SpiderGame) -> usize {
        let on_piles: usize = (0..SPIDER_TABLEAUS)
            .map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
            .sum();
        on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
    }

    proptest! {
        #[test]
        fn random_walks_conserve_cards_and_stay_valid(
            seed in any::<u64>(),
            suit_pick in 0u8..3,
            steps in 0usize..40,
            choices in proptest::collection::vec(any::<u32>(), 40),
        ) {
            let suits = match suit_pick {
                0 => SpiderSuits::One,
                1 => SpiderSuits::Two,
                _ => SpiderSuits::Four,
            };
            let mut state = SpiderGameState::new_with_suits(seed, suits);
            prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);

            for choice in choices.iter().take(steps) {
                let legal = state.possible_instructions();
                if legal.is_empty() {
                    break;
                }
                let instruction = legal[*choice as usize % legal.len()];
                prop_assert!(state.apply_instruction(instruction).is_ok());
                prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);
            }
        }
    }
}
