package web

import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"pete/internal/games/blackjack"
	"pete/internal/games/holdem"
	"pete/internal/storage"
)

// Texas hold'em, played for chips against the trained bots.
//
// This is the only table in the casino that is a *session* rather than a game.
// Everywhere else you stake, you play once, and a multiple pays: the hand is the
// unit and the money moves at both ends of it. Poker is not that shape. You buy
// chips onto the table, you play as many hands as you feel like, and you leave
// with whatever is in front of you — so the live row lives across hands, and the
// chips move exactly twice: once when you sit down, once when you get up.
//
// Which means there is no "payout" until you stand up, and `commit` is only ever
// told the game is Done when you do (or when you have nothing left to sit with).
// In between, every pot won and lost is inside the engine, and storage sees none
// of it. That is the honest model, and it is also the safe one: a hand that dies
// halfway leaves the chips where they were, on the table, in the live row.
//
// What the browser is allowed to see: your two cards, the board, everybody's
// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until
// a showdown turns it over, which is the moment it stops being a secret.

// holdemSeatView is one seat. Cards are present only when the viewer is entitled
// to them: yours always, a bot's never, until the hand is shown down.
type holdemSeatView struct {
	Name  string     `json:"name"`
	Bot   bool       `json:"bot"`
	You   bool       `json:"you"`
	Stack int64      `json:"stack"`
	Bet   int64      `json:"bet"`
	State string     `json:"state"` // active | folded | allin | out
	Pos   string     `json:"pos"`   // BTN, SB, BB, UTG…
	Cards []cardView `json:"cards,omitempty"`
	Won   int64      `json:"won,omitempty"`
}

var seatStates = map[holdem.SeatState]string{
	holdem.Active: "active",
	holdem.Folded: "folded",
	holdem.AllIn:  "allin",
	holdem.Out:    "out",
}

// holdemView is the table as its player may see it.
type holdemView struct {
	Tier holdem.Tier `json:"tier"`
	// YourSeat is which chair in Seats is the viewer's. It used to be a convention
	// (seat zero is you) that the felt hardcoded; at a shared table it is whatever
	// chair you took, so it rides in the view and the browser reads it rather than
	// assuming it.
	YourSeat int              `json:"your_seat"`
	Seats    []holdemSeatView `json:"seats"`
	Button   int              `json:"button"`
	HandNo   int              `json:"hand_no"`

	Board  []cardView `json:"board"`
	Street string     `json:"street"`
	Pot    int64      `json:"pot"`
	Side   []int64    `json:"side,omitempty"`

	ToAct int    `json:"to_act"`
	Phase string `json:"phase"`

	// What you may do, decided here rather than in the browser — the felt should
	// never offer a button the table would refuse.
	Owed     int64 `json:"owed"`
	CanCheck bool  `json:"can_check"`
	CanRaise bool  `json:"can_raise"`
	MinRaise int64 `json:"min_raise_to"`
	MaxRaise int64 `json:"max_raise_to"`

	Stack    int64 `json:"stack"` // what's in front of you
	BoughtIn int64 `json:"bought_in"`
	Rake     int64 `json:"rake"` // what the house has taken this session
	MaxTopUp int64 `json:"max_topup"`
	Payout   int64 `json:"payout,omitempty"`
}

// viewHoldem renders the table as one seat may see it. viewer is which seat is
// looking — their cards are the only hole cards it will ever put in the payload
// (until a showdown turns the rest over), and the action panel is filled in only
// when it is that seat's turn.
//
// This is the security boundary. Before SSE a missed check here leaked one bot's
// cards to one player through a bug in one handler; now the same view fans to
// every subscriber's stream, so a seat that renders anyone else's hole card fans
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
// seat's view at every street and greps for cards that are not theirs.
func viewHoldem(g holdem.State, viewer int) holdemView {
	v := holdemView{
		Tier:     g.Tier,
		YourSeat: viewer,
		Button:   g.Button,
		HandNo:   g.HandNo,
		Street:   g.Street.String(),
		Pot:      g.Total(),
		ToAct:    g.ToAct,
		Phase:    string(g.Phase),
		Stack:    g.Seats[viewer].Stack,
		BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
		Rake:     g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt
		Payout:   g.Payout,
	}
	for _, p := range g.Side {
		v.Side = append(v.Side, p.Amount)
	}
	// An empty board is an empty board, not null. A Go slice with nothing in it
	// marshals to null, and a browser that has to write `(board || [])` everywhere
	// is a browser one forgotten guard away from a crash on every preflop.
	v.Board = []cardView{}
	for _, c := range g.Community {
		v.Board = append(v.Board, viewCard(c))
	}

	// The wall. Another seat's hand crosses the wire in exactly one situation — the
	// hand was shown down and they did not fold — because that is the only situation
	// in which a player at a real table would be looking at it.
	shown := g.Street == holdem.Showdown
	for i, p := range g.Seats {
		seat := holdemSeatView{
			Name:  p.Name,
			Bot:   p.Bot,
			You:   i == viewer,
			Stack: p.Stack,
			Bet:   p.Bet,
			State: seatStates[p.State],
			Pos:   g.Position(i),
			Won:   p.Won,
		}
		mine := i == viewer
		dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
		if dealt && (mine || (shown && p.State != holdem.Folded)) {
			seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
		}
		v.Seats = append(v.Seats, seat)
	}

	if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
		v.Owed = g.Owed(viewer)
		v.CanCheck = v.Owed == 0
		v.CanRaise = g.CanRaise(viewer)
		v.MinRaise = g.MinRaiseTo(viewer)
		v.MaxRaise = g.MaxRaiseTo(viewer)
	}
	if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
		v.MaxTopUp = top
	}
	return v
}

// holdemEventView is one beat of the script the felt plays back. The engine only
// ever attaches a bot's cards to a showdown; this drops them again anywhere else,
// which is the second of the two walls.
type holdemEventView struct {
	Kind   string     `json:"kind"`
	Seat   int        `json:"seat"`
	Cards  []cardView `json:"cards,omitempty"`
	Amount int64      `json:"amount,omitempty"`
	Total  int64      `json:"total,omitempty"`
	Text   string     `json:"text,omitempty"`
}

// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
// every seat's hole cards now (it cannot know who a shared stream is for), so
// this is where the cards that are not the viewer's are stripped — turning a
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
//
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
// own, or a hand being shown down. Everything else is nulled here, and a missed
// case is the leak that fans one seat's hole card to every subscriber.
func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
	out := make([]holdemEventView, 0, len(evs))
	for _, e := range evs {
		v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
		for _, c := range e.Cards {
			v.Cards = append(v.Cards, viewCard(c))
		}
		if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
			v.Cards = nil
		}
		out = append(out, v)
	}
	return out
}
// ---- sitting down: a table of your own, or somebody else's -----------------

// displayName is what goes on the felt. It is the player's session name if they
// have one, the local part of their Matrix id otherwise — never empty, which
// would sit a nameless chair at the table.
func (s *Server) displayName(r *http.Request, user string) string {
	if u := s.auth.userFromRequest(r); u != nil {
		if u.Name != "" {
			return u.Name
		}
		if u.Username != "" {
			return u.Username
		}
	}
	name := strings.TrimPrefix(user, "@")
	if i := strings.IndexByte(name, ':'); i > 0 {
		name = name[:i]
	}
	return name
}

// seatRows mirrors the engine's seats into the storage rows that shadow them,
// index for index — seat i in the blob is seat i in game_seats — which is the
// alignment the view redacts by and the audit attributes by. A human's staked is
// the buy-in that actually crossed the border; a bot's is zero, because the only
// real money at the table is in the human seats and staked is where the border
// accounting lives.
func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat {
	rows := make([]storage.Seat, len(g.Seats))
	for i := range g.Seats {
		p := g.Seats[i]
		row := storage.Seat{Seat: i, Name: p.Name}
		if !p.Bot {
			row.MatrixUser = human
			row.Staked = buyIn
		}
		rows[i] = row
	}
	return rows
}

// handleHoldemSit seats a player: at a fresh table of their own (solo is just a
// table nobody else has joined yet), or at an open chair on somebody else's.
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req struct {
		Tier  string `json:"tier"`
		Bots  int    `json:"bots"`
		BuyIn int64  `json:"buyin"`
		Table string `json:"table"` // set to join an existing table rather than open one
		Seat  *int   `json:"seat"`  // which chair to take when joining; optional
	}
	if err := decodeJSON(r, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if req.Table != "" {
		s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn)
		return
	}
	s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn)
}

// openHoldem opens a fresh table with the player in seat zero and bots in the
// rest. It is the old solo flow, now a real shared table that simply has no other
// humans on it yet.
func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) {
	tier, err := holdem.TierBySlug(tierSlug)
	if err != nil {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
		return
	}
	if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
		return
	}
	if bots < 1 || bots > holdem.MaxBots {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
		return
	}

	name := s.displayName(r, user)
	seed1, seed2 := newSeeds()
	g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
	if err != nil {
		slog.Error("games: holdem open", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	blob, err := json.Marshal(g)
	if err != nil {
		slog.Error("games: marshal new holdem", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	id, err := storage.NewTableID()
	if err != nil {
		slog.Error("games: mint table id", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	t := storage.Table{
		ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob,
		Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
	}
	err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn)
	switch {
	case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
		return
	case errors.Is(err, storage.ErrHandInProgress):
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
		return
	case err != nil:
		slog.Error("games: open solo table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	s.writeHoldemTable(w, user, nil)
}

// pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
// bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit.
func pickOpenSeat(g holdem.State, want *int) int {
	if want != nil {
		i := *want
		if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
			return i
		}
		return -1
	}
	for i := range g.Seats {
		if g.Seats[i].Bot {
			return i
		}
	}
	return -1
}

// joinHoldem sits a player down at an open chair on an existing table. It runs
// under the table lock, and every step of the sit-down is one transaction in
// SitDown — stake, claim, take the chair out of a bot's hands, save the state —
// so two people racing for the last seat cannot both win it.
func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
	name := s.displayName(r, user)
	var respErr error
	err := s.tableLocks.withTable(tableID, func() error {
		t, _, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		if t.Game != gameHoldem {
			respErr = holdem.ErrUnknownMove
			return nil
		}
		var g holdem.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}
		if g.Phase == holdem.PhaseBetting {
			respErr = holdem.ErrHandLive // you join between hands, not into one
			return nil
		}
		seat := pickOpenSeat(g, wantSeat)
		if seat < 0 {
			respErr = holdem.ErrTableFull
			return nil
		}
		if err := g.Occupy(seat, name, buyIn); err != nil {
			respErr = err
			return nil
		}
		blob, err := json.Marshal(g)
		if err != nil {
			return err
		}
		t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
		err = storage.SitDown(storage.Sit{
			Table: t,
			Seat:  storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
			BuyIn: buyIn,
		})
		switch {
		case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
			respErr = storage.ErrInsufficientChips
			return nil
		case errors.Is(err, storage.ErrHandInProgress):
			respErr = storage.ErrHandInProgress
			return nil
		case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
			respErr = storage.ErrSeatTaken
			return nil
		case err != nil:
			return err
		}
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: join holdem", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)})
		return
	}
	s.writeHoldemTable(w, user, nil)
}

func joinStatus(err error) int {
	switch {
	case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull),
		errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive):
		return http.StatusConflict
	default:
		return http.StatusBadRequest
	}
}

func joinMessage(err error) string {
	switch {
	case errors.Is(err, storage.ErrNoSuchTable):
		return "that table has closed"
	case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
		return "that seat is taken"
	case errors.Is(err, holdem.ErrHandLive):
		return "a hand is in play — sit down when it's over"
	case errors.Is(err, holdem.ErrBadBuyIn):
		return "that isn't a legal buy-in for this table"
	case errors.Is(err, storage.ErrInsufficientChips):
		return "not enough chips to sit down"
	case errors.Is(err, storage.ErrHandInProgress):
		return "finish the game you're in first"
	default:
		return "you can't sit there"
	}
}

// ---- playing a hand --------------------------------------------------------

// handleHoldemMove plays one move at the player's table: a betting action, or the
// two session moves that are not leaving (deal the next hand, top up between
// them). Leaving is its own endpoint, because it is a storage operation rather
// than an engine one.
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var move holdem.Move
	if err := decodeJSON(r, &move); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if move.Kind == holdem.Leave {
		s.leaveHoldem(w, user)
		return
	}

	tableID, seat, err := storage.PlayerSeat(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
		return
	}
	if err != nil {
		slog.Error("games: holdem move seat", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	var respErr error
	var respEvents []holdem.Event
	err = s.tableLocks.withTable(tableID, func() error {
		t, seats, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		var g holdem.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}

		// A top-up is real chips crossing the border, so it comes off the stack
		// before the engine is asked, and goes straight back if the engine says no —
		// the same order, and the same reason, as doubling down at blackjack.
		topped := int64(0)
		if move.Kind == holdem.TopUp {
			if err := storage.Stake(user, move.Amount); err != nil {
				if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
					respErr = holdem.ErrTooBig
					return nil
				}
				return err
			}
			topped = move.Amount
		}

		next, evs, aerr := holdem.ApplyMove(g, seat, move)
		if aerr != nil {
			if topped > 0 {
				_ = storage.Award(user, topped) // the top-up didn't happen
			}
			respErr = aerr
			return nil
		}

		// A solo session that just ended (the one human busted) is not a table any
		// more: cash the seat out — for nothing, but the claim still has to be
		// released — and close the felt. Only a one-human bust reaches PhaseDone; a
		// shared table sits the busted seat Out and plays on.
		if next.Phase == holdem.PhaseDone {
			if err := s.settleLeave(t, next, seat, user); err != nil {
				if topped > 0 {
					_ = storage.Award(user, topped)
				}
				if errors.Is(err, storage.ErrStaleTable) {
					respErr = storage.ErrStaleTable
					return nil
				}
				return err
			}
			respEvents = evs
			s.publishTable(tableID)
			return nil
		}

		st, err := holdemStep(g, next, evs, seats)
		if err != nil {
			if topped > 0 {
				_ = storage.Award(user, topped)
			}
			return err
		}
		acting := seats[seat]
		acting.Away = false
		acting.LastSeen = time.Now().Unix()
		// A top-up is more real money crossing the border, so the seat's staked row —
		// the record of what they brought and can still take home — has to grow with
		// it. Without this the storage invariant (stacks + pot == staked - rake) drifts
		// by every top-up, and the felt under-reports what they bought in for.
		acting.Staked += topped

		t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
		if err := storage.CommitTable(storage.TableCommit{
			Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
		}); err != nil {
			if errors.Is(err, storage.ErrStaleTable) {
				if topped > 0 {
					_ = storage.Award(user, topped)
				}
				respErr = storage.ErrStaleTable
				return nil
			}
			return err
		}
		respEvents = evs
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: holdem move", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
		return
	}
	s.writeHoldemTable(w, user, respEvents)
}

func moveStatus(err error) int {
	// A 409 is a concurrency verdict: the table is not where the caller thought, so
	// reload and look again. Everything else — an illegal move for this state — is
	// the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
	// the request was simply not allowed, not raced.
	switch {
	case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
		return http.StatusConflict
	default:
		return http.StatusBadRequest
	}
}

func moveMessage(err error) string {
	switch {
	case errors.Is(err, storage.ErrStaleTable):
		return "the table moved on — take another look"
	case errors.Is(err, storage.ErrNoSuchTable):
		return "that table has closed"
	case errors.Is(err, holdem.ErrHandLive):
		return "finish the hand first"
	case errors.Is(err, holdem.ErrNotYourTurn):
		return "it isn't your turn"
	case errors.Is(err, holdem.ErrCantCheck):
		return "there's a bet to you"
	case errors.Is(err, holdem.ErrTooSmall):
		return "that's under the minimum raise"
	case errors.Is(err, holdem.ErrTooBig):
		return "you don't have that many chips"
	case errors.Is(err, holdem.ErrBadBuyIn):
		return "that would put you over the table maximum"
	default:
		return "that move isn't legal here"
	}
}

// ---- getting up ------------------------------------------------------------

// handleHoldemLeave is the get-up endpoint. Leaving is its own route because it
// is a storage operation, not an engine move — the chips cross the border and the
// felt may close — even though the felt also lets you send it as a "leave" move.
func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	s.leaveHoldem(w, user)
}

// leaveHoldem gets a player up from their table, turning what is in front of them
// back into chips. It refuses mid-hand — you cannot walk out on chips you have in
// the pot — and closes the felt behind the last human to leave.
func (s *Server) leaveHoldem(w http.ResponseWriter, user string) {
	tableID, seat, err := storage.PlayerSeat(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
		return
	}
	if err != nil {
		slog.Error("games: holdem leave seat", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	var respErr error
	err = s.tableLocks.withTable(tableID, func() error {
		t, _, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		var g holdem.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}
		if g.Phase == holdem.PhaseBetting {
			respErr = holdem.ErrHandLive
			return nil
		}
		if err := s.settleLeave(t, g, seat, user); err != nil {
			if errors.Is(err, storage.ErrStaleTable) {
				respErr = storage.ErrStaleTable
				return nil
			}
			return err
		}
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
		return
	}
	s.writeHoldemTable(w, user, nil)
}

// settleLeave vacates a seat, credits the stack home, and closes the table if
// nobody human is left — all inside the caller's lock. The engine's Vacate turns
// the chair back into the house's (its chips become house money, rebought like
// any bot's), and storage.LeaveTable does the border crossing in one transaction
// with the state write, so a crash can never pay a player and then leave their
// seat sitting there to be cashed out again.
func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error {
	home, err := g.Vacate(seat)
	if err != nil {
		return err
	}
	blob, err := json.Marshal(g)
	if err != nil {
		return err
	}
	t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
	if err := storage.LeaveTable(storage.Leave{
		Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
	}); err != nil {
		return err
	}
	if err := storage.CloseTable(t.ID); err != nil {
		return err
	}
	return nil
}

// ---- the response ----------------------------------------------------------

// writeHoldemTable answers with the whole page state — the money and the table as
// the player's own seat may see it — plus, when a move produced one, the redacted
// event script for that seat to animate. A player who has just got up has no seat
// and no table; the money view carries the leftover verdict and the felt clears.
func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) {
	v, err := s.table(user)
	if err != nil {
		slog.Error("games: holdem table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if len(evs) > 0 {
		if _, seat, serr := storage.PlayerSeat(user); serr == nil {
			v.HoldemEvents = viewHoldemEvents(evs, seat)
		}
	}
	writeJSON(w, v)
}
