package klondike

import (
	"encoding/json"
	"math/rand/v2"
	"strconv"
	"testing"

	"pete/internal/games/cards"
)

const rake = 0.05

func vegas() Tier   { t, _ := TierBySlug("vegas"); return t }
func patient() Tier { t, _ := TierBySlug("patient"); return t }
func cut() Tier     { t, _ := TierBySlug("cutthroat"); return t }

func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }

// ordered builds the 52 cards in a fixed order — the deck deal() would get if
// the shuffle were the identity. Tests that care about the board build their own.
func ordered() cards.Deck { return cards.NewDeck(1) }

func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
	t.Helper()
	s, evs, err := deal(bet, tier, d, rake)
	if err != nil {
		t.Fatalf("deal: %v", err)
	}
	if len(evs) != 1 || evs[0].Kind != "deal" {
		t.Fatalf("deal events = %+v, want one deal", evs)
	}
	return s
}

func apply(t *testing.T, s State, m Move) (State, []Event) {
	t.Helper()
	next, evs, err := ApplyMove(s, m)
	if err != nil {
		t.Fatalf("ApplyMove(%+v): %v", m, err)
	}
	return next, evs
}

func refuses(t *testing.T, s State, m Move, want error) {
	t.Helper()
	next, evs, err := ApplyMove(s, m)
	if err == nil {
		t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
	}
	if want != nil && err != want {
		t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
	}
	if evs != nil {
		t.Errorf("an illegal move emitted events: %+v", evs)
	}
	// The board an illegal move hands back must be the one it was given. This is
	// the whole contract of the reducer, and it's cheap to check by value.
	if !sameBoard(next, s) {
		t.Errorf("an illegal move changed the board")
	}
}

func sameBoard(a, b State) bool {
	x, _ := json.Marshal(a)
	y, _ := json.Marshal(b)
	return string(x) == string(y)
}

// ---- the deal --------------------------------------------------------------

func TestDealLaysOutTheBoard(t *testing.T) {
	s := mustDeal(t, 520, vegas(), ordered())

	seen := 0
	for i := 0; i < Piles; i++ {
		p := s.Table[i]
		if len(p.Up) != 1 {
			t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
		}
		if len(p.Down) != i {
			t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
		}
		seen += len(p.Up) + len(p.Down)
	}
	if seen != 28 {
		t.Errorf("tableau holds %d cards, want 28", seen)
	}
	if len(s.Stock) != 24 {
		t.Errorf("stock is %d, want 24", len(s.Stock))
	}
	if s.Home() != 0 || s.Pays() != 0 {
		t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
	}
}

func TestDealRefusesABadStake(t *testing.T) {
	if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
		t.Fatalf("deal(0) = %v, want ErrBadBet", err)
	}
	if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
		t.Fatalf("New(-5) = %v, want ErrBadBet", err)
	}
}

// ---- the stock -------------------------------------------------------------

func TestDrawTurnsTheTiersCount(t *testing.T) {
	for _, tier := range []Tier{patient(), vegas()} {
		s := mustDeal(t, 100, tier, ordered())
		next, evs := apply(t, s, Move{Kind: "draw"})
		if len(next.Waste) != tier.Draw {
			t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
		}
		if len(next.Stock) != 24-tier.Draw {
			t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
		}
		if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
			t.Errorf("%s: draw events = %+v", tier.Slug, evs)
		}
	}
}

// The last pull off a short stock turns over what's left rather than refusing.
func TestDrawTakesWhatIsLeft(t *testing.T) {
	s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
	for i := 0; i < 7; i++ {
		s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
	}
	s, _ = apply(t, s, Move{Kind: "draw"})
	if len(s.Stock) != 0 || len(s.Waste) != 24 {
		t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
	}
	refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
}

// drained empties the waste too, so there is genuinely nothing to turn over.
func drained(t *testing.T, s State) State {
	t.Helper()
	s = s.clone()
	s.Waste = nil
	s.Stock = nil
	return s
}

// The waste goes back under the stock in the order it came out — a recycle is a
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
// the audit log would stop replaying the game.
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
	s := mustDeal(t, 100, patient(), ordered())
	want := append(cards.Deck(nil), s.Stock...)

	for i := 0; i < 24; i++ {
		s, _ = apply(t, s, Move{Kind: "draw"})
	}
	next, evs := apply(t, s, Move{Kind: "draw"})
	if len(evs) != 1 || evs[0].Kind != "recycle" {
		t.Fatalf("events = %+v, want a recycle", evs)
	}
	if len(next.Waste) != 0 {
		t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
	}
	for i := range want {
		if next.Stock[i] != want[i] {
			t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
				i, next.Stock[i], want[i])
		}
	}
	if next.Recycles != 1 {
		t.Errorf("recycles = %d, want 1", next.Recycles)
	}
}

// Passes is how many times you may go *through* the stock, so it is one more
// than the number of times you may turn it back over.
func TestPassesRunOut(t *testing.T) {
	tests := []struct {
		tier     Tier
		recycles int // how many turn-overs the tier should allow
	}{
		{cut(), 0},      // one pass: you never get to turn it back over
		{vegas(), 2},    // three passes: two turn-overs
		{patient(), -1}, // unlimited
	}
	for _, tc := range tests {
		s := mustDeal(t, 100, tc.tier, ordered())
		if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
			t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
		}
		allowed := 0
		for i := 0; i < 5; i++ {
			// Empty the stock, then try to turn it over.
			for len(s.Stock) > 0 {
				s, _ = apply(t, s, Move{Kind: "draw"})
			}
			next, _, err := ApplyMove(s, Move{Kind: "draw"})
			if err == ErrNoPasses {
				break
			}
			if err != nil {
				t.Fatalf("%s: %v", tc.tier.Slug, err)
			}
			s = next
			allowed++
		}
		if tc.recycles < 0 {
			if allowed != 5 {
				t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
			}
			continue
		}
		if allowed != tc.recycles {
			t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
		}
		if s.PassesLeft() != 1 {
			t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
		}
	}
}

// ---- the rules -------------------------------------------------------------

// board builds a State directly, so a rule can be tested against the position
// that exercises it rather than against whatever a shuffle happened to deal.
func board(tier Tier, bet int64) State {
	return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
}

func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
	s := board(vegas(), 520)
	s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
	s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
	s.Table[2].Up = []cards.Card{card(7, cards.Clubs)}  // black 7 — does not
	s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8

	next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
	if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
		t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
	}
	if len(next.Table[1].Up) != 0 {
		t.Errorf("the seven is still in its old column")
	}
	if len(evs) != 1 || evs[0].Kind != "move" {
		t.Errorf("events = %+v, want one move", evs)
	}

	refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
	refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
}

func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
	s := board(vegas(), 520)
	// t0 is empty and has nothing under it.
	s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
	s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}

	refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)

	next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
	if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
		t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
	}
}

// A run comes off the tableau as a block, and only if it is a run.
func TestLiftingARun(t *testing.T) {
	s := board(vegas(), 520)
	s.Table[0].Up = []cards.Card{
		card(9, cards.Hearts),   // red
		card(8, cards.Spades),   // black
		card(7, cards.Diamonds), // red
	}
	s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9

	next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
	if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
		t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
	}

	// Not a run: same colour in the middle of it.
	bad := board(vegas(), 520)
	bad.Table[0].Up = []cards.Card{
		card(9, cards.Hearts),
		card(8, cards.Diamonds), // red on red
	}
	bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
	refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)

	// And you can't lift more cards than the column has.
	refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
}

// Taking the last face-up card off a column turns the next one over. This is the
// only thing in the game that reveals a card, which is the point of the test.
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
	s := board(vegas(), 520)
	hidden := card(cards.Queen, cards.Clubs)
	s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
	s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
	s.Table[1].Up = []cards.Card{card(8, cards.Spades)}

	next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
	if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
		t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
	}
	if len(next.Table[0].Down) != 1 {
		t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
	}
	if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
		t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
	}
}

func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
	s := board(vegas(), 520)
	s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
	s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
	s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
	s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}

	// A two can't start a foundation.
	refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)

	s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
	if len(s.Found[cards.Hearts]) != 1 {
		t.Fatalf("the ace didn't go home: %+v", s.Found)
	}
	if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
		t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
	}
	if evs[0].Home != 1 {
		t.Errorf("event carries Home=%d, want 1", evs[0].Home)
	}

	// The three can't jump the two, and the two of spades can't go on hearts.
	refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
	refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)

	s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
	if s.Home() != 2 {
		t.Errorf("Home = %d, want 2", s.Home())
	}
}

// A card can come back off a foundation — a real rule, and one that matters when
// you need a low card to move a column. The payout follows it back down, because
// the payout reads the board rather than counting events.
func TestACardComesBackOffAFoundation(t *testing.T) {
	s := board(vegas(), 5200)
	s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
	s.Table[0].Up = []cards.Card{card(3, cards.Spades)}

	before := s.Pays()
	next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
	if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
		t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
	}
	if next.Home() != 1 {
		t.Errorf("Home = %d after taking a card back, want 1", next.Home())
	}
	if next.Pays() >= before {
		t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
	}
}

func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
	s := board(vegas(), 520)
	s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
	s.Table[0].Up = []cards.Card{card(8, cards.Spades)}

	// The 5 is under the 7 and is not available, however much you'd like it.
	refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)

	next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
	if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
		t.Fatalf("the wrong card left the waste: %+v", next.Waste)
	}
}

func TestEmptyPilesAndNonsensePiles(t *testing.T) {
	s := board(vegas(), 520)
	s.Table[0].Up = []cards.Card{card(8, cards.Spades)}

	refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
	refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
	refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
	refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
	refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
	refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
}

// ---- auto ------------------------------------------------------------------

func TestAutoSendsEverythingItCanHome(t *testing.T) {
	s := board(vegas(), 5200)
	// Two aces and the hearts two, sitting on top of three columns.
	s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
	s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
	s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
	s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere

	next, evs := apply(t, s, Move{Kind: "auto"})
	if next.Home() != 3 {
		t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
	}
	if len(next.Table[3].Up) != 1 {
		t.Errorf("the nine went somewhere it couldn't go")
	}
	homes := 0
	for _, e := range evs {
		if e.Kind == "home" {
			homes++
		}
	}
	if homes != 3 {
		t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
	}

	// Nothing left to do: the button says so rather than doing nothing quietly.
	if next.CanAuto() {
		t.Errorf("CanAuto is true with only a nine on the board")
	}
	refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
}

// Won is the state the finish button finishes: every card face up, the stock and
// waste drained, so a single auto sweeps the lot home. The button is only honest
// if these two agree — Won lighting up on a board auto can't clear would stall.
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
	s := board(patient(), 5200)
	// A won board: three short face-up runs across the columns, nothing face down,
	// nothing in the stock or waste. Every card is reachable.
	s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
	s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
	s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
	s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}

	if !s.Won() {
		t.Fatalf("a board with everything face up and the piles empty is won")
	}

	next, _ := apply(t, s, Move{Kind: "auto"})
	if next.Home() != 8 { // the eight cards laid out above
		t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
	}

	// Anything still hidden or still in a pile is not won: auto would stall on it.
	withStock := s.clone()
	withStock.Stock = cards.Deck{card(5, cards.Spades)}
	if withStock.Won() {
		t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
	}
	withWaste := s.clone()
	withWaste.Waste = []cards.Card{card(5, cards.Spades)}
	if withWaste.Won() {
		t.Errorf("a board with a card still in the waste is not won")
	}
	withDown := s.clone()
	withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
	if withDown.Won() {
		t.Errorf("a board with a face-down card is not won")
	}

	// A finished board is never won: the button belongs to a game still in play.
	cleared := s.clone()
	cleared.Phase = PhaseDone
	if cleared.Won() {
		t.Errorf("a settled board reports won")
	}
}

// ---- the money -------------------------------------------------------------

// The number the felt quotes while you play and the number settle() lands on are
// the same function. Hangman had these as two sums once and the table advertised
// a payout the house didn't honour; this asserts they can't drift here.
func TestTheQuoteIsThePayout(t *testing.T) {
	s := board(vegas(), 1000)
	for home := 0; home <= FullDeck; home++ {
		s.Found = [Foundations][]cards.Card{}
		left := home
		for suit := 0; suit < Foundations && left > 0; suit++ {
			n := left
			if n > 13 {
				n = 13
			}
			for r := 1; r <= n; r++ {
				s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
			}
			left -= n
		}
		if s.Home() != home {
			t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
		}

		quoted := s.Pays()
		var evs []Event
		done := s.clone()
		done.settle(OutcomeCashed, &evs)
		if done.Payout != quoted {
			t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
		}
		if done.Payout+done.Rake != done.Earned() {
			t.Fatalf("%d home: payout %d + rake %d != earned %d",
				home, done.Payout, done.Rake, done.Earned())
		}
	}
}

func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
	for _, tier := range Tiers {
		s := board(tier, 1000)
		for suit := 0; suit < Foundations; suit++ {
			for r := 1; r <= 13; r++ {
				s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
			}
		}
		// Gross is the multiple exactly — computed from the total, not summed 52
		// times, so it doesn't bleed a rounding per card.
		want := int64(float64(s.Bet) * tier.Base)
		if s.Earned() != want {
			t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
		}
		// And the rake comes out of the winnings, never the stake.
		profit := want - s.Bet
		if s.Pays() != want-int64(float64(profit)*rake) {
			t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
		}
	}
}

// An empty board owes nothing, and is not charged a fee for owing nothing.
func TestNothingHomePaysNothing(t *testing.T) {
	s := board(cut(), 500)
	if s.Pays() != 0 || s.rakeNow() != 0 {
		t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
	}
	var evs []Event
	s.settle(OutcomeCashed, &evs)
	if s.Payout != 0 || s.Net() != -500 {
		t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
	}
}

// Below break-even the player is down but is not raked: there is no profit to
// take a cut of.
func TestNoRakeBelowTheStake(t *testing.T) {
	tier := vegas()
	s := board(tier, 5200)
	for i := 0; i < tier.BreakEven()-1; i++ {
		suit, r := i/13, i%13+1
		s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
	}
	if s.Earned() > s.Bet {
		t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
			s.Home(), s.Earned(), s.Bet)
	}
	if s.rakeNow() != 0 {
		t.Errorf("raked %d off a losing board", s.rakeNow())
	}
	if s.Pays() != s.Earned() {
		t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
	}
}

func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
	for _, tier := range Tiers {
		s := board(tier, 5200)
		for i := 0; i < tier.BreakEven(); i++ {
			suit, r := i/13, i%13+1
			s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
		}
		if s.Earned() < s.Bet {
			t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
				tier.Slug, s.Home(), s.Earned(), s.Bet)
		}
	}
}

// ---- settling --------------------------------------------------------------

func TestConcedeCashesTheBoard(t *testing.T) {
	s := board(vegas(), 5200)
	s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
	want := s.Pays()

	next, evs := apply(t, s, Move{Kind: "concede"})
	if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
		t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
	}
	if next.Payout != want {
		t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
	}
	if evs[len(evs)-1].Kind != "settle" {
		t.Errorf("no settle event: %+v", evs)
	}
	refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
}

// The last card home ends the game on its own — the player doesn't have to tell
// the table they've won.
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
	s := board(vegas(), 1000)
	for suit := 0; suit < Foundations; suit++ {
		top := 13
		if suit == int(cards.Clubs) {
			top = 12 // the king of clubs is the one card still out
		}
		for r := 1; r <= top; r++ {
			s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
		}
	}
	s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}

	next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
	if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
		t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
	}
	if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
		t.Errorf("a cleared board paid %d", next.Payout)
	}
	if evs[len(evs)-1].Kind != "settle" {
		t.Errorf("the winning card didn't settle the game: %+v", evs)
	}
}

// ---- the shape of the thing ------------------------------------------------

// A game survives a redeploy: the whole state, shoe and face-down cards and all,
// goes through JSON and comes back the same board.
func TestAGameSurvivesJSON(t *testing.T) {
	s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
	if err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 6; i++ {
		s, _, _ = ApplyMove(s, Move{Kind: "draw"})
	}
	blob, err := json.Marshal(s)
	if err != nil {
		t.Fatal(err)
	}
	var back State
	if err := json.Unmarshal(blob, &back); err != nil {
		t.Fatal(err)
	}
	if !sameBoard(s, back) {
		t.Fatal("the board didn't come back the same")
	}
}

// The same seed deals the same board. This is what lets a disputed game be dealt
// again exactly as it fell, and it is why the RNG is threaded rather than global.
func TestASeedDealsTheSameBoard(t *testing.T) {
	a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
	if err != nil {
		t.Fatal(err)
	}
	b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
	if err != nil {
		t.Fatal(err)
	}
	if !sameBoard(a, b) {
		t.Fatal("the same seed dealt two different boards")
	}

	c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
	if sameBoard(a, c) {
		t.Fatal("two seeds dealt the same board")
	}
}

// Every card is on the board exactly once, whatever you do to it. A move that
// duplicated a card would be a move that printed money.
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
	rng := rand.New(rand.NewPCG(3, 5))
	s, _, err := New(1000, patient(), rake, rng)
	if err != nil {
		t.Fatal(err)
	}
	countDeck(t, s, "the deal")

	// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
	for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
		m := randomMove(rng)
		next, _, err := ApplyMove(s, m)
		if err != nil {
			continue // an illegal move is a fine thing for a fuzzer to find
		}
		s = next
		countDeck(t, s, "after "+m.Kind)
	}
}

func randomMove(rng *rand.Rand) Move {
	pile := func() string {
		switch rng.IntN(3) {
		case 0:
			return "waste"
		case 1:
			return "t" + strconv.Itoa(rng.IntN(Piles))
		default:
			return "f" + strconv.Itoa(rng.IntN(Foundations))
		}
	}
	switch rng.IntN(10) {
	case 0, 1, 2, 3:
		return Move{Kind: "draw"}
	case 4:
		return Move{Kind: "home", From: pile()}
	case 5:
		return Move{Kind: "auto"}
	default:
		return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
	}
}

func countDeck(t *testing.T, s State, when string) {
	t.Helper()
	seen := map[cards.Card]int{}
	add := func(cs []cards.Card) {
		for _, c := range cs {
			seen[c]++
		}
	}
	add(s.Stock)
	add(s.Waste)
	for _, p := range s.Table {
		add(p.Down)
		add(p.Up)
	}
	for _, f := range s.Found {
		add(f)
	}
	if len(seen) != FullDeck {
		t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
	}
	for c, n := range seen {
		if n != 1 {
			t.Fatalf("%s: %v appears %d times", when, c, n)
		}
	}
}

// The face-up run in every tableau column is always a legal run, and a column
// with cards face-up never has an unturned card left under it. Both are things
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
func TestTheBoardStaysWellFormed(t *testing.T) {
	rng := rand.New(rand.NewPCG(11, 13))
	s, _, err := New(1000, vegas(), rake, rng)
	if err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
		next, _, err := ApplyMove(s, randomMove(rng))
		if err != nil {
			continue
		}
		s = next
		for j, p := range s.Table {
			if !isRun(p.Up) {
				t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
			}
			if len(p.Up) == 0 && len(p.Down) > 0 {
				t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
			}
		}
		for suit, f := range s.Found {
			for r, c := range f {
				if int(c.Suit) != suit || int(c.Rank) != r+1 {
					t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
				}
			}
		}
	}
}
