// Package hangman is a pure hangman engine, played for chips.
//
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
// an error means the move was illegal and nothing else. No HTTP, no timers, no
// player names. The state is a plain value, so a game survives a redeploy and
// replays from its seed.
//
// The casino version differs from the one gogobee plays in Matrix in one way
// that matters: there is money on it. That makes the gallows a payout meter as
// well as a death clock — every wrong guess takes a tenth off what a win is
// worth. You can still win from five wrong, you just won't win much.
package hangman

import (
	"errors"
	"math"
	"math/rand/v2"
	"strings"
	"unicode"
)

// Errors an illegal move can produce.
var (
	ErrGameOver      = errors.New("hangman: the game is already over")
	ErrNotALetter    = errors.New("hangman: that is not a letter")
	ErrAlreadyTried  = errors.New("hangman: that letter has been tried")
	ErrUnknownMove   = errors.New("hangman: unknown move")
	ErrBadBet        = errors.New("hangman: bet must be positive")
	ErrEmptySolution = errors.New("hangman: guess something")
	ErrUnknownTier   = errors.New("hangman: no such tier")
)

// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
// two legs. It is not a tier setting — the drawing has six parts, so the game
// has six lives, and the tiers vary what a win pays instead.
const MaxWrong = 6

// Decay is what one wrong guess costs, as a fraction of the tier's base
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
const Decay = 0.10

// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
// there is less of them to read, and fewer distinct letters to hit by accident.
// A long phrase mostly reveals itself.
type Tier struct {
	Slug  string  `json:"slug"`
	Name  string  `json:"name"`
	Min   int     `json:"min"`  // phrase length, in characters
	Max   int     `json:"max"`  // inclusive
	Base  float64 `json:"base"` // what a win pays, before any wrong guesses
	Blurb string  `json:"blurb"`
}

// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
// long, so none of them runs thin.
var Tiers = []Tier{
	{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
		Blurb: "A handful of letters and nothing to go on."},
	{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
		Blurb: "Long enough to read once it starts falling open."},
	{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
		Blurb: "It gives itself away. It also pays the least."},
}

// 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.
type Outcome string

const (
	OutcomeNone   Outcome = ""
	OutcomeSolved Outcome = "solved" // guessed the phrase outright
	OutcomeFilled Outcome = "filled" // revealed the last letter
	OutcomeHung   Outcome = "hung"   // six wrong
)

// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }

// State is one game. The phrase is in here, which is exactly why this value
// never leaves the server — the browser gets a Masked() view of it instead.
type State struct {
	Tier    Tier    `json:"tier"`
	Phrase  string  `json:"phrase"`
	Runes   []rune  `json:"runes"` // the phrase, indexable safely
	Shown   []bool  `json:"shown"` // one per rune: is it face up
	Tried   []rune  `json:"tried"` // every letter guessed, in order, right or wrong
	Wrong   []rune  `json:"wrong"` // just the ones that missed
	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: a letter turning over, a limb being
// drawn. The engine emits them rather than drawing anything itself.
type Event struct {
	Kind   string `json:"kind"`             // "start" | "hit" | "miss" | "solve" | "settle"
	Letter string `json:"letter,omitempty"` // the letter guessed
	At     []int  `json:"at,omitempty"`     // which rune positions it turned over
	Text   string `json:"text,omitempty"`
}

// Move is a player action: a single letter, or the whole phrase.
type Move struct {
	Letter string `json:"letter"`
	Solve  string `json:"solve"`
}

// New starts a game on a phrase drawn from the tier's shelf of the bank.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
	if bet <= 0 {
		return State{}, nil, ErrBadBet
	}
	phrase, err := drawPhrase(t, rng)
	if err != nil {
		return State{}, nil, err
	}
	return start(bet, t, phrase, rakePct)
}

// start builds the opening state for a known phrase. Split out from New so a
// test can pin the phrase instead of the seed.
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
	if bet <= 0 {
		return State{}, nil, ErrBadBet
	}
	rs := []rune(phrase)
	s := State{
		Tier: t, Phrase: phrase, Runes: rs,
		Shown:   make([]bool, len(rs)),
		RakePct: rakePct,
		Bet:     bet, Phase: PhasePlaying,
	}
	// Spaces and punctuation are never guessed — they start face up, because a
	// row of blanks with the word breaks hidden is a puzzle about typography.
	for i, r := range rs {
		if !Guessable(r) {
			s.Shown[i] = true
		}
	}
	return s, []Event{{Kind: "start"}}, nil
}

// Guessable reports whether a rune is one you'd guess: a letter or a digit.
// Everything else is scaffolding and is shown from the start.
//
// Exported because the renderer needs the same answer — a rune you'd guess is a
// rune that gets a tile to guess it into. Asking the drawing side to decide that
// for itself is how you get a board with no tile for a letter the engine is
// waiting on, and a phrase that cannot be finished.
func Guessable(r rune) bool {
	return unicode.IsLetter(r) || unicode.IsDigit(r)
}

// ApplyMove is the engine. A legal move in, the new state 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
	}
	s = s.clone()

	switch {
	case m.Solve != "":
		return applySolve(s, m.Solve)
	case m.Letter != "":
		return applyLetter(s, m.Letter)
	default:
		return s, nil, ErrUnknownMove
	}
}

// applyLetter guesses one letter.
func applyLetter(s State, letter string) (State, []Event, error) {
	rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
	if len(rs) != 1 || !Guessable(rs[0]) {
		return s, nil, ErrNotALetter
	}
	g := rs[0]
	if containsRune(s.Tried, g) {
		// Not a miss — a no-op. Charging a life for a letter the board already
		// shows you tried is punishing a mis-click, not a bad guess.
		return s, nil, ErrAlreadyTried
	}
	s.Tried = append(s.Tried, g)

	var at []int
	for i, r := range s.Runes {
		if !s.Shown[i] && foldEq(r, g) {
			s.Shown[i] = true
			at = append(at, i)
		}
	}

	evs := []Event{}
	if len(at) > 0 {
		evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
		if s.filled() {
			s.settle(OutcomeFilled, &evs)
		}
		return s, evs, nil
	}

	s.Wrong = append(s.Wrong, g)
	evs = append(evs, Event{Kind: "miss", Letter: string(g)})
	if len(s.Wrong) >= MaxWrong {
		s.settle(OutcomeHung, &evs)
	}
	return s, evs, nil
}

// applySolve guesses the whole phrase. Right, and it's over at the multiple you
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
// solving would be a free roll you could spam until it landed.
func applySolve(s State, attempt string) (State, []Event, error) {
	if strings.TrimSpace(attempt) == "" {
		return s, nil, ErrEmptySolution
	}
	evs := []Event{{Kind: "solve", Text: attempt}}

	if normalize(attempt) == normalize(s.Phrase) {
		for i := range s.Shown {
			s.Shown[i] = true
		}
		s.settle(OutcomeSolved, &evs)
		return s, evs, nil
	}

	// A wrong solve is a miss with no letter attached to it.
	s.Wrong = append(s.Wrong, '·')
	evs = append(evs, Event{Kind: "miss", Text: attempt})
	if len(s.Wrong) >= MaxWrong {
		s.settle(OutcomeHung, &evs)
	}
	return s, evs, nil
}

// Multiple is what a win is worth right now: the tier's base, less a tenth of
// it for every wrong guess so far. Floored at 1, so a win never hands back less
// than the stake — a player who fought through five wrong guesses and got there
// has not earned a loss.
func (s State) Multiple() float64 {
	m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
	if m < 1 {
		return 1
	}
	return m
}

// Pays is what a win *right now* would actually put back on the player's stack:
// the stake, plus the winnings, less the house's cut of the winnings.
//
// It exists because the felt shows this number while the game is still running,
// and settle() is the only other thing that computes it. If the two ever
// disagreed the table would be quoting a payout it doesn't honour — so settle
// calls this rather than doing the sum a second time.
func (s State) Pays() int64 {
	total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
	if total < s.Bet {
		total = s.Bet // a win never hands back less than the stake
	}
	profit := total - s.Bet
	if profit > 0 {
		rake := int64(math.Floor(float64(profit) * s.RakePct))
		if rake < 0 {
			rake = 0
		}
		profit -= rake
	}
	return s.Bet + profit
}

// Rake taken on a win right now — the other half of what Pays works out.
func (s State) rakeNow() int64 {
	total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
	if total < s.Bet {
		return 0
	}
	profit := total - s.Bet
	if profit <= 0 {
		return 0
	}
	rake := int64(math.Floor(float64(profit) * s.RakePct))
	if rake < 0 {
		return 0
	}
	return rake
}

// Lives is how many wrong guesses are left.
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }

// filled reports whether every guessable rune is face up.
func (s State) filled() bool {
	for _, up := range s.Shown {
		if !up {
			return false
		}
	}
	return true
}

// settle decides the payout. Same rule as blackjack: the rake comes out of
// winnings, never out of the stake. A loss is never charged a fee.
func (s *State) settle(o Outcome, evs *[]Event) {
	s.Outcome = o
	s.Phase = PhaseDone

	if o.Won() {
		s.Payout = s.Pays()
		s.Rake = s.rakeNow()
	} else {
		s.Payout = 0
	}

	// The phrase goes face up when it's over, win or lose. Losing without ever
	// being told the answer is the one thing hangman must never do.
	for i := range s.Shown {
		s.Shown[i] = true
	}
	*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
}

// 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
}

// Masked is the phrase as the player may see it: revealed runes as themselves,
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
func (s State) Masked() string {
	var b strings.Builder
	for i, r := range s.Runes {
		if s.Shown[i] {
			b.WriteRune(r)
		} else {
			b.WriteRune('_')
		}
	}
	return b.String()
}

// clone deep-copies the slices, so a derived state shares no backing array with
// the one it came from and a state can be replayed freely.
func (s State) clone() State {
	s.Runes = append([]rune(nil), s.Runes...)
	s.Shown = append([]bool(nil), s.Shown...)
	s.Tried = append([]rune(nil), s.Tried...)
	s.Wrong = append([]rune(nil), s.Wrong...)
	return s
}

// normalize flattens a phrase for comparison: case, spacing and punctuation all
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
func normalize(s string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		if Guessable(r) {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// foldEq compares two runes the way a guess should: case-insensitively.
func foldEq(a, b rune) bool {
	return unicode.ToLower(a) == unicode.ToLower(b)
}

func containsRune(rs []rune, r rune) bool {
	for _, x := range rs {
		if foldEq(x, r) {
			return true
		}
	}
	return false
}
