package storage

import (
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"errors"
	"fmt"
	"time"
)

// The chip ledger and the euro/chip border.
//
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
// they are not a second wallet: every chip that exists came from a euro gogobee
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
// never writes a euro balance. The border is crossed only by a game_escrow row,
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
// claim whose acknowledgement is lost on the wire can be retried without the
// player paying for it twice.
//
// The whole reason for the border is latency. gogobee has no inbound API and is
// not getting one, so it polls; a bet that round-tripped through a poll loop
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
// — buy in, cash out — and every hand in between plays against chips held here,
// at full speed, with no economy call in the hot path.

// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
// would push them over is refused before it ever reaches gogobee.
//
// This is the inflation brake. A web casino runs orders of magnitude more hands
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
// compounds far faster in both directions. The cap bounds the worst case for a
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
const MaxChipsOnTable int64 = 10_000

// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
// the poll endpoint offers it again. gogobee can die between claiming a row and
// pushing its result; without a re-offer, the player's money sits in limbo
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
const EscrowStaleAfter = 90 * time.Second

// SessionIdleAfter is when the reaper decides a player has walked away and cashes
// their chips back to euros on their behalf. Chips in an abandoned session are
// euros in limbo, and limbo is not a state a player's money should be in.
const SessionIdleAfter = 30 * time.Minute

// Escrow kinds and states. These strings cross the wire to gogobee, so they are
// part of the contract — see §4 of pete_games_plan.md.
const (
	KindBuyIn   = "buyin"
	KindCashOut = "cashout"

	EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
	EscrowClaimed   = "claimed"   // gogobee has it and is moving the euros
	EscrowFunded    = "funded"    // buy-in landed; the chips are spendable
	EscrowRejected  = "rejected"  // buy-in refused; no chips, no euros moved
	EscrowSettled   = "settled"   // cash-out landed; chips destroyed, euros credited
)

var (
	ErrInsufficientChips = errors.New("games: not enough chips")
	ErrOverTableCap      = errors.New("games: that would put more than the cap on the table")
	ErrBadAmount         = errors.New("games: amount must be positive")
	ErrNoSuchEscrow      = errors.New("games: no such escrow row")
)

// Escrow is one crossing of the euro/chip border.
type Escrow struct {
	GUID         string  `json:"guid"`
	MatrixUser   string  `json:"matrix_user"`
	Kind         string  `json:"kind"`
	Amount       int64   `json:"amount"`
	State        string  `json:"state,omitempty"`
	Reason       string  `json:"reason,omitempty"`
	BalanceAfter float64 `json:"balance_after,omitempty"`
	CreatedAt    int64   `json:"created_at,omitempty"`
}

// ChipStack is what a player has on the table right now.
type ChipStack struct {
	Chips int64 // spendable
	// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
	// claimed or settled. Shown as "buying chips…", never spendable.
	Pending     int64
	EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
	LastPlayed  int64
}

// newGUID mints an escrow id. It's the idempotency key for a real money move, so
// it comes from crypto/rand rather than anything a caller could collide with.
func newGUID() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("games: mint guid: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// Chips reports a player's stack. A player who has never played has no row and
// reads as an empty stack rather than an error.
func Chips(user string) (ChipStack, error) {
	var st ChipStack
	var euro sql.NullFloat64
	err := Get().QueryRow(
		`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
	).Scan(&st.Chips, &euro, &st.LastPlayed)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
	}
	st.EuroBalance = euro.Float64

	if err := Get().QueryRow(
		`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
		 WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
		user, KindBuyIn, EscrowRequested, EscrowClaimed,
	).Scan(&st.Pending); err != nil {
		return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
	}
	return st, nil
}

// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
// No chips exist yet — they appear only when gogobee confirms it took the euros.
// The table cap is enforced here, against chips already held *plus* buy-ins still
// in flight, so a player can't clear the cap by firing several at once.
func RequestBuyIn(user string, amount int64) (Escrow, error) {
	if amount <= 0 {
		return Escrow{}, ErrBadAmount
	}
	st, err := Chips(user)
	if err != nil {
		return Escrow{}, err
	}
	if st.Chips+st.Pending+amount > MaxChipsOnTable {
		return Escrow{}, ErrOverTableCap
	}

	guid, err := newGUID()
	if err != nil {
		return Escrow{}, err
	}
	now := nowUnix()
	if _, err := Get().Exec(
		`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
		guid, user, KindBuyIn, amount, EscrowRequested, now, now,
	); err != nil {
		return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
	}
	return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}

// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
// euros arrive when gogobee claims the row.
//
// Destroying them up front is what keeps the invariant true. If the chips lingered
// until gogobee confirmed, a player could bet them while the cash-out was in
// flight and the same euro would exist on both sides of the border. If the credit
// somehow fails, RefundCashOut puts the chips back.
func RequestCashOut(user string, amount int64) (Escrow, error) {
	if amount <= 0 {
		return Escrow{}, ErrBadAmount
	}
	guid, err := newGUID()
	if err != nil {
		return Escrow{}, err
	}
	now := nowUnix()

	tx, err := Get().Begin()
	if err != nil {
		return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once committed

	// Conditional update: the chips leave only if they're actually there.
	res, err := tx.Exec(
		`UPDATE game_chips SET chips = chips - ?, updated_at = ?
		 WHERE matrix_user = ? AND chips >= ?`,
		amount, now, user, amount,
	)
	if err != nil {
		return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return Escrow{}, ErrInsufficientChips
	}

	if _, err := tx.Exec(
		`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
		guid, user, KindCashOut, amount, EscrowRequested, now, now,
	); err != nil {
		return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
	}
	return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}

// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
//
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
// have to assume gogobee died holding them. Re-offering a claimed row is safe
// because the guid is idempotent end to end: if gogobee already moved the euros,
// the retry is a no-op that reports the same answer.
func PendingEscrow(limit int) ([]Escrow, error) {
	if limit <= 0 {
		limit = 100
	}
	stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
	rows, err := Get().Query(
		`SELECT guid, matrix_user, kind, amount, state, created_at
		   FROM game_escrow
		  WHERE state = ?
		     OR (state = ? AND COALESCE(claimed_at, 0) < ?)
		  ORDER BY created_at
		  LIMIT ?`,
		EscrowRequested, EscrowClaimed, stale, limit,
	)
	if err != nil {
		return nil, fmt.Errorf("games: pending escrow: %w", err)
	}
	defer rows.Close()

	var out []Escrow
	for rows.Next() {
		var e Escrow
		if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
			return nil, fmt.Errorf("games: scan escrow: %w", err)
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
// works), but a row already *finished* cannot be, which is what stops a settled
// cash-out from being paid a second time.
func ClaimEscrow(guid string) (Escrow, error) {
	now := nowUnix()
	res, err := Get().Exec(
		`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
		  WHERE guid = ? AND state IN (?, ?)`,
		EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
	)
	if err != nil {
		return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		// Either it doesn't exist or it's already finished. Tell the caller which.
		e, err := EscrowByGUID(guid)
		if err != nil {
			return Escrow{}, err
		}
		return e, nil
	}
	return EscrowByGUID(guid)
}

// EscrowByGUID reads one row.
func EscrowByGUID(guid string) (Escrow, error) {
	var e Escrow
	var reason sql.NullString
	var bal sql.NullFloat64
	err := Get().QueryRow(
		`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
		   FROM game_escrow WHERE guid = ?`, guid,
	).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
	if errors.Is(err, sql.ErrNoRows) {
		return Escrow{}, ErrNoSuchEscrow
	}
	if err != nil {
		return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
	}
	e.Reason, e.BalanceAfter = reason.String, bal.Float64
	return e, nil
}

// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
// chips are created or finally destroyed.
//
//	buy-in,  ok    -> chips appear                      (funded)
//	buy-in,  !ok   -> nothing happens, nothing moved    (rejected)
//	cash-out, ok   -> chips stay destroyed, euros paid  (settled)
//	cash-out, !ok  -> chips come back                   (funded — the player never lost them)
//
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
// more than once and only the first one may move chips. A row that has already
// reached a terminal state is a no-op, not an error.
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
	now := nowUnix()

	tx, err := Get().Begin()
	if err != nil {
		return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once committed

	var e Escrow
	var st string
	if err := tx.QueryRow(
		`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
	).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
		return Escrow{}, ErrNoSuchEscrow
	} else if err != nil {
		return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
	}

	// Terminal already — a retried push. Report what we decided the first time.
	if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
		if err := tx.Commit(); err != nil {
			return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
		}
		return EscrowByGUID(guid)
	}

	final := EscrowFunded
	switch {
	case e.Kind == KindBuyIn && ok:
		if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
			return Escrow{}, err
		}
	case e.Kind == KindBuyIn && !ok:
		final = EscrowRejected // gogobee took nothing, so we create nothing
	case e.Kind == KindCashOut && ok:
		final = EscrowSettled // the chips were destroyed when the row was opened
	case e.Kind == KindCashOut && !ok:
		// gogobee couldn't pay. The chips were already destroyed on our side, so
		// give them back rather than vanishing the player's money.
		if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
			return Escrow{}, err
		}
	}

	if _, err := tx.Exec(
		`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
		  WHERE guid = ?`,
		final, reason, balanceAfter, now, guid,
	); err != nil {
		return Escrow{}, fmt.Errorf("games: settle update: %w", err)
	}

	// The euro balance gogobee just reported is the freshest one we'll get.
	// Advisory only — we display it, we never decide anything with it.
	if _, err := tx.Exec(
		`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
		balanceAfter, now, e.MatrixUser,
	); err != nil {
		return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
	}
	return EscrowByGUID(guid)
}

// addChips credits a stack inside an open transaction, creating the row if the
// player has never held chips before.
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
	if _, err := tx.Exec(
		`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
		 VALUES (?, ?, ?, ?)
		 ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
		user, amount, now, now,
	); err != nil {
		return fmt.Errorf("games: credit chips: %w", err)
	}
	return nil
}

// Stake takes chips off a player's stack to put them at risk on a hand. It is the
// conditional-update kind of debit: the chips leave in the same statement that
// checks they're there, so two hands opened at once can't spend the same chip.
func Stake(user string, amount int64) error {
	if amount <= 0 {
		return ErrBadAmount
	}
	now := nowUnix()
	res, err := Get().Exec(
		`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
		  WHERE matrix_user = ? AND chips >= ?`,
		amount, now, now, user, amount,
	)
	if err != nil {
		return fmt.Errorf("games: stake: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return ErrInsufficientChips
	}
	return nil
}

// Award returns chips to a player when a hand settles: stake plus winnings, net
// of rake, exactly as the engine computed it. A losing hand awards nothing and
// should not call this.
//
// This is the standalone form, for a caller with no transaction of its own. A
// settle must not use it — see award, and the warning on CommitHand.
func Award(user string, amount int64) error {
	if amount <= 0 {
		return nil
	}
	tx, err := Get().Begin()
	if err != nil {
		return fmt.Errorf("games: begin award: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once committed

	if err := award(tx, user, amount, nowUnix()); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("games: commit award: %w", err)
	}
	return nil
}

// award credits a stack inside an open transaction.
//
// It differs from addChips in one deliberate way: it moves last_played, because
// being paid is something that happens at a table and the reaper should see it.
// A buy-in is not — that is why addChips leaves the idle clock alone.
func award(tx *sql.Tx, user string, amount int64, now int64) error {
	if amount <= 0 {
		return nil
	}
	if _, err := tx.Exec(
		`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
		 VALUES (?, ?, ?, ?)
		 ON CONFLICT(matrix_user) DO UPDATE SET
		   chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
		user, amount, now, now,
	); err != nil {
		return fmt.Errorf("games: award chips: %w", err)
	}
	return nil
}

// Hand is one settled hand, as the audit log keeps it.
type Hand struct {
	MatrixUser string
	Game       string
	Bet        int64
	Payout     int64
	Rake       int64
	Outcome    string
	Seed1      uint64
	Seed2      uint64
}

// RecordHand writes a finished hand to the audit trail. The seeds are the point:
// with them, any hand in the log can be dealt again exactly as it fell, which is
// how a dispute gets answered with a fact instead of an apology.
func RecordHand(h Hand) error {
	tx, err := Get().Begin()
	if err != nil {
		return fmt.Errorf("games: begin record hand: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once committed

	if err := recordHand(tx, h, nowUnix()); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("games: commit record hand: %w", err)
	}
	return nil
}

// recordHand writes the audit row inside an open transaction.
func recordHand(tx *sql.Tx, h Hand, now int64) error {
	if _, err := tx.Exec(
		`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
		int64(h.Seed1), int64(h.Seed2), now,
	); err != nil {
		return fmt.Errorf("games: record hand: %w", err)
	}
	return nil
}

// IdleStacks lists players holding chips who stopped playing a while ago. The
// reaper cashes these out on their behalf: chips in an abandoned session are
// euros in limbo, and they should be back in the player's balance where they can
// see them.
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
	cutoff := nowUnix() - int64(idleFor.Seconds())
	rows, err := Get().Query(
		`SELECT matrix_user, chips, last_played FROM game_chips
		  WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
	)
	if err != nil {
		return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
	}
	defer rows.Close()

	var stacks []ChipStack
	var users []string
	for rows.Next() {
		var st ChipStack
		var user string
		if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
			return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
		}
		stacks = append(stacks, st)
		users = append(users, user)
	}
	return stacks, users, rows.Err()
}

// ReapIdleSessions cashes out everyone who walked away, and reports how many it
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
// and a cash-out that's already in flight can't be opened twice because the chips
// are gone from the stack the moment the first one is.
func ReapIdleSessions(idleFor time.Duration) (int, error) {
	stacks, users, err := IdleStacks(idleFor)
	if err != nil {
		return 0, err
	}
	reaped := 0
	for i, user := range users {
		if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
			// One player's stack failing to reap shouldn't strand everyone else's.
			if !errors.Is(err, ErrInsufficientChips) {
				return reaped, fmt.Errorf("games: reap %s: %w", user, err)
			}
			continue
		}
		reaped++
	}
	return reaped, nil
}

// Touch marks a player as active, so the reaper leaves them alone. Called on any
// deliberate action at a table — not on a page load, or an open tab would keep a
// walked-away player's chips hostage forever.
func Touch(user string) {
	exec("games: touch session",
		`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
		nowUnix(), nowUnix(), user)
}

// ---- the hand in progress -------------------------------------------------

var (
	// ErrNoLiveHand means the player isn't in a hand right now.
	ErrNoLiveHand = errors.New("games: no hand in progress")
	// ErrHandInProgress means they already are, and may not be dealt another.
	ErrHandInProgress = errors.New("games: already in a hand")
)

// LiveHand is a hand a player is in the middle of. State is the engine's own
// State, serialized whole — the shoe is in there, which is exactly why this row
// never leaves the server.
type LiveHand struct {
	Game  string
	State []byte
	Seed1 uint64
	Seed2 uint64
	// TableID is set when the player is sitting at a shared table instead of playing
	// alone. The cards are then in game_tables and State here is empty: this row is
	// the occupancy claim and nothing else. One row per player either way, which is
	// the point — the primary key that stops a second solo hand is the same one that
	// stops a second seat.
	TableID string
}

// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
// The plain INSERT is the point: it is the primary key, not a prior read, that
// decides. Two Deal clicks racing each other would otherwise both see an empty
// felt, both take a stake, and the second would overwrite the first — taking the
// player's chips for a hand that no longer exists anywhere.
func StartLiveHand(user string, h LiveHand) error {
	res, err := Get().Exec(
		`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON CONFLICT(matrix_user) DO NOTHING`,
		user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
	)
	if err != nil {
		return fmt.Errorf("games: start live hand: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return ErrHandInProgress
	}
	return nil
}

// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
// player's stake has already left their stack by the time this is called, so
// the write is what makes the hand recoverable if Pete restarts mid-deal.
func SaveLiveHand(user string, h LiveHand) error {
	now := nowUnix()
	if _, err := Get().Exec(
		`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON CONFLICT(matrix_user) DO UPDATE SET
		   game = excluded.game, state = excluded.state,
		   seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
		user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
	); err != nil {
		return fmt.Errorf("games: save live hand: %w", err)
	}
	return nil
}

// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
func LoadLiveHand(user string) (LiveHand, error) {
	var h LiveHand
	var state string
	var s1, s2 int64
	var tableID sql.NullString
	err := Get().QueryRow(
		`SELECT game, state, seed1, seed2, table_id FROM game_live_hands WHERE matrix_user = ?`, user,
	).Scan(&h.Game, &state, &s1, &s2, &tableID)
	if errors.Is(err, sql.ErrNoRows) {
		return LiveHand{}, ErrNoLiveHand
	}
	if err != nil {
		return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
	}
	h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
	return h, nil
}

// ClearLiveHand ends a hand. Called when it settles — the audit log in
// game_hands is what survives it.
func ClearLiveHand(user string) error {
	if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
		return fmt.Errorf("games: clear live hand: %w", err)
	}
	return nil
}

// ---- the settle ------------------------------------------------------------

// Commit is one write-back of a game: the state, and — if the game is over —
// everything settling it takes.
type Commit struct {
	Live  LiveHand
	Fresh bool // a game just started, which is the one write that may be refused

	// Stake is what the player put up to open this game. It is refunded, in this
	// same transaction, if the seat turns out to be taken. Only meaningful with
	// Fresh.
	Stake int64

	Done   bool
	Payout int64 // stake plus winnings, net of rake. Zero on a loss.
	Audit  Hand  // the audit row. Ignored unless Done.
}

// CommitHand writes a game back and settles it if it is over — all of it in one
// transaction.
//
// It used to be four separate autocommit statements (save, award, record,
// clear), which was survivable while a game belonged to exactly one player: the
// ordering paid first and cleared second, so a crash in between left a settled
// game on the felt, which reads as done and can be cleared. It does not survive
// a game with a pot in it. Pay the winner, die before the state write, and the
// table still says the hand is live — so it settles a second time and the winner
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
// into euros.
//
// So: one Begin, one Commit, and the money and the state move together or not at
// all.
//
// The rule this enforces, and the reason award/recordHand exist in tx-taking
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
// connection that this transaction is holding — forever. It is not an error, it
// is a hung process, and since the news app shares the pool it takes that down
// too. The tx-taking helper is the pattern; addChips has done it this way since
// the escrow ledger was written.
func CommitHand(user string, c Commit) error {
	now := nowUnix()

	tx, err := Get().Begin()
	if err != nil {
		return fmt.Errorf("games: begin commit: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once committed

	// Seat the game first, even one that is already over — a blackjack natural
	// settles the instant it is dealt. The INSERT is what enforces one game at a
	// time, and it has to happen for *every* new one, or a natural dealt on top of
	// a game already in progress would settle, clear the felt, and take the other
	// game's stake with it.
	if c.Fresh {
		res, err := tx.Exec(
			`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
			 VALUES (?, ?, ?, ?, ?, ?)
			 ON CONFLICT(matrix_user) DO NOTHING`,
			user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
		)
		if err != nil {
			return fmt.Errorf("games: start live hand: %w", err)
		}
		if n, _ := res.RowsAffected(); n == 0 {
			// Somebody is already sitting here. This game was never seated, so the
			// chips it staked go back — and they go back *in this transaction*, which
			// is the point. As two statements, a crash between the refusal and the
			// refund took the player's stake for a game that never existed anywhere.
			if err := award(tx, user, c.Stake, now); err != nil {
				return err
			}
			if err := tx.Commit(); err != nil {
				return fmt.Errorf("games: commit refund: %w", err)
			}
			return ErrHandInProgress
		}
	} else if _, err := tx.Exec(
		`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON CONFLICT(matrix_user) DO UPDATE SET
		   game = excluded.game, state = excluded.state,
		   seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
		user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
	); err != nil {
		return fmt.Errorf("games: save live hand: %w", err)
	}

	if c.Done {
		if err := award(tx, user, c.Payout, now); err != nil {
			return err
		}
		// The audit row is now inside the transaction with the payout, which means a
		// failure to write it rolls the payout back rather than paying quietly and
		// logging. That is a deliberate change: the two are the same fact, and a
		// payout nobody can account for is worse than a payout that didn't happen —
		// the game stays live and settles again on the next request.
		if err := recordHand(tx, c.Audit, now); err != nil {
			return err
		}
		if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
			return fmt.Errorf("games: clear live hand: %w", err)
		}
	}

	// Touch, folded in: a deliberate action at a table, so the reaper leaves them
	// alone. A player with no chip row yet has nothing to touch, and this is a
	// no-op for them.
	if _, err := tx.Exec(
		`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
		now, now, user,
	); err != nil {
		return fmt.Errorf("games: touch session: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("games: commit hand: %w", err)
	}
	return nil
}

// HouseTake is the total rake collected since a given time — the number that
// answers "is this economy inflating".
func HouseTake(since int64) (int64, error) {
	var total int64
	if err := Get().QueryRow(
		`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
	).Scan(&total); err != nil {
		return 0, fmt.Errorf("games: house take: %w", err)
	}
	return total, nil
}
