package game

import (
	"context"
	crand "crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/gamelimits"
	"scrabble/backend/internal/notify"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/session"
)

// Service is the game domain: it drives the engine over a single match, persists
// the event-sourced journal, keeps live games warm in a cache, serves hints and
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
// only writer of the game tables and is safe for concurrent use (per-game
// serialised by an internal keyed mutex).
type Service struct {
	store    *Store
	accounts *account.Store
	registry *engine.Registry
	cache    *gameCache
	locks    *keyedMutex
	// verMu guards version, the dictionary version new games pin. It is read on
	// the game-create path and rewritten when an operator activates a new version
	// through the admin console, so access is serialised.
	verMu   sync.RWMutex
	version string
	clock   func() time.Time
	rng     func() int64
	// firstMoveEntropy returns the entropy source for one game's first-move draw
	// (docs/ARCHITECTURE.md §6). The default is crypto/rand — an honest, seedless draw;
	// tests override it via SetFirstMoveEntropy for a deterministic turn order. It is a
	// factory so a stateful test source restarts cleanly per game.
	firstMoveEntropy func() drawIntn
	pub              notify.Publisher
	// aiTrigger, when set, is called after an honest-AI game is created or advanced and
	// is still on a robot's potential turn, so the robot replies at once instead of
	// waiting for the next driver scan. It is fire-and-forget (the robot package wires
	// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
	// these games). Kept as a func so the game package never imports the robot package.
	aiTrigger func(gameID uuid.UUID)
	// hintWallet is the payments surface the online-game hint path spends against (the
	// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
	// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
	// wallet-free and never touch it.
	hintWallet HintWallet
	// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
	// when nil, no active-game limit is enforced (a game is always creatable).
	limits *gamelimits.Service
	// clearNudges, when set, marks the actor's pending nudges in a game read once they
	// have committed a move (a nudge answered by moving stops counting as unread). It is
	// best-effort and kept as a func so the game package never imports the social package.
	clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
	// expireNudges, when set, marks every pending nudge in a game read once the game
	// finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
	// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
	// every completion path through commit. Best-effort; a func so the game package never
	// imports the social package.
	expireNudges func(ctx context.Context, gameID uuid.UUID) error
	metrics      *gameMetrics
	log          *zap.Logger
}

// NewService constructs a Service. store and accounts wrap the same pool;
// registry holds the resident dictionaries; cfg supplies the pinned version and
// the cache idle window; log is used by the background sweeper.
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
	clock := func() time.Time { return time.Now().UTC() }
	return &Service{
		store:            store,
		accounts:         accounts,
		registry:         registry,
		cache:            newGameCache(cfg.CacheTTL, clock),
		locks:            newKeyedMutex(),
		version:          cfg.DictVersion,
		clock:            clock,
		rng:              randomSeed,
		firstMoveEntropy: func() drawIntn { return cryptoIntn },
		pub:              notify.Nop{},
		metrics:          defaultGameMetrics(),
		log:              log,
	}
}

// SetNotifier installs the live-event publisher. It must be called during
// startup wiring, before the service serves traffic or the sweeper runs; the
// default is notify.Nop (no live events). The game service emits your_turn and
// opponent_moved after every committed move, whatever the source (a player's
// request, the robot driver or the timeout sweeper, which all funnel through
// commit).
func (svc *Service) SetNotifier(p notify.Publisher) {
	if p != nil {
		svc.pub = p
	}
}

// SetAITrigger installs the honest-AI fast-move hook called when a vs_ai game is
// created or advanced and a robot may now be on the clock. It must be called during
// startup wiring; the default (nil) leaves only the periodic driver scan. The robot
// package wires its asynchronous TriggerMove here.
func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
	svc.aiTrigger = trigger
}

// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
// context and the account's present identity sources. *payments.Service satisfies it.
type HintWallet interface {
	HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
	SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
}

// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
func (svc *Service) SetHintWallet(w HintWallet) {
	svc.hintWallet = w
}

// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
// unset (nil), a game is always creatable.
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
	svc.limits = l
}

// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
// game_limit_reached) and the lobby's at-limit flag.
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
	if svc.limits == nil || accountID == uuid.Nil {
		return false, nil
	}
	acc, err := svc.accounts.GetByID(ctx, accountID)
	if err != nil {
		return false, err
	}
	limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
	if limit == gamelimits.Unlimited {
		return false, nil
	}
	n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
	if err != nil {
		return false, err
	}
	return n >= limit, nil
}

// walletContext resolves the payments gate inputs for an account on the current request: the
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
// the account's present identity sources (which chip/benefit segments are awake, §6).
func (svc *Service) walletContext(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
	var cxt payments.Context
	if p, ok := session.PlatformFromContext(ctx); ok {
		cxt = payments.NewContext(p.Kind, p.Subtype)
	}
	ids, err := svc.accounts.Identities(ctx, accountID)
	if err != nil {
		return payments.Context{}, nil, err
	}
	kinds := make([]string, len(ids))
	for i, id := range ids {
		kinds[i] = id.Kind
	}
	return cxt, payments.PresentSources(kinds), nil
}

// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat.
// The social package wires its ClearNudges here.
func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) {
	svc.clearNudges = fn
}

// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
// game's nudges to expire only when a recipient opens the move history or chat. The social
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
	svc.expireNudges = fn
}

// SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden.
// factory returns a fresh draw function per game, so a stateful deterministic test
// source restarts cleanly for each game. It exists for deterministic tests.
func (svc *Service) SetFirstMoveEntropy(factory func() func(n int) (int, error)) {
	if factory == nil {
		return
	}
	svc.firstMoveEntropy = func() drawIntn { return drawIntn(factory()) }
}

// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit.
func (svc *Service) triggerAI(g Game) {
	if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive {
		svc.aiTrigger(g.ID)
	}
}

// activeVersion returns the dictionary version new games currently pin.
func (svc *Service) activeVersion() string {
	svc.verMu.RLock()
	defer svc.verMu.RUnlock()
	return svc.version
}

// ActiveVersion returns the dictionary version new games currently pin. It backs
// the admin console's "active version" display.
func (svc *Service) ActiveVersion() string {
	return svc.activeVersion()
}

// InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
// version thus activates it for new games without any admin step, while old games keep
// the version they pin. The one exception is a version installed out-of-band through
// the admin console that is NEWER than the build version and still resident — it is
// kept, so a restart on an older image never downgrades a hotfix. A build version that
// is not resident (delivery disabled or a partial state) falls back to a resident
// persisted version. The chosen version is persisted so the admin console reflects it.
// Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error {
	persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
	if err != nil {
		return err
	}
	target := svc.activeVersion() // the build seed version (config.DictVersion)
	if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
		target = persisted // a newer out-of-band install wins over the build version
	}
	if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
		target = persisted // the build version is unloadable — keep a resident one
	}
	svc.verMu.Lock()
	svc.version = target
	svc.verMu.Unlock()
	return svc.store.SetActiveDictVersion(ctx, target)
}

// SetActiveVersion records version as the active dictionary version, persisting it
// and updating the in-memory pointer new games read. The caller must have made the
// version resident in the registry first.
func (svc *Service) SetActiveVersion(ctx context.Context, version string) error {
	if err := svc.store.SetActiveDictVersion(ctx, version); err != nil {
		return err
	}
	svc.verMu.Lock()
	svc.version = version
	svc.verMu.Unlock()
	return nil
}

// versionResident reports whether any variant of version is loaded in the
// registry, so a persisted active version pointing at a no-longer-present version
// (e.g. after a volume loss) falls back to the seed.
func (svc *Service) versionResident(version string) bool {
	for _, v := range engine.Variants() {
		if _, err := svc.registry.Solver(v, version); err == nil {
			return true
		}
	}
	return false
}

// noRepeatWordsFor reports whether a game of variant v is created with the no-repeat-words rule
// (see engine/norepeat.go): a word already on the board cannot be laid again. It is a rule of
// Russian "Эрудит" alone — both Scrabble variants let a word be played as often as a player can
// manage. The answer is pinned into games.no_repeat_words at creation and read back from there
// afterwards, never re-derived, so games created before the rule existed keep their own answer.
func noRepeatWordsFor(v engine.Variant) bool { return v == engine.VariantErudit }

// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (2–4), the move clock, the hint allowance and that every seat
// is a distinct existing account.
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
	if n := len(params.Seats); n < 2 || n > 4 {
		return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
	}
	if params.HintsPerPlayer < 0 {
		return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
	}
	timeout := params.TurnTimeout
	if params.VsAI {
		// Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value
		// from AllowedTurnTimeouts (it is never offered in the creation UI).
		timeout = AIInactivityTimeout
	} else {
		if timeout == 0 {
			timeout = DefaultTurnTimeout
		}
		if !allowedTimeout(timeout) {
			return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
		}
	}
	seen := make(map[uuid.UUID]bool, len(params.Seats))
	for _, id := range params.Seats {
		if seen[id] {
			return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
		}
		seen[id] = true
	}

	// Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated
	// account draws a tile, the one closest to "A" leads (a blank supersedes all letters),
	// ties re-drawing until a single leader remains. Each draw uses fresh entropy, not the
	// game seed, so the recorded draws — persisted with the game — are the only account of
	// the outcome. The winner takes seat 0; the rest keep their order.
	seeding, err := seedFirstMove(params.Variant, params.Seats, svc.firstMoveEntropy())
	if err != nil {
		if errors.Is(err, engine.ErrUnknownVariant) {
			return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
		}
		return Game{}, err
	}

	// Build the seats in the drawn turn order, snapshotting each seat's display name. For a
	// vs-AI game this stamps the robot's seeded account name (unchanged behaviour); a
	// disguised auto-match robot instead gets a fresh per-game name when the reaper attaches
	// it (AttachRobot).
	seats := make([]seatInsert, len(seeding.order))
	for i, id := range seeding.order {
		acc, err := svc.accounts.GetByID(ctx, id)
		if err != nil {
			if errors.Is(err, account.ErrNotFound) {
				return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
			}
			return Game{}, err
		}
		seats[i] = seatInsert{accountID: id, displayName: acc.DisplayName}
	}

	seed := params.Seed
	if seed == 0 {
		seed = svc.rng()
	}
	// Capture the active version once so the engine game and the persisted
	// dict_version agree even if an operator activates a new version concurrently.
	version := svc.activeVersion()
	g, err := engine.New(svc.registry, engine.Options{
		Variant:              params.Variant,
		Version:              version,
		Players:              len(params.Seats),
		Seed:                 seed,
		DropoutTiles:         params.DropoutTiles,
		MultipleWordsPerTurn: params.MultipleWordsPerTurn,
		NoRepeatWords:        noRepeatWordsFor(params.Variant),
	})
	if err != nil {
		if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
			return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
		}
		return Game{}, err
	}

	id, err := uuid.NewV7()
	if err != nil {
		return Game{}, fmt.Errorf("game: new id: %w", err)
	}
	ins := gameInsert{
		id:                   id,
		variant:              params.Variant.String(),
		dictVersion:          version,
		seed:                 seed,
		players:              len(params.Seats),
		turnTimeoutSecs:      int(timeout / time.Second),
		hintsAllowed:         params.HintsAllowed,
		hintsPerPlayer:       params.HintsPerPlayer,
		dropoutTiles:         params.DropoutTiles.String(),
		multipleWordsPerTurn: params.MultipleWordsPerTurn,
		noRepeatWords:        noRepeatWordsFor(params.Variant),
		vsAI:                 params.VsAI,
		kind:                 params.Kind,
	}
	if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
		return Game{}, err
	}
	svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
	created, err := svc.store.GetGame(ctx, id)
	if err != nil {
		return Game{}, err
	}
	svc.cache.put(id, g, params.Variant.String(), created.Seats)
	// Honest-AI game seated with a robot: if the robot moves first, reply at once
	// (the periodic driver is the fallback). No-op for every human-only game.
	svc.triggerAI(created)
	return created, nil
}

// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
// params and returns the game they land in immediately: another waiting player's open
// game (joined=true), or a fresh open game seating only the caller with an empty
// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the
// caller is already waiting opens another game rather than returning their own.
// openDeadline is when the reaper substitutes a robot into a freshly opened game
// (ignored when joining one). The bag seed defaults to random; params.Seed
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
	acc, err := svc.accounts.GetByID(ctx, accountID)
	if err != nil {
		if errors.Is(err, account.ErrNotFound) {
			return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
		}
		return Game{}, false, err
	}
	timeout := params.TurnTimeout
	if timeout == 0 {
		timeout = DefaultTurnTimeout
	}
	if !allowedTimeout(timeout) {
		return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
	}
	id, err := uuid.NewV7()
	if err != nil {
		return Game{}, false, fmt.Errorf("game: new id: %w", err)
	}
	seed := params.Seed
	if seed == 0 {
		seed = svc.rng()
	}
	deadline := openDeadline
	ins := gameInsert{
		id:                   id,
		variant:              params.Variant.String(),
		dictVersion:          svc.activeVersion(),
		seed:                 seed,
		players:              2,
		turnTimeoutSecs:      int(timeout / time.Second),
		hintsAllowed:         params.HintsAllowed,
		hintsPerPlayer:       params.HintsPerPlayer,
		dropoutTiles:         params.DropoutTiles.String(),
		multipleWordsPerTurn: params.MultipleWordsPerTurn,
		noRepeatWords:        noRepeatWordsFor(params.Variant),
		status:               StatusOpen,
		openDeadline:         &deadline,
		kind:                 gamelimits.KindRandom,
	}
	// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
	// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
	// first — before either player acts (docs/ARCHITECTURE.md §6). The caller takes their
	// drawn seat; the opponent seat is left empty. The opponent's draw rows are recorded with
	// a NULL account and back-filled when a real opponent joins. Each draw uses fresh entropy,
	// not the game seed. seats/draws are used only when a fresh game is opened.
	seeding, err := seedFirstMove(params.Variant, []uuid.UUID{accountID, uuid.Nil}, svc.firstMoveEntropy())
	if err != nil {
		if errors.Is(err, engine.ErrUnknownVariant) {
			return Game{}, false, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
		}
		return Game{}, false, err
	}
	caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
	seats := make([]seatInsert, len(seeding.order))
	for seat, who := range seeding.order {
		if who == accountID {
			seats[seat] = caller // the empty opponent seat stays a zero seatInsert
		}
	}
	gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude, seeding.draws)
	if err != nil {
		return Game{}, false, err
	}
	if created {
		svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
	}
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return Game{}, false, err
	}
	return g, joined, nil
}

// AttachRobot seats robotID in the empty opponent seat of open game gameID, stamping
// displayName (the robot's fresh per-game name) on the seat, and flips it to active,
// returning the now-active game and whether it attached (false, with a zero Game, when a
// human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (Game, bool, error) {
	attached, err := svc.store.AttachRobot(ctx, gameID, robotID, displayName)
	if err != nil {
		return Game{}, false, err
	}
	if !attached {
		return Game{}, false, nil
	}
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return Game{}, false, err
	}
	return g, true, nil
}

// ExpiredOpen returns the open games due for a robot substitution (deadline at or
// before now) for the matchmaking reaper.
func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
	return svc.store.ExpiredOpen(ctx, now)
}

// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)

// SubmitPlay validates, scores and commits the player's placement. The engine
// infers the play's orientation from the tiles and the board, so the caller
// supplies only the placed tiles (docs/ARCHITECTURE.md §5).
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (MoveResult, error) {
	return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
		rec, err := g.SubmitPlay(tiles)
		return rec, nil, err
	})
}

// Pass commits a forfeited turn.
func (svc *Service) Pass(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
	return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
		rec, err := g.Pass()
		return rec, nil, err
	})
}

// Exchange swaps the named tiles ("?" for a blank) and commits the turn.
func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (MoveResult, error) {
	return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
		rec, err := g.SubmitExchange(tiles)
		return rec, tiles, err
	})
}

// Resign ends the game on the player's turn; the remaining player wins.
// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is
// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does
// not go through transition's turn check: it resigns the actor's own seat, whoever is to
// move. The resigning seat always loses (docs/ARCHITECTURE.md §7).
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return MoveResult{}, err
	}
	seat, ok := pre.seatOf(accountID)
	if !ok {
		return MoveResult{}, ErrNotAPlayer
	}
	// Resign needs a present opponent to award the win, so it is refused while the game
	// is still waiting for one; the UI keeps the button disabled until then.
	if pre.Status == StatusOpen {
		return MoveResult{}, ErrNoOpponentYet
	}
	if pre.Status != StatusActive {
		return MoveResult{}, ErrFinished
	}

	unlock := svc.locks.lock(gameID)
	defer unlock()

	g, err := svc.liveGame(ctx, pre)
	if err != nil {
		return MoveResult{}, err
	}
	if g.Over() {
		return MoveResult{}, ErrFinished
	}

	rackBefore := g.Hand(seat)
	rec, err := g.ResignSeat(seat)
	if err != nil {
		return MoveResult{}, err
	}
	post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats, pre.VsAI)
	if err != nil {
		return MoveResult{}, err
	}
	svc.afterCommitDrafts(ctx, gameID, accountID, rec)
	// A resignation carries no think time (it can happen on the opponent's turn), so it
	// is intentionally excluded from the move-duration metric.
	return MoveResult{Move: rec, Game: post}, nil
}

// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game
// it has opened but no opponent has joined, returning how many it acted on. It is the game-side
// effect of an admin block: the blocked player instantly loses each live game (the opponent
// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game.
// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to
// call while play continues and is idempotent — an already-resigned or finished game is skipped.
// A per-game failure is logged and skipped so one bad game does not strand the rest; the
// turn-timeout sweeper remains the backstop for anything missed in a race.
func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) {
	games, err := svc.store.ListGamesForAccount(ctx, accountID)
	if err != nil {
		return 0, err
	}
	count := 0
	for _, g := range games {
		if g.Status != StatusActive && g.Status != StatusOpen {
			continue // a finished game holds no turn to forfeit
		}
		acted, err := svc.forfeitOne(ctx, g.ID, accountID)
		if err != nil {
			svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err))
			continue
		}
		if acted {
			count++
		}
	}
	return count, nil
}

// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match
// game still awaiting an opponent. It reports whether it changed the game. A game that has already
// finished, or in which the account no longer holds a seat, is a benign no-op.
func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) {
	_, err := svc.Resign(ctx, gameID, accountID)
	switch {
	case err == nil:
		return true, nil
	case errors.Is(err, ErrNoOpponentYet):
		// An open game with no opponent cannot be resigned (there is no one to award the win), so
		// delete it to clear it from the matchmaking pool. If it filled in the race window it is
		// now active, so resign the seat instead.
		deleted, derr := svc.store.DeleteOpenGame(ctx, gameID)
		if derr != nil {
			return false, derr
		}
		if deleted {
			svc.cache.remove(gameID)
			return true, nil
		}
		if _, err := svc.Resign(ctx, gameID, accountID); err != nil &&
			!errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) {
			return false, err
		}
		return true, nil
	case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound):
		return false, nil
	default:
		return false, err
	}
}

// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
// indices to concrete letters before delegating to the letter-based play, exchange and
// word-check methods, keeping a single domain path shared with the robot.
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
	return svc.store.GetGameVariant(ctx, gameID)
}

// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
	return svc.store.RobotSchedule(ctx, gameID)
}

// LastMoveAt returns the time of an account's most recent move in a game (and whether it
// has moved). The social service uses it to reset the nudge cooldown once a player has
// taken a turn.
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
	return svc.store.LastMoveAt(ctx, gameID, accountID)
}

// TurnStartedAt returns the start time of a game's current turn. The social service uses
// it as the per-turn boundary for the one-chat-message-per-turn limit.
func (svc *Service) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) {
	return svc.store.TurnStartedAt(ctx, gameID)
}

// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return MoveResult{}, err
	}
	seat, ok := pre.seatOf(accountID)
	if !ok {
		return MoveResult{}, ErrNotAPlayer
	}
	// A move is allowed while the game is active or still open (the starter may move on
	// their turn before an opponent joins; the first-move draw ran when the game opened, so
	// the seats are already fixed); only a finished game rejects it. The turn check below
	// keeps the starter off the still-empty opponent seat.
	if pre.Status == StatusFinished {
		return MoveResult{}, ErrFinished
	}
	if pre.ToMove != seat {
		return MoveResult{}, ErrNotYourTurn
	}

	unlock := svc.locks.lock(gameID)
	defer unlock()

	g, err := svc.liveGame(ctx, pre)
	if err != nil {
		return MoveResult{}, err
	}
	if g.Over() {
		return MoveResult{}, ErrFinished
	}
	if g.ToMove() != seat {
		return MoveResult{}, ErrNotYourTurn
	}

	rackBefore := g.Hand(seat)
	rec, exchanged, err := op(g)
	if err != nil {
		return MoveResult{}, err
	}
	post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats, pre.VsAI)
	if err != nil {
		return MoveResult{}, err
	}
	svc.afterCommitDrafts(ctx, gameID, accountID, rec)
	// A nudge the actor answered by moving stops counting as unread (best-effort; the
	// move has committed, so a cleanup failure is logged, not surfaced).
	if svc.clearNudges != nil {
		if err := svc.clearNudges(ctx, gameID, accountID); err != nil {
			svc.log.Warn("clear nudges after move", zap.Error(err))
		}
	}
	// Record the seat's think time (turn start to commit) for the move-duration
	// metric; the timeout path commits separately and is excluded by design.
	svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
	return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
}

// afterCommitDrafts maintains the drafts after a committed move: the actor's own
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
// draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move.
func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) {
	if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil {
		svc.log.Warn("clear actor draft", zap.Error(err))
	}
	if rec.Action == engine.ActionPlay {
		if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil {
			svc.log.Warn("reset conflicting board drafts", zap.Error(err))
		}
	}
}

// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal.
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat, vsAI bool) (Game, error) {
	now := svc.clock()
	logLen := len(g.Log())
	scores := make([]int, g.Players())
	for i := range scores {
		scores[i] = g.Score(i)
	}
	c := commit{
		gameID:        gameID,
		seq:           logLen - 1,
		seat:          rec.Player,
		action:        action,
		score:         rec.Score,
		runningTotal:  rec.Total,
		exchanged:     exchanged,
		rec:           rec,
		rackBefore:    rackBefore,
		toMove:        g.ToMove(),
		turnStartedAt: now,
		moveCount:     logLen,
		scores:        scores,
		now:           now,
	}
	if g.Over() {
		c.finished = true
		c.finishedAt = now
		c.endReason = g.Reason().String()
		if action == "timeout" {
			c.endReason = "timeout"
		}
		c.winner = g.Result().Winner
		// Honest-AI games are practice and never touch player statistics (like guest
		// games); a human game records them for its non-guest seats.
		if !vsAI {
			statSeats, err := svc.nonGuestSeats(ctx, seats)
			if err != nil {
				svc.cache.remove(gameID)
				return Game{}, err
			}
			c.stats = buildStats(g, statSeats)
		}
	}
	if err := svc.store.CommitMove(ctx, c); err != nil {
		svc.cache.remove(gameID)
		return Game{}, err
	}
	if c.finished {
		svc.cache.remove(gameID)
		// A finished game's nudges are stale, so clear them all here — every completion path
		// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
		// and only the move path also clears the mover's nudge on its own. Best-effort like
		// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
		// ExpireNudges leaves chat messages unread.
		if svc.expireNudges != nil {
			if err := svc.expireNudges(ctx, gameID); err != nil {
				svc.log.Warn("expire nudges on game finish", zap.Error(err))
			}
		}
	}
	post, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return Game{}, err
	}
	svc.emitMove(ctx, post, rec, g.BagLen())
	// Honest-AI game still going: nudge the robot to take its turn at once (the
	// periodic driver is the fallback). No-op for human games and finished ones.
	svc.triggerAI(post)
	return post, nil
}

// emitMove publishes the live events for a just-committed move: opponent_moved to
// every seat — including the actor's own account, so the mover's other devices (and
// their lobby) refresh too — and, in human games only, your_turn to the next mover
// while the game is still active (an honest-AI game suppresses your_turn, since the
// robot replies at once). opponent_moved is in-app only (the gateway never turns it into an
// out-of-app push), so the actor is not notified out of band about their own move.
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
// event out to all of the recipient's live streams.
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) {
	// Resolve the seat names once and reuse them for every recipient's enriched summary.
	names := svc.seatNames(ctx, post)
	summary := gameSummary(post, names)
	intents := make([]notify.Intent, 0, 2*len(post.Seats))
	for _, s := range post.Seats {
		if s.AccountID == uuid.Nil {
			continue // an open game's opponent seat is not yet filled — nobody to notify
		}
		intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
	}
	switch post.Status {
	case StatusActive:
		// Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
		// notification would arrive together with the AI's move and carry no value — the
		// human's UI already advances from opponent_moved. Only human games signal the mover.
		if next, ok := seatAccount(post.Seats, post.ToMove); ok && !post.VsAI {
			deadline := post.TurnStartedAt.Add(post.TurnTimeout)
			action := rec.Action.String()
			word := ""
			if action == "play" && len(rec.Words) > 0 {
				word = rec.Words[0]
			}
			opponent := svc.displayName(ctx, post.Seats, rec.Player)
			yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
			intents = append(intents, yourTurn)
		}
	case StatusFinished:
		// The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every
		// seat, each with their own perspective + recipient-first score, so an offline player gets
		// an out-of-app "game over" push (online players take it from the in-app refresh).
		for _, s := range post.Seats {
			if s.AccountID == uuid.Nil {
				continue
			}
			over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
			intents = append(intents, over)
		}
	}
	svc.pub.Publish(intents...)
}

// seatDisplayName returns the name shown for a seat: its captured display-name snapshot
// (docs/ARCHITECTURE.md §7), falling back to the account's current display name for a
// pre-snapshot legacy row, or "" when neither is available.
func (svc *Service) seatDisplayName(ctx context.Context, s Seat) string {
	if s.DisplayName != "" {
		return s.DisplayName
	}
	if svc.accounts == nil || s.AccountID == uuid.Nil {
		return ""
	}
	if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
		return acc.DisplayName
	}
	return ""
}

// displayName resolves the display name shown for the account at the given seat, or ""
// when the seat is absent (the enriched push then falls back to its plain text).
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
	for _, s := range seats {
		if s.Seat == seat {
			return svc.seatDisplayName(ctx, s)
		}
	}
	return ""
}

// scoreLine formats the running scores with recipientSeat's score first, then the remaining
// seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the
// out-of-app notifications.
func scoreLine(g Game, recipientSeat int) string {
	n := len(g.Seats)
	bySeat := make([]int, n)
	for _, s := range g.Seats {
		if s.Seat >= 0 && s.Seat < n {
			bySeat[s.Seat] = s.Score
		}
	}
	parts := make([]string, 0, n)
	if recipientSeat >= 0 && recipientSeat < n {
		parts = append(parts, strconv.Itoa(bySeat[recipientSeat]))
	}
	for seat := 0; seat < n; seat++ {
		if seat != recipientSeat {
			parts = append(parts, strconv.Itoa(bySeat[seat]))
		}
	}
	return strings.Join(parts, ":")
}

// seatResult reports the finished-game outcome from recipientSeat's perspective: "draw" when no
// seat is flagged the winner, "won" when recipientSeat is, otherwise "lost".
func seatResult(seats []Seat, recipientSeat int) string {
	winner := false
	for _, s := range seats {
		if s.IsWinner {
			winner = true
			if s.Seat == recipientSeat {
				return "won"
			}
		}
	}
	if !winner {
		return "draw"
	}
	return "lost"
}

// seatAccount returns the account seated at the given seat index, or false when
// no seat matches (the slice is not assumed to be ordered by seat).
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
	for _, s := range seats {
		if s.Seat == seat {
			return s.AccountID, true
		}
	}
	return uuid.UUID{}, false
}

// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
// under the per-game lock, that the game is still active and still past the
// effective deadline (so a move made since the sweep is not clobbered), records
// the move as a timeout, and reports whether it timed the game out.
func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.Time) (bool, error) {
	unlock := svc.locks.lock(gameID)
	defer unlock()

	cur, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return false, err
	}
	if cur.Status != StatusActive {
		return false, nil
	}
	seat := cur.ToMove
	if seat < 0 || seat >= len(cur.Seats) {
		return false, nil
	}
	acc, err := svc.accounts.GetByID(ctx, cur.Seats[seat].AccountID)
	if err != nil {
		return false, err
	}
	deadline := effectiveDeadline(cur.TurnStartedAt, cur.TurnTimeout, loadLocation(acc.TimeZone), minutesOfDay(acc.AwayStart), minutesOfDay(acc.AwayEnd))
	if now.Before(deadline) {
		return false, nil
	}

	g, err := svc.liveGame(ctx, cur)
	if err != nil {
		return false, err
	}
	if g.Over() {
		return false, nil
	}
	rackBefore := g.Hand(g.ToMove())
	rec, err := g.Resign()
	if err != nil {
		return false, err
	}
	if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats, cur.VsAI); err != nil {
		return false, err
	}
	svc.metrics.recordAbandoned(ctx, cur.Variant, cur.VsAI)
	return true, nil
}

// EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
	unlock := svc.locks.lock(gameID)
	defer unlock()

	// Hot path: an active game stays cached — the engine game is mutated in place across
	// moves and evicted only when it finishes — so on a hit the cached live game and its
	// immutable seat list answer the membership check and the score with no DB read. This
	// preview is fired on every tile placement, the hottest gameplay call at scale.
	g, seats, ok := svc.cache.get(gameID)
	if !ok {
		// Cold path: load and validate from the store, then replay into the cache.
		pre, err := svc.store.GetGame(ctx, gameID)
		if err != nil {
			return EvalResult{}, err
		}
		if pre.Status == StatusFinished {
			return EvalResult{}, ErrFinished
		}
		if g, err = svc.liveGame(ctx, pre); err != nil {
			return EvalResult{}, err
		}
		seats = pre.Seats
	}
	if !seatedIn(seats, accountID) {
		return EvalResult{}, ErrNotAPlayer
	}

	validateStart := time.Now()
	rec, err := g.EvaluatePlay(tiles)
	svc.metrics.recordValidate(ctx, g.Variant(), validateStart)
	if err != nil {
		if errors.Is(err, engine.ErrIllegalPlay) {
			return EvalResult{Valid: false}, nil
		}
		return EvalResult{}, err
	}
	return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words, Dir: rec.Dir.String()}, nil
}

// CheckWord reports whether word is in the game's pinned dictionary. It is the
// unlimited word-check tool; an input outside the variant's alphabet is simply
// not a word.
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return false, err
	}
	return svc.lookupWord(pre.Variant, pre.DictVersion, word)
}

// FileComplaint records a word-check complaint against the game's dictionary for
// later admin review, stamping the disputed lookup result.
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return Complaint{}, err
	}
	if _, ok := pre.seatOf(accountID); !ok {
		return Complaint{}, ErrNotAPlayer
	}
	normalized := normalizeWord(word)
	valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
	if err != nil {
		return Complaint{}, err
	}
	return svc.store.FileComplaint(ctx, Complaint{
		ComplainantID: accountID,
		GameID:        gameID,
		Variant:       pre.Variant,
		DictVersion:   pre.DictVersion,
		Word:          normalized,
		WasValid:      valid,
		Note:          note,
	})
}

// ListComplaints returns word-check complaints for the admin review queue,
// newest first. status filters by lifecycle state ("" = all); limit is clamped
// to a sane page size and offset is floored at zero.
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
	return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
}

// GetComplaint loads a single complaint for the admin detail view.
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
	return svc.store.GetComplaint(ctx, id)
}

// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
	return svc.store.CountComplaints(ctx, status)
}

// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
	return svc.store.CountComplaintsSince(ctx, since)
}

// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
// change is marked applied. It returns ErrInvalidConfig for an unknown
// disposition and ErrNotFound when no complaint matches.
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
	if !validDisposition(disposition) {
		return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
	}
	return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
}

// DictionaryChanges returns the pending wordlist edits implied by resolved,
// accepted complaints not yet marked applied — the input to the offline DAWG
// rebuild.
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
	rows, err := svc.store.ListDictionaryChanges(ctx)
	if err != nil {
		return nil, err
	}
	out := make([]DictionaryChange, 0, len(rows))
	for _, c := range rows {
		ch := DictionaryChange{
			ComplaintID: c.ID,
			Variant:     c.Variant,
			Word:        c.Word,
			Add:         c.Disposition == DispositionAcceptAdd,
			Note:        c.Note,
		}
		if c.ResolvedAt != nil {
			ch.ResolvedAt = *c.ResolvedAt
		}
		out = append(out, ch)
	}
	return out, nil
}

// MarkChangesApplied records that every pending accepted change for variant has
// been folded into the dictionary version that was just installed, removing
// them from DictionaryChanges. It returns the number of changes marked.
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
	return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}

// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
const hintIdleWindow = 30 * time.Minute

// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
	if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
		return 0
	}
	left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
	if left <= 0 {
		return 0
	}
	return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}

// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return HintResult{}, err
	}
	seat, ok := pre.seatOf(accountID)
	if !ok {
		return HintResult{}, ErrNotAPlayer
	}
	if pre.Status == StatusFinished {
		return HintResult{}, ErrFinished
	}
	if pre.ToMove != seat {
		return HintResult{}, ErrNotYourTurn
	}
	if !pre.HintsAllowed {
		return HintResult{}, ErrHintsDisabled
	}
	if pre.VsAI {
		// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
		// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
		// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
		// touching the allowance, the wallet or hints_used.
		if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
			return HintResult{}, ErrHintLocked
		}
		unlock := svc.locks.lock(gameID)
		defer unlock()
		g, err := svc.liveGame(ctx, pre)
		if err != nil {
			return HintResult{}, err
		}
		move, ok := g.HintView()
		if !ok {
			return HintResult{}, ErrNoHintAvailable
		}
		return HintResult{Move: move}, nil
	}
	cxt, present, err := svc.walletContext(ctx, accountID)
	if err != nil {
		return HintResult{}, err
	}
	wallet := 0
	if svc.hintWallet != nil {
		wallet, err = svc.hintWallet.HintsAvailable(ctx, accountID, cxt, present)
		if err != nil {
			return HintResult{}, err
		}
	}
	used := pre.Seats[seat].HintsUsed
	fromAllowance := used < pre.HintsPerPlayer
	if !fromAllowance && wallet <= 0 {
		return HintResult{}, ErrNoHintsLeft
	}

	unlock := svc.locks.lock(gameID)
	defer unlock()
	g, err := svc.liveGame(ctx, pre)
	if err != nil {
		return HintResult{}, err
	}
	move, ok := g.HintView()
	if !ok {
		return HintResult{}, ErrNoHintAvailable
	}

	walletAfter := wallet
	if !fromAllowance {
		spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
		if err != nil {
			return HintResult{}, err
		}
		if !spent {
			return HintResult{}, ErrNoHintsLeft
		}
		walletAfter--
	}
	// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
	// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
	// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
	if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
		return HintResult{}, err
	}
	used++
	return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
}

// Candidates returns the to-move player's legal plays for a seated player on
// their turn, ranked by descending score. It is the read the robot opponent uses
// to choose a move by margin; it spends nothing and mutates no state. It returns
// ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads.
func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return nil, err
	}
	seat, ok := pre.seatOf(accountID)
	if !ok {
		return nil, ErrNotAPlayer
	}
	if pre.Status == StatusFinished {
		return nil, ErrFinished
	}
	if pre.ToMove != seat {
		return nil, ErrNotYourTurn
	}

	unlock := svc.locks.lock(gameID)
	defer unlock()
	g, err := svc.liveGame(ctx, pre)
	if err != nil {
		return nil, err
	}
	return g.Candidates(), nil
}

// RobotTurns returns the robot driver's view of every active game seating one of
// robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout
// sweeper's ActiveGames read; the driver derives each robot's deadline from the
// returned seed and turn cursor.
func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) {
	return svc.store.RobotTurns(ctx, robotIDs)
}

// RobotTurn returns the robot driver's view of a single active game seating one of
// robotIDs, and true, or false when the game holds no pooled robot or is no longer
// active. It backs the honest-AI fast-move trigger, which drives just the one game.
func (svc *Service) RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (RobotTurn, bool, error) {
	return svc.store.RobotTurnByGame(ctx, gameID, robotIDs)
}

// VsAI reports whether a game is an honest-AI game (games.vs_ai). The social
// service uses it to reject chat and nudge in AI games (which otherwise report
// status 'active').
func (svc *Service) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) {
	return svc.store.GameVsAI(ctx, gameID)
}

// GameState returns a seated player's view of the game: the shared summary plus
// their private rack, the bag size and their remaining hint budget.
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
	pre, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return StateView{}, err
	}
	seat, ok := pre.seatOf(accountID)
	if !ok {
		return StateView{}, ErrNotAPlayer
	}

	unlock := svc.locks.lock(gameID)
	defer unlock()
	g, err := svc.liveGame(ctx, pre)
	if err != nil {
		return StateView{}, err
	}
	if g.Reason() == engine.EndAborted {
		// liveGame voided the game; re-read so the view reflects the finished/aborted state.
		if pre, err = svc.store.GetGame(ctx, gameID); err != nil {
			return StateView{}, err
		}
	}
	return StateView{
		Game:   pre,
		Seat:   seat,
		Rack:   g.Hand(seat),
		BagLen: g.BagLen(),
		// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
		// (payments) and the client adds it (lib/hints.hintsLeft).
		HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
		// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
		HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
	}, nil
}

// InitialState returns accountID's full initial view of game gameID as the notify
// PlayerState carried by the match_found / game_started events, so a client can
// render a freshly started game from the event without a follow-up fetch. The variant
// alphabet table is always embedded (the recipient may be seeing the variant for the
// first time). It satisfies lobby.GameCreator.
func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) {
	v, err := svc.GameState(ctx, gameID, accountID)
	if err != nil {
		return notify.PlayerState{}, err
	}
	names := svc.seatNames(ctx, v.Game)
	return playerState(v, names, true)
}

// Participants returns the seated account IDs in seat order, the seat index whose
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
// lets the social package gate per-game chat and nudges without importing the
// engine or the game's private state.
func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return nil, 0, "", err
	}
	seats := make([]uuid.UUID, len(g.Seats))
	for _, s := range g.Seats {
		seats[s.Seat] = s.AccountID
	}
	return seats, g.ToMove, g.Status, nil
}

// SeatName returns the display name shown for the account seated in the game — the seat's
// captured snapshot, falling back to the account's current name (see seatDisplayName) — or ""
// when the account holds no seat. It lets the social package name a nudge's sender by the same
// per-game identity the rest of the game uses, without exposing the seats.
func (svc *Service) SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error) {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return "", err
	}
	for _, s := range g.Seats {
		if s.AccountID == accountID {
			return svc.seatDisplayName(ctx, s), nil
		}
	}
	return "", nil
}

// SharedGame reports whether accounts a and b are seated together in any game
// (active or finished). It backs the social package's "befriend an opponent"
// request gate without exposing the games tables; a self-pair is never shared.
func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) {
	if a == b {
		return false, nil
	}
	return svc.store.SharedGameExists(ctx, a, b)
}

// ListForAccount returns every game the account is seated in, newest first — the full
// set behind the admin console, the account-merge count and the block-time forfeit
// sweep. The player's own lobby uses ListForLobby, which drops the honest-AI games the
// player left. The live position is not loaded — the summaries come straight from the
// durable rows.
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
	return svc.store.ListGamesForAccount(ctx, accountID)
}

// ListForLobby returns the games shown in the account's own lobby: ListForAccount minus
// the honest-AI games the player left — by resigning or by abandoning to the inactivity
// timeout (games.end_reason 'resign'/'timeout') — which drop out of the finished list
// automatically. The robot never leaves (it moves at once, never sleeps, never resigns),
// so only a human's departure matches; the predicate is a game property, not a seat
// check, so it extends to any player should the robot ever resign. Admin and
// account-merge views keep using ListForAccount and see the full set.
func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
	games, err := svc.ListForAccount(ctx, accountID)
	if err != nil {
		return nil, err
	}
	kept := games[:0]
	for _, g := range games {
		if g.VsAI && (g.EndReason == "resign" || g.EndReason == "timeout") {
			continue
		}
		kept = append(kept, g)
	}
	return kept, nil
}

// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return err
	}
	seated := false
	for _, s := range g.Seats {
		if s.AccountID == accountID {
			seated = true
			break
		}
	}
	if !seated {
		return ErrNotAPlayer
	}
	if g.Status != StatusFinished {
		return ErrGameActive
	}
	return svc.store.HideGame(ctx, accountID, gameID)
}

// GameByID returns a game with its seats for the admin console detail view.
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
	return svc.store.GetGame(ctx, id)
}

// ListGames returns games for the admin list, newest-updated first, paginated,
// optionally filtered by status.
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
	return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
}

// CountGames returns the game count, optionally filtered by status, for the admin
// list pager and dashboard.
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
	return svc.store.CountGames(ctx, status)
}

// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return HistoryView{}, err
	}
	moves, err := svc.store.GetJournal(ctx, gameID)
	if err != nil {
		return HistoryView{}, err
	}
	return HistoryView{Game: g, Moves: moves}, nil
}

// SetupDraws returns a game's recorded first-move draws (docs/ARCHITECTURE.md §6), ordered by
// round then pick. It backs the admin console's first-move section. An auto-match opponent's
// draws carry uuid.Nil until a real opponent joins and back-fills them; an empty slice means
// the game predates the draw record.
func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDraw, error) {
	return svc.store.SetupDraws(ctx, gameID)
}

// ExportView returns a finished game with its journal and per-seat display names —
// the material every export artifact (the GCG text, the PNG render payload) is built
// from. It is allowed only on a finished game: exporting an in-progress game would
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
// honest-AI game the robot seat is labelled "AI", not its pool name.
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return Game{}, nil, nil, err
	}
	if g.Status != StatusFinished {
		return Game{}, nil, nil, ErrGameActive
	}
	moves, err := svc.store.GetJournal(ctx, gameID)
	if err != nil {
		return Game{}, nil, nil, err
	}
	names := svc.seatNames(ctx, g)
	if g.VsAI {
		for _, s := range g.Seats {
			if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
				names[s.Seat] = aiPlayerName
			}
		}
	}
	return g, moves, names, nil
}

// EnsureExportable reports whether a game may be exported (it exists and is
// finished) without loading the journal — the export-URL mint check.
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
	g, err := svc.store.GetGame(ctx, gameID)
	if err != nil {
		return err
	}
	if g.Status != StatusFinished {
		return ErrGameActive
	}
	return nil
}

// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
	g, moves, names, err := svc.ExportView(ctx, gameID)
	if err != nil {
		return "", err
	}
	return writeGCG(g, names, moves), nil
}

// liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
	if g, _, ok := svc.cache.get(pre.ID); ok {
		return g, nil
	}
	g, err := svc.replay(ctx, pre)
	if err != nil {
		return nil, err
	}
	if g.Reason() == engine.EndAborted && pre.Status != StatusFinished {
		// First open after the game became unreplayable: persist the void (a finished draw)
		// so the lobby and later opens see it settled. Guarded so it runs exactly once.
		if err := svc.voidGame(ctx, pre, g); err != nil {
			return nil, err
		}
	}
	if !g.Over() {
		svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats)
	}
	return g, nil
}

// replay reconstructs an engine.Game by dealing from the pinned seed and
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
	defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now())
	seed, err := svc.store.GameSeed(ctx, pre.ID)
	if err != nil {
		return nil, err
	}
	g, err := engine.New(svc.registry, engine.Options{
		Variant:              pre.Variant,
		Version:              pre.DictVersion,
		Players:              pre.Players,
		Seed:                 seed,
		DropoutTiles:         pre.DropoutTiles,
		MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
		NoRepeatWords:        pre.NoRepeatWords,
	})
	if err != nil {
		return nil, err
	}
	moves, err := svc.store.GetJournal(ctx, pre.ID)
	if err != nil {
		return nil, err
	}
	for _, mv := range moves {
		if err := replayMove(g, mv); err != nil {
			if errors.Is(err, engine.ErrIllegalPlay) {
				// A committed move is no longer legal under the current rules, so the game
				// cannot be reconstructed past it: close it as a draw (liveGame persists the
				// void) rather than leave it unopenable. Other errors are genuine and propagate.
				g.Abort()
				break
			}
			return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
		}
	}
	return g, nil
}

// voidGame closes pre as a draw because its journal can no longer be replayed; g is the
// partial reconstruction, already Aborted. It persists the finish (end_reason 'aborted'),
// each seat's partial score as a draw, and the draw statistics for the non-guest seats. The
// journal is left intact.
func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) error {
	scores := make([]int, g.Players())
	for i := range scores {
		scores[i] = g.Score(i)
	}
	statSeats, err := svc.nonGuestSeats(ctx, pre.Seats)
	if err != nil {
		return err
	}
	if err := svc.store.VoidGame(ctx, voidCommit{
		gameID:    pre.ID,
		endReason: g.Reason().String(),
		scores:    scores,
		now:       svc.clock(),
		stats:     buildStats(g, statSeats),
	}); err != nil {
		return err
	}
	// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
	// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
	// is logged, not surfaced.
	if svc.expireNudges != nil {
		if err := svc.expireNudges(ctx, pre.ID); err != nil {
			svc.log.Warn("expire nudges on voided game", zap.Error(err))
		}
	}
	return nil
}

// replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error {
	switch mv.Action {
	case "play":
		dir := engine.Horizontal
		if mv.Dir == "V" {
			dir = engine.Vertical
		}
		_, err := g.SubmitPlayDir(dir, mv.Tiles)
		return err
	case "pass":
		_, err := g.Pass()
		return err
	case "exchange":
		_, err := g.SubmitExchange(mv.Exchanged)
		return err
	case "resign", "timeout":
		_, err := g.Resign()
		return err
	default:
		return fmt.Errorf("unknown action %q", mv.Action)
	}
}

// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
// single play from the log — its score and, for the per-variant breakdown, its main
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
// blank laid by an earlier move and embedded in the best word is honoured), which is
// equivalent to reading the final board since a placed tile never moves.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
	res := g.Result()
	bestRec := make(map[int]engine.MoveRecord)
	blanks := make(map[[2]int]bool)
	plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat
	for _, rec := range g.Log() {
		if rec.Action != engine.ActionPlay {
			continue
		}
		plays[rec.Player]++
		for _, t := range rec.Tiles {
			if t.Blank {
				blanks[[2]int{t.Row, t.Col}] = true
			}
		}
		if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
			bestRec[rec.Player] = rec
		}
	}
	variant := g.Variant().String()
	values := letterValues(g.Variant())
	out := make([]statDelta, 0, len(seats))
	for _, s := range seats {
		// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are
		// summed into account_stats so the screen can show the hint share (hints_used / moves).
		d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed}
		if rec, ok := bestRec[s.Seat]; ok {
			d.wordPoints = rec.Score
			if rec.Score > 0 {
				d.bestVariant = variant
				d.bestScore = rec.Score
				d.bestTiles = mainWordTiles(rec, blanks, values)
			}
		}
		switch {
		case res.Winner < 0:
			d.draws = 1
		case res.Winner == s.Seat:
			d.wins = 1
		default:
			d.losses = 1
		}
		out = append(out, d)
	}
	return out
}

// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
// unrecognised variant, leaving every value zero.
func letterValues(v engine.Variant) map[string]int {
	table, err := engine.AlphabetTable(v)
	if err != nil {
		return nil
	}
	m := make(map[string]int, len(table))
	for _, e := range table {
		m[strings.ToLower(e.Letter)] = e.Value
	}
	return m
}

// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
// the word from its first-letter coordinate along the play's orientation.
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
	if len(rec.Words) == 0 {
		return nil
	}
	dr, dc := 0, 1
	if rec.Dir == engine.Vertical {
		dr, dc = 1, 0
	}
	letters := []rune(rec.Words[0])
	out := make([]account.BestMoveTile, len(letters))
	for i, r := range letters {
		row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
		blank := blanks[[2]int{row, col}]
		letter := string(r)
		value := 0
		if !blank {
			value = values[strings.ToLower(letter)]
		}
		out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
	}
	return out
}

// nonGuestSeats filters out guest seats so the finish-time statistics are
// recomputed for durable non-guest accounts only — guests never accrue
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) {
	out := make([]Seat, 0, len(seats))
	for _, s := range seats {
		acc, err := svc.accounts.GetByID(ctx, s.AccountID)
		if err != nil {
			return nil, err
		}
		if acc.IsGuest {
			continue
		}
		out = append(out, s)
	}
	return out, nil
}

// seatNames resolves each seat's display name — its captured snapshot, else the
// account's current name (seatDisplayName) — for game state and GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
	names := make([]string, g.Players)
	for _, s := range g.Seats {
		if s.Seat >= 0 && s.Seat < len(names) {
			names[s.Seat] = svc.seatDisplayName(ctx, s)
		}
	}
	return names
}

// lookupWord checks word against a variant/version dictionary, treating an
// out-of-alphabet input as simply not a word (a real registry error still
// surfaces).
func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bool, error) {
	present, err := svc.registry.Lookup(variant, version, normalizeWord(word))
	if err != nil {
		if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
			return false, err
		}
		return false, nil
	}
	return present, nil
}

// DictBytes returns the raw serialized dictionary for the (variant, version) pair
// from the registry, backing the client-side dictionary download used by the
// local move preview. It surfaces engine.ErrUnknownVariant /
// engine.ErrUnknownVersion when that dictionary is not resident.
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
	return svc.registry.DictBytes(variant, version)
}

// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
func hintsRemaining(allowance, used int) int {
	return max(0, allowance-used)
}

// allowedTimeout reports whether d is one of the offered move clocks.
func allowedTimeout(d time.Duration) bool {
	return slices.Contains(AllowedTurnTimeouts, d)
}

// normalizeWord lower-cases and trims a word-check input to the alphabet's form.
func normalizeWord(word string) string {
	return strings.ToLower(strings.TrimSpace(word))
}

// validDisposition reports whether d is an accepted complaint disposition.
func validDisposition(d string) bool {
	switch d {
	case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
		return true
	default:
		return false
	}
}

// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
// (non-positive) request to 50.
func clampPageSize(limit int) int {
	switch {
	case limit <= 0:
		return 50
	case limit > 200:
		return 200
	default:
		return limit
	}
}

// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
	var b [8]byte
	if _, err := crand.Read(b[:]); err != nil {
		return time.Now().UnixNano()
	}
	return int64(binary.LittleEndian.Uint64(b[:]))
}
