import { randomUUID } from 'node:crypto';
import {
  botDecision,
  createSession,
  dedupeNickname,
  DEFAULT_SESSION_SETTINGS,
  hostView,
  needsRound,
  nextDeadline,
  nicknameKey,
  privateView,
  publicView,
  rankIndex,
  rankPlayers,
  reduce,
  selectRoundQuestions,
  validateNickname,
  validateTeamName,
  windowFor,
  type Command,
  type DomainEvent,
  type GameState,
  type PrivateState,
  type PublicState,
  type ReduceResult,
  type RoundSetup,
  type SessionSettings,
  type Timings,
} from '@pintrush/engine';
import { isWithinSchedule } from '@pintrush/db';
import type { RoomSnapshot } from '@pintrush/protocol';
import type { Clock, TimerHandle } from '../clock';
import type { ServerConfig } from '../config';
import { hashDeviceId, type TokenService } from '../auth';
import { log } from '../logger';
import { send } from '../outgoing';
import { StaleSessionError, type ActorRef, type Catalog, type EventRecord, type PersistBatch, type RoomRecord, type SessionContext, type SessionStore, type VenueRecord } from '../store/types';
import type { AckFn, Attached, Identity } from './types';

const SYSTEM: ActorRef = { type: 'SYSTEM', id: null };
const HEARTBEAT_MS = 1_000;
const ROOM_STATE_THROTTLE_MS = 500;
const COMPLETED_SESSION_TTL_MS = 30 * 60_000;
const JOIN_IDEMPOTENCY_TTL_MS = 10 * 60_000;
const MAX_JOIN_RESULTS = 5_000;
/** Hard cap on players in one game session (bars are far smaller; this stops join floods). */
export const MAX_PLAYERS_PER_SESSION = 500;
/** How long a finished championship stays on screen (podium, final standings) before always-on returns. */
export const RESULTS_WINDOW_MS = 120_000;
/** A championship lobby nobody starts is closed this long after its scheduled (or opening) time. */
export const STALE_LOBBY_MS = 6 * 3_600_000;
/** Per-batch retries before a batch that cannot be saved is quarantined (logged in full, then dropped). */
const MAX_BATCH_ATTEMPTS = 3;
const BOT_NAMES = ['Barfly Bea', 'Pint Pilot', 'Lager Legend', 'Quizzly Bear', 'Hoppy Hal', 'Stout Scout', 'Cider Rider'];
const BOT_SKILLS = [0.85, 0.7, 0.62, 0.55, 0.45, 0.38, 0.3];

export interface RoomActorDeps {
  clock: Clock;
  catalog: Catalog;
  store: SessionStore;
  tokens: TokenService;
  config: ServerConfig;
  /** Override timings for new sessions (tests run rounds in seconds). */
  timingsOverride?: Partial<Timings>;
  /** Override how long a finished championship stays on screen (tests). */
  resultsWindowMs?: number;
}

interface SessionHandle {
  id: string;
  event: EventRecord;
  ctx: SessionContext;
  state: GameState;
  seq: number;
  connected: Map<string, number>;
  bots: Map<string, number>;
  devices: Map<string, string>;
  tokenVersions: Map<string, number>;
  usedQuestionIds: Set<string>;
  timer: TimerHandle | null;
  timerAt: number | null;
  building: boolean;
  pendingWrites: PersistBatch[];
  flushing: boolean;
  writeFailures: number;
  roomStateTimer: TimerHandle | null;
  lastRoomStateAt: number;
  botTimers: Set<TimerHandle>;
  completedAt: number | null;
  /** GameSession.stateVersion last written by this process (optimistic concurrency). */
  stateVersion: number;
  /** When this process opened/restored the session (stale-lobby fallback when there is no scheduledAt). */
  openedAt: number;
  /** True when the scheduler (closing time), not a host, paused this always-on session. */
  pausedBySchedule: boolean;
  /** Another process owns this session now (its snapshot moved on); stop writing to it. */
  fenced: boolean;
}

type Fail = { ok: false; error: { code: string; message: string } };
const fail = (code: string, message: string): Fail => ({ ok: false, error: { code, message } });

/** Maps the last phase-relevant engine event in a batch to the public message type. */
const PUBLIC_TYPES: Partial<Record<DomainEvent['type'], string>> = {
  SessionStarted: 'event.state',
  SessionPaused: 'event.state',
  SessionResumed: 'event.state',
  RoundStarted: 'round.start',
  QuestionPending: 'question.pending',
  QuestionOpened: 'question.open',
  QuestionLocked: 'question.lock',
  QuestionRevealed: 'question.reveal',
  RoundOver: 'leaderboard.updated',
  RoundCompleted: 'round.complete',
  SessionCompleted: 'event.complete',
};
const SESSION_STATUS_EVENTS = new Set<DomainEvent['type']>(['SessionStarted', 'SessionPaused', 'SessionResumed', 'SessionCompleted']);
const THROTTLED_TYPES = new Set<DomainEvent['type']>([
  'PlayerJoined',
  'PlayerRenamed',
  'PlayerKicked',
  'PlayerLeft',
  'PlayerTeamChanged',
  'TeamCreated',
  'AnswerAccepted',
  'PredictionMade',
  'BoardDealt',
]);

/**
 * Owns one physical room: its live game sessions (at most one active), the sockets watching it,
 * a strictly serial command queue, timers, and the ordered write-behind log.
 */
export class RoomActor {
  readonly roomId: string;
  private readonly sessions = new Map<string, SessionHandle>();
  private readonly attached = new Map<string, Attached>();
  private activeSessionId: string | null = null;
  private queue: Promise<unknown> = Promise.resolve();
  private heartbeat: TimerHandle | null = null;
  private nextChampionship: RoomSnapshot['attract']['nextChampionship'] = null;
  private readonly joinResults = new Map<string, { at: number; result: JoinResult; deviceHash: string }>();
  /** Joins in flight, keyed by session+device and by idempotency key, so concurrent taps share one result. */
  private readonly inflightJoins = new Map<string, Promise<{ ok: true; data: JoinResult } | Fail>>();
  private resultsTimer: TimerHandle | null = null;
  private stopped = false;

  constructor(
    private readonly deps: RoomActorDeps,
    private room: RoomRecord,
    private venue: VenueRecord,
    readonly isDemo: boolean,
  ) {
    this.roomId = room.id;
    this.heartbeat = deps.clock.setInterval(() => this.onHeartbeat(), HEARTBEAT_MS);
  }

  get venueId(): string {
    return this.venue.id;
  }

  get tvTokenVersion(): number {
    return this.room.tvTokenVersion;
  }

  updateRecords(room: RoomRecord, venue: VenueRecord): void {
    this.room = room;
    this.venue = venue;
  }

  /** Public room facts for the join page (no player data). */
  publicInfo(now: number) {
    const active = this.activeSession();
    return {
      roomId: this.roomId,
      active: !!active && active.state.status !== 'COMPLETED',
      activeEventKind: active?.event.kind ?? null,
      activeEventName: active?.event.name ?? null,
      alwaysOnEnabled: this.venue.settings.alwaysOn.enabled,
      openNow: isWithinSchedule(this.venue.settings, this.venue.timezone, now),
      nextChampionship: this.nextChampionship,
    };
  }

  get socketCount(): number {
    return this.attached.size;
  }

  get sessionCount(): number {
    return this.sessions.size;
  }

  activeSession(): SessionHandle | null {
    return this.activeSessionId ? (this.sessions.get(this.activeSessionId) ?? null) : null;
  }

  sessionForEvent(eventId: string): SessionHandle | null {
    for (const h of this.sessions.values()) if (h.event.id === eventId) return h;
    return null;
  }

  alwaysOnSession(): SessionHandle | null {
    for (const h of this.sessions.values()) if (h.event.kind === 'ALWAYS_ON' && h.state.status !== 'COMPLETED') return h;
    return null;
  }

  /** For tests and diagnostics. */
  getState(sessionId: string): GameState | null {
    return this.sessions.get(sessionId)?.state ?? null;
  }

  // ───────────────────────────── session lifecycle ─────────────────────────────

  private settingsFor(event: EventRecord): SessionSettings {
    const timings: Timings = { ...DEFAULT_SESSION_SETTINGS.timings, ...(event.config.timings ?? {}), ...(this.deps.timingsOverride ?? {}) };
    return {
      ...DEFAULT_SESSION_SETTINGS,
      timings,
      mergeSolos: event.config.mergeSolos ?? this.venue.settings.mergeSolos,
      // Demo visitors arrive at random moments: deal them in until the wildcard so nobody waits minutes.
      ...(this.isDemo ? { lateJoinUntilQuestionIndex: 9 } : {}),
    };
  }

  private createHandle(event: EventRecord, state: GameState, seq: number, stateVersion = 0): SessionHandle {
    const h: SessionHandle = {
      id: state.sessionId,
      event,
      ctx: {
        venueId: event.venueId,
        roomId: event.roomId,
        gameSessionId: state.sessionId,
        eventId: event.id,
        eventKind: event.kind,
        businessDate: event.businessDate,
      },
      state,
      seq,
      connected: new Map(),
      bots: new Map(),
      devices: new Map(),
      tokenVersions: new Map(),
      usedQuestionIds: new Set(),
      timer: null,
      timerAt: null,
      building: false,
      pendingWrites: [],
      flushing: false,
      writeFailures: 0,
      roomStateTimer: null,
      lastRoomStateAt: 0,
      botTimers: new Set(),
      completedAt: state.status === 'COMPLETED' ? (state.completedAt ?? this.deps.clock.now()) : null,
      stateVersion,
      openedAt: this.deps.clock.now(),
      pausedBySchedule: false,
      fenced: false,
    };
    this.sessions.set(h.id, h);
    return h;
  }

  /** A non-completed championship other than `exceptId` (running, paused or in its lobby). */
  private liveChampionship(exceptId?: string): SessionHandle | null {
    for (const h of this.sessions.values()) {
      if (h.id !== exceptId && h.event.kind === 'CHAMPIONSHIP' && h.state.status !== 'COMPLETED' && !h.fenced) return h;
    }
    return null;
  }

  /** Re-attach a session that was live when the process stopped. */
  async restoreSession(event: EventRecord, sessionId: string, state: GameState, seq: number, stateVersion = 0): Promise<void> {
    if (this.sessions.has(sessionId)) return;
    // Seq only ever goes up for clients; throttled updates were never persisted, so leave a safe gap.
    const h = this.createHandle(event, state, seq + 100, stateVersion);
    const [players, used] = await Promise.all([this.deps.store.listPlayers(sessionId), this.deps.store.usedQuestionIds(sessionId)]);
    for (const p of players) {
      if (p.deviceHash && p.status !== 'KICKED') h.devices.set(p.deviceHash, p.id);
      h.tokenVersions.set(p.id, p.tokenVersion);
    }
    for (const id of used) h.usedQuestionIds.add(id);
    for (const p of Object.values(state.players)) if (p.isBot) h.bots.set(p.id, BOT_SKILLS[p.joinIndex % BOT_SKILLS.length] ?? 0.5);
    const current = this.activeSession();
    if (!current || (event.kind === 'CHAMPIONSHIP' && current.event.kind === 'ALWAYS_ON')) this.activate(h.id);
    this.armTimer(h);
    if (needsRound(h.state)) void this.buildRound(h);
    log.info('session restored', { roomId: this.roomId, sessionId, status: state.status, players: players.length });
  }

  /**
   * Keep tonight's always-on session running. Called by the scheduler while the venue is open.
   * A session the scheduler paused at closing time resumes; a new business date starts a new session.
   */
  async ensureAlwaysOn(businessDate: string): Promise<void> {
    if (this.stopped) return;
    await this.expireStaleLobbies();
    const current = this.alwaysOnSession();
    if (current && current.event.businessDate === businessDate) {
      if (current.state.status === 'PAUSED' && current.pausedBySchedule) {
        current.pausedBySchedule = false;
        await this.dispatch(current, { type: 'RESUME', now: this.deps.clock.now() }, SYSTEM);
      }
      return;
    }
    if (current) await this.dispatch(current, { type: 'END_SESSION', now: this.deps.clock.now() }, SYSTEM);

    const event = await this.deps.store.ensureAlwaysOnEvent(this.room, this.venue, businessDate);
    // Ended by a host for tonight (event.end on the always-on event): stays off until the next business date.
    if (event.status === 'COMPLETED' || event.status === 'CANCELLED') return;
    const existing = await this.deps.store.findSessionByEvent(event.id);
    if (existing?.state) {
      if (existing.status !== 'COMPLETED') await this.restoreSession(event, existing.id, existing.state, existing.seq, existing.stateVersion);
      return;
    }
    const now = this.deps.clock.now();
    const state = createSession({
      sessionId: randomUUID(),
      eventId: event.id,
      venueId: this.venue.id,
      roomId: this.room.id,
      mode: 'ALWAYS_ON',
      now,
      settings: this.settingsFor(event),
      plan: null,
      alwaysOnPackKey: this.venue.settings.alwaysOn.packKey,
    });
    const rec = await this.deps.store.createGameSession(event, state);
    const h = this.createHandle(event, state, 0, rec.stateVersion);
    if (!this.activeSession()) this.activate(h.id);
    await this.dispatch(h, { type: 'START_SESSION', now: this.deps.clock.now() }, SYSTEM);
    if (this.isDemo) await this.addBots(h);
    log.info('always-on session started', { roomId: this.roomId, businessDate, sessionId: h.id });
  }

  /**
   * Closing time (or always-on switched off): PAUSE rather than END, so reopening the same business
   * date (split hours, a toggle off/on) resumes tonight's session and its nightly leaderboard.
   */
  async pauseAlwaysOn(): Promise<void> {
    const h = this.alwaysOnSession();
    if (!h || h.state.status !== 'RUNNING') return;
    h.pausedBySchedule = true;
    await this.dispatch(h, { type: 'PAUSE', now: this.deps.clock.now() }, SYSTEM);
  }

  /** End an always-on session left over from an earlier business date (writes the nightly leaderboard). */
  async endStaleAlwaysOn(businessDate: string): Promise<void> {
    const h = this.alwaysOnSession();
    if (h && h.event.businessDate !== businessDate) await this.dispatch(h, { type: 'END_SESSION', now: this.deps.clock.now() }, SYSTEM);
  }

  /** Close championship lobbies nobody started (no-show nights) so always-on can return. */
  async expireStaleLobbies(): Promise<void> {
    const now = this.deps.clock.now();
    for (const h of [...this.sessions.values()]) {
      if (h.event.kind !== 'CHAMPIONSHIP' || h.state.status !== 'WAITING') continue;
      const since = h.event.scheduledAt ?? h.openedAt;
      if (now - since < STALE_LOBBY_MS) continue;
      log.info('closing a championship lobby that was never started', { roomId: this.roomId, eventId: h.event.id });
      await this.dispatch(h, { type: 'END_SESSION', now }, SYSTEM);
      await this.deps.store.markEventCancelled(h.event.id).catch((err) => log.warn('could not cancel stale event', { err }));
    }
  }

  /**
   * Open a championship lobby (status WAITING) and make it the room's active session. Refused while
   * another championship in this room is still live, so a running show can never be orphaned.
   */
  async openEvent(eventId: string, actor: ActorRef = SYSTEM): Promise<SessionHandle | Fail> {
    if (this.stopped) return fail('ROOM_CLOSED', 'The game server is restarting — try again in a moment.');
    const loaded = this.sessionForEvent(eventId);
    const busy = this.liveChampionship(loaded?.id);
    if (busy && busy.event.id !== eventId) {
      return fail('ALREADY_STARTED', `"${busy.event.name}" is still running in this room. End it before opening another show.`);
    }
    if (loaded) {
      if (loaded.state.status === 'COMPLETED') return fail('ROOM_CLOSED', 'This event has already finished');
      this.activate(loaded.id);
      return loaded;
    }
    const event = await this.deps.catalog.findEvent(eventId);
    if (!event || event.roomId !== this.roomId) return fail('NOT_FOUND', 'Event not found for this room');
    if (event.kind !== 'CHAMPIONSHIP') return fail('BAD_REQUEST', 'Only championships have a lobby');
    if (event.status === 'COMPLETED' || event.status === 'CANCELLED') return fail('ROOM_CLOSED', 'This event has already finished');

    const existing = await this.deps.store.findSessionByEvent(eventId);
    if (existing?.state) {
      if (existing.status === 'COMPLETED') return fail('ROOM_CLOSED', 'This event has already finished');
      await this.restoreSession(event, existing.id, existing.state, existing.seq, existing.stateVersion);
      const h = this.sessionForEvent(eventId);
      if (h) this.activate(h.id);
      return h ?? fail('INTERNAL', 'Could not restore the session');
    }
    const now = this.deps.clock.now();
    const state = createSession({
      sessionId: randomUUID(),
      eventId: event.id,
      venueId: this.venue.id,
      roomId: this.room.id,
      mode: event.kind,
      now,
      settings: this.settingsFor(event),
      plan: event.config.plan,
      alwaysOnPackKey: this.venue.settings.alwaysOn.packKey,
    });
    const rec = await this.deps.store.createGameSession(event, state);
    await this.deps.store.markEventLive(event.id);
    const h = this.createHandle(event, state, 0, rec.stateVersion);
    this.activate(h.id);
    log.info('event opened', { roomId: this.roomId, eventId, sessionId: h.id, by: actor.type });
    return h;
  }

  private activate(sessionId: string | null): void {
    if (this.resultsTimer) {
      this.deps.clock.clearTimeout(this.resultsTimer);
      this.resultsTimer = null;
    }
    if (this.activeSessionId === sessionId) return;
    this.activeSessionId = sessionId;
    // Everyone in the room gets a fresh, tailored snapshot (TVs switch streams; players learn a show is live).
    for (const a of this.attached.values()) this.sendSnapshot(a);
    const h = sessionId ? this.sessions.get(sessionId) : null;
    if (h && needsRound(h.state)) void this.buildRound(h);
    if (h && h.event.kind === 'ALWAYS_ON') void this.returnFinishedGuests(h);
  }

  /**
   * After a championship, phones are still bound to the finished show. Any phone that already has a
   * player in tonight's always-on game (same device) is moved straight back into it, with a fresh token,
   * so the room keeps playing without anyone rescanning.
   */
  private async returnFinishedGuests(target: SessionHandle): Promise<void> {
    for (const a of [...this.attached.values()]) {
      const id = a.identity;
      if (id.role !== 'guest' || id.gameSessionId === target.id) continue;
      const own = this.sessions.get(id.gameSessionId);
      if (own && own.state.status !== 'COMPLETED') continue;
      let deviceHash: string | null = null;
      for (const [hash, pid] of own?.devices ?? []) if (pid === id.playerId) deviceHash = hash;
      const playerId = deviceHash ? target.devices.get(deviceHash) : undefined;
      const player = playerId ? target.state.players[playerId] : undefined;
      if (!player || player.status === 'KICKED') continue;
      try {
        const version = target.tokenVersions.get(player.id) ?? 1;
        target.tokenVersions.set(player.id, version);
        const { token, expiresAt } = await this.deps.tokens.signGuest(
          { sub: player.id, gsid: target.id, roomId: this.roomId, venueId: this.venue.id, tv: version },
          this.deps.clock.now(),
        );
        if (this.activeSessionId !== target.id || !this.attached.has(a.socket.id)) return;
        send(a.socket, 'session.token', { type: 'session.token', seq: target.seq, serverNow: this.deps.clock.now(), data: { token, expiresAt, playerSessionId: player.id, gameSessionId: target.id } });
        this.reidentify(a, { role: 'guest', playerId: player.id, gameSessionId: target.id });
      } catch (err) {
        log.warn('could not return a guest to always-on', { roomId: this.roomId, err });
      }
    }
  }

  /** What the room shows next: another live championship first, then tonight's always-on, else attract mode. */
  private fallbackSession(exceptId: string): SessionHandle | null {
    const champ = this.liveChampionship(exceptId);
    if (champ) return champ;
    const alwaysOn = this.alwaysOnSession();
    return alwaysOn && alwaysOn.id !== exceptId ? alwaysOn : null;
  }

  private onSessionCompleted(h: SessionHandle): void {
    h.completedAt = this.deps.clock.now();
    for (const t of h.botTimers) this.deps.clock.clearTimeout(t);
    h.botTimers.clear();
    if (h.timer) this.deps.clock.clearTimeout(h.timer);
    h.timer = null;
    if (this.activeSessionId !== h.id) return;
    // Only a show that actually ran has a finale worth keeping on screen (not a no-show lobby).
    if (h.event.kind === 'CHAMPIONSHIP' && h.state.roundsStarted > 0 && !this.stopped) {
      // Keep the finale (podium, final standings) on the TVs and phones for a while before moving on.
      if (this.resultsTimer) this.deps.clock.clearTimeout(this.resultsTimer);
      this.resultsTimer = this.deps.clock.setTimeout(() => {
        this.resultsTimer = null;
        if (this.activeSessionId === h.id) this.activate(this.fallbackSession(h.id)?.id ?? null);
      }, this.deps.resultsWindowMs ?? RESULTS_WINDOW_MS);
      return;
    }
    this.activate(this.fallbackSession(h.id)?.id ?? null);
  }

  // ───────────────────────────── command pipeline ─────────────────────────────

  /** Run a command against a session. Commands for a room never interleave. */
  dispatch(h: SessionHandle, command: Command, actor: ActorRef): Promise<ReduceResult> {
    const run = (): ReduceResult => {
      if (this.stopped || h.fenced) {
        // Shutting down (or another process owns the session): refuse rather than ack something unsaved.
        return { state: h.state, events: [], reply: { ok: false, error: { code: 'SESSION_NOT_RUNNING', message: 'The game server is restarting — try again in a moment.' } } };
      }
      const before = h.state;
      const result = reduce(h.state, command);
      h.state = result.state;
      if (result.events.length > 0) this.project(h, result.events);
      const changed = result.reply.ok && !result.reply.duplicate;
      // A TICK with no events still matters for replay when it changes who is present (it decides who
      // gets dealt into the next round), so it is logged too; idle heartbeats are not.
      const presenceChanged = command.type === 'TICK' && !samePresence(before.present, result.state.present);
      if (changed && (result.events.length > 0 || command.type !== 'TICK' || presenceChanged)) {
        // Enqueued after projecting so the stored seq is the one clients saw for this transition.
        this.enqueueWrite(h, { ctx: h.ctx, command, actor, events: result.events, state: result.state, seq: h.seq });
      }
      if (result.events.length > 0) this.afterEvents(h, result.events);
      this.armTimer(h);
      return result;
    };
    const p = this.queue.then(run, run);
    this.queue = p.catch(() => undefined);
    return p;
  }

  private present(h: SessionHandle): string[] {
    const ids: string[] = [];
    for (const [id, n] of h.connected) if (n > 0) ids.push(id);
    for (const id of h.bots.keys()) ids.push(id);
    return ids;
  }

  private tick(h: SessionHandle): Promise<ReduceResult> {
    return this.dispatch(h, { type: 'TICK', now: this.deps.clock.now(), present: this.present(h) }, SYSTEM);
  }

  private armTimer(h: SessionHandle): void {
    if (this.stopped) return;
    const at = h.state.status === 'COMPLETED' ? null : nextDeadline(h.state);
    if (at === h.timerAt && h.timer) return;
    if (h.timer) this.deps.clock.clearTimeout(h.timer);
    h.timer = null;
    h.timerAt = at;
    if (at === null) return;
    h.timer = this.deps.clock.setTimeout(() => {
      h.timer = null;
      h.timerAt = null;
      void this.tick(h);
    }, at - this.deps.clock.now());
  }

  private onHeartbeat(): void {
    const now = this.deps.clock.now();
    for (const h of [...this.sessions.values()]) {
      if (h.state.status === 'RUNNING') void this.tick(h);
      if (h.completedAt && now - h.completedAt > COMPLETED_SESSION_TTL_MS && this.activeSessionId !== h.id) {
        this.unloadSession(h);
      }
    }
    for (const [key, v] of this.joinResults) if (now - v.at > JOIN_IDEMPOTENCY_TTL_MS) this.joinResults.delete(key);
  }

  private unloadSession(h: SessionHandle): void {
    if (h.pendingWrites.length > 0 || h.flushing) return;
    if (h.roomStateTimer) this.deps.clock.clearTimeout(h.roomStateTimer);
    const watchers = [...this.attached.values()].filter((a) => this.viewSession(a) === h);
    this.sessions.delete(h.id);
    // Phones and host pages still showing the finished session get a fresh snapshot (no stale stream).
    for (const a of watchers) this.sendSnapshot(a);
  }

  private afterEvents(h: SessionHandle, events: DomainEvent[]): void {
    for (const ev of events) {
      switch (ev.type) {
        case 'RoundDue':
          void this.buildRound(h);
          break;
        case 'QuestionOpened':
          if (h.bots.size > 0) this.scheduleBots(h);
          break;
        case 'PlayerKicked':
          this.disconnectPlayer(h, ev.playerId);
          break;
        case 'SessionCompleted':
          this.onSessionCompleted(h);
          break;
        default:
          break;
      }
    }
  }

  /** RoundDue → choose questions from the pack and dispatch START_ROUND. Only the active session plays rounds. */
  private async buildRound(h: SessionHandle): Promise<void> {
    if (h.building || this.stopped) return;
    if (this.activeSessionId !== h.id) return; // resumed when the session becomes active again
    if (!needsRound(h.state)) return;
    h.building = true;
    try {
      const roundIndex = h.state.roundsStarted;
      const planItem = h.state.plan?.[h.state.planIndex];
      const kind = planItem?.kind ?? 'RUSH';
      const packKey = planItem?.packKey ?? h.state.alwaysOnPackKey;
      const label = planItem?.label ?? 'The Rush';
      const pack = await this.deps.catalog.loadPack(packKey);
      const seed = `${h.id}:${roundIndex}`;
      const questions = selectRoundQuestions({ kind, pool: pack.questions, categories: pack.categories, seed, exclude: h.usedQuestionIds });
      for (const q of questions) h.usedQuestionIds.add(q.id);
      const round: RoundSetup = { id: randomUUID(), kind, label, packKey, seed, categories: pack.categories, questions };
      const result = await this.dispatch(h, { type: 'START_ROUND', now: this.deps.clock.now(), round }, SYSTEM);
      if (!result.reply.ok) log.warn('START_ROUND rejected', { roomId: this.roomId, error: result.reply.error });
    } catch (err) {
      log.error('could not build round; retrying in 5s', { roomId: this.roomId, sessionId: h.id, err });
      this.deps.clock.setTimeout(() => void this.buildRound(h), 5_000);
    } finally {
      h.building = false;
    }
  }

  // ───────────────────────────── write-behind ─────────────────────────────

  private enqueueWrite(h: SessionHandle, batch: PersistBatch): void {
    h.pendingWrites.push(batch);
    if (!h.flushing) void this.flush(h);
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((r) => this.deps.clock.setTimeout(() => r(), ms));
  }

  /**
   * Flush pending batches in order. Normally everything pending goes in one transaction. If that
   * fails, batches are retried one at a time so a single bad batch is found, logged in full
   * (quarantined) and skipped, while every good batch around it is still saved.
   */
  private async flush(h: SessionHandle): Promise<void> {
    h.flushing = true;
    try {
      while (h.pendingWrites.length > 0 && !h.fenced) {
        const batches = h.pendingWrites.slice();
        try {
          h.stateVersion = await this.deps.store.persist(batches, h.stateVersion);
          h.pendingWrites.splice(0, batches.length);
          h.writeFailures = 0;
          continue;
        } catch (err) {
          if (this.fenceIfStale(h, err)) return;
          log.warn('batch write failed; retrying one batch at a time', { roomId: this.roomId, sessionId: h.id, batches: batches.length, err });
        }
        await this.flushOneByOne(h, batches.length);
      }
    } finally {
      h.flushing = false;
    }
  }

  private async flushOneByOne(h: SessionHandle, count: number): Promise<void> {
    for (let i = 0; i < count && h.pendingWrites.length > 0 && !h.fenced; i++) {
      const batch = h.pendingWrites[0]!;
      for (let attempt = 1; ; attempt++) {
        try {
          h.stateVersion = await this.deps.store.persist([batch], h.stateVersion);
          h.pendingWrites.shift();
          break;
        } catch (err) {
          if (this.fenceIfStale(h, err)) return;
          if (attempt >= MAX_BATCH_ATTEMPTS) {
            // Quarantine: keep the full batch in the log so it can be replayed by hand, then move on.
            log.error('quarantined a batch that cannot be saved', {
              roomId: this.roomId,
              sessionId: h.id,
              command: batch.command,
              events: batch.events,
              err,
            });
            h.pendingWrites.shift();
            break;
          }
          await this.sleep(Math.min(5_000, 400 * 2 ** attempt));
        }
      }
    }
  }

  /** Another process wrote this session's snapshot: stop running it here instead of overwriting. */
  private fenceIfStale(h: SessionHandle, err: unknown): boolean {
    if (!(err instanceof StaleSessionError)) return false;
    log.error('another process owns this session; stopping it here', { roomId: this.roomId, sessionId: h.id });
    h.fenced = true;
    h.pendingWrites.length = 0;
    if (h.timer) this.deps.clock.clearTimeout(h.timer);
    for (const t of h.botTimers) this.deps.clock.clearTimeout(t);
    h.timer = null;
    if (this.activeSessionId === h.id) this.activate(null);
    this.sessions.delete(h.id);
    return true;
  }

  /** Resolves once everything accepted so far is durable (tests, shutdown). */
  async drain(): Promise<void> {
    await this.queue;
    for (const h of this.sessions.values()) {
      while (h.pendingWrites.length > 0 || h.flushing) {
        if (!h.flushing) void this.flush(h);
        await new Promise((r) => setTimeout(r, 10));
      }
    }
  }

  // ───────────────────────────── messaging ─────────────────────────────

  private envelope(type: string, h: SessionHandle | null, extra: Record<string, unknown>) {
    return { type, seq: h?.seq ?? 0, serverNow: this.deps.clock.now(), ...extra };
  }

  private viewSession(a: Attached): SessionHandle | null {
    const id = a.identity;
    if (id.role === 'guest') return this.sessions.get(id.gameSessionId) ?? null;
    // A host page is bound to one event: show only that event (nothing until it is opened).
    if (id.role === 'host' && id.eventId) return this.sessionForEvent(id.eventId);
    return this.activeSession();
  }

  private eventStateData() {
    const active = this.activeSession();
    return { activeSessionId: active?.id ?? null, activeEventKind: active?.event.kind ?? null, activeEventName: active?.event.name ?? null };
  }

  /**
   * Public transition: bump seq once, then send every watcher of the session its tailored envelope.
   * `room.state` carries a full per-socket snapshot; other types carry the public (+ private/host) views.
   */
  private broadcast(h: SessionHandle, type: string, opts: { revealedFor?: Set<string>; data?: unknown } = {}): void {
    h.seq++;
    h.lastRoomStateAt = this.deps.clock.now();
    if (h.roomStateTimer) {
      this.deps.clock.clearTimeout(h.roomStateTimer);
      h.roomStateTimer = null;
    }
    const cache = this.renderCache();
    const pub = cache.publicOf(h);
    const ranks = cache.ranksOf(h);
    let host: ReturnType<typeof hostView> | undefined;
    const meCache = new Map<string, PrivateState | null>();
    const extra = opts.data !== undefined ? { data: opts.data } : {};
    for (const a of this.attached.values()) {
      if (this.viewSession(a) !== h) continue;
      if (type === 'room.state') {
        // Hosts also get the roster here, so joins and answer counts show up between transitions.
        if (a.identity.role === 'host') host ??= hostView(h.state);
        send(a.socket, type, this.envelope(type, h, { snapshot: this.snapshotFor(a, cache), ...(a.identity.role === 'host' ? { host } : {}) }));
        continue;
      }
      const id = a.identity;
      if (id.role === 'guest') {
        let me = meCache.get(id.playerId);
        if (me === undefined) {
          me = privateView(h.state, id.playerId, ranks);
          meCache.set(id.playerId, me);
        }
        const t = type === 'question.reveal' && opts.revealedFor?.has(id.playerId) ? 'answer.result' : type;
        send(a.socket, t, this.envelope(t, h, { public: pub, me, ...extra }));
      } else if (id.role === 'host') {
        host ??= hostView(h.state);
        send(a.socket, type, this.envelope(type, h, { public: pub, host, ...extra }));
      } else {
        send(a.socket, type, this.envelope(type, h, { public: pub, ...extra }));
      }
    }
  }

  private scheduleRoomState(h: SessionHandle): void {
    if (h.roomStateTimer) return;
    const wait = Math.max(0, ROOM_STATE_THROTTLE_MS - (this.deps.clock.now() - h.lastRoomStateAt));
    h.roomStateTimer = this.deps.clock.setTimeout(() => {
      h.roomStateTimer = null;
      if (this.sessions.has(h.id)) this.broadcast(h, 'room.state');
    }, wait);
  }

  /** Personal update (no seq bump): the player's private view changed outside a public transition. */
  private sendPersonal(h: SessionHandle, playerId: string, type: string): void {
    const pub = publicView(h.state);
    const me = privateView(h.state, playerId);
    for (const a of this.attached.values()) {
      if (a.identity.role === 'guest' && a.identity.playerId === playerId && a.identity.gameSessionId === h.id) {
        send(a.socket, type, this.envelope(type, h, { public: pub, me }));
      }
    }
  }

  private project(h: SessionHandle, events: DomainEvent[]): void {
    let publicType: string | null = null;
    let data: unknown;
    let throttled = false;
    const revealedFor = new Set<string>();
    const personal = new Map<string, string>();
    for (const ev of events) {
      const t = PUBLIC_TYPES[ev.type];
      if (t) {
        publicType = t;
        data = undefined;
        if (ev.type === 'RoundCompleted') data = { summary: ev.summary, standings: ev.standings };
        else if (ev.type === 'SessionCompleted') data = { standings: ev.standings };
        else if (t === 'event.state') data = this.eventStateData();
      } else if (THROTTLED_TYPES.has(ev.type)) throttled = true;
      if (ev.type === 'QuestionRevealed') {
        for (const pid of h.state.round?.participants ?? []) revealedFor.add(pid);
      }
      if (ev.type === 'BoardDealt') personal.set(ev.playerId, 'board.updated');
      if (ev.type === 'PowerUpUsed') personal.set(ev.playerId, ev.powerUp === 'CATEGORY_SWAP' ? 'board.updated' : 'score.updated');
      if (ev.type === 'AnswerAccepted' || ev.type === 'PredictionMade' || ev.type === 'PlayerTeamChanged') {
        if (!personal.has(ev.playerId)) personal.set(ev.playerId, 'score.updated');
      }
    }
    // When the room's active show starts, pauses, resumes or ends, phones still finishing another session
    // (e.g. an always-on round) need a fresh snapshot so their "Lobby open" / "is live" banner is right.
    if (this.activeSessionId === h.id && events.some((e) => SESSION_STATUS_EVENTS.has(e.type))) {
      for (const a of this.attached.values()) if (this.viewSession(a) !== h) this.sendSnapshot(a);
    }
    if (publicType) {
      this.broadcast(h, publicType, { revealedFor, data });
      return; // every player's `me` rode along with the public envelope
    }
    for (const [pid, type] of personal) this.sendPersonal(h, pid, type);
    if (throttled) this.scheduleRoomState(h);
  }

  /** Views computed once per broadcast and shared by every socket (ranking is the expensive part). */
  private renderCache() {
    const pubs = new Map<string, PublicState>();
    const ranks = new Map<string, Map<string, number>>();
    let nightly: ReturnType<typeof rankPlayers> | undefined;
    return {
      publicOf: (h: SessionHandle) => {
        let p = pubs.get(h.id);
        if (!p) pubs.set(h.id, (p = publicView(h.state)));
        return p;
      },
      ranksOf: (h: SessionHandle) => {
        let r = ranks.get(h.id);
        if (!r) ranks.set(h.id, (r = rankIndex(h.state)));
        return r;
      },
      nightlyTop: (h: SessionHandle | null) => {
        if (!h) return [];
        nightly ??= rankPlayers(h.state).slice(0, 10);
        return nightly;
      },
    };
  }

  snapshotFor(a: Attached, cache = this.renderCache()): RoomSnapshot {
    const active = this.activeSession();
    const id = a.identity;
    const own = id.role === 'guest' ? (this.sessions.get(id.gameSessionId) ?? null) : null;
    const view = this.viewSession(a);
    const alwaysOn = this.alwaysOnSession();
    return {
      roomId: this.room.id,
      venue: { id: this.venue.id, name: this.venue.name, joinCode: this.venue.joinCode },
      room: { id: this.room.id, name: this.room.name },
      activeSessionId: active?.id ?? null,
      activeEventKind: active?.event.kind ?? null,
      activeEventName: active?.event.name ?? null,
      activeEventScheduledAt: active?.event.scheduledAt ?? null,
      activeSessionStatus: active?.state.status ?? null,
      public: view ? cache.publicOf(view) : null,
      me: own && id.role === 'guest' ? privateView(own.state, id.playerId, cache.ranksOf(own)) : null,
      mySessionId: own?.id ?? null,
      mySessionActive: !!own && own.id === active?.id,
      attract: {
        nightlyTop: cache.nightlyTop(alwaysOn),
        nextChampionship: this.nextChampionship,
        nextRoundAt: alwaysOn?.state.nextRoundAt ?? null,
      },
      seq: view?.seq ?? 0,
      serverNow: this.deps.clock.now(),
    };
  }

  sendSnapshot(a: Attached): void {
    const view = this.viewSession(a);
    const host = a.identity.role === 'host' && view ? { host: hostView(view.state) } : {};
    send(a.socket, 'room.state', this.envelope('room.state', view, { snapshot: this.snapshotFor(a), ...host }));
  }

  async refreshAttract(): Promise<void> {
    try {
      this.nextChampionship = await this.deps.catalog.nextChampionship(this.roomId, this.deps.clock.now());
    } catch (err) {
      log.warn('could not refresh next championship', { roomId: this.roomId, err });
    }
  }

  // ───────────────────────────── sockets ─────────────────────────────

  attach(a: Attached): void {
    this.attached.set(a.socket.id, a);
    if (a.identity.role === 'guest') {
      const h = this.sessions.get(a.identity.gameSessionId);
      if (h) {
        h.connected.set(a.identity.playerId, (h.connected.get(a.identity.playerId) ?? 0) + 1);
        if (h.state.status === 'RUNNING') void this.tick(h); // deal a late board right away
        void this.deps.store.touchPlayer(a.identity.playerId, this.deps.clock.now()).catch(() => undefined);
      }
    }
    this.sendSnapshot(a);
  }

  detach(socketId: string): Attached | null {
    const a = this.attached.get(socketId);
    if (!a) return null;
    this.attached.delete(socketId);
    if (a.identity.role === 'guest') {
      const h = this.sessions.get(a.identity.gameSessionId);
      if (h) {
        const n = (h.connected.get(a.identity.playerId) ?? 1) - 1;
        if (n <= 0) h.connected.delete(a.identity.playerId);
        else h.connected.set(a.identity.playerId, n);
      }
    }
    return a;
  }

  /** Swap a socket's identity (e.g. an anonymous socket that just joined). */
  reidentify(a: Attached, identity: Identity): void {
    this.detach(a.socket.id);
    a.identity = identity;
    this.attach(a);
  }

  private disconnectPlayer(h: SessionHandle, playerId: string): void {
    h.tokenVersions.set(playerId, (h.tokenVersions.get(playerId) ?? 1) + 1000);
    for (const [hash, pid] of h.devices) if (pid === playerId) h.devices.delete(hash);
    for (const a of [...this.attached.values()]) {
      if (a.identity.role === 'guest' && a.identity.playerId === playerId && a.identity.gameSessionId === h.id) {
        send(a.socket, 'player.kicked', this.envelope('player.kicked', h, { data: { playerSessionId: playerId } }));
        this.reidentify(a, { role: 'anon' });
      }
    }
  }

  /**
   * Reconnect with a guest token: accept the current or previous token version (a phone may have
   * dropped before saving the rotated token), then rotate.
   */
  async authenticateGuest(claims: { sub: string; gsid: string; tv: number }): Promise<
    { ok: true; identity: Identity; token: string; expiresAt: number } | Fail
  > {
    const h = this.sessions.get(claims.gsid);
    if (!h) return fail('NO_ACTIVE_SESSION', 'That game has ended');
    const player = h.state.players[claims.sub];
    if (!player) return fail('UNAUTHORIZED', 'Unknown player');
    if (player.status === 'KICKED') return fail('FORBIDDEN', 'Removed by the host');
    let current = h.tokenVersions.get(claims.sub);
    if (current === undefined) {
      const rec = (await this.deps.store.listPlayers(h.id)).find((p) => p.id === claims.sub);
      current = rec?.tokenVersion ?? 1;
      h.tokenVersions.set(claims.sub, current);
    }
    if (claims.tv < current - 1 || claims.tv > current) return fail('UNAUTHORIZED', 'This link has expired — please rejoin');
    const next = this.deps.store.durable ? await this.deps.store.bumpTokenVersion(claims.sub) : current + 1;
    h.tokenVersions.set(claims.sub, next);
    const { token, expiresAt } = await this.deps.tokens.signGuest(
      { sub: claims.sub, gsid: h.id, roomId: this.roomId, venueId: this.venue.id, tv: next },
      this.deps.clock.now(),
    );
    return { ok: true, identity: { role: 'guest', playerId: claims.sub, gameSessionId: h.id }, token, expiresAt };
  }

  // ───────────────────────────── guest commands ─────────────────────────────

  async join(
    a: Attached,
    p: { nickname: string; teamName?: string | null | undefined; deviceId: string; idempotencyKey: string },
  ): Promise<{ ok: true; data: JoinResult & { snapshot: RoomSnapshot } } | Fail> {
    if (this.stopped) return fail('ROOM_CLOSED', 'The game server is restarting — try again in a moment.');
    const deviceHash = hashDeviceId(p.deviceId, this.deps.config.deviceSalt);

    // A retried join (same idempotency key) gets the same answer — but only if that player is still
    // in good standing and it is the same phone; a kicked player cannot replay their way back in.
    const cached = this.joinResults.get(p.idempotencyKey);
    if (cached) {
      const h = this.sessions.get(cached.result.gameSessionId);
      const player = h?.state.players[cached.result.playerSessionId];
      if (!h || !player || player.status === 'KICKED' || cached.deviceHash !== deviceHash || h.state.status === 'COMPLETED') {
        return fail('FORBIDDEN', 'That join link is no longer valid — join again.');
      }
      this.reidentify(a, { role: 'guest', playerId: player.id, gameSessionId: h.id });
      return { ok: true, data: { ...cached.result, snapshot: this.snapshotFor(a) } };
    }

    // During a finished championship's results window, newcomers go straight into tonight's always-on.
    const active = this.activeSession();
    const h = active && active.state.status !== 'COMPLETED' ? active : active ? this.fallbackSession(active.id) : null;
    if (!h) return fail('NO_ACTIVE_SESSION', 'No game is running here right now. Check the TV for the next round.');

    // Simultaneous taps from one phone (or the same retry in flight) share one join instead of racing.
    const deviceKey = `${h.id}:${deviceHash}`;
    const keyKey = `key:${p.idempotencyKey}`;
    let pending = this.inflightJoins.get(deviceKey) ?? this.inflightJoins.get(keyKey);
    if (!pending) {
      pending = this.joinOnce(h, p, deviceHash).finally(() => {
        this.inflightJoins.delete(deviceKey);
        this.inflightJoins.delete(keyKey);
      });
      this.inflightJoins.set(deviceKey, pending);
      this.inflightJoins.set(keyKey, pending);
    }
    const res = await pending;
    if (!res.ok) return res;
    this.reidentify(a, { role: 'guest', playerId: res.data.playerSessionId, gameSessionId: res.data.gameSessionId });
    return { ok: true, data: { ...res.data, snapshot: this.snapshotFor(a) } };
  }

  private async joinOnce(
    h: SessionHandle,
    p: { nickname: string; teamName?: string | null | undefined; deviceId: string; idempotencyKey: string },
    deviceHash: string,
  ): Promise<{ ok: true; data: JoinResult } | Fail> {
    const nick = validateNickname(p.nickname);
    if (!nick.ok) {
      return nick.reason === 'PROFANITY'
        ? fail('NICKNAME_PROFANE', "Let's keep it friendly — try another nickname.")
        : fail('NICKNAME_INVALID', nicknameMessage(nick.reason));
    }
    let team: { id: string; name: string } | null = null;
    if (p.teamName && p.teamName.trim()) {
      const t = validateTeamName(p.teamName);
      if (!t.ok) return fail('TEAM_NAME_INVALID', t.reason === 'PROFANITY' ? "Let's keep the team name friendly." : 'Team names are 2–24 letters or numbers.');
      // The engine resolves teams by name inside the serial command, so racing joins share one team.
      team = this.teamFor(h, t.nickname);
    }

    const now = this.deps.clock.now();
    // Same phone, same game: hand back the existing player instead of creating a duplicate.
    const existingId = h.devices.get(deviceHash);
    const existing = existingId ? h.state.players[existingId] : undefined;
    let playerId: string;
    let nickname: string;
    if (existing && existing.status === 'KICKED') return fail('FORBIDDEN', 'You were removed from this game by the host.');
    if (existing) {
      playerId = existing.id;
      nickname = existing.nickname;
      if (team && existing.teamId !== team.id) await this.dispatch(h, { type: 'SET_TEAM', now, playerId, team }, { type: 'GUEST', id: playerId });
    } else {
      const active = Object.values(h.state.players).filter((pl) => pl.status !== 'KICKED').length;
      if (active >= MAX_PLAYERS_PER_SESSION) return fail('ROOM_CLOSED', 'This game is full right now — try the next one.');
      const created = await this.createPlayer(h, nick.nickname, deviceHash);
      if (!created.ok) return created;
      playerId = created.id;
      nickname = created.nickname;
      const r = await this.dispatch(h, { type: 'JOIN', now, player: { id: playerId, nickname }, team }, { type: 'GUEST', id: playerId });
      if (!r.reply.ok) return fail(r.reply.error?.code ?? 'INTERNAL', r.reply.error?.message ?? 'Could not join');
      h.devices.set(deviceHash, playerId);
    }
    const version = h.tokenVersions.get(playerId) ?? 1;
    h.tokenVersions.set(playerId, version);
    const { token, expiresAt } = await this.deps.tokens.signGuest(
      { sub: playerId, gsid: h.id, roomId: this.roomId, venueId: this.venue.id, tv: version },
      this.deps.clock.now(),
    );
    const teamId = h.state.players[playerId]?.teamId ?? null;
    const teamName = teamId ? (h.state.teams[teamId]?.name ?? null) : null;
    const result: JoinResult = { token, expiresAt, playerSessionId: playerId, gameSessionId: h.id, roomId: this.roomId, nickname, teamName };
    this.rememberJoin(p.idempotencyKey, result, deviceHash);
    return { ok: true, data: result };
  }

  /** Bounded idempotency cache: oldest entries go first once it is full. */
  private rememberJoin(key: string, result: JoinResult, deviceHash: string): void {
    this.joinResults.set(key, { at: this.deps.clock.now(), result, deviceHash });
    while (this.joinResults.size > MAX_JOIN_RESULTS) {
      const oldest = this.joinResults.keys().next().value;
      if (oldest === undefined) break;
      this.joinResults.delete(oldest);
    }
  }

  private async createPlayer(h: SessionHandle, base: string, deviceHash: string | null): Promise<{ ok: true; id: string; nickname: string } | Fail> {
    const taken = new Set(Object.values(h.state.players).map((pl) => nicknameKey(pl.nickname)));
    for (let attempt = 0; attempt < 5; attempt++) {
      const nickname = dedupeNickname(base, taken);
      const key = nicknameKey(nickname);
      const created = await this.deps.store.createPlayerSession({
        venueId: this.venue.id,
        roomId: this.roomId,
        eventId: h.event.id,
        gameSessionId: h.id,
        nickname,
        nicknameKey: key,
        deviceHash,
      });
      if (created !== 'NICKNAME_TAKEN') {
        h.tokenVersions.set(created.id, 1);
        return { ok: true, id: created.id, nickname };
      }
      taken.add(key); // raced with another join (or a player this process hasn't seen) — try the next suffix
    }
    return fail('NICKNAME_INVALID', 'That nickname is busy — try another.');
  }

  private teamFor(h: SessionHandle, name: string): { id: string; name: string } {
    const key = nicknameKey(name);
    for (const t of Object.values(h.state.teams)) if (nicknameKey(t.name) === key) return { id: t.id, name: t.name };
    return { id: randomUUID(), name };
  }

  private guestSession(a: Attached): { h: SessionHandle; playerId: string } | Fail {
    if (a.identity.role !== 'guest') return fail('UNAUTHORIZED', 'Join the game first');
    const h = this.sessions.get(a.identity.gameSessionId);
    if (!h) return fail('NO_ACTIVE_SESSION', 'That game has ended');
    return { h, playerId: a.identity.playerId };
  }

  /** Run a player command; `toData` shapes the ack's success data (protocol CLIENT_ACK_DATA). */
  async guestCommand(
    a: Attached,
    build: (playerId: string) => Command,
    toData?: (r: ReduceResult, h: SessionHandle, playerId: string) => unknown,
  ): Promise<{ ok: true; data?: unknown } | Fail> {
    const g = this.guestSession(a);
    if ('ok' in g) return g;
    const r = await this.dispatch(g.h, build(g.playerId), { type: 'GUEST', id: g.playerId });
    if (!r.reply.ok) return fail(r.reply.error?.code ?? 'BAD_REQUEST', r.reply.error?.message ?? 'Not allowed right now');
    return toData ? { ok: true, data: toData(r, g.h, g.playerId) } : { ok: true };
  }

  async setTeam(a: Attached, teamName: string | null, now: number): Promise<{ ok: true; data?: unknown } | Fail> {
    const g = this.guestSession(a);
    if ('ok' in g) return g;
    let team: { id: string; name: string } | null = null;
    if (teamName && teamName.trim()) {
      const t = validateTeamName(teamName);
      if (!t.ok) return fail('TEAM_NAME_INVALID', t.reason === 'PROFANITY' ? "Let's keep the team name friendly." : 'Team names are 2–24 letters or numbers.');
      team = this.teamFor(g.h, t.nickname);
    }
    return this.guestCommand(
      a,
      (playerId) => ({ type: 'SET_TEAM', now, playerId, team }),
      (_r, h, playerId) => {
        const teamId = h.state.players[playerId]?.teamId ?? null;
        return { teamId, teamName: teamId ? (h.state.teams[teamId]?.name ?? null) : null };
      },
    );
  }

  // ───────────────────────────── host commands ─────────────────────────────

  async hostCommand(
    a: Attached,
    action: 'open' | 'start' | 'pause' | 'resume' | 'end' | 'advance',
    eventId: string,
    now: number,
    idempotencyKey: string,
  ): Promise<{ ok: true; data?: unknown } | Fail> {
    if (a.identity.role !== 'host') return fail('FORBIDDEN', 'Host access required');
    const actor: ActorRef = { type: 'HOST', id: a.identity.venueUserId };
    if (action === 'open' || action === 'start') {
      const opened = await this.openEvent(eventId, actor);
      if ('ok' in opened) return opened;
      if (action === 'open') return { ok: true };
      const r = await this.dispatch(opened, { type: 'START_SESSION', now, idempotencyKey }, actor);
      return r.reply.ok || r.reply.error?.code === 'ALREADY_STARTED'
        ? { ok: true }
        : fail(r.reply.error?.code ?? 'BAD_REQUEST', r.reply.error?.message ?? 'Could not start');
    }
    const h = this.sessionForEvent(eventId);
    if (!h) return fail('NOT_FOUND', 'Open the event first');
    const type = ({ pause: 'PAUSE', resume: 'RESUME', end: 'END_SESSION', advance: 'ADVANCE' } as const)[action];
    const r = await this.dispatch(h, { type, now, idempotencyKey }, actor);
    return r.reply.ok ? { ok: true } : fail(r.reply.error?.code ?? 'BAD_REQUEST', r.reply.error?.message ?? 'Not allowed right now');
  }

  async moderate(
    a: Attached,
    p: { eventId: string; playerSessionId: string; action: 'rename' | 'kick'; nickname?: string | undefined },
    now: number,
  ): Promise<{ ok: true; data?: unknown } | Fail> {
    if (a.identity.role !== 'host') return fail('FORBIDDEN', 'Host access required');
    const h = this.sessionForEvent(p.eventId);
    if (!h) return fail('NOT_FOUND', 'Event is not running');
    const actor: ActorRef = { type: 'HOST', id: a.identity.venueUserId };
    if (p.action === 'kick') {
      const r = await this.dispatch(h, { type: 'KICK_PLAYER', now, playerId: p.playerSessionId }, actor);
      const player = h.state.players[p.playerSessionId];
      return r.reply.ok && player
        ? { ok: true, data: { playerSessionId: player.id, status: player.status, nickname: player.nickname } }
        : fail(r.reply.error?.code ?? 'BAD_REQUEST', r.reply.error?.message ?? 'Could not remove player');
    }
    const nick = validateNickname(p.nickname ?? '');
    if (!nick.ok) return fail(nick.reason === 'PROFANITY' ? 'NICKNAME_PROFANE' : 'NICKNAME_INVALID', nicknameMessage(nick.reason));
    const taken = new Set(
      Object.values(h.state.players)
        .filter((pl) => pl.id !== p.playerSessionId)
        .map((pl) => nicknameKey(pl.nickname)),
    );
    const nickname = dedupeNickname(nick.nickname, taken);
    const r = await this.dispatch(h, { type: 'RENAME_PLAYER', now, playerId: p.playerSessionId, nickname }, actor);
    const player = h.state.players[p.playerSessionId];
    return r.reply.ok && player
      ? { ok: true, data: { playerSessionId: player.id, status: player.status, nickname: player.nickname } }
      : fail(r.reply.error?.code ?? 'BAD_REQUEST', r.reply.error?.message ?? 'Could not rename');
  }

  /** host.sync ack (protocol HostSyncResult). */
  hostSnapshot(eventId: string | null) {
    const h = eventId ? this.sessionForEvent(eventId) : this.activeSession();
    return { eventId: h?.event.id ?? eventId, roomId: this.roomId, seq: h?.seq ?? 0, host: h ? hostView(h.state) : null };
  }

  // ───────────────────────────── demo bots ─────────────────────────────

  private async addBots(h: SessionHandle): Promise<void> {
    for (let i = 0; i < BOT_NAMES.length; i++) {
      const name = BOT_NAMES[i]!;
      const created = await this.createPlayer(h, name, null);
      if (!created.ok) continue;
      h.bots.set(created.id, BOT_SKILLS[i] ?? 0.5);
      await this.dispatch(h, { type: 'JOIN', now: this.deps.clock.now(), player: { id: created.id, nickname: created.nickname, isBot: true } }, SYSTEM);
    }
  }

  private scheduleBots(h: SessionHandle): void {
    const round = h.state.round;
    if (!round || round.phase !== 'OPEN' || round.openedAt === null) return;
    const question = round.questions[round.qIndex];
    if (!question) return;
    const isWildcard = round.wildcardIndex === round.qIndex;
    const windowMs = windowFor(question, isWildcard, h.state.settings.timings);
    for (const [botId, skill] of h.bots) {
      if (!round.participants.includes(botId)) continue;
      const d = botDecision({ seed: `${round.seed}:${botId}`, question, skill, windowMs });
      const at = round.openedAt + d.delayMs;
      const timer = this.deps.clock.setTimeout(() => {
        h.botTimers.delete(timer);
        void this.dispatch(
          h,
          {
            type: 'SUBMIT_ANSWER',
            now: this.deps.clock.now(),
            playerId: botId,
            questionId: question.id,
            optionId: d.optionId,
            wildcardPosition: null,
            idempotencyKey: `bot:${round.id}:${question.id}:${botId}`,
          },
          SYSTEM,
        );
      }, at - this.deps.clock.now());
      h.botTimers.add(timer);
    }
  }

  // ───────────────────────────── shutdown ─────────────────────────────

  /** Refuse new commands from now on (nothing gets acked that the final drain would miss). */
  beginShutdown(): void {
    this.stopped = true;
  }

  async stop(): Promise<void> {
    this.stopped = true;
    if (this.heartbeat) this.deps.clock.clearInterval(this.heartbeat);
    if (this.resultsTimer) this.deps.clock.clearTimeout(this.resultsTimer);
    for (const h of this.sessions.values()) {
      if (h.timer) this.deps.clock.clearTimeout(h.timer);
      if (h.roomStateTimer) this.deps.clock.clearTimeout(h.roomStateTimer);
      for (const t of h.botTimers) this.deps.clock.clearTimeout(t);
    }
    await this.drain();
  }
}

export interface JoinResult {
  token: string;
  expiresAt: number;
  playerSessionId: string;
  gameSessionId: string;
  roomId: string;
  nickname: string;
  teamName: string | null;
}

function samePresence(a: readonly string[], b: readonly string[]): boolean {
  if (a.length !== b.length) return false;
  return a.every((id, i) => id === b[i]);
}

function nicknameMessage(reason: string): string {
  switch (reason) {
    case 'TOO_SHORT':
      return 'Nicknames need at least 2 characters.';
    case 'TOO_LONG':
      return 'Nicknames can be up to 16 characters.';
    case 'RESERVED':
      return 'That nickname is reserved — try another.';
    default:
      return 'Use letters, numbers and simple punctuation.';
  }
}

export type { AckFn };
