package plugin

import (
	"encoding/gob"
	"fmt"
	"log/slog"
	"math"
	"math/rand/v2"
	"os"
	"sync/atomic"
	"time"

	"github.com/chehsunliu/poker"
)

// TrainProgress tracks iteration progress across workers for logging.
type TrainProgress struct {
	Total     int
	Completed atomic.Int64
	StartTime time.Time
}

// CFR action indices.
const (
	cfrFold       = 0
	cfrCallCheck  = 1
	cfrRaiseHalf  = 2
	cfrRaisePot   = 3
	cfrAllIn      = 4
	cfrNumActions = 5
)

// Maximum raises per street in the simplified game tree.
const cfrMaxRaisesPerStreet = 2

// Regret pruning threshold — actions below this are skipped after warmup.
const cfrPruneThreshold = -300.0

// Equity bucket thresholds (12 buckets for finer granularity).
func equityBucket(eq float64) int {
	switch {
	case eq < 0.08:
		return 0 // Trash
	case eq < 0.17:
		return 1 // Air
	case eq < 0.25:
		return 2 // Weak
	case eq < 0.33:
		return 3 // Below avg
	case eq < 0.42:
		return 4 // Marginal low
	case eq < 0.50:
		return 5 // Marginal high
	case eq < 0.58:
		return 6 // Above avg
	case eq < 0.67:
		return 7 // Good
	case eq < 0.75:
		return 8 // Strong
	case eq < 0.83:
		return 9 // Very strong
	case eq < 0.92:
		return 10 // Premium
	default:
		return 11 // Monster
	}
}

// SPR bucket thresholds (5 buckets).
func sprBucket(spr float64) int {
	switch {
	case spr < 1:
		return 0 // micro
	case spr < 3:
		return 1 // low
	case spr < 6:
		return 2 // medium
	case spr < 12:
		return 3 // high
	default:
		return 4 // deep
	}
}

// Board texture categories (post-flop only).
const (
	boardDry    = 0 // no flush/straight draws, no pairs
	boardWet    = 1 // flush draw or straight draw potential
	boardPaired = 2 // board has a pair or trips
)

// boardTexture classifies the community cards into dry/wet/paired.
func boardTexture(community []poker.Card) int {
	if len(community) < 3 {
		return boardDry
	}

	// Check for paired board.
	rankCounts := make(map[byte]int)
	suitCounts := make(map[byte]int)
	var rankValues []int

	for _, c := range community {
		s := c.String()
		if len(s) >= 2 {
			rankCounts[s[0]]++
			suitCounts[s[1]]++
			rankValues = append(rankValues, cardRankIndex(c))
		}
	}

	for _, count := range rankCounts {
		if count >= 2 {
			return boardPaired
		}
	}

	// Check for flush draw (3+ of same suit).
	for _, count := range suitCounts {
		if count >= 3 {
			return boardWet
		}
	}

	// Check for straight draw potential (3+ cards within a 5-card span).
	if len(rankValues) >= 3 {
		// Sort ranks.
		for i := 0; i < len(rankValues); i++ {
			for j := i + 1; j < len(rankValues); j++ {
				if rankValues[j] < rankValues[i] {
					rankValues[i], rankValues[j] = rankValues[j], rankValues[i]
				}
			}
		}
		// Check if any 3 consecutive sorted ranks fit in a 5-card window.
		for i := 0; i <= len(rankValues)-3; i++ {
			if rankValues[i+2]-rankValues[i] <= 4 {
				return boardWet
			}
		}
	}

	return boardDry
}

// ── Preflop Hand Strength Lookup ────────────────────────────────────────────

// preflopEquityTable maps the 169 strategically distinct starting hands to
// precomputed equity buckets. Built once at init via Monte Carlo (10K iterations
// per hand class). This eliminates all MC work during preflop training nodes.
var preflopEquityTable [13][13]int // [rank1][rank2], suited when rank1 < rank2

func init() {
	// We compute these once at startup. ~169 * 10K MC = ~1.7M evaluations,
	// takes about 1-2 seconds but saves billions of MC calls during training.
	ranks := []string{"2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"}

	for i := 0; i < 13; i++ {
		for j := i; j < 13; j++ {
			// Pick representative cards for this hand class.
			var hole [2]poker.Card
			if i == j {
				// Pair: use two different suits.
				hole = [2]poker.Card{
					poker.NewCard(ranks[i] + "s"),
					poker.NewCard(ranks[j] + "h"),
				}
			} else {
				// Suited (stored in upper triangle).
				hole = [2]poker.Card{
					poker.NewCard(ranks[i] + "s"),
					poker.NewCard(ranks[j] + "s"),
				}
			}
			eq := Equity(hole, nil, 1, 10000)
			preflopEquityTable[i][j] = equityBucket(eq.Win + eq.Tie*0.5)

			if i != j {
				// Offsuit (lower triangle).
				hole = [2]poker.Card{
					poker.NewCard(ranks[i] + "s"),
					poker.NewCard(ranks[j] + "h"),
				}
				eq = Equity(hole, nil, 1, 10000)
				preflopEquityTable[j][i] = equityBucket(eq.Win + eq.Tie*0.5)
			}
		}
	}
}

// cardRankIndex returns 0-12 for card rank (2=0, 3=1, ..., A=12).
func cardRankIndex(c poker.Card) int {
	// poker.Card ranks: the library uses specific encoding.
	// We extract the rank string and map it.
	s := c.String()
	if len(s) < 1 {
		return 0
	}
	switch s[0] {
	case '2':
		return 0
	case '3':
		return 1
	case '4':
		return 2
	case '5':
		return 3
	case '6':
		return 4
	case '7':
		return 5
	case '8':
		return 6
	case '9':
		return 7
	case 'T':
		return 8
	case 'J':
		return 9
	case 'Q':
		return 10
	case 'K':
		return 11
	case 'A':
		return 12
	}
	return 0
}

// cardSuitChar returns the suit character for a card.
func cardSuitChar(c poker.Card) byte {
	s := c.String()
	if len(s) >= 2 {
		return s[1]
	}
	return '?'
}

// preflopBucket returns the precomputed equity bucket for a hole hand.
func preflopBucket(hole [2]poker.Card) int {
	r0 := cardRankIndex(hole[0])
	r1 := cardRankIndex(hole[1])
	suited := cardSuitChar(hole[0]) == cardSuitChar(hole[1])

	lo, hi := r0, r1
	if lo > hi {
		lo, hi = hi, lo
	}

	if suited {
		return preflopEquityTable[lo][hi] // upper triangle = suited
	}
	if lo == hi {
		return preflopEquityTable[lo][hi] // diagonal = pair
	}
	return preflopEquityTable[hi][lo] // lower triangle = offsuit
}

// ── Fast Training Equity ────────────────────────────────────────────────────

// trainingEquityFast computes equity for post-flop using the already-dealt
// remaining deck, avoiding allCards()/map rebuilds. Uses partial Fisher-Yates.
func trainingEquityFast(hero [2]poker.Card, community []poker.Card, remaining []poker.Card, iterations int) float64 {
	// Build a deck of unknowns (remaining minus hero cards and community).
	// remaining already excludes the 4 hole cards from the deal.
	// community is a sub-slice of remaining, so we need to skip those indices.
	boardLen := len(community)

	// The unknowns start after the community cards in remaining.
	unknowns := remaining[boardLen:]
	boardNeeded := 5 - boardLen
	cardsNeeded := 2 + boardNeeded // 1 opponent + board completion

	if cardsNeeded > len(unknowns) {
		cardsNeeded = len(unknowns)
	}

	var wins, ties int
	var heroCards, oppCards [7]poker.Card
	heroCards[0] = hero[0]
	heroCards[1] = hero[1]

	var fullBoard [5]poker.Card
	copy(fullBoard[:boardLen], community)

	for i := 0; i < iterations; i++ {
		// Partial Fisher-Yates on unknowns.
		for j := 0; j < cardsNeeded; j++ {
			k := j + rand.IntN(len(unknowns)-j)
			unknowns[j], unknowns[k] = unknowns[k], unknowns[j]
		}

		// Opponent hole cards.
		oppCards[0] = unknowns[0]
		oppCards[1] = unknowns[1]

		// Complete board.
		for b := 0; b < boardNeeded; b++ {
			fullBoard[boardLen+b] = unknowns[2+b]
		}

		copy(heroCards[2:], fullBoard[:])
		copy(oppCards[2:], fullBoard[:])

		heroRank := poker.Evaluate(heroCards[:])
		oppRank := poker.Evaluate(oppCards[:])

		if heroRank < oppRank {
			wins++
		} else if heroRank == oppRank {
			ties++
		}
	}

	return float64(wins)/float64(iterations) + float64(ties)/float64(iterations)*0.5
}

// trainingEquity computes equity for runtime/validation using the standard Equity function.
func trainingEquity(hero [2]poker.Card, community []poker.Card, iterations int) float64 {
	eq := Equity(hero, community, 1, iterations)
	return eq.Win + eq.Tie*0.5
}

// ── Integer Info Set Keys ───────────────────────────────────────────────────

// InfoSetKey packs all info set dimensions into a uint64 for fast map access.
// Layout: [street:3][position:1][eqBucket:4][sprBucket:3][boardTex:2][history:24][histLen:3]
//
// History is encoded as up to 6 action chars, 4 bits each.
type InfoSetKey = uint64

// RegretTableInt maps integer info set keys to cumulative regrets per action.
type RegretTableInt map[InfoSetKey][cfrNumActions]float64

// PolicyTable maps info set keys to action probability distributions.
type PolicyTable map[string][cfrNumActions]float64

// RegretTable maps info set keys to cumulative regrets per action.
// Used for gob serialization (string keys for compatibility).
type RegretTable map[string][cfrNumActions]float64

// CFRTrainingMeta stores metadata about a trained policy.
type CFRTrainingMeta struct {
	Iterations int
	Seed       int64
	Date       string
}

// CFRData holds both regret and strategy tables for training persistence.
type CFRData struct {
	Regrets  RegretTable
	Strategy RegretTable // cumulative strategy (sum of all iteration strategies)
	Meta     CFRTrainingMeta
}

func packInfoSetKey(street Street, posIP bool, eqBucket, sprBkt, boardTex int, history string) InfoSetKey {
	var key uint64
	key |= uint64(street) & 0x7 // bits 0-2
	if posIP {
		key |= 1 << 3 // bit 3
	}
	key |= (uint64(eqBucket) & 0xF) << 4  // bits 4-7 (4 bits for 12 buckets)
	key |= (uint64(sprBkt) & 0x7) << 8    // bits 8-10
	key |= (uint64(boardTex) & 0x3) << 11 // bits 11-12

	// Pack up to 6 history chars, 4 bits each (bits 13-36).
	h := history
	if len(h) > 6 {
		h = h[len(h)-6:]
	}
	for i := 0; i < len(h); i++ {
		var v uint64
		switch h[i] {
		case 'f':
			v = 1
		case 'c':
			v = 2
		case 'r':
			v = 3
		case 'R':
			v = 4
		case 'a':
			v = 5
		}
		key |= v << (13 + uint(i)*4)
	}
	// Encode history length (bits 37-39).
	key |= uint64(len(h)) << 37

	return key
}

func infoSetKeyToString(key InfoSetKey) string {
	street := key & 0x7
	pos := "OOP"
	if (key>>3)&1 == 1 {
		pos = "IP"
	}
	eqBkt := (key >> 4) & 0xF
	sprBkt := (key >> 8) & 0x7
	boardTex := (key >> 11) & 0x3
	hLen := (key >> 37) & 0x7
	var history string
	charMap := [6]byte{0, 'f', 'c', 'r', 'R', 'a'}
	for i := uint64(0); i < hLen; i++ {
		v := (key >> (13 + i*4)) & 0xF
		if v < 6 {
			history += string(charMap[v])
		}
	}
	return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eqBkt, sprBkt, boardTex, history)
}

// buildInfoSetKey constructs a string info set key (used for runtime policy lookup).
func buildInfoSetKey(street Street, position string, eqBucket, sprBkt, boardTex int, actionHistory string) string {
	return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, position, eqBucket, sprBkt, boardTex, actionHistory)
}

// truncateHistory keeps only the last 6 action characters.
func truncateHistory(h string) string {
	if len(h) > 6 {
		return h[len(h)-6:]
	}
	return h
}

// actionChar maps CFR action index to a history character.
func actionChar(a int) byte {
	switch a {
	case cfrFold:
		return 'f'
	case cfrCallCheck:
		return 'c'
	case cfrRaiseHalf:
		return 'r'
	case cfrRaisePot:
		return 'R'
	case cfrAllIn:
		return 'a'
	default:
		return '?'
	}
}

// getStrategy computes the current strategy from regrets via regret matching.
func getStrategy(regrets [cfrNumActions]float64) [cfrNumActions]float64 {
	var strategy [cfrNumActions]float64
	positiveSum := 0.0
	for _, r := range regrets {
		if r > 0 {
			positiveSum += r
		}
	}
	if positiveSum > 0 {
		for i, r := range regrets {
			if r > 0 {
				strategy[i] = r / positiveSum
			}
		}
	} else {
		// Uniform strategy.
		for i := range strategy {
			strategy[i] = 1.0 / float64(cfrNumActions)
		}
	}
	return strategy
}

// sampleAction samples an action index from a probability distribution.
func sampleAction(probs [cfrNumActions]float64) int {
	r := rand.Float64()
	cumulative := 0.0
	for i, p := range probs {
		cumulative += p
		if r < cumulative {
			return i
		}
	}
	return cfrNumActions - 1
}

// LoadPolicy loads a pre-trained policy table from a gob file.
func LoadPolicy(path string) (PolicyTable, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open policy: %w", err)
	}
	defer f.Close()

	var data CFRData
	if err := gob.NewDecoder(f).Decode(&data); err != nil {
		return nil, fmt.Errorf("decode policy: %w", err)
	}

	slog.Info("holdem: loaded CFR policy",
		"entries", len(data.Strategy),
		"iterations", data.Meta.Iterations,
		"date", data.Meta.Date)

	// Normalize strategy table to produce probabilities.
	policy := make(PolicyTable, len(data.Strategy))
	for key, strat := range data.Strategy {
		var total float64
		for _, v := range strat {
			total += v
		}
		var probs [cfrNumActions]float64
		if total > 0 {
			for i, v := range strat {
				probs[i] = v / total
			}
		} else {
			for i := range probs {
				probs[i] = 1.0 / float64(cfrNumActions)
			}
		}
		policy[key] = probs
	}

	return policy, nil
}

// SaveCFRData saves training data (regrets + strategy) to a gob file.
func SaveCFRData(path string, data *CFRData) error {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create file: %w", err)
	}
	defer f.Close()

	if err := gob.NewEncoder(f).Encode(data); err != nil {
		return fmt.Errorf("encode data: %w", err)
	}
	return nil
}

// LoadCFRData loads training checkpoint data.
func LoadCFRData(path string) (*CFRData, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open checkpoint: %w", err)
	}
	defer f.Close()

	var data CFRData
	if err := gob.NewDecoder(f).Decode(&data); err != nil {
		return nil, fmt.Errorf("decode checkpoint: %w", err)
	}
	return &data, nil
}

// NPCChooseAction selects an action for the NPC using the policy table.
func NPCChooseAction(policy PolicyTable, g *HoldemGame, npcIdx int) (action int, delay time.Duration) {
	p := g.Players[npcIdx]

	// Compute equity.
	numOpp := g.activeCount() - 1
	if numOpp < 1 {
		numOpp = 1
	}
	eq := Equity(p.Hole, g.Community, numOpp, 1000)

	// Build info set key.
	eqBkt := equityBucket(eq.Win + eq.Tie*0.5)

	totalPot := g.Pot
	for _, pp := range g.Players {
		totalPot += pp.Bet
	}
	spr := 0.0
	if totalPot > 0 {
		spr = float64(p.Stack) / float64(totalPot)
	}
	sprBkt := sprBucket(spr)

	pos := g.positionLabel(npcIdx)
	history := buildActionHistory(g)
	boardTex := boardTexture(g.Community)

	key := buildInfoSetKey(g.Street, pos, eqBkt, sprBkt, boardTex, truncateHistory(history))

	probs, ok := policy[key]
	if !ok {
		// Fallback: pot-odds rule.
		probs = fallbackStrategy(eq, g, npcIdx)
	}

	// Filter out illegal actions.
	probs = filterLegalActions(probs, g, npcIdx)

	action = sampleAction(probs)

	// Random delay for natural feel.
	delayMs := 500 + rand.IntN(1500)
	delay = time.Duration(delayMs) * time.Millisecond

	return action, delay
}

// fallbackStrategy produces a simple strategy when no policy entry exists.
func fallbackStrategy(eq EquityResult, g *HoldemGame, npcIdx int) [cfrNumActions]float64 {
	p := g.Players[npcIdx]
	equity := eq.Win + eq.Tie*0.5

	toCall := g.CurrentBet - p.Bet
	totalPot := g.Pot
	for _, pp := range g.Players {
		totalPot += pp.Bet
	}

	potOdds := 0.0
	if toCall > 0 && totalPot+toCall > 0 {
		potOdds = float64(toCall) / float64(totalPot+toCall)
	}

	var probs [cfrNumActions]float64

	switch {
	case equity > 0.8:
		probs[cfrRaisePot] = 0.6
		probs[cfrAllIn] = 0.2
		probs[cfrCallCheck] = 0.2
	case equity > 0.6:
		probs[cfrRaiseHalf] = 0.4
		probs[cfrCallCheck] = 0.5
		probs[cfrFold] = 0.1
	case toCall > 0 && equity > potOdds:
		probs[cfrCallCheck] = 0.7
		probs[cfrRaiseHalf] = 0.2
		probs[cfrFold] = 0.1
	case toCall > 0:
		probs[cfrFold] = 0.7
		probs[cfrCallCheck] = 0.3
	default:
		probs[cfrCallCheck] = 0.6 // check
		probs[cfrRaiseHalf] = 0.3
		probs[cfrFold] = 0.1
	}

	return probs
}

// filterLegalActions zeroes out illegal actions and renormalizes.
func filterLegalActions(probs [cfrNumActions]float64, g *HoldemGame, npcIdx int) [cfrNumActions]float64 {
	p := g.Players[npcIdx]
	toCall := g.CurrentBet - p.Bet

	// Can't check if there's a bet.
	if toCall > 0 {
		// cfrCallCheck is "call" here, which is legal.
	} else {
		// Can't fold if no bet (well, technically can but shouldn't).
		probs[cfrFold] = 0
	}

	// Can't raise if stack is 0 or would be under min raise.
	if p.Stack <= toCall {
		probs[cfrRaiseHalf] = 0
		probs[cfrRaisePot] = 0
		probs[cfrAllIn] = 0
		if toCall > 0 {
			// Can only call or fold.
		} else {
			probs[cfrCallCheck] = 1.0
		}
	}

	// Renormalize.
	total := 0.0
	for _, v := range probs {
		total += v
	}
	if total > 0 {
		for i := range probs {
			probs[i] /= total
		}
	} else {
		// Default to call/check.
		probs[cfrCallCheck] = 1.0
	}

	return probs
}

// buildActionHistory returns the action history for the current street,
// matching the format used during CFR training (f/c/r/R/a chars).
func buildActionHistory(g *HoldemGame) string {
	return truncateHistory(g.StreetHistory)
}

// cfrActionToGameAction converts a CFR action index to concrete game parameters.
func cfrActionToGameAction(action int, g *HoldemGame, npcIdx int) (string, int64) {
	p := g.Players[npcIdx]
	toCall := g.CurrentBet - p.Bet

	totalPot := g.Pot
	for _, pp := range g.Players {
		totalPot += pp.Bet
	}

	switch action {
	case cfrFold:
		if toCall <= 0 {
			return "check", 0 // don't fold when checking is free
		}
		return "fold", 0
	case cfrCallCheck:
		if toCall > 0 {
			return "call", 0
		}
		return "check", 0
	case cfrRaiseHalf:
		raiseSize := totalPot / 2
		if raiseSize < g.MinRaise {
			raiseSize = g.MinRaise
		}
		raiseTo := g.CurrentBet + raiseSize
		maxRaise := p.Bet + p.Stack
		if raiseTo > maxRaise {
			return "allin", 0
		}
		return "raise", raiseTo
	case cfrRaisePot:
		raiseSize := totalPot
		if raiseSize < g.MinRaise {
			raiseSize = g.MinRaise
		}
		raiseTo := g.CurrentBet + raiseSize
		maxRaise := p.Bet + p.Stack
		if raiseTo > maxRaise {
			return "allin", 0
		}
		return "raise", raiseTo
	case cfrAllIn:
		return "allin", 0
	default:
		return "check", 0
	}
}

// ── Training Engine ─────────────────────────────────────────────────────────

// TrainCFR runs External Sampling MCCFR for the given number of iterations.
func TrainCFR(data *CFRData, iterations int, progressEvery int, workerLabel string, progress *TrainProgress) {
	// Use integer-keyed tables internally for speed, convert at the end.
	regrets := make(RegretTableInt, len(data.Regrets))
	strategy := make(RegretTableInt, len(data.Strategy))

	// Import existing string-keyed data.
	for k, v := range data.Regrets {
		// Parse the string key back... or just start fresh for training.
		// Since we need a fresh start anyway (broken policy), we skip import.
		_ = k
		_ = v
	}

	lastLog := time.Now()
	logInterval := 30 * time.Second

	for i := 0; i < iterations; i++ {
		// Create a random game state.
		deck := newShuffledDeck()
		holes := [2][2]poker.Card{
			{deck[0], deck[1]},
			{deck[2], deck[3]},
		}

		prune := i >= 500_000 // enable regret pruning after warmup

		// Traverse for each player.
		for player := 0; player < 2; player++ {
			cfrTraverseFast(regrets, strategy, holes, deck[4:], player, StreetPreFlop, "", 20, 20, 1, 2, 0, 0, prune)
		}

		if progress != nil {
			progress.Completed.Add(1)
		}

		hitInterval := progressEvery > 0 && (i+1)%progressEvery == 0
		hitTimer := time.Since(lastLog) >= logInterval
		if hitInterval || hitTimer {
			lastLog = time.Now()
			attrs := []any{
				"iteration", i + 1,
				"worker_total", iterations,
				"nodes", len(regrets),
			}
			if workerLabel != "" {
				attrs = append(attrs, "worker", workerLabel)
			}
			if progress != nil {
				completed := int(progress.Completed.Load())
				pct := float64(completed) / float64(progress.Total) * 100
				elapsed := time.Since(progress.StartTime)
				eta := time.Duration(0)
				if completed > 0 {
					eta = time.Duration(float64(elapsed) / float64(completed) * float64(progress.Total-completed))
				}
				attrs = append(attrs,
					"overall", fmt.Sprintf("%d/%d (%.1f%%)", completed, progress.Total, pct),
					"eta", eta.Round(time.Second),
				)
			}
			slog.Info("CFR training progress", attrs...)
		}
	}

	// Convert integer-keyed tables back to string-keyed for serialization.
	data.Regrets = make(RegretTable, len(regrets))
	data.Strategy = make(RegretTable, len(strategy))
	for k, v := range regrets {
		data.Regrets[infoSetKeyToString(k)] = v
	}
	for k, v := range strategy {
		data.Strategy[infoSetKeyToString(k)] = v
	}
}

// cfrTraverseFast is the optimized training traversal using integer keys,
// preflop lookup, fast equity, raise caps, and regret pruning.
func cfrTraverseFast(
	regrets, strategy RegretTableInt,
	holes [2][2]poker.Card,
	remaining []poker.Card,
	traversingPlayer int,
	street Street,
	history string,
	stack0, stack1 int64,
	pot int64,
	currentBet int64,
	depth int,
	raisesThisStreet int,
	prune bool,
) float64 {
	if depth > 20 || street == StreetShowdown {
		return cfrTerminalValue(holes, remaining, street, traversingPlayer, pot, stack0, stack1)
	}

	// Determine whose turn it is based on history length (alternating).
	actingPlayer := len(history) % 2

	// Compute equity bucket and board texture.
	var eqBkt int
	var boardTex int
	if street == StreetPreFlop {
		// Use precomputed lookup — zero MC cost.
		eqBkt = preflopBucket(holes[actingPlayer])
		boardTex = boardDry // no board yet
	} else {
		// Post-flop: fast equity using pre-dealt remaining cards.
		var community []poker.Card
		switch street {
		case StreetFlop:
			community = remaining[:3]
		case StreetTurn:
			community = remaining[:4]
		case StreetRiver:
			community = remaining[:5]
		}
		eqVal := trainingEquityFast(holes[actingPlayer], community, remaining, 30)
		eqBkt = equityBucket(eqVal)
		boardTex = boardTexture(community)
	}

	spr := 0.0
	if pot > 0 {
		stack := stack0
		if actingPlayer == 1 {
			stack = stack1
		}
		spr = float64(stack) / float64(pot)
	}
	sprBkt := sprBucket(spr)

	posIP := actingPlayer == 1

	key := packInfoSetKey(street, posIP, eqBkt, sprBkt, boardTex, history)

	regretArr := regrets[key]
	strat := getStrategy(regretArr)

	// Accumulate strategy for average policy.
	stratArr := strategy[key]
	for a := 0; a < cfrNumActions; a++ {
		stratArr[a] += strat[a]
	}
	strategy[key] = stratArr

	// Determine which actions are available (raise cap).
	raiseAllowed := raisesThisStreet < cfrMaxRaisesPerStreet

	if actingPlayer != traversingPlayer {
		// External sampling: sample one action for the opponent.
		// If raises not allowed, redistribute raise probability to call.
		samplingStrat := strat
		if !raiseAllowed {
			samplingStrat = clampRaises(strat)
		}
		action := sampleAction(samplingStrat)

		// Fold: opponent forfeits — traversing player wins the pot.
		if action == cfrFold {
			return float64(pot) / 2.0
		}

		newHistory := history + string(actionChar(action))
		newRaises := raisesThisStreet
		if action == cfrRaiseHalf || action == cfrRaisePot {
			newRaises++
		}

		ns0, ns1, np, nb, ns, nd := applyTrainingAction(action, actingPlayer, stack0, stack1, pot, currentBet, street, depth)

		// Reset raise counter on street change.
		if ns != street {
			newRaises = 0
		}

		return cfrTraverseFast(regrets, strategy, holes, remaining, traversingPlayer, ns, newHistory, ns0, ns1, np, nb, nd, newRaises, prune)
	}

	// Traversing player: enumerate all actions.
	var actionValues [cfrNumActions]float64
	nodeValue := 0.0

	for a := 0; a < cfrNumActions; a++ {
		// Skip raise actions if raise cap reached.
		if !raiseAllowed && (a == cfrRaiseHalf || a == cfrRaisePot) {
			actionValues[a] = actionValues[cfrCallCheck] // treat as call
			nodeValue += strat[a] * actionValues[a]
			continue
		}

		// Regret pruning: skip deeply negative regret actions after warmup.
		if prune && regretArr[a] < cfrPruneThreshold {
			actionValues[a] = 0
			continue
		}

		// Fold: traversing player forfeits — they lose their share of the pot.
		if a == cfrFold {
			actionValues[a] = -float64(pot) / 2.0
			nodeValue += strat[a] * actionValues[a]
			continue
		}

		newHistory := history + string(actionChar(a))
		newRaises := raisesThisStreet
		if a == cfrRaiseHalf || a == cfrRaisePot {
			newRaises++
		}

		_, ns0, ns1, np, nb, ns, nd := applyTrainingActionFull(a, actingPlayer, stack0, stack1, pot, currentBet, street, depth)

		if ns != street {
			newRaises = 0
		}

		actionValues[a] = cfrTraverseFast(regrets, strategy, holes, remaining, traversingPlayer, ns, newHistory, ns0, ns1, np, nb, nd, newRaises, prune)
		nodeValue += strat[a] * actionValues[a]
	}

	// Update regrets.
	for a := 0; a < cfrNumActions; a++ {
		regretArr[a] += actionValues[a] - nodeValue
	}
	regrets[key] = regretArr

	return nodeValue
}

// clampRaises redistributes raise probability to call when raises are capped.
func clampRaises(strat [cfrNumActions]float64) [cfrNumActions]float64 {
	clamped := strat
	clamped[cfrCallCheck] += clamped[cfrRaiseHalf] + clamped[cfrRaisePot]
	clamped[cfrRaiseHalf] = 0
	clamped[cfrRaisePot] = 0
	return clamped
}

// cfrTerminalValue computes the payoff at a terminal node.
func cfrTerminalValue(
	holes [2][2]poker.Card,
	remaining []poker.Card,
	street Street,
	traversingPlayer int,
	pot, stack0, stack1 int64,
) float64 {
	// Deal out remaining community cards.
	var community []poker.Card
	if len(remaining) >= 5 {
		community = remaining[:5]
	} else {
		community = remaining
	}

	rank0, _ := handRank(holes[0], community)
	rank1, _ := handRank(holes[1], community)

	halfPot := float64(pot) / 2.0

	if rank0 < rank1 {
		// Player 0 wins.
		if traversingPlayer == 0 {
			return halfPot
		}
		return -halfPot
	} else if rank1 < rank0 {
		// Player 1 wins.
		if traversingPlayer == 1 {
			return halfPot
		}
		return -halfPot
	}
	return 0 // tie
}

// applyTrainingAction applies a CFR action and returns new game state.
func applyTrainingAction(
	action, actor int,
	s0, s1, pot, currentBet int64,
	street Street,
	depth int,
) (ns0, ns1, newPot, newBet int64, newStreet Street, newDepth int) {
	_, ns0, ns1, newPot, newBet, newStreet, newDepth = applyTrainingActionFull(action, actor, s0, s1, pot, currentBet, street, depth)
	return
}

// applyTrainingActionFull applies a CFR action with full return values.
func applyTrainingActionFull(
	action, actor int,
	s0, s1, pot, currentBet int64,
	street Street,
	depth int,
) (folded bool, ns0, ns1, newPot, newBet int64, newStreet Street, newDepth int) {
	ns0, ns1, newPot, newBet = s0, s1, pot, currentBet
	newStreet = street
	newDepth = depth + 1

	betSize := func(frac float64) int64 {
		return int64(math.Max(float64(pot)*frac, 2))
	}

	stack := &ns0
	if actor == 1 {
		stack = &ns1
	}

	switch action {
	case cfrFold:
		folded = true
		newStreet = StreetShowdown
	case cfrCallCheck:
		callAmt := currentBet
		if callAmt > *stack {
			callAmt = *stack
		}
		*stack -= callAmt
		newPot += callAmt
		newBet = 0
		// Advance street after call (simplified: assume 2-player, 1 raise per street).
		if newStreet < StreetRiver {
			newStreet++
		} else {
			newStreet = StreetShowdown
		}
	case cfrRaiseHalf:
		amt := betSize(0.5)
		if amt > *stack {
			amt = *stack
		}
		*stack -= amt
		newPot += amt
		newBet = amt
	case cfrRaisePot:
		amt := betSize(1.0)
		if amt > *stack {
			amt = *stack
		}
		*stack -= amt
		newPot += amt
		newBet = amt
	case cfrAllIn:
		newPot += *stack
		*stack = 0
		newStreet = StreetShowdown
	}

	return
}

// ── Validation ──────────────────────────────────────────────────────────────

// ValidatePolicy plays test hands between a trained policy and a random opponent.
// The policy plays both positions (alternating), and we simulate multi-street play
// using the same CFR action format as training.
func ValidatePolicy(policy PolicyTable, numHands int) (winRate, vpip, aggFactor float64) {
	wins := 0
	vpipHands := 0
	raises := 0
	calls := 0
	totalChips := int64(0)

	startStack := int64(20)
	bigBlind := int64(2)

	for i := 0; i < numHands; i++ {
		deck := newShuffledDeck()
		holes := [2][2]poker.Card{
			{deck[0], deck[1]},
			{deck[2], deck[3]},
		}
		remaining := deck[4:]

		// Alternate positions so policy plays both IP and OOP.
		policyPlayer := i % 2

		result, policyVPIP, policyRaises, policyCalls := simulateValidationHand(
			policy, holes, remaining, policyPlayer, startStack, startStack, bigBlind,
		)

		if result > 0 {
			wins++
		}
		totalChips += result
		if policyVPIP {
			vpipHands++
		}
		raises += policyRaises
		calls += policyCalls
	}

	winRate = float64(wins) / float64(numHands)
	vpip = float64(vpipHands) / float64(numHands)
	if calls > 0 {
		aggFactor = float64(raises) / float64(calls)
	}
	return
}

// simulateValidationHand plays a full hand between the trained policy and a random opponent.
func simulateValidationHand(
	policy PolicyTable,
	holes [2][2]poker.Card,
	remaining []poker.Card,
	policyPlayer int,
	stack0, stack1, bigBlind int64,
) (chipResult int64, vpip bool, policyRaises, policyCalls int) {
	pot := bigBlind + bigBlind/2 // SB + BB
	stack0 -= bigBlind / 2       // SB (player 0 = OOP)
	stack1 -= bigBlind           // BB (player 1 = IP)
	currentBet := bigBlind
	street := StreetPreFlop
	history := ""

	for depth := 0; depth < 30 && street != StreetShowdown; depth++ {
		actingPlayer := len(history) % 2

		// Build community for this street.
		var community []poker.Card
		switch street {
		case StreetFlop:
			community = remaining[:3]
		case StreetTurn:
			community = remaining[:4]
		case StreetRiver:
			community = remaining[:5]
		}

		stack := stack0
		if actingPlayer == 1 {
			stack = stack1
		}

		var action int

		if actingPlayer == policyPlayer {
			// Policy player: use trained strategy.
			eqVal := trainingEquity(holes[actingPlayer], community, 100)
			eqBkt := equityBucket(eqVal)

			spr := 0.0
			if pot > 0 {
				spr = float64(stack) / float64(pot)
			}
			sprBkt := sprBucket(spr)

			pos := "IP"
			if actingPlayer == 0 {
				pos = "OOP"
			}

			boardTex := boardTexture(community)
			key := buildInfoSetKey(street, pos, eqBkt, sprBkt, boardTex, truncateHistory(history))
			probs, ok := policy[key]
			if !ok {
				probs = [cfrNumActions]float64{0.1, 0.4, 0.25, 0.15, 0.1}
			}

			// Don't fold when checking is free.
			if currentBet == 0 {
				probs[cfrFold] = 0
				total := 0.0
				for _, p := range probs {
					total += p
				}
				if total > 0 {
					for j := range probs {
						probs[j] /= total
					}
				}
			}

			action = sampleAction(probs)

			// Track stats.
			if street == StreetPreFlop && action != cfrFold {
				vpip = true
			}
			if action == cfrRaiseHalf || action == cfrRaisePot || action == cfrAllIn {
				policyRaises++
			} else if action == cfrCallCheck && currentBet > 0 {
				policyCalls++
			}
		} else {
			// Random opponent: simple equity-based strategy.
			eqVal := trainingEquity(holes[actingPlayer], community, 50)
			if currentBet > 0 {
				if eqVal > 0.6 {
					action = cfrRaiseHalf
				} else if eqVal > 0.35 {
					action = cfrCallCheck
				} else {
					action = cfrFold
				}
			} else {
				if eqVal > 0.65 {
					action = cfrRaiseHalf
				} else {
					action = cfrCallCheck
				}
			}
		}

		history += string(actionChar(action))

		// Apply action.
		if action == cfrFold {
			halfPot := pot / 2
			if actingPlayer == policyPlayer {
				return -halfPot, vpip, policyRaises, policyCalls
			}
			return halfPot, vpip, policyRaises, policyCalls
		}

		_, stack0, stack1, pot, currentBet, street, _ = applyTrainingActionFull(
			action, actingPlayer, stack0, stack1, pot, currentBet, street, 0,
		)
	}

	// Showdown.
	var community []poker.Card
	if len(remaining) >= 5 {
		community = remaining[:5]
	} else {
		community = remaining
	}

	rank0, _ := handRank(holes[0], community)
	rank1, _ := handRank(holes[1], community)

	halfPot := pot / 2
	if rank0 < rank1 {
		if policyPlayer == 0 {
			return halfPot, vpip, policyRaises, policyCalls
		}
		return -halfPot, vpip, policyRaises, policyCalls
	} else if rank1 < rank0 {
		if policyPlayer == 1 {
			return halfPot, vpip, policyRaises, policyCalls
		}
		return -halfPot, vpip, policyRaises, policyCalls
	}
	return 0, vpip, policyRaises, policyCalls
}
