// Package klondike is a pure Klondike solitaire engine, played for chips.
//
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
// error), where an error means the move was illegal and nothing else. The state
// is a plain value, so a game survives a redeploy and replays from its seed.
//
// The casino version is Vegas scoring, which is the only way solitaire has ever
// been a gambling game and the only shape that makes sense with money on it.
// You do not win or lose the deal. You *buy the deck* for your stake, and every
// card you get home to a foundation pays a slice of it back. Fifty-two cards
// home pays the tier's full multiple; nothing home pays nothing. You can stop
// whenever you like and keep what you have banked, which is what makes a game
// that has gone dead a decision rather than a wall.
//
// There is no undo. The stake is spent the moment the deck is bought, so an undo
// would be a way to walk a losing board backwards until it wins.
package klondike

import (
	"errors"
	"math"
	"math/rand/v2"
	"strconv"
	"strings"

	"pete/internal/games/cards"
)

// Errors an illegal move can produce.
var (
	ErrGameOver     = errors.New("klondike: the game is already over")
	ErrUnknownMove  = errors.New("klondike: unknown move")
	ErrBadBet       = errors.New("klondike: bet must be positive")
	ErrUnknownTier  = errors.New("klondike: no such tier")
	ErrBadPile      = errors.New("klondike: no such pile")
	ErrEmptyPile    = errors.New("klondike: there is nothing there to move")
	ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
	ErrWontGo       = errors.New("klondike: that card doesn't go there")
	ErrNoDraw       = errors.New("klondike: there is nothing left to turn over")
	ErrNoPasses     = errors.New("klondike: you've used your passes through the stock")
	ErrNothingHome  = errors.New("klondike: nothing can go home right now")
)

// Piles is the number of tableau columns. Foundations is one per suit.
const (
	Piles       = 7
	Foundations = 4
	FullDeck    = 52
)

// Tier is a difficulty, chosen with the bet. The two dials are how many cards
// the stock turns over at a time and how many times you may go through it —
// which between them are the whole difficulty of Klondike. Turning three at a
// time hides two of every three cards behind a card you may never reach; a
// single pass means the ones you leave behind are gone for good.
//
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
// pays 3.4×, which means you are ahead from sixteen cards home even though most
// of those boards never clear.
type Tier struct {
	Slug   string  `json:"slug"`
	Name   string  `json:"name"`
	Draw   int     `json:"draw"`   // cards turned over per pull on the stock
	Passes int     `json:"passes"` // times through the stock; 0 means unlimited
	Base   float64 `json:"base"`   // what a full 52 cards home pays, as a multiple of the stake
	Blurb  string  `json:"blurb"`
}

// BreakEven is how many cards have to reach the foundations before the player is
// square with the house. It's the number the felt actually quotes, because
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
func (t Tier) BreakEven() int {
	if t.Base <= 0 {
		return FullDeck
	}
	n := int(math.Ceil(float64(FullDeck) / t.Base))
	if n > FullDeck {
		return FullDeck
	}
	return n
}

// Tiers are the three deals.
var Tiers = []Tier{
	{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
		Blurb: "One card at a time, through the stock as often as you like."},
	{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
		Blurb: "Three at a time, three times round. The house game."},
	{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
		Blurb: "Three at a time, one pass. What you leave behind is gone."},
}

// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
	for _, t := range Tiers {
		if t.Slug == slug {
			return t, nil
		}
	}
	return Tier{}, ErrUnknownTier
}

// Phase is where the game is.
type Phase string

const (
	PhasePlaying Phase = "playing"
	PhaseDone    Phase = "done"
)

// Outcome is how it ended. Note there is no "lost": a board that goes dead is
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
// improve, and the honest thing to do with one is pay out what's on it.
type Outcome string

const (
	OutcomeNone    Outcome = ""
	OutcomeCleared Outcome = "cleared" // all 52 home
	OutcomeCashed  Outcome = "cashed"  // the player stopped and took the board
)

// Pile is one tableau column: a face-down stack with a face-up run on top of it.
// Down is the part the browser never sees.
type Pile struct {
	Down []cards.Card `json:"down"`
	Up   []cards.Card `json:"up"`
}

// State is one game. The stock and every Down card are in here, which is exactly
// why this value never leaves the server.
type State struct {
	Tier     Tier                      `json:"tier"`
	Stock    cards.Deck                `json:"stock"`
	Waste    []cards.Card              `json:"waste"`
	Table    [Piles]Pile               `json:"table"`
	Found    [Foundations][]cards.Card `json:"found"`    // indexed by suit
	Recycles int                       `json:"recycles"` // times the waste has gone back under
	Moves    int                       `json:"moves"`
	RakePct  float64                   `json:"rake_pct"`

	Bet     int64   `json:"bet"`
	Phase   Phase   `json:"phase"`
	Outcome Outcome `json:"outcome"`
	Payout  int64   `json:"payout"`
	Rake    int64   `json:"rake"`
}

// Event is something the table animates. The engine emits them rather than
// leaving the browser to diff two boards and guess what moved — a card that
// slides from a column to a foundation and a card that was simply redrawn there
// are the same diff and very different things to watch.
//
// Home and Pays ride on every event, so the meter on the felt is always quoting
// a number the engine worked out. The browser never does this arithmetic: it did
// once, and the felt advertised a payout the house didn't honour.
type Event struct {
	Kind  string       `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
	Cards []cards.Card `json:"cards,omitempty"`
	From  string       `json:"from,omitempty"`
	To    string       `json:"to,omitempty"`
	Text  string       `json:"text,omitempty"`
	Home  int          `json:"home"`
	Pays  int64        `json:"pays"`
}

// Move is a player action.
//
// Home is its own kind rather than a Move To a foundation the player picked,
// because there is only ever one foundation a card can go to and asking the
// player to name it would be a quiz about suit ordering. The browser sends
// "this card, home"; the engine finds the pile.
type Move struct {
	Kind  string `json:"kind"`  // "draw" | "move" | "home" | "auto" | "concede"
	From  string `json:"from"`  // "waste" | "t0".."t6" | "f0".."f3"
	To    string `json:"to"`    // "t0".."t6" | "f0".."f3"
	Count int    `json:"count"` // how many cards off the end of a tableau run; 0 means 1
}

// New deals a game.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
	if bet <= 0 {
		return State{}, nil, ErrBadBet
	}
	if t.Draw < 1 {
		return State{}, nil, ErrUnknownTier
	}
	d := cards.NewDeck(1)
	d.Shuffle(rng)
	return deal(bet, t, d, rakePct)
}

// deal lays the board out. Split out from New so a test can pin the deck
// instead of the seed.
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
	if bet <= 0 {
		return State{}, nil, ErrBadBet
	}
	if len(d) != FullDeck {
		return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
	}
	s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}

	// The classic lay-out: column i gets i+1 cards, the last of them face up.
	for i := 0; i < Piles; i++ {
		for j := 0; j <= i; j++ {
			c, _ := d.Draw()
			if j == i {
				s.Table[i].Up = append(s.Table[i].Up, c)
			} else {
				s.Table[i].Down = append(s.Table[i].Down, c)
			}
		}
	}
	s.Stock = d
	return s, []Event{s.event("deal", nil, "", "")}, nil
}

// ApplyMove is the engine. A legal move in, the new board and what happened out.
// An error means the move was illegal and the caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
	if s.Phase == PhaseDone {
		return s, nil, ErrGameOver
	}
	// The move is played against a copy, and an illegal one hands the original
	// back untouched. Nothing below mutates before it has decided the move is
	// legal — but "nothing below mutates early" is an invariant seven functions
	// have to keep, and this is one line that doesn't need them to.
	orig := s
	s = s.clone()

	var evs []Event
	var err error
	switch m.Kind {
	case "draw":
		evs, err = s.draw()
	case "move":
		evs, err = s.move(m.From, m.To, m.Count)
	case "home":
		evs, err = s.home(m.From)
	case "auto":
		evs, err = s.auto()
	case "concede":
		s.settle(OutcomeCashed, &evs)
		return s, evs, nil
	default:
		return orig, nil, ErrUnknownMove
	}
	if err != nil {
		return orig, nil, err
	}
	s.Moves++

	// A cleared board settles itself. Nothing else does: a board with no move left
	// on it is not something the engine gets to decide, because "no move left" in
	// Klondike depends on cards nobody has turned over yet.
	if s.cleared() {
		s.settle(OutcomeCleared, &evs)
	}
	return s, evs, nil
}

// ---- the moves -------------------------------------------------------------

// draw turns cards off the stock, or puts the waste back under it if the stock
// is spent and the tier still owes a pass.
func (s *State) draw() ([]Event, error) {
	if len(s.Stock) == 0 {
		if len(s.Waste) == 0 {
			return nil, ErrNoDraw
		}
		// Passes is how many times you may go *through* the stock, so the number of
		// times you may turn it back over is one less than that. Zero means unlimited.
		if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
			return nil, ErrNoPasses
		}
		// The waste is turned over as a block, not reshuffled — so the card that
		// comes out first on the next pass is the one that came out first on this
		// one. Which means no reversal: the waste's *bottom* card is the one your
		// hand lands on when you flip the pile, and the bottom card is the one that
		// was drawn first. Reversing here would deal a different game on every pass
		// and quietly break the seed in the audit log.
		s.Stock = cards.Deck(s.Waste)
		s.Waste = nil
		s.Recycles++
		return []Event{s.event("recycle", nil, "waste", "stock")}, nil
	}

	n := s.Tier.Draw
	if n > len(s.Stock) {
		n = len(s.Stock)
	}
	drawn := make([]cards.Card, 0, n)
	for i := 0; i < n; i++ {
		c, _ := s.Stock.Draw()
		drawn = append(drawn, c)
		s.Waste = append(s.Waste, c)
	}
	return []Event{s.event("draw", drawn, "stock", "waste")}, nil
}

// move takes cards from one pile and puts them on another.
func (s *State) move(from, to string, count int) ([]Event, error) {
	if count < 1 {
		count = 1
	}
	lifted, err := s.peek(from, count)
	if err != nil {
		return nil, err
	}
	if !s.accepts(to, lifted) {
		return nil, ErrWontGo
	}
	if err := s.take(from, count); err != nil {
		return nil, err
	}
	s.put(to, lifted)

	kind := "move"
	if isFoundation(to) {
		kind = "home"
	}
	evs := []Event{s.event(kind, lifted, from, to)}
	return s.withFlip(from, evs), nil
}

// home sends the top card of a pile to the foundation that will take it. There
// is only ever one, so the player doesn't have to say which.
func (s *State) home(from string) ([]Event, error) {
	top, err := s.peek(from, 1)
	if err != nil {
		return nil, err
	}
	to := "f" + strconv.Itoa(int(top[0].Suit))
	if !s.accepts(to, top) {
		return nil, ErrWontGo
	}
	return s.move(from, to, 1)
}

// auto sends everything that can go home, home, and keeps doing it until nothing
// else can. It is the finish button, and it is also the shortcut for the tail of
// a board that is already decided.
//
// It can cost you: a two you needed on the tableau is a two that has gone home.
// That is the player's call to make by pressing it, and it is the same call the
// button makes in every other solitaire ever written.
func (s *State) auto() ([]Event, error) {
	var evs []Event
	for {
		moved := false
		for _, from := range sources() {
			top, err := s.peek(from, 1)
			if err != nil {
				continue
			}
			to := "f" + strconv.Itoa(int(top[0].Suit))
			if !s.accepts(to, top) {
				continue
			}
			one, err := s.move(from, to, 1)
			if err != nil {
				continue
			}
			evs = append(evs, one...)
			moved = true
		}
		if !moved {
			break
		}
	}
	if len(evs) == 0 {
		return nil, ErrNothingHome
	}
	return evs, nil
}

// sources are the piles auto() will lift a card off, in the order it tries them.
func sources() []string {
	out := make([]string, 0, Piles+1)
	out = append(out, "waste")
	for i := 0; i < Piles; i++ {
		out = append(out, "t"+strconv.Itoa(i))
	}
	return out
}

// withFlip turns up the card a tableau column was hiding, if taking from it left
// its face-down stack exposed. This is the only thing in the game that reveals a
// card the player hadn't earned yet, so it is the only place it can happen.
func (s *State) withFlip(from string, evs []Event) []Event {
	i, ok := tableauIndex(from)
	if !ok {
		return evs
	}
	p := &s.Table[i]
	if len(p.Up) > 0 || len(p.Down) == 0 {
		return evs
	}
	c := p.Down[len(p.Down)-1]
	p.Down = p.Down[:len(p.Down)-1]
	p.Up = append(p.Up, c)
	return append(evs, s.event("flip", []cards.Card{c}, from, from))
}

// ---- piles -----------------------------------------------------------------

// peek returns the top `count` cards of a pile without taking them, and refuses
// a run that isn't one you could lift: a tableau run has to descend in rank and
// alternate colour all the way down, exactly as it does on the felt.
func (s *State) peek(name string, count int) ([]cards.Card, error) {
	switch {
	case name == "waste":
		if count != 1 {
			return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
		}
		if len(s.Waste) == 0 {
			return nil, ErrEmptyPile
		}
		return []cards.Card{s.Waste[len(s.Waste)-1]}, nil

	case isFoundation(name):
		i, ok := foundationIndex(name)
		if !ok {
			return nil, ErrBadPile
		}
		if count != 1 {
			return nil, ErrNotASequence
		}
		f := s.Found[i]
		if len(f) == 0 {
			return nil, ErrEmptyPile
		}
		return []cards.Card{f[len(f)-1]}, nil

	default:
		i, ok := tableauIndex(name)
		if !ok {
			return nil, ErrBadPile
		}
		up := s.Table[i].Up
		if len(up) == 0 {
			return nil, ErrEmptyPile
		}
		if count > len(up) {
			return nil, ErrNotASequence
		}
		run := up[len(up)-count:]
		if !isRun(run) {
			return nil, ErrNotASequence
		}
		return append([]cards.Card(nil), run...), nil
	}
}

// take removes the top `count` cards. peek has already vetted them.
func (s *State) take(name string, count int) error {
	switch {
	case name == "waste":
		s.Waste = s.Waste[:len(s.Waste)-count]
		return nil
	case isFoundation(name):
		i, _ := foundationIndex(name)
		s.Found[i] = s.Found[i][:len(s.Found[i])-count]
		return nil
	default:
		i, ok := tableauIndex(name)
		if !ok {
			return ErrBadPile
		}
		s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
		return nil
	}
}

// put drops cards onto a pile. accepts has already vetted them.
func (s *State) put(name string, cs []cards.Card) {
	if isFoundation(name) {
		i, _ := foundationIndex(name)
		s.Found[i] = append(s.Found[i], cs...)
		return
	}
	i, _ := tableauIndex(name)
	s.Table[i].Up = append(s.Table[i].Up, cs...)
}

// accepts is the rule the whole game is made of: what may be put where.
//
// A foundation takes its own suit in order from the ace, one card at a time. A
// tableau column takes a run that descends by one and alternates colour from its
// top card, and an empty column takes a King and nothing else.
func (s *State) accepts(name string, cs []cards.Card) bool {
	if len(cs) == 0 {
		return false
	}
	if isFoundation(name) {
		i, ok := foundationIndex(name)
		if !ok || len(cs) != 1 {
			return false
		}
		c := cs[0]
		return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
	}

	i, ok := tableauIndex(name)
	if !ok {
		return false
	}
	if !isRun(cs) {
		return false
	}
	up := s.Table[i].Up
	if len(up) == 0 {
		// An empty column is the most valuable thing on the board, so it costs a
		// King to take one. A column with cards still face-down under it is not
		// empty, and Up being empty there can't happen: withFlip turns one over.
		return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
	}
	top := up[len(up)-1]
	return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
}

// isRun reports whether these cards, in this order, are a tableau sequence:
// descending by one, alternating colour.
func isRun(cs []cards.Card) bool {
	for i := 1; i < len(cs); i++ {
		if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
			return false
		}
	}
	return true
}

func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }

func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }

func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }

func pileIndex(name, prefix string, n int) (int, bool) {
	if !strings.HasPrefix(name, prefix) {
		return 0, false
	}
	i, err := strconv.Atoi(name[len(prefix):])
	if err != nil || i < 0 || i >= n {
		return 0, false
	}
	return i, true
}

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

// Home is how many cards have reached the foundations. It is the only number in
// this game that the payout depends on.
func (s State) Home() int {
	n := 0
	for _, f := range s.Found {
		n += len(f)
	}
	return n
}

// PerCard is what one card home is worth, before the rake. The felt quotes this
// because "2.2×" tells a player nothing about a game where the multiple is paid
// out a fifty-second at a time.
func (s State) PerCard() float64 {
	return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
}

// Earned is the gross: what the cards home have bought back, before the house
// takes anything. Computed from the total rather than card by card, so 52 cards
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
func (s State) Earned() int64 {
	return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
}

// Pays is what stopping *right now* would actually put back on the player's
// stack: the gross, less the house's cut of anything above the stake.
//
// The felt shows this number while the game is still running and settle() lands
// on it, and they are the same function for the reason hangman's are: the moment
// they are two sums, the table is quoting a payout it doesn't honour.
//
// Unlike the other games it can be less than the stake, and can be zero. That is
// the game — you bought the deck, and a deck that gives you nothing owes you
// nothing.
func (s State) Pays() int64 {
	total := s.Earned()
	profit := total - s.Bet
	if profit > 0 {
		rake := int64(math.Floor(float64(profit) * s.RakePct))
		if rake > 0 {
			total -= rake
		}
	}
	return total
}

// rakeNow is the house's cut if the board were cashed right now — the other half
// of what Pays works out.
func (s State) rakeNow() int64 {
	profit := s.Earned() - s.Bet
	if profit <= 0 {
		return 0
	}
	rake := int64(math.Floor(float64(profit) * s.RakePct))
	if rake < 0 {
		return 0
	}
	return rake
}

// Net is what the game did to the player's stack.
func (s State) Net() int64 {
	if s.Phase != PhaseDone {
		return 0
	}
	return s.Payout - s.Bet
}

// cleared reports whether every card is home.
func (s State) cleared() bool { return s.Home() == FullDeck }

// Won reports that the board is a guaranteed clear: nothing left in the stock or
// waste, and not a single face-down card under any column. From here every card
// is a face-up run and a single auto() drains the whole board to the foundations
// without a choice left to make — which is exactly the tedious tail the felt
// should offer to finish in one gesture instead of thirty double-clicks.
//
// It's deliberately narrower than "solvable": a draw-one board with cards still
// in the stock is winnable too, but auto() won't turn the stock over, so calling
// that won would light a finish button that then stalls. This is the state the
// finish button actually finishes.
func (s State) Won() bool {
	if s.Phase == PhaseDone || s.cleared() {
		return false
	}
	if len(s.Stock) > 0 || len(s.Waste) > 0 {
		return false
	}
	for _, p := range s.Table {
		if len(p.Down) > 0 {
			return false
		}
	}
	return true
}

// CanAuto reports whether anything can go home at all — which is what greys the
// finish button out rather than letting it be pressed at a board that has nothing
// for it.
func (s State) CanAuto() bool {
	for _, from := range sources() {
		top, err := (&s).peek(from, 1)
		if err != nil {
			continue
		}
		if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
			return true
		}
	}
	return false
}

// PassesLeft is how many more times the player may go through the stock,
// counting the one they are in. -1 means unlimited.
func (s State) PassesLeft() int {
	if s.Tier.Passes <= 0 {
		return -1
	}
	left := s.Tier.Passes - s.Recycles
	if left < 0 {
		return 0
	}
	return left
}

// settle closes the game at whatever is on the board. Same rule as everywhere
// else in the room: the rake comes out of winnings, never out of the stake.
func (s *State) settle(o Outcome, evs *[]Event) {
	s.Outcome = o
	s.Phase = PhaseDone
	s.Payout = s.Pays()
	s.Rake = s.rakeNow()
	*evs = append(*evs, s.event("settle", nil, "", string(o)))
}

// event stamps an event with the two numbers the felt's meter reads off it, so
// the browser never has to work out what the board is worth.
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
	return Event{
		Kind: kind, Cards: cs, From: from, To: to,
		Home: s.Home(), Pays: s.Pays(),
	}
}

// clone deep-copies everything with a backing array, so a derived state shares
// none of it with the one it came from and a board can be replayed freely.
func (s State) clone() State {
	s.Stock = append(cards.Deck(nil), s.Stock...)
	s.Waste = append([]cards.Card(nil), s.Waste...)
	for i := range s.Table {
		s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
		s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
	}
	for i := range s.Found {
		s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
	}
	return s
}
