package holdem

import (
	"math/rand/v2"
	"testing"

	"pete/internal/games/cards"
)

// The one that matters: no chip is ever created or destroyed.
//
// Everything else in this package is a rule you could argue about. This is the
// one that would lose somebody money. Every chip at the table is in exactly one
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
// the only thing that ever adds to the total is a bot reloading. So: play a
// hundred sessions of real hands, with the trained bots making real decisions,
// and count the chips after every single move.
func TestChipsAreConserved(t *testing.T) {
	for game := 0; game < 100; game++ {
		rng := rand.New(rand.NewPCG(uint64(game), 99))
		bots := 1 + game%MaxBots
		tier := Tiers[game%len(Tiers)]

		s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
		if err != nil {
			t.Fatalf("new table: %v", err)
		}

		want := chipsAt(s) // what the table started with

		for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
			var evs []Event
			s, evs, err = apply(s, Move{Kind: Deal})
			if err != nil {
				t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
			}
			want += reloaded(evs) // a bot that rebought brought new chips with it
			check(t, s, want, game, hand, "deal")

			for step := 0; s.Phase == PhaseBetting; step++ {
				if step > 200 {
					t.Fatalf("game %d hand %d: the hand will not end", game, hand)
				}
				s, _, err = apply(s, randomMove(s, rng))
				if err != nil {
					t.Fatalf("game %d hand %d: %v", game, hand, err)
				}
				check(t, s, want, game, hand, "move")
			}
		}
	}
}

// chipsAt totals every chip the table can see, plus every one the house has
// already taken out of it.
func chipsAt(s State) int64 {
	total := s.Rake + s.Pot
	for _, p := range s.Seats {
		total += p.Stack + p.Bet
	}
	for _, pot := range s.Side {
		total += pot.Amount
	}
	return total
}

// reloaded is what the bots brought back to the table on this deal. It is the
// only thing in the game that is allowed to make chips out of nothing, which is
// exactly why the test has to know about it and nothing else does.
func reloaded(evs []Event) int64 {
	var n int64
	for _, e := range evs {
		if e.Kind == "rebuy" {
			n += e.Amount
		}
	}
	return n
}

func check(t *testing.T, s State, want int64, game, hand int, when string) {
	t.Helper()
	if got := chipsAt(s); got != want {
		t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
			"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
	}
	for i, p := range s.Seats {
		if p.Stack < 0 {
			t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
		}
	}
}

func stacks(s State) []int64 {
	out := make([]int64, len(s.Seats))
	for i, p := range s.Seats {
		out[i] = p.Stack
	}
	return out
}

// You is seat zero — the shape every test in this file is written against. The
// engine no longer has this constant: a table is a list of seats and which are
// human is a per-seat property, not a fixed index. But these tests all seat one
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
// them readable as the regression guard they are. The multiway behaviour has its
// own tests, which do not assume it.
const You = 0

// apply plays a move as the seat whose turn it is at a solo table — always the
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
// read as they did before the reshape.
func apply(s State, m Move) (State, []Event, error) {
	return ApplyMove(s, You, m)
}

// randomMove picks something legal for the player, without any thought at all.
// A bad player is exactly what this test wants: it gets into all-ins, folds,
// short stacks and split pots far faster than a good one would.
func randomMove(s State, rng *rand.Rand) Move {
	owed := s.Owed(You)
	var legal []Move
	if owed > 0 {
		legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
	} else {
		legal = append(legal, Move{Kind: Check})
	}
	if s.Seats[You].Stack > owed && s.canBet() {
		legal = append(legal, Move{Kind: Shove})
		if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
			legal = append(legal, Move{Kind: Raise, To: to})
		}
	}
	return legal[rng.IntN(len(legal))]
}

// ---- the rules a poker player would notice were wrong -----------------------

func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
	s := table(t, Tiers[0], 1, 200)
	s, evs, err := apply(s, Move{Kind: Deal})
	if err != nil {
		t.Fatal(err)
	}

	// The button posted the small blind, not the big one.
	var small, big int
	for _, e := range evs {
		if e.Kind == "blind" && e.Text == "small" {
			small = e.Seat
		}
		if e.Kind == "blind" && e.Text == "big" {
			big = e.Seat
		}
	}
	if small != s.Button {
		t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
	}
	if big == s.Button {
		t.Error("heads-up: the button posted the big blind too")
	}
	// And it is the first to act before the flop. (If the button is a bot it has
	// already acted, so what we can check is that the player didn't get skipped.)
	if s.Phase != PhaseBetting {
		t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
	}
}

func TestTheBigBlindGetsTheirOption(t *testing.T) {
	// A table where everyone just calls: the big blind has the bet matched without
	// ever having chosen anything, and the street must not end until they speak.
	s := table(t, Tiers[0], 1, 200)
	s, _, _ = apply(s, Move{Kind: Deal})

	// Find a hand where the player is the big blind. The button alternates, so at
	// most a couple of deals.
	for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
		s = playOut(t, s)
		s, _, _ = apply(s, Move{Kind: Deal})
	}
	if s.Position(You) != "BB" {
		t.Skip("never dealt the big blind")
	}
	if s.Phase != PhaseBetting {
		return // the bot folded or raised; either way the option isn't the question
	}
	if s.ToAct == You && s.Owed(You) == 0 {
		// This is the option: nothing to call, but the hand is still ours to act on.
		if _, _, err := apply(s, Move{Kind: Check}); err != nil {
			t.Errorf("the big blind cannot check their option: %v", err)
		}
	}
}

func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
	s := State{
		Tier:      Tiers[1], // 5/10
		Seats:     []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
		Bet:       100,
		MinRaise:  100, // a full raise would be to 200
		Aggressor: You,
		Phase:     PhaseBetting,
	}
	s.Seats[You].Bet = 100
	s.Seats[1].Bet = 0
	s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one

	var evs []Event
	if err := s.allin(1, &evs); err != nil {
		t.Fatal(err)
	}
	if s.Bet != 150 {
		t.Errorf("the bet to call is %d, want 150", s.Bet)
	}
	if s.MinRaise != 100 {
		t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
	}
	if s.Aggressor != You {
		t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
	}
}

func TestSidePotsPayInLayers(t *testing.T) {
	// Three players all-in for different amounts. The short stack can only win
	// what everyone could have lost to them.
	s := State{
		Tier:  Tiers[1],
		Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
	}
	s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
	s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
	s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers

	s.sidePots()

	if len(s.Side) != 2 {
		t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
	}
	main, side := s.Side[0], s.Side[1]
	if main.Amount != 300 { // 100 from each of the three
		t.Errorf("main pot is %d, want 300", main.Amount)
	}
	if len(main.Eligible) != 3 {
		t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
	}
	if side.Amount != 800 { // 400 more from each of the two who had it
		t.Errorf("side pot is %d, want 800", side.Amount)
	}
	if len(side.Eligible) != 2 {
		t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
	}
	if total := main.Amount + side.Amount; total != 1100 {
		t.Errorf("the pots hold %d, but %d went in", total, 1100)
	}
}

// A covered all-in player can only ever win what they matched, and the hand does
// not have to end in a run-out for that to be true.
//
// This one got through everything. The side pots were only ever cut in runout(),
// which happens when the betting stops because *nobody* can bet — so a short stack
// who shoves and gets called by two players who still have chips behind, and who
// then keep betting past them all the way to the river, reached a showdown with the
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
//
// Chip conservation never saw it: the chips balance perfectly, they just land in
// the wrong seat. And every browser session went through runout(), because the
// player shoving is what ends the betting. It took reading the code.
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
	c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }

	s := State{
		Tier:   Tiers[1],
		Phase:  PhaseBetting,
		Street: River,
		Community: []cards.Card{
			c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
			c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
		},
		Seats: []Seat{
			{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
			// All-in for 100, and holding the best hand at the table.
			{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
			{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
		},
	}
	s.Seats[0].Total, s.Seats[0].State = 500, Active
	s.Seats[1].Total, s.Seats[1].State = 100, AllIn
	s.Seats[2].Total, s.Seats[2].State = 500, Active
	s.Pot = 1100 // 100 + 500 + 500

	var evs []Event
	s.showdown(&evs)

	// The main pot is 100 from each of the three. The other 800 is between the two
	// who were still betting, and the short stack cannot touch it.
	if s.Seats[1].Won != 300 {
		t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
			"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
	}
	if s.Seats[0].Won+s.Seats[2].Won != 800 {
		t.Errorf("the 800 side pot paid out %d between the two players who were "+
			"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
	}
}

func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
	s := State{
		Tier:  Tiers[1],
		Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
	}
	s.Seats[0].Total, s.Seats[0].State = 200, AllIn
	s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
	s.Seats[2].Total, s.Seats[2].State = 200, AllIn

	s.sidePots()

	var total int64
	for _, p := range s.Side {
		total += p.Amount
		for _, seat := range p.Eligible {
			if seat == 1 {
				t.Error("a folded seat is eligible to win a pot")
			}
		}
	}
	if total != 450 {
		t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
	}
}

func TestAnUncalledBetComesBack(t *testing.T) {
	s := State{
		Tier:  Tiers[1],
		Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
	}
	s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
	s.Seats[1].Total, s.Seats[1].State = 200, Folded

	var evs []Event
	s.uncalled(&evs)

	if s.Seats[You].Stack != 300 {
		t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
	}
	if s.Seats[You].Total != 200 {
		t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
	}
	if s.Seats[You].State != Active {
		t.Error("still marked all-in for chips that came back")
	}
}

// ---- the rake --------------------------------------------------------------

func TestNoFlopNoDrop(t *testing.T) {
	s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
	var evs []Event
	s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)

	if s.Rake != 0 {
		t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
	}
	if s.Seats[You].Stack != 1000 {
		t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
	}
}

func TestTheRakeIsCapped(t *testing.T) {
	s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
	var evs []Event
	// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
	s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)

	want := s.Tier.BB * rakeCapBB
	if s.Rake != want {
		t.Errorf("raked %d, want the %d cap", s.Rake, want)
	}
	if s.Seats[You].Stack != 10000-want {
		t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
	}
}

func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
	s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
	var evs []Event
	s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2

	if s.Rake != 5 {
		t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
	}
	if s.Seats[You].Stack != 95 {
		t.Errorf("paid %d, want 95", s.Seats[You].Stack)
	}
}

// The rake has to survive the wiring, not only the arithmetic.
//
// This is the test that was missing, and a browser found what it would have
// found: New() overwrites the tier's rake with the one the casino hands it, and
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
// meaning percent. Every other rake test builds a State by hand and sets the tier
// itself, so not one of them ever saw the number a real table runs on — and the
// house quietly took nothing from every pot for an afternoon.
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
	tier := Tiers[1] // 5/10, so the cap is 30
	s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
	if err != nil {
		t.Fatal(err)
	}
	s.Flopped = true

	var evs []Event
	s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)

	if s.Rake != 20 {
		t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
			"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
	}
	if !has(evs, "rake") {
		t.Error("the rake was taken with no event to say so, so the felt cannot show it")
	}
}

// The house only makes money off you. A pot a bot wins is raked — that is what a
// pot is — but the chips it comes out of are not real, so it has cost you nothing
// and the number the felt quotes you must not move.
func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
	s := State{Tier: Tiers[1], Flopped: true,
		Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}}
	var evs []Event

	// A bot takes it.
	s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
	if s.Paid != 0 {
		t.Errorf("you paid %d in rake on a pot a bot won", s.Paid)
	}
	if s.Rake != 20 {
		t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+
			"whoever won it", s.Rake)
	}

	// Now you take one.
	s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
	if s.Paid != 20 {
		t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid)
	}
	if s.Rake != 40 {
		t.Errorf("the table has lifted %d in total, want 40", s.Rake)
	}

	// And a chop costs you half of it.
	s.Paid, s.Rake = 0, 0
	s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}},
		[]ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs)
	if s.Paid != 10 {
		t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+
			"because you won half the pot", s.Paid)
	}
}

// At a table with two humans the session-rake line each is shown is their own,
// not the table's: the house took it out of a pot one of them won, and quoting it
// to the other says the house has taken money off them that it has not. So the
// per-seat tally only ever moves on the seat that won the pot.
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
	s := State{Tier: Tiers[1], Flopped: true,
		Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
	var evs []Event

	// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
	s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
	if s.Seats[0].Paid != 20 {
		t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
	}
	if s.Seats[1].Paid != 0 {
		t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
	}
	if s.Paid != 20 {
		t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
	}

	// Bob wins the next one. His tally moves; Reala's stays where it was.
	s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
	if s.Seats[0].Paid != 20 {
		t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
	}
	if s.Seats[1].Paid != 10 {
		t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
	}
	if s.Paid != 30 {
		t.Errorf("the table total is %d, want 30", s.Paid)
	}
}

func TestASplitPotSplits(t *testing.T) {
	s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
	var evs []Event
	// Same rank: they chop. The odd chip goes to one of them, not into the air.
	s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
		[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)

	if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
		t.Errorf("paid out %d of a 101 pot", got)
	}
	if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
		t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
	}
}

// ---- the session -----------------------------------------------------------

func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
	s := table(t, Tiers[0], 2, 200)
	s, _, _ = apply(s, Move{Kind: Deal})
	if s.Phase != PhaseBetting {
		t.Skip("the hand ended before the player could act")
	}
	if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
		t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
	}
	if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
		t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
	}
}

func TestLeavingTakesTheStackHome(t *testing.T) {
	s := table(t, Tiers[0], 1, 200)
	s, _, _ = apply(s, Move{Kind: Deal})
	s = playOut(t, s)

	stack := s.Seats[You].Stack
	s, _, err := apply(s, Move{Kind: Leave})
	if err != nil {
		t.Fatal(err)
	}
	if s.Phase != PhaseDone {
		t.Errorf("phase %q after leaving, want done", s.Phase)
	}
	if s.Payout != stack {
		t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
	}
	if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
		t.Errorf("dealt a hand at a table we got up from: %v", err)
	}
}

func TestBustingEndsTheSession(t *testing.T) {
	s := table(t, Tiers[0], 1, 200)
	s.Seats[You].Stack = 0
	var evs []Event
	s.endHand(&evs)

	if s.Phase != PhaseDone {
		t.Errorf("phase %q with no chips left, want done", s.Phase)
	}
	if s.Payout != 0 {
		t.Errorf("payout %d for a busted player", s.Payout)
	}
	if !has(evs, "bust") {
		t.Error("no bust event")
	}
}

func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
	s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
	if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
		t.Errorf("topped up over the table maximum: %v", err)
	}

	s = table(t, Tiers[0], 1, 100)
	s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
	if err != nil {
		t.Fatal(err)
	}
	if s.Seats[You].Stack != 150 {
		t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
	}
	if s.BoughtIn != 150 {
		t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
	}
}

func TestABuyInHasToBeInRange(t *testing.T) {
	tier := Tiers[0]
	for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
		if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
			t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
		}
	}
}

// ---- what the raw script carries ------------------------------------------

// The engine no longer redacts the event stream, and this pins why: a shared
// table has more than one human, so the engine cannot know who a stream is for.
// It emits every seat's hole cards, and the *view* layer builds each viewer's
// redacted copy — that per-seat redaction is the security boundary now, and it
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
//
// So the engine-level contract flipped: the deal must carry a hole event for
// every dealt seat, or a viewer would have no cards of their own to be shown.
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
	s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
	s, evs, err := apply(s, Move{Kind: Deal})
	if err != nil {
		t.Fatal(err)
	}

	holes := map[int][]cards.Card{}
	for _, e := range evs {
		if e.Kind == "hole" {
			holes[e.Seat] = e.Cards
		}
	}
	for i := range s.Seats {
		if s.Seats[i].State == Out {
			continue
		}
		got := holes[i]
		if len(got) != 2 {
			t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
		}
		if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
			t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
		}
	}
}

// ---- hand strength ---------------------------------------------------------

func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
	board := []cards.Card{
		{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
		{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
		{Rank: 7, Suit: cards.Clubs},
	}
	flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
	straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
	royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
	pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)

	if !(royal < flush && flush < straight && straight < pair) {
		t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
			royal, flush, straight, pair)
	}
}

func TestEquityKnowsAcesAreGood(t *testing.T) {
	rng := rand.New(rand.NewPCG(1, 1))
	aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
	rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)

	if aces.Strength() < 0.8 {
		t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
	}
	if rags.Strength() > 0.4 {
		t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
	}
	if aces.Strength() <= rags.Strength() {
		t.Error("seven-deuce is not better than pocket aces")
	}
}

// The policy loads, and every node in it is a probability distribution.
func TestThePolicyLoads(t *testing.T) {
	p := loadPolicy()
	if len(p) < 1000 {
		t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
	}
	for key, probs := range p {
		var sum float64
		for _, v := range probs {
			if v < 0 {
				t.Fatalf("%s: a negative probability (%v)", key, probs)
			}
			sum += v
		}
		if sum < 0.99 || sum > 1.01 {
			t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
		}
	}
}

// TestTheBotsAreActuallyTrained is the test this game most needed and did not
// have.
//
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
// pot-odds rule, and looks exactly like a bot that is working — which is how
// gogobee shipped a trained poker AI whose policy was *never read once* for the
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
// said so.
//
// So: deal real hands, let the bots think, and count how often the thinking lands
// in the table. Heads-up is the number that has to hold — that is what the policy
// was trained on. A six-handed table is a documented approximation of it and
// drops off as seats are added, which is why this only asserts on the duel.
func TestTheBotsAreActuallyTrained(t *testing.T) {
	hits.Store(0)
	misses.Store(0)

	rng := rand.New(rand.NewPCG(11, 12))
	for game := 0; game < 40; game++ {
		tier := Tiers[1]
		s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
		if err != nil {
			t.Fatal(err)
		}
		for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
			s, _, err = apply(s, Move{Kind: Deal})
			if err != nil {
				t.Fatal(err)
			}
			for s.Phase == PhaseBetting {
				s, _, err = apply(s, randomMove(s, rng))
				if err != nil {
					t.Fatal(err)
				}
			}
		}
	}

	h, m := hits.Load(), misses.Load()
	if h+m < 100 {
		t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
	}
	rate := float64(h) / float64(h+m)
	if rate < 0.6 {
		t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
			"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
			"trainer writes and the key the table reads have drifted apart. See infoSet.",
			rate*100, h, h+m)
	}
	t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
}

// ---- helpers ---------------------------------------------------------------

func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
	t.Helper()
	s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
	if err != nil {
		t.Fatalf("new table: %v", err)
	}
	return s
}

// playOut folds every decision until the hand is over.
func playOut(t *testing.T, s State) State {
	t.Helper()
	for i := 0; s.Phase == PhaseBetting; i++ {
		if i > 100 {
			t.Fatal("the hand will not end")
		}
		move := Move{Kind: Fold}
		if s.Owed(You) == 0 {
			move = Move{Kind: Check}
		}
		var err error
		s, _, err = apply(s, move)
		if err != nil {
			t.Fatalf("playing out: %v", err)
		}
	}
	return s
}

func has(evs []Event, kind string) bool {
	for _, e := range evs {
		if e.Kind == kind {
			return true
		}
	}
	return false
}

// Where you sit is decided when the button moves, and a fold does not move it.
// Position walked the table with nextIn, which steps over folded seats while the
// seat count still includes them — so as players mucked, the labels slid round
// and a six-handed felt printed CO on three different seats at once. The badge is
// the only thing that reads this, which is exactly why nothing caught it.
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
	s := table(t, Tiers[0], 5, 200) // six-handed
	s, _, _ = apply(s, Move{Kind: Deal})

	before := make([]string, len(s.Seats))
	for i := range s.Seats {
		before[i] = s.Position(i)
	}

	// Every seat has its own label, and the ones a six-max table prints are these.
	seen := map[string]int{}
	for _, p := range before {
		seen[p]++
	}
	for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
		if seen[want] != 1 {
			t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
				want, seen[want], before)
		}
	}

	// Now fold seats out of the hand, one at a time. Nobody's position changes by
	// mucking — folding is done to the state directly because a fold in the engine
	// belongs to whoever is to act, and what is under test is the label, not the turn.
	for i := range s.Seats {
		if i == You || s.Seats[i].State != Active {
			continue
		}
		s.Seats[i].State = Folded
		for j := range s.Seats {
			if got := s.Position(j); got != before[j] {
				t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
					"where you sit, not who is left", j, before[j], got, i)
			}
		}
	}
}
