package game

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"time"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/gamelimits"
	"scrabble/backend/internal/postgres/jet/backend/model"
	"scrabble/backend/internal/postgres/jet/backend/table"
)

// Store is the Postgres-backed query surface for games, seats, the move journal,
// complaints and per-account statistics.
type Store struct {
	db *sql.DB
}

// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
	return &Store{db: db}
}

// gameInsert carries the immutable fields of a new game.
type gameInsert struct {
	id              uuid.UUID
	variant         string
	dictVersion     string
	seed            int64
	players         int
	turnTimeoutSecs int
	hintsAllowed    bool
	hintsPerPlayer  int
	dropoutTiles    string
	// multipleWordsPerTurn false selects the single-word rule for the game.
	multipleWordsPerTurn bool
	// noRepeatWords true forbids laying a word that is already on the board (games.no_repeat_words).
	noRepeatWords bool
	// vsAI marks an honest-AI game (games.vs_ai).
	vsAI bool
	// kind tags the game's origin (games.game_kind) for the active-game limits.
	kind gamelimits.Kind
	// status is the lifecycle state to create the game in: StatusActive for a normal
	// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
	// empty string defaults to StatusActive.
	status string
	// openDeadline, set only for a StatusOpen game, is when the matchmaking reaper
	// substitutes a robot if no human has joined; nil for a normal game.
	openDeadline *time.Time
}

// statDelta is one account's contribution to its statistics on a game finish.
// bestVariant/bestScore/bestTiles describe the game's best play for this account when
// it scored (bestVariant empty otherwise): the variant label, the play's total score and
// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
// which keeps only the account's highest-scoring play per variant.
type statDelta struct {
	accountID   uuid.UUID
	wins        int
	losses      int
	draws       int
	gamePoints  int
	wordPoints  int
	moves       int // plays this game (tile placements), summed into account_stats.moves
	hintsUsed   int // hints used this game (allowance + wallet), summed into account_stats.hints_used
	bestVariant string
	bestScore   int
	bestTiles   []account.BestMoveTile
}

// commit is everything a single committed transition persists: the journal row,
// the post-move game cursor and per-seat scores, and — when the move ended the
// game — the finish stamp and the statistics deltas.
type commit struct {
	gameID       uuid.UUID
	seq          int
	seat         int
	action       string
	score        int
	runningTotal int
	exchanged    []string
	rec          engine.MoveRecord
	rackBefore   []string

	toMove        int
	turnStartedAt time.Time
	moveCount     int
	scores        []int
	now           time.Time

	finished   bool
	endReason  string
	finishedAt time.Time
	winner     int // -1 on a draw
	stats      []statDelta
}

// voidCommit is everything voiding an unreplayable game persists: the finish stamp with its
// end reason, each seat's partial score as a draw, and the draw statistics. It appends no
// journal row and leaves the move cursor untouched, so the journal is preserved.
type voidCommit struct {
	gameID    uuid.UUID
	endReason string
	scores    []int
	now       time.Time
	stats     []statDelta
}

// activeGame is the sweeper's view of an in-progress game's turn clock.
type activeGame struct {
	gameID          uuid.UUID
	toMove          int
	turnStartedAt   time.Time
	turnTimeoutSecs int
}

// seatInsert is one seat to create: the account to seat (uuid.Nil for the still-empty
// opponent seat of an open auto-match game) and the display-name snapshot to stamp on
// it — the player's name as of when the seat was taken (account.go, docs/ARCHITECTURE.md §7).
type seatInsert struct {
	accountID   uuid.UUID
	displayName string
}

// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and
// the first-move seeding draws inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		if err := insertGameTx(ctx, tx, ins, seats); err != nil {
			return err
		}
		return insertSetupDrawsTx(ctx, tx, ins.id, draws)
	})
}

// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
// per-tier active-game limit is checked against it before a new game of that kind is created.
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
	var n int
	if err := s.db.QueryRowContext(ctx,
		`SELECT count(*) FROM backend.games g
		   JOIN backend.game_players p ON p.game_id = g.game_id
		  WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
		accountID, int16(kind)).Scan(&n); err != nil {
		return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
	}
	return n, nil
}

// insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
// opponent seat of a StatusOpen auto-match game.
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatInsert) error {
	status := ins.status
	if status == "" {
		status = StatusActive
	}
	var deadline any = postgres.NULL
	if ins.openDeadline != nil {
		deadline = postgres.TimestampzT(*ins.openDeadline)
	}
	gi := table.Games.INSERT(
		table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
		table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
		table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
		table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
		table.Games.NoRepeatWords,
	).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
		ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind),
		ins.noRepeatWords)
	if _, err := gi.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("insert game: %w", err)
	}
	for seat, si := range seats {
		var acc any = si.accountID
		if si.accountID == uuid.Nil {
			acc = postgres.NULL
		}
		pi := table.GamePlayers.INSERT(
			table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, table.GamePlayers.DisplayName,
		).VALUES(ins.id, seat, acc, si.displayName)
		if _, err := pi.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("insert seat %d: %w", seat, err)
		}
	}
	return nil
}

// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
// the dictionary-independent record of how the first player was chosen
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
	for _, d := range draws {
		// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
		// a NULL account_id and back-filled when a real opponent joins.
		var acc any = d.Account
		if d.Account == uuid.Nil {
			acc = postgres.NULL
		}
		di := table.GameSetupDraws.INSERT(
			table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
			table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
			table.GameSetupDraws.DrawRank,
		).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
		if _, err := di.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err)
		}
	}
	return nil
}

// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
// players never both open a game instead of pairing.
func openMatchKey(variant string, multipleWords bool) int64 {
	h := fnv.New64a()
	_, _ = h.Write([]byte(variant))
	if multipleWords {
		_, _ = h.Write([]byte{1})
	} else {
		_, _ = h.Write([]byte{0})
	}
	return int64(h.Sum64())
}

// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
// lands in: it joins another player's waiting open game and flips it active
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat
// (created=true). A re-enqueue while the caller already has an open game in the bucket
// opens another fresh game (or joins a different player's) rather than returning the
// caller's own, so tapping "random opponent" again always starts a new search. ins
// supplies the new game's immutable fields and is used only when a game is created. A
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent
// enqueues so two callers pair rather than each opening a game. seats is the two-seat
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one. seats and draws (the first-move draw recorded against the synthetic
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) {
	err = withTx(ctx, s.db, func(tx *sql.Tx) error {
		if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
			openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
			return fmt.Errorf("open match lock: %w", e)
		}
		// 1. Another player's open game waiting for an opponent — fill its seat and start it.
		// A game whose waiting player is in exclude (the caller's per-user block set, either
		// direction) is skipped, so a block keeps the pair out of the same anonymous game;
		// an empty exclude (the "{}" literal) excludes nothing.
		var other uuid.UUID
		switch e := tx.QueryRowContext(ctx,
			`SELECT g.game_id FROM backend.games g
			  WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
			    AND NOT EXISTS (SELECT 1 FROM backend.game_players p
			                     WHERE p.game_id = g.game_id AND p.account_id = $3)
			    AND NOT EXISTS (SELECT 1 FROM backend.game_players b
			                     WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
			  ORDER BY g.created_at
			  LIMIT 1 FOR UPDATE SKIP LOCKED`,
			ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
		case e == nil:
			if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil {
				return er
			}
			gameID, joined = other, true
			return nil
		case !errors.Is(e, sql.ErrNoRows):
			return fmt.Errorf("find open game: %w", e)
		}
		// 2. None waiting — open a fresh game seating the caller (the other seat empty) and
		// recording the first-move draw; the synthetic opponent's rows carry a NULL account
		// until a real opponent joins and back-fills them.
		if e := insertGameTx(ctx, tx, ins, seats); e != nil {
			return e
		}
		if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
			return e
		}
		gameID, created = ins.id, true
		return nil
	})
	return gameID, joined, created, err
}

// AttachRobot fills the empty opponent seat of open game gameID with robotID, stamping
// displayName (the robot's per-game name) on the seat, and flips it to active, returning
// whether it attached. It is a no-op (false) when the game is no longer open — a human
// joined first — so the reaper never double-fills.
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (bool, error) {
	attached := false
	err := withTx(ctx, s.db, func(tx *sql.Tx) error {
		var status string
		switch e := tx.QueryRowContext(ctx,
			`SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); {
		case errors.Is(e, sql.ErrNoRows):
			return nil
		case e != nil:
			return fmt.Errorf("lock game for robot: %w", e)
		}
		if status != StatusOpen {
			return nil
		}
		if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
			return e
		}
		attached = true
		return nil
	})
	return attached, err
}

// fillAndActivate seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
// open time, so the journal (any opening move the starter made while waiting) is never
// disturbed. The caller holds the game row.
func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
	if _, err := tx.ExecContext(ctx,
		`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
		gameID, accountID, displayName); err != nil {
		return fmt.Errorf("fill opponent seat: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
		gameID, accountID); err != nil {
		return fmt.Errorf("back-fill opponent draws: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
		  WHERE game_id = $1`, gameID); err != nil {
		return fmt.Errorf("activate game: %w", err)
	}
	return nil
}

// ExpiredOpen returns the open games whose robot deadline has passed (at or before
// now), oldest deadline first, for the matchmaking reaper to fill with a robot.
func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
	rows, err := s.db.QueryContext(ctx,
		`SELECT game_id, variant FROM backend.games
		  WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1
		  ORDER BY open_deadline_at`, now)
	if err != nil {
		return nil, fmt.Errorf("game: expired open: %w", err)
	}
	defer rows.Close()
	var out []OpenGame
	for rows.Next() {
		var id uuid.UUID
		var variantStr string
		if err := rows.Scan(&id, &variantStr); err != nil {
			return nil, fmt.Errorf("game: scan expired open: %w", err)
		}
		variant, err := engine.ParseVariant(variantStr)
		if err != nil {
			return nil, fmt.Errorf("game: expired open %s: %w", id, err)
		}
		out = append(out, OpenGame{ID: id, Variant: variant})
	}
	return out, rows.Err()
}

// GetGame loads the games row joined with its seats (ordered by seat), or
// ErrNotFound.
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
	// One round-trip: the game joined with its seats. A LEFT JOIN keeps a (would-be)
	// seatless game returning the game with no seats, exactly as the prior two-query
	// version did; ORDER BY seat preserves seat order. The games columns repeat per seat
	// row — cheap at 2-4 seats, and one round-trip instead of two, which matters because
	// GetGame is the universal "load the game" step on every game operation.
	stmt := postgres.SELECT(table.Games.AllColumns, table.GamePlayers.AllColumns).
		FROM(table.Games.LEFT_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
		WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
		ORDER_BY(table.GamePlayers.Seat.ASC())
	var rows []struct {
		model.Games
		model.GamePlayers
	}
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return Game{}, fmt.Errorf("game: get %s: %w", id, err)
	}
	if len(rows) == 0 {
		return Game{}, ErrNotFound
	}
	seats := make([]model.GamePlayers, 0, len(rows))
	for i := range rows {
		// Skip the phantom all-NULL seat row a LEFT JOIN yields for a seatless game.
		if rows[i].GamePlayers.GameID == id {
			seats = append(seats, rows[i].GamePlayers)
		}
	}
	return projectGame(rows[0].Games, seats)
}

// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
// to map wire alphabet indices to concrete letters without loading the whole
// game and its seats.
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
	stmt := postgres.SELECT(table.Games.Variant).
		FROM(table.Games).
		WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return 0, ErrNotFound
		}
		return 0, fmt.Errorf("game: get variant %s: %w", id, err)
	}
	return engine.ParseVariant(row.Variant)
}

// SharedGameExists reports whether accounts a and b are both seated in at least
// one game (active or finished). It backs the social package's "befriend an
// opponent" gate via a self-join on game_players.
func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
	other := table.GamePlayers.AS("other")
	stmt := postgres.SELECT(table.GamePlayers.GameID).
		FROM(table.GamePlayers.INNER_JOIN(other, other.GameID.EQ(table.GamePlayers.GameID))).
		WHERE(
			table.GamePlayers.AccountID.EQ(postgres.UUID(a)).
				AND(other.AccountID.EQ(postgres.UUID(b))),
		).LIMIT(1)
	var rows []model.GamePlayers
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return false, fmt.Errorf("game: shared game exists: %w", err)
	}
	return len(rows) > 0, nil
}

// DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an
// opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade
// away with it. It no-ops (false) once the game has filled and become active, so a caller can
// fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking.
func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) {
	stmt := table.Games.DELETE().WHERE(
		table.Games.GameID.EQ(postgres.UUID(gameID)).
			AND(table.Games.Status.EQ(postgres.String(StatusOpen))),
	)
	res, err := stmt.ExecContext(ctx, s.db)
	if err != nil {
		return false, fmt.Errorf("game: delete open game %s: %w", gameID, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err)
	}
	return n > 0, nil
}

// ListGamesForAccount loads every game the account is seated in (active and
// finished), newest first, each joined with its ordered seats. It backs the lobby's
// "my games" lists.
func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
	gstmt := postgres.SELECT(table.Games.AllColumns).
		FROM(table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
		WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
		ORDER_BY(table.Games.UpdatedAt.DESC())
	var grows []model.Games
	if err := gstmt.QueryContext(ctx, s.db, &grows); err != nil {
		return nil, fmt.Errorf("game: list for account: %w", err)
	}
	if len(grows) == 0 {
		return nil, nil
	}
	// Drop games the account has hidden from its own lobby.
	hidden, err := s.hiddenGameIDs(ctx, accountID)
	if err != nil {
		return nil, err
	}
	if len(hidden) > 0 {
		kept := grows[:0]
		for _, g := range grows {
			if !hidden[g.GameID] {
				kept = append(kept, g)
			}
		}
		grows = kept
		if len(grows) == 0 {
			return nil, nil
		}
	}

	ids := make([]postgres.Expression, len(grows))
	for i, g := range grows {
		ids[i] = postgres.UUID(g.GameID)
	}
	sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
		FROM(table.GamePlayers).
		WHERE(table.GamePlayers.GameID.IN(ids...)).
		ORDER_BY(table.GamePlayers.GameID.ASC(), table.GamePlayers.Seat.ASC())
	var srows []model.GamePlayers
	if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
		return nil, fmt.Errorf("game: list seats for account: %w", err)
	}
	byGame := make(map[uuid.UUID][]model.GamePlayers, len(grows))
	for _, r := range srows {
		byGame[r.GameID] = append(byGame[r.GameID], r)
	}

	out := make([]Game, 0, len(grows))
	for _, g := range grows {
		pg, err := projectGame(g, byGame[g.GameID])
		if err != nil {
			return nil, err
		}
		out = append(out, pg)
	}
	return out, nil
}

// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
	_, err := s.db.ExecContext(ctx,
		`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
		accountID, gameID)
	if err != nil {
		return fmt.Errorf("game: hide game: %w", err)
	}
	return nil
}

// hiddenGameIDs returns the set of games the account has hidden from its lobby.
func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) {
	rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID)
	if err != nil {
		return nil, fmt.Errorf("game: hidden ids: %w", err)
	}
	defer rows.Close()
	out := map[uuid.UUID]bool{}
	for rows.Next() {
		var id uuid.UUID
		if err := rows.Scan(&id); err != nil {
			return nil, fmt.Errorf("game: scan hidden id: %w", err)
		}
		out[id] = true
	}
	return out, rows.Err()
}

// ListGames returns games for the admin games list, most-recently-updated first,
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
// The seats are not loaded — the list shows summaries; the detail view uses
// GetGame.
func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
	where := postgres.Bool(true)
	if status != "" {
		where = table.Games.Status.EQ(postgres.String(status))
	}
	stmt := postgres.SELECT(table.Games.AllColumns).
		FROM(table.Games).
		WHERE(where).
		ORDER_BY(table.Games.UpdatedAt.DESC()).
		LIMIT(int64(limit)).
		OFFSET(int64(offset))
	var rows []model.Games
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: list games: %w", err)
	}
	out := make([]Game, 0, len(rows))
	for _, g := range rows {
		pg, err := projectGame(g, nil)
		if err != nil {
			return nil, err
		}
		out = append(out, pg)
	}
	return out, nil
}

// CountGames returns the number of games, optionally restricted to a status, for
// admin-list pagination.
func (s *Store) CountGames(ctx context.Context, status string) (int, error) {
	where := postgres.Bool(true)
	if status != "" {
		where = table.Games.Status.EQ(postgres.String(status))
	}
	stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")).
		FROM(table.Games).
		WHERE(where)
	var dest struct{ Count int64 }
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("game: count games: %w", err)
	}
	return int(dest.Count), nil
}

// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
	stmt := postgres.SELECT(table.GameMoves.AllColumns).
		FROM(table.GameMoves).
		WHERE(table.GameMoves.GameID.EQ(postgres.UUID(id))).
		ORDER_BY(table.GameMoves.Seq.ASC())
	var rows []model.GameMoves
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: get journal %s: %w", id, err)
	}
	out := make([]HistoryMove, 0, len(rows))
	for _, r := range rows {
		p, err := parsePayload(r.Payload)
		if err != nil {
			return nil, err
		}
		out = append(out, HistoryMove{
			Seq:          int(r.Seq),
			Seat:         int(r.Seat),
			Action:       r.Action,
			Score:        int(r.Score),
			RunningTotal: int(r.RunningTotal),
			Dir:          p.Dir,
			MainRow:      p.MainRow,
			MainCol:      p.MainCol,
			Tiles:        p.tileRecords(),
			Words:        p.Words,
			Exchanged:    p.Exchanged,
			Rack:         p.Rack,
		})
	}
	return out, nil
}

// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
// order), or an empty slice for a game created before the draw was recorded. It
// backs the admin console's first-move section.
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
	stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
		FROM(table.GameSetupDraws).
		WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
		ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
	var rows []model.GameSetupDraws
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
	}
	out := make([]SetupDraw, len(rows))
	for i, r := range rows {
		// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
		acc := uuid.Nil
		if r.AccountID != nil {
			acc = *r.AccountID
		}
		out[i] = SetupDraw{
			Round:   int(r.Round),
			PickNo:  int(r.PickNo),
			Account: acc,
			Letter:  r.Letter,
			Blank:   r.IsBlank,
			Rank:    int(r.DrawRank),
		}
	}
	return out, nil
}

// CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction.
func (s *Store) CommitMove(ctx context.Context, c commit) error {
	payload, err := buildPayload(c.rec, c.rackBefore, c.exchanged).marshal()
	if err != nil {
		return err
	}
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		mi := table.GameMoves.INSERT(
			table.GameMoves.GameID, table.GameMoves.Seq, table.GameMoves.Seat, table.GameMoves.Action,
			table.GameMoves.Score, table.GameMoves.RunningTotal, table.GameMoves.ExchangedCount, table.GameMoves.Payload,
		).VALUES(c.gameID, c.seq, c.seat, c.action, c.score, c.runningTotal, len(c.exchanged), payload)
		if _, err := mi.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("append move: %w", err)
		}

		if c.finished {
			gu := table.Games.UPDATE(
				table.Games.Status, table.Games.ToMove, table.Games.MoveCount,
				table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
			).SET(
				postgres.String(StatusFinished), postgres.Int(int64(c.toMove)), postgres.Int(int64(c.moveCount)),
				postgres.String(c.endReason), postgres.TimestampzT(c.now), postgres.TimestampzT(c.finishedAt),
			).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
			if _, err := gu.ExecContext(ctx, tx); err != nil {
				return fmt.Errorf("finish game: %w", err)
			}
		} else {
			gu := table.Games.UPDATE(
				table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.UpdatedAt,
			).SET(
				postgres.Int(int64(c.toMove)), postgres.TimestampzT(c.turnStartedAt), postgres.Int(int64(c.moveCount)), postgres.TimestampzT(c.now),
			).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID)))
			if _, err := gu.ExecContext(ctx, tx); err != nil {
				return fmt.Errorf("advance game: %w", err)
			}
		}

		for seat, score := range c.scores {
			if err := updateSeatScore(ctx, tx, c.gameID, seat, score, c.finished, c.finished && seat == c.winner); err != nil {
				return fmt.Errorf("update seat %d: %w", seat, err)
			}
		}

		if c.finished {
			for _, d := range c.stats {
				if err := upsertStats(ctx, tx, d, c.now); err != nil {
					return err
				}
				if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
					return err
				}
			}
		}
		return nil
	})
}

// VoidGame closes a game that can no longer be reconstructed from its journal: it stamps the
// finish (status 'finished', the end reason, finished_at), writes each seat's partial score
// as a draw (is_winner false for all) and upserts the draw statistics, in one transaction.
// Unlike CommitMove it appends no journal row and leaves the move cursor untouched.
func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		gu := table.Games.UPDATE(
			table.Games.Status, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
		).SET(
			postgres.String(StatusFinished), postgres.String(v.endReason), postgres.TimestampzT(v.now), postgres.TimestampzT(v.now),
		).WHERE(table.Games.GameID.EQ(postgres.UUID(v.gameID)))
		if _, err := gu.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("void game: %w", err)
		}
		for seat, score := range v.scores {
			if err := updateSeatScore(ctx, tx, v.gameID, seat, score, true, false); err != nil {
				return fmt.Errorf("void seat %d: %w", seat, err)
			}
		}
		for _, d := range v.stats {
			if err := upsertStats(ctx, tx, d, v.now); err != nil {
				return err
			}
			if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
				return err
			}
		}
		return nil
	})
}

// updateSeatScore writes a seat's running score, also stamping is_winner when the
// game has finished.
func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error {
	where := table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
		AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat))))
	var stmt postgres.UpdateStatement
	if finished {
		stmt = table.GamePlayers.
			UPDATE(table.GamePlayers.Score, table.GamePlayers.IsWinner).
			SET(postgres.Int(int64(score)), postgres.Bool(isWinner)).
			WHERE(where)
	} else {
		stmt = table.GamePlayers.
			UPDATE(table.GamePlayers.Score).
			SET(postgres.Int(int64(score))).
			WHERE(where)
	}
	_, err := stmt.ExecContext(ctx, tx)
	return err
}

// upsertStats folds one account's deltas into account_stats, locking the row for
// the read-modify-write so concurrent finishes accumulate correctly.
func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
	ensure := table.AccountStats.
		INSERT(table.AccountStats.AccountID).
		VALUES(d.accountID).
		ON_CONFLICT(table.AccountStats.AccountID).
		DO_NOTHING()
	if _, err := ensure.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("ensure stats %s: %w", d.accountID, err)
	}

	sel := postgres.SELECT(table.AccountStats.AllColumns).
		FROM(table.AccountStats).
		WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))).
		FOR(postgres.UPDATE())
	var row model.AccountStats
	if err := sel.QueryContext(ctx, tx, &row); err != nil {
		return fmt.Errorf("lock stats %s: %w", d.accountID, err)
	}

	wins := row.Wins + int32(d.wins)
	losses := row.Losses + int32(d.losses)
	draws := row.Draws + int32(d.draws)
	maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
	maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
	moves := row.Moves + int32(d.moves)
	hintsUsed := row.HintsUsed + int32(d.hintsUsed)

	upd := table.AccountStats.UPDATE(
		table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
		table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
		table.AccountStats.Moves, table.AccountStats.HintsUsed,
	).SET(
		postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
		postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
		postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)),
	).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("update stats %s: %w", d.accountID, err)
	}
	return nil
}

// upsertBestMove records the account's best play for a variant, keeping only the
// highest-scoring one: a first play inserts, a later one replaces it only when it scored
// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
// finishes without a separate lock). It is a no-op when the finish carries no scoring play
// for the account (a draw with no plays, or an exchange/pass-only game).
func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
	if d.bestVariant == "" || len(d.bestTiles) == 0 {
		return nil
	}
	tiles, err := json.Marshal(d.bestTiles)
	if err != nil {
		return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
	}
	stmt := table.AccountBestMove.
		INSERT(
			table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
			table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
		).
		VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)).
		ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
		DO_UPDATE(postgres.SET(
			table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
			table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
			table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
		).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
	if _, err := stmt.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
	}
	return nil
}

// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter
// is the seat's total hints used this game (the first HintsPerPlayer being the allowance).
func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error {
	stmt := table.GamePlayers.
		UPDATE(table.GamePlayers.HintsUsed).
		SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
		WHERE(
			table.GamePlayers.GameID.EQ(postgres.UUID(gameID)).
				AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
		)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("game: increment hints used: %w", err)
	}
	return nil
}

// FileComplaint persists a word-check complaint in status open and returns the
// stored row.
func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, error) {
	id, err := uuid.NewV7()
	if err != nil {
		return Complaint{}, fmt.Errorf("game: new complaint id: %w", err)
	}
	stmt := table.Complaints.INSERT(
		table.Complaints.ComplaintID, table.Complaints.ComplainantID, table.Complaints.GameID,
		table.Complaints.Variant, table.Complaints.DictVersion, table.Complaints.Word,
		table.Complaints.WasValid, table.Complaints.Note,
	).VALUES(
		id, c.ComplainantID, c.GameID, c.Variant.String(), c.DictVersion, c.Word, c.WasValid, c.Note,
	).RETURNING(table.Complaints.AllColumns)
	var row model.Complaints
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Complaint{}, fmt.Errorf("game: file complaint: %w", err)
	}
	return projectComplaint(row)
}

// ListComplaints returns complaints for the admin review queue, newest first.
// status filters by lifecycle state when non-empty; limit and offset paginate.
func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
	where := postgres.Bool(true)
	if status != "" {
		where = table.Complaints.Status.EQ(postgres.String(status))
	}
	stmt := postgres.SELECT(table.Complaints.AllColumns).
		FROM(table.Complaints).
		WHERE(where).
		ORDER_BY(table.Complaints.CreatedAt.DESC()).
		LIMIT(int64(limit)).
		OFFSET(int64(offset))
	var rows []model.Complaints
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: list complaints: %w", err)
	}
	return projectComplaints(rows)
}

// GetComplaint loads one complaint by id, or ErrNotFound.
func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
	stmt := postgres.SELECT(table.Complaints.AllColumns).
		FROM(table.Complaints).
		WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.Complaints
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Complaint{}, ErrNotFound
		}
		return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err)
	}
	return projectComplaint(row)
}

// ResolveComplaint closes a complaint with a disposition and note, stamping
// resolved_at, and returns the updated row (ErrNotFound when none matches). It
// leaves applied_in_version untouched.
func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) {
	stmt := table.Complaints.UPDATE(
		table.Complaints.Status, table.Complaints.Disposition,
		table.Complaints.ResolutionNote, table.Complaints.ResolvedAt,
	).SET(
		postgres.String(StatusComplaintResolved), postgres.String(disposition),
		postgres.String(note), postgres.TimestampzT(now),
	).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
		RETURNING(table.Complaints.AllColumns)
	var row model.Complaints
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Complaint{}, ErrNotFound
		}
		return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err)
	}
	return projectComplaint(row)
}

// ListDictionaryChanges returns the resolved, accepted complaints not yet marked
// applied (the pending wordlist edits), ordered by variant then resolution time.
func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) {
	stmt := postgres.SELECT(table.Complaints.AllColumns).
		FROM(table.Complaints).
		WHERE(
			table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
				AND(table.Complaints.Disposition.IN(
					postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
				)).
				AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
		).
		ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC())
	var rows []model.Complaints
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: list dictionary changes: %w", err)
	}
	return projectComplaints(rows)
}

// MarkChangesApplied stamps every pending accepted change for variant with
// version (so it drops out of ListDictionaryChanges) and returns the count.
func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) {
	stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion).
		SET(postgres.String(version)).
		WHERE(
			table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
				AND(table.Complaints.Variant.EQ(postgres.String(variant))).
				AND(table.Complaints.Disposition.IN(
					postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
				)).
				AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
		)
	res, err := stmt.ExecContext(ctx, s.db)
	if err != nil {
		return 0, fmt.Errorf("game: mark changes applied: %w", err)
	}
	n, _ := res.RowsAffected()
	return n, nil
}

// GetActiveDictVersion returns the persisted active dictionary version and true,
// or ("", false, nil) when none has been recorded yet (a fresh database). It reads
// the dictionary_state singleton (docs/ARCHITECTURE.md §5).
func (s *Store) GetActiveDictVersion(ctx context.Context) (string, bool, error) {
	stmt := postgres.SELECT(table.DictionaryState.ActiveVersion).
		FROM(table.DictionaryState).
		WHERE(table.DictionaryState.ID.EQ(postgres.Bool(true)))
	var row model.DictionaryState
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return "", false, nil
		}
		return "", false, fmt.Errorf("game: get active dict version: %w", err)
	}
	return row.ActiveVersion, true, nil
}

// SetActiveDictVersion records version as the active dictionary version, upserting
// the dictionary_state singleton so the choice survives a restart.
func (s *Store) SetActiveDictVersion(ctx context.Context, version string) error {
	now := time.Now().UTC()
	stmt := table.DictionaryState.
		INSERT(table.DictionaryState.ID, table.DictionaryState.ActiveVersion, table.DictionaryState.UpdatedAt).
		VALUES(true, version, postgres.TimestampzT(now)).
		ON_CONFLICT(table.DictionaryState.ID).
		DO_UPDATE(postgres.SET(
			table.DictionaryState.ActiveVersion.SET(postgres.String(version)),
			table.DictionaryState.UpdatedAt.SET(postgres.TimestampzT(now)),
		))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("game: set active dict version: %w", err)
	}
	return nil
}

// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
	where := postgres.Bool(true)
	if status != "" {
		where = table.Complaints.Status.EQ(postgres.String(status))
	}
	stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
		FROM(table.Complaints).
		WHERE(where)
	var dest struct{ Count int64 }
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("game: count complaints: %w", err)
	}
	return int(dest.Count), nil
}

// CountComplaintsSince counts word complaints filed strictly after since — the operator
// alert worker's "new since the last check" signal.
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
	stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
		FROM(table.Complaints).
		WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
	var dest struct{ Count int64 }
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("game: count complaints since: %w", err)
	}
	return int(dest.Count), nil
}

// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
	stmt := postgres.SELECT(
		table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.TurnTimeoutSecs,
	).FROM(table.Games).
		WHERE(table.Games.Status.EQ(postgres.String(StatusActive))).
		ORDER_BY(table.Games.TurnStartedAt.ASC())
	var rows []model.Games
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: list active: %w", err)
	}
	out := make([]activeGame, 0, len(rows))
	for _, r := range rows {
		out = append(out, activeGame{
			gameID:          r.GameID,
			toMove:          int(r.ToMove),
			turnStartedAt:   r.TurnStartedAt,
			turnTimeoutSecs: int(r.TurnTimeoutSecs),
		})
	}
	return out, nil
}

// RobotTurns returns one row per active game seating any of the given accounts,
// for the robot scheduler. It joins games to game_players on the robot's seat and
// carries the game's turn cursor and bag seed; the driver filters these against
// each robot's per-game deadline. An empty id list returns no rows.
func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	exprs := make([]postgres.Expression, len(ids))
	for i, id := range ids {
		exprs[i] = postgres.UUID(id)
	}
	stmt := postgres.SELECT(
		table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
		table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
		table.GamePlayers.Seat, table.GamePlayers.AccountID,
	).FROM(
		table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
	).WHERE(
		table.Games.Status.EQ(postgres.String(StatusActive)).
			AND(table.GamePlayers.AccountID.IN(exprs...)),
	).ORDER_BY(table.Games.TurnStartedAt.ASC())

	var rows []struct {
		model.Games
		model.GamePlayers
	}
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("game: list robot turns: %w", err)
	}
	out := make([]RobotTurn, 0, len(rows))
	for _, r := range rows {
		out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
	}
	if err := s.fillEndgamePass(ctx, out); err != nil {
		return nil, err
	}
	return out, nil
}

// RobotTurnByGame returns the robot turn for a single active game — the seat held by
// one of ids (the robot pool) — and true, or false when the game is not active, holds
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
// drives one game at once rather than scanning the whole pool (RobotTurns). It leaves
// EndgamePass false: honest-AI games move at once, so the endgame think-time shrink is a
// human-mimicry concern computed only on the RobotTurns scan.
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
	if len(ids) == 0 {
		return RobotTurn{}, false, nil
	}
	exprs := make([]postgres.Expression, len(ids))
	for i, id := range ids {
		exprs[i] = postgres.UUID(id)
	}
	stmt := postgres.SELECT(
		table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
		table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
		table.GamePlayers.Seat, table.GamePlayers.AccountID,
	).FROM(
		table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
	).WHERE(
		table.Games.GameID.EQ(postgres.UUID(gameID)).
			AND(table.Games.Status.EQ(postgres.String(StatusActive))).
			AND(table.GamePlayers.AccountID.IN(exprs...)),
	).LIMIT(1)

	var rows []struct {
		model.Games
		model.GamePlayers
	}
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return RobotTurn{}, false, fmt.Errorf("game: robot turn by game: %w", err)
	}
	if len(rows) == 0 {
		return RobotTurn{}, false, nil
	}
	return robotTurnFrom(rows[0].Games, rows[0].GamePlayers), true, nil
}

// robotTurnFrom projects a games row joined with the robot's seat into a RobotTurn.
// The query matches only the robot's (non-null) seat, so AccountID is set.
func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
	robotID := uuid.Nil
	if p.AccountID != nil {
		robotID = *p.AccountID
	}
	return RobotTurn{
		GameID:        g.GameID,
		RobotID:       robotID,
		RobotSeat:     int(p.Seat),
		ToMove:        int(g.ToMove),
		TurnStartedAt: g.TurnStartedAt,
		MoveCount:     int(g.MoveCount),
		Seed:          g.Seed,
		VsAI:          g.VsAi,
	}
}

// fillEndgamePass marks the turns whose game is a dead-drawn endgame — its two most
// recent committed moves are both passes, so the board and racks are frozen and the
// seated robot is bound to pass again — setting EndgamePass and OppLastMove from the
// move journal so the driver can shorten the robot's think time. Turns whose game is
// not in that state are left unchanged. A nil or empty slice is a no-op. It runs one
// batched journal query for the whole scan, so it adds no per-game round trip.
func (s *Store) fillEndgamePass(ctx context.Context, turns []RobotTurn) error {
	if len(turns) == 0 {
		return nil
	}
	ids := make([]uuid.UUID, len(turns))
	for i := range turns {
		ids[i] = turns[i].GameID
	}
	info, err := s.endgamePassInfo(ctx, ids)
	if err != nil {
		return err
	}
	for i := range turns {
		if d, ok := info[turns[i].GameID]; ok {
			turns[i].EndgamePass = true
			turns[i].OppLastMove = d
		}
	}
	return nil
}

// endgamePassInfo returns, for each of ids whose two most recent committed moves are
// both passes, the human's think time on the most recent of them (the gap between the
// last two journal entries' created_at). Games with fewer than two moves, or whose last
// two are not both passes, are absent from the map. It reads the move journal only — no
// schema change — mirroring the analytics.go duration reports. A negative gap (clock
// skew) is floored to zero.
func (s *Store) endgamePassInfo(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]time.Duration, error) {
	if len(ids) == 0 {
		return map[uuid.UUID]time.Duration{}, nil
	}
	const q = `
SELECT q.game_id, q.secs FROM (
  SELECT t.game_id,
         bool_and(t.action = 'pass') AS both_pass,
         COUNT(*) AS n,
         EXTRACT(EPOCH FROM (MAX(t.created_at) - MIN(t.created_at))) AS secs
  FROM (
    SELECT m.game_id, m.action, m.created_at,
           ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq DESC) AS rn
    FROM backend.game_moves m
    WHERE m.game_id = ANY($1::uuid[])
  ) t
  WHERE t.rn <= 2
  GROUP BY t.game_id
) q
WHERE q.n = 2 AND q.both_pass`
	rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(ids))
	if err != nil {
		return nil, fmt.Errorf("game: endgame pass info: %w", err)
	}
	defer rows.Close()
	out := make(map[uuid.UUID]time.Duration, len(ids))
	for rows.Next() {
		var id uuid.UUID
		var secs float64
		if err := rows.Scan(&id, &secs); err != nil {
			return nil, fmt.Errorf("game: scan endgame pass info: %w", err)
		}
		if secs < 0 {
			secs = 0
		}
		out[id] = time.Duration(secs * float64(time.Second))
	}
	return out, rows.Err()
}

// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
// single-column read for the social chat/nudge gate, which must reject both in an
// AI game even though it reports status 'active'. ErrNotFound when the game is gone.
func (s *Store) GameVsAI(ctx context.Context, id uuid.UUID) (bool, error) {
	stmt := postgres.SELECT(table.Games.VsAi).
		FROM(table.Games).
		WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return false, ErrNotFound
		}
		return false, fmt.Errorf("game: get vs_ai %s: %w", id, err)
	}
	return row.VsAi, nil
}

// GameSeed returns the bag seed a game was dealt from, used to replay it. The
// seed is server-only state and never travels in the public Game view.
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
	stmt := postgres.SELECT(table.Games.Seed).
		FROM(table.Games).
		WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return 0, ErrNotFound
		}
		return 0, fmt.Errorf("game: get seed %s: %w", id, err)
	}
	return row.Seed, nil
}

// LastMoveAt returns the time of the account's most recent move in the game and true, or
// the zero time and false when it has not moved. The social service uses it to reset the
// nudge cooldown once the player has taken a turn.
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
	var at sql.NullTime
	err := s.db.QueryRowContext(ctx,
		`SELECT MAX(m.created_at) FROM backend.game_moves m
		 JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
		 WHERE m.game_id = $1 AND p.account_id = $2`,
		gameID, accountID).Scan(&at)
	if err != nil {
		return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
	}
	if !at.Valid {
		return time.Time{}, false, nil
	}
	return at.Time, true, nil
}

// TurnStartedAt returns the start time of a game's current turn (games.turn_started_at).
// The social service uses it as the per-turn boundary for the one-chat-message-per-turn
// limit; it advances only on a move, never on chat or a nudge.
func (s *Store) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) {
	var at time.Time
	err := s.db.QueryRowContext(ctx,
		`SELECT turn_started_at FROM backend.games WHERE game_id = $1`, gameID).Scan(&at)
	if err != nil {
		return time.Time{}, fmt.Errorf("game: turn started at %s: %w", gameID, err)
	}
	return at, nil
}

// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
// next-move ETA. Both are server-only state, never part of the public game view.
func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
	stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt).
		FROM(table.Games).
		WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return 0, time.Time{}, ErrNotFound
		}
		return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err)
	}
	return row.Seed, row.TurnStartedAt, nil
}

// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
	variant, err := engine.ParseVariant(g.Variant)
	if err != nil {
		return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
	}
	dropout, err := engine.ParseDropoutTiles(g.DropoutTiles)
	if err != nil {
		return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
	}
	out := Game{
		ID:             g.GameID,
		Variant:        variant,
		DictVersion:    g.DictVersion,
		Status:         g.Status,
		Players:        int(g.Players),
		ToMove:         int(g.ToMove),
		TurnStartedAt:  g.TurnStartedAt,
		TurnTimeout:    time.Duration(g.TurnTimeoutSecs) * time.Second,
		HintsAllowed:   g.HintsAllowed,
		HintsPerPlayer: int(g.HintsPerPlayer),
		DropoutTiles:   dropout,
		MoveCount:      int(g.MoveCount),
		CreatedAt:      g.CreatedAt,
		UpdatedAt:      g.UpdatedAt,
	}
	out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
	out.NoRepeatWords = g.NoRepeatWords
	out.VsAI = g.VsAi
	out.Kind = gamelimits.Kind(g.GameKind)
	if g.EndReason != nil {
		out.EndReason = *g.EndReason
	}
	if g.FinishedAt != nil {
		t := *g.FinishedAt
		out.FinishedAt = &t
	}
	out.Seats = make([]Seat, 0, len(seats))
	for _, p := range seats {
		// A NULL account_id is the still-empty opponent seat of an open game; surface it
		// as uuid.Nil so callers keep the "seats == players" invariant.
		accountID := uuid.Nil
		if p.AccountID != nil {
			accountID = *p.AccountID
		}
		out.Seats = append(out.Seats, Seat{
			Seat:        int(p.Seat),
			AccountID:   accountID,
			Score:       int(p.Score),
			HintsUsed:   int(p.HintsUsed),
			IsWinner:    p.IsWinner,
			DisplayName: p.DisplayName,
		})
	}
	return out, nil
}

// projectComplaint builds a Complaint from a stored row.
func projectComplaint(row model.Complaints) (Complaint, error) {
	variant, err := engine.ParseVariant(row.Variant)
	if err != nil {
		return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
	}
	return Complaint{
		ID:               row.ComplaintID,
		ComplainantID:    row.ComplainantID,
		GameID:           row.GameID,
		Variant:          variant,
		DictVersion:      row.DictVersion,
		Word:             row.Word,
		WasValid:         row.WasValid,
		Note:             row.Note,
		Status:           row.Status,
		CreatedAt:        row.CreatedAt,
		Disposition:      row.Disposition,
		ResolutionNote:   row.ResolutionNote,
		ResolvedAt:       row.ResolvedAt,
		AppliedInVersion: row.AppliedInVersion,
	}, nil
}

// projectComplaints projects a slice of complaint rows, preserving order.
func projectComplaints(rows []model.Complaints) ([]Complaint, error) {
	out := make([]Complaint, 0, len(rows))
	for _, r := range rows {
		c, err := projectComplaint(r)
		if err != nil {
			return nil, err
		}
		out = append(out, c)
	}
	return out, nil
}

// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	if err := fn(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}
