// Package trivia is a pure trivia-ladder engine, played for chips.
//
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
// events, error), where an error means the move was illegal and nothing else.
// The one difference is that clock: trivia is the only game in the room where
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
// So the time is an argument. The engine stays a value in, value out, and the
// only thing that knows what o'clock it is remains the caller.
//
// The shape is a ladder. You stake once, and then answer a run of questions:
// every right answer multiplies what the stake is worth, a wrong one loses the
// lot, and you may walk with what you've built at any point after the first.
// It is the oldest quiz-show bet there is — the tension is entirely in whether
// you take the money.
//
// The reason for the clock is less pretty: trivia answers are googlable, and a
// game that paid the same for a slow right answer as a fast one would be a game
// about typing into another tab. So the multiple a question is worth decays
// from Fast to Buzzer across the tier's time limit, and running out of time
// loses exactly as much as being wrong. The countdown in the browser is
// decoration; this is the clock that counts.
package trivia

import (
	"errors"
	"math"
	"math/rand/v2"
	"time"
)

// Errors an illegal move can produce.
var (
	ErrGameOver      = errors.New("trivia: the game is already over")
	ErrUnknownMove   = errors.New("trivia: unknown move")
	ErrBadBet        = errors.New("trivia: bet must be positive")
	ErrUnknownTier   = errors.New("trivia: no such tier")
	ErrShortLadder   = errors.New("trivia: not enough questions to build a ladder")
	ErrNothingBanked = errors.New("trivia: answer one before you walk")
)

// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
// and banks, because a ladder with no top is just a slot machine you can't stop
// playing, and eventually every player loses everything to one bad question.
const Rungs = 12

// Tier is a difficulty, chosen before the bet. It sets three things that move
// together: how hard the questions are, how long you get, and what a right
// answer is worth. Hard questions pay more and give you less time to look them
// up, which is the whole bargain.
type Tier struct {
	Slug       string  `json:"slug"`
	Name       string  `json:"name"`
	Difficulty string  `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
	Fast       float64 `json:"fast"`       // what a right answer multiplies by, answered instantly
	Buzzer     float64 `json:"buzzer"`     // ...and what it's worth answered on the last tick
	Limit      int     `json:"limit"`      // seconds on the clock, per question
	Blurb      string  `json:"blurb"`
}

// Tiers are the three tables.
var Tiers = []Tier{
	{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
		Blurb: "Things you know. The clock is the only thing in your way."},
	{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
		Blurb: "Things you nearly know."},
	{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
		Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
}

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

// Step is what a right answer multiplies the running total by, given how long
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
//
// Answering at the buzzer still pays *something* — the decay is a reason to be
// quick, not a punishment for thinking. The punishment for thinking too long is
// the timeout, and that one takes everything.
func (t Tier) Step(elapsed time.Duration) float64 {
	limit := t.Clock()
	switch {
	case elapsed <= 0:
		return t.Fast
	case elapsed >= limit:
		return t.Buzzer
	}
	speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
	return t.Buzzer + (t.Fast-t.Buzzer)*speed
}

// Clock is the tier's time limit as a duration.
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }

// Question is one rung. It carries its own correct index, which is exactly why
// a State never crosses the wire — the browser is sent the answers and not
// which of them is right.
type Question struct {
	Category string   `json:"category"`
	Text     string   `json:"text"`
	Answers  []string `json:"answers"` // already shuffled: the right one is not always first
	Correct  int      `json:"correct"` // index into Answers
}

// 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 = ""
	OutcomeWalked  Outcome = "walked"  // took the money
	OutcomeCleared Outcome = "cleared" // answered all twelve
	OutcomeWrong   Outcome = "wrong"   // picked the wrong one
	OutcomeTimeout Outcome = "timeout" // ran out of clock
)

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

// State is one game. The ladder — every question, and every right answer — is
// in here, which is why this value stays on the server. The browser gets a view
// of the current rung and nothing about the ones ahead of it.
type State struct {
	Tier   Tier       `json:"tier"`
	Ladder []Question `json:"ladder"` // the whole run, drawn up front
	Rung   int        `json:"rung"`   // how many answered right; also the index of the live question

	// AskedAt is when the current question was *put to the player*, by the
	// server's clock. It is the only clock in the game. A reload does not reset
	// it: you cannot stop time by refreshing.
	AskedAt time.Time `json:"asked_at"`

	Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
	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.
type Event struct {
	Kind     string  `json:"kind"`               // "ask" | "right" | "wrong" | "timeout" | "settle"
	Choice   int     `json:"choice"`             // what the player picked (-1 when they didn't)
	Correct  int     `json:"correct"`            // which one was right — sent only once it's decided
	Step     float64 `json:"step,omitempty"`     // what this answer multiplied by
	Multiple float64 `json:"multiple,omitempty"` // the running total, after
	Text     string  `json:"text,omitempty"`
}

// Move is a player action: pick an answer, or take the money.
type Move struct {
	Choice int  `json:"choice"` // index into the live question's answers
	Walk   bool `json:"walk"`
}

// New starts a game on a ladder of questions the caller has already drawn. The
// engine does not reach for a database any more than blackjack reaches for a
// deck: the questions arrive as a value, so a game is reproducible and a test
// can pin every rung.
//
// The answers are shuffled here, with the caller's seeded rng, because a bank
// that always stores the right answer first would otherwise be a game about
// clicking first.
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
	if bet <= 0 {
		return State{}, nil, ErrBadBet
	}
	if len(qs) < Rungs {
		return State{}, nil, ErrShortLadder
	}
	ladder := make([]Question, Rungs)
	for i := range ladder {
		ladder[i] = shuffleAnswers(qs[i], rng)
	}
	s := State{
		Tier: t, Ladder: ladder, RakePct: rakePct,
		Multiple: 1,
		AskedAt:  now,
		Bet:      bet, Phase: PhasePlaying,
	}
	return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
}

// shuffleAnswers moves the right answer somewhere the player can't guess from
// position, and keeps track of where it went.
func shuffleAnswers(q Question, rng *rand.Rand) Question {
	answers := append([]string(nil), q.Answers...)
	correct := q.Answers[q.Correct]
	rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
	out := q
	out.Answers = answers
	for i, a := range answers {
		if a == correct {
			out.Correct = i
			break
		}
	}
	return out
}

// Live is the question the player is looking at.
func (s State) Live() Question {
	if s.Rung < 0 || s.Rung >= len(s.Ladder) {
		return Question{}
	}
	return s.Ladder[s.Rung]
}

// Left is how much clock the live question has, at the given moment. It goes to
// the browser so its countdown starts where the server's does — but the browser
// is never asked what it says.
func (s State) Left(now time.Time) time.Duration {
	d := s.Tier.Clock() - now.Sub(s.AskedAt)
	if d < 0 {
		return 0
	}
	return d
}

// ApplyMove is the engine. now is the server's clock, and the only one that
// counts toward the answer.
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
	if s.Phase == PhaseDone {
		return s, nil, ErrGameOver
	}
	s = s.clone()

	q := s.Live()
	if len(q.Answers) == 0 {
		return s, nil, ErrUnknownMove
	}

	elapsed := now.Sub(s.AskedAt)

	// Out of time. This is a loss, and it has to be — a timeout that merely cost
	// you the speed bonus would make "leave it open in another tab and go and
	// look it up" the strongest way to play.
	//
	// It is checked before *everything*, walking included. A dead clock that you
	// could still walk away from would be no clock at all: you would sit on every
	// question for as long as you liked, answer the ones you found and walk off
	// the ones you didn't, and never once lose the ladder. The timeout has to be
	// the first thing that happens to a move, or it is not a deadline.
	if elapsed > s.Tier.Clock() {
		evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
		s.settle(OutcomeTimeout, &evs)
		return s, evs, nil
	}

	if m.Walk {
		// You cannot walk off a rung you haven't climbed. If you could, seeing the
		// first question and walking away would be a free look: stake, peek, walk,
		// stake again, and keep reshuffling until the question is one you know.
		// The first question is therefore the price of sitting down.
		if s.Rung == 0 {
			return s, nil, ErrNothingBanked
		}
		evs := []Event{}
		s.settle(OutcomeWalked, &evs)
		return s, evs, nil
	}

	if m.Choice < 0 || m.Choice >= len(q.Answers) {
		return s, nil, ErrUnknownMove
	}

	if m.Choice != q.Correct {
		evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
		s.settle(OutcomeWrong, &evs)
		return s, evs, nil
	}

	// Right, and quick enough to be worth something.
	step := s.Tier.Step(elapsed)
	s.Multiple *= step
	s.Rung++
	evs := []Event{{
		Kind: "right", Choice: m.Choice, Correct: q.Correct,
		Step: step, Multiple: s.Multiple,
	}}

	if s.Rung >= Rungs {
		s.settle(OutcomeCleared, &evs)
		return s, evs, nil
	}

	// The next question goes up, and its clock starts now.
	s.AskedAt = now
	evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
	return s, evs, nil
}

// Pays is what banking *right now* would put back on the player's stack: the
// stake, plus the winnings, less the house's cut of the winnings.
//
// It exists for the same reason hangman's does. The felt quotes this number
// while the game is still running — it is the "take the money" button's label —
// and settle() calls it rather than doing the sum a second time, so the table
// can never advertise a payout the house doesn't hand over.
func (s State) Pays() int64 {
	total := int64(math.Floor(float64(s.Bet) * s.Multiple))
	if total < s.Bet {
		total = s.Bet // banking 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 {
			profit -= rake
		}
	}
	return s.Bet + profit
}

// rakeNow is the other half of what Pays works out: the house's cut of a win
// banked at this moment. Never taken from the stake, so a player who walks
// having answered nothing — which they can't — and one who loses, pay nothing.
func (s State) rakeNow() int64 {
	total := int64(math.Floor(float64(s.Bet) * s.Multiple))
	if total <= s.Bet {
		return 0
	}
	rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
	if rake < 0 {
		return 0
	}
	return rake
}

// settle decides the payout. Same rule as every other table in the room: the
// rake comes out of winnings, never out of the stake, and 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
	}
	*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, 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
}

// clone deep-copies the ladder, so a derived state shares no backing array with
// the one it came from and a game can be replayed freely.
func (s State) clone() State {
	s.Ladder = append([]Question(nil), s.Ladder...)
	return s
}
