package holdem

import (
	_ "embed"
	"fmt"
	"log/slog"
	"math/rand/v2"
	"sync"
	"sync/atomic"

	"pete/internal/games/cards"
)

// The bots' brain.
//
// gogobee ran counterfactual regret minimisation against this game for a very
// long time, and policy.gob is what it converged on: a table from "the situation
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
// the single highest-value thing in either repository, and none of it is
// re-derived here — this file is the *runtime*, the trainer stayed behind.
//
// A situation is squeezed down to six things, and this is the whole reason the
// table fits in memory: the street, whether the bot is in position, which of
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
// whether the board is dry, wet or paired, and the last six actions. Two hands
// that hash to the same key get the same strategy, and that is the approximation
// the whole thing is built on.
//
// The key has to be *exactly* the key the trainer wrote, character for
// character. Change the bucket edges, the position label or the history encoding
// and every lookup misses — silently, because a miss is not an error, it is a
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
// worse. So: don't touch these numbers.

//go:embed policy.gob
var policyGob []byte

// The five things the trainer let a bot consider.
const (
	actFold = iota
	actCallCheck
	actRaiseHalf
	actRaisePot
	actAllIn
	numActions
)

// policyTable maps an info-set key to how often to take each action.
type policyTable map[string][numActions]float64

var (
	policyOnce sync.Once
	policy     policyTable
)

// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
//
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
// never deals a card. Every test in the repo and every cold start would pay for
// it. The first player to sit down pays for it instead, and only they do.
func loadPolicy() policyTable {
	policyOnce.Do(func() {
		t, err := loadTrained(policyGob)
		if err != nil {
			// The bots still play — on pot odds — rather than the table 500ing.
			slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
			policy = policyTable{}
			return
		}
		policy = t.Strategy
		slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
			"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
	})
	return policy
}

// ---- the info-set key ------------------------------------------------------
//
// Every function below is a load-bearing copy of the trainer's. See the note at
// the top of the file before changing a number in any of them.

// equityBucket puts a hand's strength in one of twelve boxes, from trash to
// monster.
func equityBucket(eq float64) int {
	switch {
	case eq < 0.08:
		return 0
	case eq < 0.17:
		return 1
	case eq < 0.25:
		return 2
	case eq < 0.33:
		return 3
	case eq < 0.42:
		return 4
	case eq < 0.50:
		return 5
	case eq < 0.58:
		return 6
	case eq < 0.67:
		return 7
	case eq < 0.75:
		return 8
	case eq < 0.83:
		return 9
	case eq < 0.92:
		return 10
	default:
		return 11
	}
}

// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
// a pot that is already committed; over 12 is a pot you can still fold out of.
func sprBucket(spr float64) int {
	switch {
	case spr < 1:
		return 0
	case spr < 3:
		return 1
	case spr < 6:
		return 2
	case spr < 12:
		return 3
	default:
		return 4
	}
}

// Board textures, which is what makes the same hand a bet or a check.
const (
	boardDry    = 0
	boardWet    = 1
	boardPaired = 2
)

// boardTexture classifies the community cards. A paired board is one somebody
// might have trips on; a wet one has a flush or straight coming.
// It is on the trainer's hot path — millions of calls — so it counts into arrays
// rather than maps. A map here cost more than the poker did.
func boardTexture(board []cards.Card) int {
	if len(board) < 3 {
		return boardDry
	}

	var ranks [14]int8
	var suits [4]int8
	var vals [5]int
	for i, c := range board {
		ranks[c.Rank]++
		suits[c.Suit]++
		vals[i] = int(c.Rank)
	}
	for _, n := range ranks {
		if n >= 2 {
			return boardPaired
		}
	}
	for _, n := range suits {
		if n >= 3 {
			return boardWet
		}
	}

	// Three cards inside a five-rank window is a straight waiting to happen.
	v := vals[:len(board)]
	for i := 1; i < len(v); i++ {
		for j := i; j > 0 && v[j] < v[j-1]; j-- {
			v[j], v[j-1] = v[j-1], v[j]
		}
	}
	for i := 0; i+2 < len(v); i++ {
		if v[i+2]-v[i] <= 4 {
			return boardWet
		}
	}
	return boardDry
}

// infoSet builds the string the policy is keyed on. The format is the trainer's.
//
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
// the game has existed: the trainer packed a single "am I last to act" bit and
// wrote its keys as IP/OOP, while the runtime looked them up with the table
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
// pot-odds rule, and the five million training iterations sitting in policy.gob
// were never once read.
//
// Nothing about that looks broken from the outside. A missing key is not an
// error, it's a fallback — the bots played, they just played a heuristic. This is
// why the hit rate is now a test.
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
	pos := "OOP"
	if inPosition {
		pos = "IP"
	}
	return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
}

// recent keeps the last six actions, which is all the trainer's key had room for.
func recent(h string) string {
	if len(h) > 6 {
		return h[len(h)-6:]
	}
	return h
}

// ---- where a seat stands ---------------------------------------------------

// mcIters is how many runouts a bot samples to judge its hand at the table.
const mcIters = 1000

// spot is everything the policy knows about a seat's situation, and it is the
// one function that builds it.
//
// The trainer calls this too. That is the point of it: the key the policy is
// written under and the key it is read under come out of the same code, so they
// cannot quietly stop matching — which is exactly what went wrong the first time
// and went unnoticed for the life of the game.
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
	eq := s.equityFor(seat, iters, rng)
	return s.spotKey(seat, eq), eq
}

// equityFor measures how good a seat's hand is right now.
//
// It depends only on the cards — the hand, the board, how many opponents — and
// not on a single thing that happened in the betting. Which is why the trainer
// can measure it once per street and reuse it down every branch it explores, and
// why doing that is most of the difference between a run that takes half an hour
// and one that takes four.
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
	opponents := s.liveCount() - 1
	if opponents < 1 {
		opponents = 1
	}
	// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
	// that differ, they have been measured to death, and a sampled answer would
	// only add noise to a bucket boundary.
	if s.Street == PreFlop && opponents == 1 {
		return preflopEquity(s.Seats[seat].Hole)
	}
	return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
}

// spotKey builds the key from an equity already measured.
func (s State) spotKey(seat int, eq Equity) string {
	pot := s.inPlay()
	spr := 0.0
	if pot > 0 {
		spr = float64(s.Seats[seat].Stack) / float64(pot)
	}
	return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
		sprBucket(spr), boardTexture(s.Community), recent(s.History))
}

// ---- choosing --------------------------------------------------------------

// hits and misses count how often a bot finds itself in the trained policy.
//
// They exist because the way this can break is silently. A key the policy has
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
// that has stopped matching the game looks exactly like a policy that is working.
// gogobee's never matched once, for the whole life of the game, and nobody could
// have known by watching it play. Now a test reads these and fails.
var hits, misses atomic.Int64

// botActs plays one bot's turn: work out where it stands, look up what it does
// there, throw out anything illegal, and roll for it.
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
	key, eq := s.spot(seat, mcIters, rng)

	probs, ok := loadPolicy()[key]
	if ok {
		hits.Add(1)
	} else {
		misses.Add(1)
		probs = potOdds(eq, s, seat)
	}
	probs = legal(probs, s, seat)

	move := s.moveFor(pick(probs, rng), seat)
	if err := s.act(seat, move, evs); err != nil {
		// A bot cannot be allowed to wedge the table by choosing something the rules
		// then refuse: it checks if it can and folds if it can't, and the mismatch
		// is loud, because it means legal() and the betting rules disagree.
		slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
		if s.Owed(seat) > 0 {
			s.fold(seat, evs)
		} else {
			_ = s.check(seat, evs)
		}
	}
}

// potOdds is what a bot does when the trained table has never seen this spot: it
// works out whether the price it is being offered beats its chance of winning,
// and mixes in enough aggression not to be a calling station.
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
	p := &s.Seats[seat]
	strength := eq.Strength()
	owed := s.Bet - p.Bet
	pot := s.inPlay()

	price := 0.0
	if owed > 0 && pot+owed > 0 {
		price = float64(owed) / float64(pot+owed)
	}

	var probs [numActions]float64
	switch {
	case strength > 0.8:
		probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
	case strength > 0.6:
		probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
	case owed > 0 && strength > price:
		probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
	case owed > 0:
		probs[actFold], probs[actCallCheck] = 0.7, 0.3
	default:
		probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
	}
	return probs
}

// mask is what a seat may actually do here. The trainer explores exactly this
// set, so it never learns a strategy the table would turn down.
func (s State) mask(seat int) (m [numActions]bool) {
	owed := s.Bet - s.Seats[seat].Bet

	// Folding a hand you could see for free is a bug, not a strategy.
	m[actFold] = owed > 0
	m[actCallCheck] = true

	// A raise needs chips behind the call, and somebody left to bet into.
	raise := s.Seats[seat].Stack > owed && s.canBet()
	m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
	return m
}

// legal zeroes out what the seat cannot do and renormalises what's left.
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
	m := s.mask(seat)
	var total float64
	for i := range probs {
		if !m[i] {
			probs[i] = 0
		}
		total += probs[i]
	}
	if total <= 0 {
		var only [numActions]float64
		only[actCallCheck] = 1
		return only
	}
	for i := range probs {
		probs[i] /= total
	}
	return probs
}

// pick rolls against the distribution.
func pick(probs [numActions]float64, rng *rand.Rand) int {
	r := rng.Float64()
	sum := 0.0
	for i, p := range probs {
		sum += p
		if r < sum {
			return i
		}
	}
	return actCallCheck
}

// moveFor turns an abstract action — "raise half the pot" — into a legal move at
// the actual size the table is at. A raise that would cost the bot everything it
// has is a shove, which is the same decision made honestly.
func (s *State) moveFor(action, seat int) Move {
	p := &s.Seats[seat]
	owed := s.Bet - p.Bet
	pot := s.inPlay()
	most := p.Bet + p.Stack

	sized := func(by int64) Move {
		if by < s.MinRaise {
			by = s.MinRaise
		}
		to := s.Bet + by
		if to >= most {
			return Move{Kind: Shove}
		}
		return Move{Kind: Raise, To: to}
	}

	switch action {
	case actFold:
		if owed <= 0 {
			return Move{Kind: Check} // never fold for free
		}
		return Move{Kind: Fold}
	case actCallCheck:
		if owed > 0 {
			return Move{Kind: Call}
		}
		return Move{Kind: Check}
	case actRaiseHalf:
		return sized(pot / 2)
	case actRaisePot:
		return sized(pot)
	case actAllIn:
		return Move{Kind: Shove}
	}
	return Move{Kind: Check}
}
