package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"math/rand/v2"
	"net/http"
	"time"

	"pete/internal/games/blackjack"
	"pete/internal/games/cards"
	"pete/internal/games/hangman"
	"pete/internal/games/holdem"
	"pete/internal/games/klondike"
	"pete/internal/games/trivia"
	"pete/internal/games/uno"
	"pete/internal/storage"
)

// The table, as a browser sees it.
//
// Everything here is server-authoritative. The browser sends intents — deal me
// in, hit, stand — and gets back a *view*: the cards it is entitled to see and
// nothing else. The shoe stays in game_live_hands, on this side of the wire.
// That is not belt-and-braces, it is the whole reason the engines are Go: a game
// with money on it cannot trust a client-reported result, and a client that
// holds the deck can read the next card.
//
// The stake leaves the player's stack *before* the hand is dealt, and comes back
// only through the engine's own payout. So a hand that crashes halfway costs the
// player their bet and nothing more, and a hand that Pete restarts through is
// still sitting there when they come back.

// gamesReady reports whether the casino can actually run: it needs sign-in (a
// player has to be someone) and a Matrix server name (that someone has to exist
// in gogobee's ledger).
func (s *Server) gamesReady() bool {
	return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != ""
}

// player resolves the signed-in visitor to their Matrix id, or writes the
// failure. An empty id means a session from before games existed, which carries
// no username: sending them back through sign-in mints one.
func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) {
	if !s.gamesReady() {
		http.NotFound(w, r)
		return "", false
	}
	u := s.auth.userFromRequest(r)
	if u == nil {
		writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"})
		return "", false
	}
	mx := u.MatrixUser(s.cfg.Games.MatrixServer)
	if mx == "" {
		writeJSONStatus(w, http.StatusForbidden, map[string]string{
			"error": "your session predates the casino — sign out and back in",
		})
		return "", false
	}
	return mx, true
}

// ---- what the browser is allowed to see -----------------------------------

// cardView is one card, pre-rendered. The browser draws faces, not logic: it
// gets the glyph and the colour rather than a rank it has to map itself.
type cardView struct {
	Label string `json:"label"` // "A♠"
	Rank  string `json:"rank"`  // "A"
	Suit  string `json:"suit"`  // "♠"
	Red   bool   `json:"red"`
}

func viewCard(c cards.Card) cardView {
	label := c.String()
	// String() renders rank then a three-byte suit glyph, except for a card that
	// isn't one ("??"), which has no glyph to split off.
	if len(label) <= len("♠") {
		return cardView{Label: label, Rank: label}
	}
	cut := len(label) - len("♠")
	return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()}
}

// handView is a blackjack hand as its player may see it. While they are still
// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but
// genuinely not in the payload. A field the browser is told to ignore is a field
// somebody reads in devtools.
type handView struct {
	Phase  string     `json:"phase"`
	Bet    int64      `json:"bet"` // everything staked this deal, across every hand
	Hands  []spotView `json:"hands"`
	Active int        `json:"active"`
	Dealer []cardView `json:"dealer"`
	Hole   bool       `json:"hole"`         // true: the dealer has a face-down card
	DTotal int        `json:"dealer_total"` // what the *shown* dealer cards add to

	Outcome string `json:"outcome,omitempty"`
	Payout  int64  `json:"payout,omitempty"`
	Rake    int64  `json:"rake,omitempty"`
	Net     int64  `json:"net"`
	Double  bool   `json:"can_double"`
	Split   bool   `json:"can_split"`
}

// spotView is one of the player's hands: its cards, its own chips, its own
// verdict. Before split there was only ever one and it was flattened into the
// hand itself; a split is the moment that stops being true.
type spotView struct {
	Cards   []cardView `json:"cards"`
	Bet     int64      `json:"bet"`
	Total   int        `json:"total"`
	Soft    bool       `json:"soft"`
	Doubled bool       `json:"doubled"`
	Done    bool       `json:"done"`
	Outcome string     `json:"outcome,omitempty"`
	Payout  int64      `json:"payout,omitempty"`
}

func viewHand(st blackjack.State) handView {
	v := handView{
		Phase:   string(st.Phase),
		Bet:     st.Bet,
		Active:  st.Active,
		Outcome: string(st.Outcome),
		Payout:  st.Payout,
		Rake:    st.Rake,
		Net:     st.Net(),
		Double:  st.CanDouble(),
		Split:   st.CanSplit(),
	}
	for _, h := range st.Hands {
		s := spotView{
			Bet:     h.Bet,
			Doubled: h.Doubled,
			Done:    h.Done,
			Outcome: string(h.Outcome),
			Payout:  h.Payout,
		}
		for _, c := range h.Cards {
			s.Cards = append(s.Cards, viewCard(c))
		}
		s.Total, s.Soft = h.Value()
		v.Hands = append(v.Hands, s)
	}

	dealer := st.Dealer
	if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
		dealer = dealer[:1] // the hole card is the dealer's business until it isn't
		v.Hole = true
	}
	for _, c := range dealer {
		v.Dealer = append(v.Dealer, viewCard(c))
	}
	v.DTotal, _ = blackjack.HandValue(dealer)
	return v
}

// eventView is the dealing script. The engine emits one event per card off the
// shoe, in order, and the table animates them one at a time — which is why the
// events go over the wire at all rather than the browser diffing two states.
type eventView struct {
	Kind string    `json:"kind"`
	Card *cardView `json:"card,omitempty"`
	Hand int       `json:"hand"` // which of the player's hands the card landed on
	Text string    `json:"text,omitempty"`
}

// viewEvents renders the engine's events for the browser, dropping the cards it
// is not yet allowed to see: the dealer's second card is dealt face-down, and
// only the "reveal" event turns it over.
func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
	out := make([]eventView, 0, len(evs))
	dealerCards := 0
	for _, e := range evs {
		v := eventView{Kind: e.Kind, Text: e.Text, Hand: e.Hand}
		if e.Card != nil {
			c := viewCard(*e.Card)
			v.Card = &c
		}
		if e.Kind == "dealer_card" {
			dealerCards++
			// The hole card, while the hand is still the player's to play: send
			// the event so the table deals a face-down card, but not the face.
			if dealerCards == 2 && phase == blackjack.PhasePlayer {
				v.Card = nil
				v.Kind = "dealer_hole"
			}
		}
		out = append(out, v)
	}
	return out
}

// tableView is the whole page state: the money, and whatever game is in progress.
//
// A player is in at most one game at a time — game_live_hands is keyed on the
// player, so the primary key enforces it — and Game says which. Each game gets
// its own field rather than a shared blob, because a hangman phrase and a
// blackjack shoe have nothing in common and pretending otherwise would mean a
// browser that has to guess what it's holding.
type tableView struct {
	Chips   int64   `json:"chips"`
	Pending int64   `json:"pending"` // buy-ins gogobee hasn't answered yet
	Euros   float64 `json:"euros"`   // advisory, and up to a couple of minutes stale
	Cap     int64   `json:"cap"`

	Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live

	Hand   *handView   `json:"hand,omitempty"`   // blackjack
	Events []eventView `json:"events,omitempty"` // blackjack, only on a move

	Hangman    *hangmanView    `json:"hangman,omitempty"`
	HangEvents []hangman.Event `json:"hang_events,omitempty"`

	Solitaire *solitaireView `json:"solitaire,omitempty"`
	SolEvents []solEventView `json:"sol_events,omitempty"`

	Trivia     *triviaView    `json:"trivia,omitempty"`
	TrivEvents []trivia.Event `json:"triv_events,omitempty"`

	Uno       *unoView       `json:"uno,omitempty"`
	UnoEvents []unoEventView `json:"uno_events,omitempty"`

	Holdem       *holdemView       `json:"holdem,omitempty"`
	HoldemEvents []holdemEventView `json:"holdem_events,omitempty"`

	Rake float64 `json:"rake_pct"`
}

// table reads the player's money and any game in progress.
func (s *Server) table(user string) (tableView, error) {
	st, err := storage.Chips(user)
	if err != nil {
		return tableView{}, err
	}
	v := tableView{
		Chips:   st.Chips,
		Pending: st.Pending,
		Euros:   st.EuroBalance,
		Cap:     storage.MaxChipsOnTable,
		Rake:    blackjack.DefaultRules().RakePct,
	}
	live, err := storage.LoadLiveHand(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		return v, nil
	}
	if err != nil {
		return tableView{}, err
	}

	// Dispatch on the game the row says it is. Unmarshalling a hangman state into
	// a blackjack one would not fail — JSON is happy to fill nothing in — it would
	// just quietly produce an empty hand, which is the worst of both.
	v.Game = live.Game
	switch live.Game {
	case gameBlackjack:
		var hand blackjack.State
		if err := json.Unmarshal(live.State, &hand); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		hv := viewHand(hand)
		v.Hand = &hv
	case gameHangman:
		var g hangman.State
		if err := json.Unmarshal(live.State, &g); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		hv := viewHangman(g)
		v.Hangman = &hv
	case gameSolitaire:
		var g klondike.State
		if err := json.Unmarshal(live.State, &g); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		sv := viewSolitaire(g)
		v.Solitaire = &sv
	case gameTrivia:
		var g trivia.State
		if err := json.Unmarshal(live.State, &g); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		// The clock does not stop for a reload: Left is measured from the AskedAt
		// the server stamped, so a player who refreshes to buy themselves a fresh
		// twenty seconds finds the countdown exactly where they left it.
		tv := viewTrivia(g, time.Now())
		v.Trivia = &tv
	case gameUno:
		// A seated UNO player's cards are in game_tables, not here — this row is only
		// their occupancy claim. Load the table and render it as their own seat sees it.
		if live.TableID == "" {
			return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
		}
		t, tableSeats, err := storage.LoadTable(live.TableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
		}
		if err != nil {
			return tableView{}, err
		}
		_, seat, err := storage.PlayerSeat(user)
		if err != nil {
			return tableView{}, err
		}
		var g uno.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		uv := viewUno(g, seat)
		for _, ts := range tableSeats {
			if ts.Seat == seat {
				uv.BoughtIn = ts.Staked
				break
			}
		}
		v.Uno = &uv
	case gameHoldem:
		// A seated hold'em player's cards are in game_tables, not here — this row is
		// only their occupancy claim, so its state is empty. Load the table and render
		// it as their own seat sees it.
		if live.TableID == "" {
			return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table"))
		}
		t, tableSeats, err := storage.LoadTable(live.TableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			// The table closed under them (reaped, or the last hand cashed them out).
			// Their claim is stale; clear it so they can sit down again.
			return s.dropUnreadable(user, v, fmt.Errorf("holdem table %s gone", live.TableID))
		}
		if err != nil {
			return tableView{}, err
		}
		_, seat, err := storage.PlayerSeat(user)
		if err != nil {
			return tableView{}, err
		}
		var g holdem.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return s.dropUnreadable(user, v, err)
		}
		hv := viewHoldem(g, seat)
		// bought_in is a per-player figure — "you bought in for X" — but the engine's
		// BoughtIn is the table's total across every human. The player's own stake is
		// border accounting, which lives in storage; take it from their seat row.
		for _, ts := range tableSeats {
			if ts.Seat == seat {
				hv.BoughtIn = ts.Staked
				break
			}
		}
		v.Holdem = &hv
	default:
		return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
	}
	return v, nil
}

// dropUnreadable throws away a live game nobody can play. Rather than wedge the
// player out of the casino forever, it goes, and their stake with it — which is
// why it is logged loudly. The alternative is a player who can never be dealt
// another hand because an old one won't parse.
func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) {
	slog.Error("games: unreadable live game, discarding", "user", user, "err", err)
	_ = storage.ClearLiveHand(user)
	v.Game = ""
	return v, nil
}

// ---- handlers -------------------------------------------------------------

// handleTable is the page's poll: chips, euros, and whatever hand is on the felt.
func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	v, err := s.table(user)
	if err != nil {
		slog.Error("games: table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, v)
}

// amountBody is {amount: n} — chips, which are euros.
type amountBody struct {
	Amount int64 `json:"amount"`
}

func decodeJSON(r *http.Request, v any) error {
	return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v)
}

// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and
// gogobee decides — up to three seconds later — whether the player could afford
// it. The browser watches `pending` fall to zero to know how it went.
func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req amountBody
	if err := decodeJSON(r, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	e, err := storage.RequestBuyIn(user, req.Amount)
	switch {
	case errors.Is(err, storage.ErrBadAmount):
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"})
		return
	case errors.Is(err, storage.ErrOverTableCap):
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{
			"error": "that would put more than the table cap in front of you",
		})
		return
	case err != nil:
		slog.Error("games: buy-in", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	storage.Touch(user)
	slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID)
	v, err := s.table(user)
	if err != nil {
		slog.Error("games: table after buy-in", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, v)
}

// handleCashOut sends chips back across the border. The chips are destroyed
// here and now — see storage.RequestCashOut for why that has to happen before
// gogobee has said anything — so the response already shows an empty stack. An
// amount of zero or less means "all of it", which is what the button does.
func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req amountBody
	if err := decodeJSON(r, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	// You cannot walk away from a hand you have chips riding on. The stake is
	// already off the stack, so this isn't about the money — it's that a hand
	// left half-played would settle into a session that no longer exists.
	if _, err := storage.LoadLiveHand(user); err == nil {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"})
		return
	} else if !errors.Is(err, storage.ErrNoLiveHand) {
		slog.Error("games: cash-out live-hand check", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	amount := req.Amount
	if amount <= 0 {
		st, err := storage.Chips(user)
		if err != nil {
			slog.Error("games: cash-out", "user", user, "err", err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		amount = st.Chips
	}

	e, err := storage.RequestCashOut(user, amount)
	switch {
	case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips):
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
		return
	case err != nil:
		slog.Error("games: cash-out", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID)
	v, err := s.table(user)
	if err != nil {
		slog.Error("games: table after cash-out", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, v)
}

// ---- blackjack ------------------------------------------------------------

// handleDeal takes the bet and deals. The order matters: chips are staked first,
// in the same statement that checks they exist, so two deals fired at once
// cannot bet the same chip. Only then is a hand dealt.
func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req struct {
		Bet int64 `json:"bet"`
	}
	if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
		return
	}

	if err := storage.Stake(user, req.Bet); err != nil {
		if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
			writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
			return
		}
		slog.Error("games: stake", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	seed1, seed2 := newSeeds()
	rng := rand.New(rand.NewPCG(seed1, seed2))
	st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng)
	if err != nil {
		// The hand never happened, so the stake never should have left. Give it back.
		_ = storage.Award(user, req.Bet)
		slog.Error("games: deal", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	s.persist(w, user, st, evs, seed1, seed2, true)
}

// handleMove plays one move of the hand in progress.
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req struct {
		Move string `json:"move"`
	}
	if err := decodeJSON(r, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	live, err := storage.LoadLiveHand(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no hand in progress"})
		return
	}
	if err != nil {
		slog.Error("games: load hand", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	var st blackjack.State
	if err := json.Unmarshal(live.State, &st); err != nil {
		slog.Error("games: unreadable live hand", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	move := blackjack.Move(req.Move)

	// Double and split are the two moves that put more chips on the table *after*
	// the cards are out, so the money has to move before the move does — and if the
	// chips aren't there, the move simply isn't legal. Take them first: if the
	// engine then refuses the move, they go straight back.
	//
	// Both cost the active hand's bet, not the whole stake. Once a hand can be
	// split those are different numbers, and doubling the third hand of a split for
	// the total of all three would be quite a thing to discover in production.
	var staked int64
	switch move {
	case blackjack.Double:
		if !st.CanDouble() {
			writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards of a hand"})
			return
		}
		staked = st.DoubleCost()
	case blackjack.Split:
		if !st.CanSplit() {
			writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only split two cards of the same rank, and only up to four hands"})
			return
		}
		staked = st.SplitCost()
	}
	if staked > 0 {
		if err := storage.Stake(user, staked); err != nil {
			if errors.Is(err, storage.ErrInsufficientChips) {
				writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that"})
				return
			}
			slog.Error("games: stake raise", "user", user, "move", move, "err", err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
	}

	next, evs, err := blackjack.ApplyMove(st, move)
	if err != nil {
		if staked > 0 {
			_ = storage.Award(user, staked) // the move didn't happen; neither did the raise
		}
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
		return
	}
	s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
}

// The games a live row can be. They're the storage key, so they're constants:
// a typo here is a game nobody can ever load again.
const (
	gameBlackjack = "blackjack"
	gameHangman   = "hangman"
	gameSolitaire = "solitaire"
	gameTrivia    = "trivia"
	gameUno       = "uno"
	gameHoldem    = "holdem"
)

// finished is what commit needs to know about a game it's writing back: enough
// to settle it, and nothing about how it's played. Both engines produce one.
type finished struct {
	Game    string
	Blob    []byte // the engine's whole state, shoe or phrase and all
	Bet     int64
	Payout  int64
	Rake    int64
	Outcome string
	Done    bool
	Seed1   uint64
	Seed2   uint64
	Fresh   bool // a game just started, which is the one write that may be refused
}

// commit writes a game back and settles it if it's over. It is the money path,
// and every game goes through it so that none has to re-derive an ordering that
// took a while to get right.
//
// The ordering now lives in storage.CommitHand, which does the whole thing —
// seat, pay, record, clear, touch — in one transaction. It used to be four
// autocommit statements here, carefully sequenced so that a crash between them
// cost the player as little as possible. That was survivable for a game owned by
// one player. It is not survivable for a game with a pot in it, which is what
// the tables are about to become: pay the winner, die before the state write,
// and the hand still reads as live, so it settles again and pays them twice.
//
// It returns the table as it now stands. ok is false when it has already
// written an error response and the caller must simply return.
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
	err := storage.CommitHand(user, storage.Commit{
		Live:   storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
		Fresh:  f.Fresh,
		Stake:  f.Bet,
		Done:   f.Done,
		Payout: f.Payout,
		Audit: storage.Hand{
			MatrixUser: user, Game: f.Game,
			Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
			Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
		},
	})
	switch {
	case errors.Is(err, storage.ErrHandInProgress):
		// Somebody was already sitting here. The game was never seated and the chips
		// it staked have gone back — in the same transaction that refused it.
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
		return tableView{}, false
	case err != nil:
		slog.Error("games: commit", "user", user, "game", f.Game, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return tableView{}, false
	}

	v, err := s.table(user)
	if err != nil {
		slog.Error("games: table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return tableView{}, false
	}
	return v, true
}

// persist writes a blackjack hand back and answers the browser.
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
	blob, err := json.Marshal(st)
	if err != nil {
		slog.Error("games: marshal hand", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	done := st.Phase == blackjack.PhaseDone
	v, ok := s.commit(w, user, finished{
		Game: gameBlackjack, Blob: blob,
		Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
		Outcome: string(st.Outcome), Done: done,
		Seed1: seed1, Seed2: seed2, Fresh: fresh,
	})
	if !ok {
		return
	}
	// A settled hand is gone from storage, so the table view has no hand to show —
	// but the browser still needs the final cards to animate the reveal onto.
	if done {
		hv := viewHand(st)
		v.Hand = &hv
	}
	v.Events = viewEvents(evs, st.Phase)
	writeJSON(w, v)
}

// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody
// disputes can be dealt again exactly as it fell.
func newSeeds() (uint64, uint64) {
	return rand.Uint64(), uint64(time.Now().UnixNano())
}

func writeJSONStatus(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		slog.Error("games: write response", "err", err)
	}
}
