import { describe, expect, it } from 'vitest';
import {
  DEFAULT_TIMINGS,
  LATE_TRANSITION_MS,
  needsRound,
  nextDeadline,
  privateView,
  publicView,
  reduce,
  type DomainEvent,
  type RoundPlanItem,
} from '../src/index';
import { CATS, Game, T0, boardSetup, finalRushSetup, makePack, mcQuestion } from './helpers';

const t = DEFAULT_TIMINGS;

function runningAlwaysOn(players: string[] = ['p1', 'p2']): Game {
  const g = Game.create();
  g.start();
  for (const p of players) g.join(p);
  return g;
}

describe('session start and round due', () => {
  it('starts WAITING, START_SESSION → RUNNING; always-on round is due only once someone is present', () => {
    const g = Game.create();
    expect(g.state.status).toBe('WAITING');
    expect(nextDeadline(g.state)).toBeNull();
    const res = g.start();
    expect(res.events.map((e) => e.type)).toEqual(['SessionStarted']);
    expect(g.state.status).toBe('RUNNING');
    expect(needsRound(g.state)).toBe(false);
    // Idle: nobody present → no deadline, TICKs keep it idle.
    expect(nextDeadline(g.state)).toBeNull();
    g.later(60_000).tick();
    expect(g.state.awaitingRound).toBe(false);
    // First join makes the round due immediately.
    const joined = g.join('p1');
    expect(joined.events.map((e) => e.type)).toEqual(['PlayerJoined', 'RoundDue']);
    const due = joined.events[1] as Extract<DomainEvent, { type: 'RoundDue' }>;
    expect(due).toMatchObject({ roundIndex: 0, kind: 'RUSH', packKey: 'originals-v1', label: 'The Rush' });
    expect(needsRound(g.state)).toBe(true);
    expect(nextDeadline(g.state)).toBeNull();
  });

  it('always-on stays idle while present is empty and starts when a TICK reports someone', () => {
    const g = Game.create();
    g.start();
    g.join('p1');
    g.startRound(boardSetup('r1'));
    g.present = [];
    g.runUntil((x) => x.state.round === null);
    expect(g.state.nextRoundAt).toBe(g.eventsOf('RoundCompleted')[0]!.at + t.breakMs);
    g.tick(g.state.nextRoundAt! + 5_000);
    expect(g.state.awaitingRound).toBe(false);
    expect(nextDeadline(g.state)).toBeNull();
    g.present = ['p1'];
    const res = g.tick(g.now + 1_000);
    expect(res.events.map((e) => e.type)).toEqual(['RoundDue']);
    // Late by > 2 s → RoundDue is stamped now, not at the stale nextRoundAt.
    expect(res.events[0]!.at).toBe(g.now);
  });

  it('rejects START_SESSION twice, honours idempotency keys', () => {
    const g = Game.create();
    expect(g.send({ type: 'START_SESSION', now: T0, idempotencyKey: 'k1' }).reply).toEqual({ ok: true });
    expect(g.send({ type: 'START_SESSION', now: T0 + 1, idempotencyKey: 'k1' }).reply).toEqual({ ok: true, duplicate: true });
    expect(g.send({ type: 'START_SESSION', now: T0 + 2 }).reply.error?.code).toBe('ALREADY_STARTED');
  });
});

describe('question lifecycle', () => {
  it('walks INTRO → (PENDING → OPEN → LOCKED → REVEALED)×10 → ROUND_OVER → completed with exact timings', () => {
    const g = runningAlwaysOn(['p1']);
    const start = g.now;
    const res = g.startRound(boardSetup('r1'));
    expect(res.events.map((e) => e.type)).toEqual(['RoundStarted', 'BoardDealt']);
    expect(g.round.phase).toBe('INTRO');
    expect(g.round.phaseEndsAt).toBe(start + t.introMs);
    g.runUntil((x) => x.state.round === null);
    const types = g.events.map((e) => e.type).filter((type) => !['PlayerJoined', 'RoundDue', 'SessionStarted', 'BoardDealt', 'RoundStarted'].includes(type));
    const expected: string[] = [];
    for (let i = 0; i < 10; i++) expected.push('QuestionPending', 'QuestionOpened', 'QuestionLocked', 'QuestionRevealed', 'QuestionCompleted');
    expected.push('RoundOver', 'RoundCompleted');
    expect(types).toEqual(expected);
    // Nobody answered → every question ran its full window; the wildcard has the long window.
    const opened = g.eventsOf('QuestionOpened');
    expect(opened[0]!.openedAt).toBe(start + t.introMs + t.pendingMs);
    expect(opened[0]!.closesAt - opened[0]!.openedAt).toBe(t.windowMs);
    expect(opened[9]!.closesAt - opened[9]!.openedAt).toBe(t.difficultWindowMs);
    const perQuestion = t.pendingMs + t.windowMs + t.lockedMs + t.revealMs;
    const completed = g.eventsOf('RoundCompleted')[0]!;
    expect(completed.at).toBe(start + t.introMs + 9 * perQuestion + (t.pendingMs + t.difficultWindowMs + t.lockedMs + t.revealMs) + t.roundOverMs);
    expect(g.state.round).toBeNull();
    expect(g.state.completedRounds).toHaveLength(1);
    expect(g.state.completedRounds[0]).toMatchObject({ id: 'r1', index: 0, kind: 'RUSH', completedAt: completed.at });
    expect(g.state.nextRoundAt).toBe(completed.at + t.breakMs);
  });

  it('locks early once every participant answered, and reveal grades in joinIndex order with ordered events', () => {
    const g = runningAlwaysOn(['p1', 'p2']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const openedAt = g.round.openedAt!;
    g.at(openedAt + 2_000);
    const a2 = g.answer('p2', 'a');
    expect(a2.events.map((e) => e.type)).toEqual(['AnswerAccepted']);
    g.at(openedAt + 3_000);
    const a1 = g.answer('p1', 'b');
    expect(a1.events.map((e) => e.type)).toEqual(['AnswerAccepted', 'QuestionLocked']);
    expect(g.round.phase).toBe('LOCKED');
    expect(g.round.closesAt).toBe(openedAt + 3_000);
    const reveal = g.tick(g.round.phaseEndsAt);
    expect(reveal.events.map((e) => e.type)).toEqual(['AnswerGraded', 'AnswerGraded', 'TileActivated', 'ScoreAwarded', 'ScoreAwarded', 'QuestionRevealed']);
    const graded = reveal.events.filter((e) => e.type === 'AnswerGraded');
    expect(graded.map((e) => e.playerId)).toEqual(['p1', 'p2']);
    const revealed = reveal.events.at(-1) as Extract<DomainEvent, { type: 'QuestionRevealed' }>;
    expect(revealed.result).toMatchObject({ correctOptionId: 'a', answeredCount: 2, correctCount: 1, distribution: { a: 1, b: 1, c: 0, d: 0 } });
    expect(revealed.result.fastest).toEqual([{ playerId: 'p2', nickname: 'p2', elapsedMs: 2_000 }]);
  });

  it('accepts an answer stamped exactly at closesAt (bonus 0) and rejects one ms later as TOO_LATE', () => {
    const g = runningAlwaysOn(['p1', 'p2']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const closesAt = g.round.closesAt!;
    g.at(closesAt);
    expect(g.answer('p1', 'a').reply.ok).toBe(true);
    g.at(closesAt + 1);
    expect(g.answer('p2', 'a').reply.error?.code).toBe('TOO_LATE');
    g.runUntil((x) => x.state.round?.phase === 'REVEALED');
    expect(g.round.answers[g.question.id]!['p1']).toMatchObject({ correct: true, points: 100, speedBonus: 0 });
  });

  it('rejects answers in the wrong phase, for the wrong question, unknown options and repeats', () => {
    const g = runningAlwaysOn(['p1', 'p2']);
    g.startRound(boardSetup('r1'));
    expect(g.answer('p1', 'a', { questionId: 'r1-q0' }).reply.error?.code).toBe('WRONG_QUESTION'); // INTRO: no current question
    g.advanceTo('PENDING', 0);
    expect(g.answer('p1', 'a').reply.error?.code).toBe('WRONG_PHASE');
    g.advanceTo('OPEN', 0);
    expect(g.answer('p1', 'a', { questionId: 'r1-q1' }).reply.error?.code).toBe('WRONG_QUESTION');
    expect(g.answer('p1', 'z').reply.error?.code).toBe('INVALID_OPTION');
    expect(g.answer('ghost', 'a').reply.error?.code).toBe('UNKNOWN_PLAYER');
    expect(g.answer('p1', 'a').reply.ok).toBe(true);
    expect(g.answer('p1', 'b', { key: 'another-key' }).reply.error?.code).toBe('ALREADY_ANSWERED');
    // Same key again → duplicate success, no state change.
    const before = g.state;
    const dup = g.answer('p1', 'c');
    expect(dup.reply).toEqual({ ok: true, duplicate: true });
    expect(dup.events).toEqual([]);
    expect(g.state).toBe(before);
  });

  it('a duplicate answer replay after early lock still returns ok + duplicate', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    g.later(1_000);
    expect(g.answer('p1', 'a').events.map((e) => e.type)).toEqual(['AnswerAccepted', 'QuestionLocked']);
    expect(g.answer('p1', 'a').reply).toEqual({ ok: true, duplicate: true });
  });

  it('client time is never used: only server receive time drives elapsed and bonus', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const openedAt = g.round.openedAt!;
    // Commands carry no client timestamp at all; a hostile extra field is ignored.
    g.at(openedAt + 7_875);
    const res = g.send({
      type: 'SUBMIT_ANSWER',
      now: g.now,
      playerId: 'p1',
      questionId: g.question.id,
      optionId: 'a',
      idempotencyKey: 'k',
      clientTime: openedAt,
    } as never);
    expect(res.events[0]).toMatchObject({ type: 'AnswerAccepted', receivedAt: openedAt + 7_875, elapsedMs: 7_875 });
    g.runUntil((x) => x.state.round?.phase === 'REVEALED');
    // (7875 - 750) / (15000 - 750) = 0.5 → 25
    expect(g.round.answers[g.question.id]!['p1']!.speedBonus).toBe(25);
  });
});

describe('late join and lazy dealing', () => {
  it('deals at round start only to present players; others are dealt on TICK presence or on action while the window is open', () => {
    const g = runningAlwaysOn(['p1', 'p2', 'p3']);
    g.present = ['p1'];
    g.tick(g.now + 10);
    const res = g.startRound(boardSetup('r1'));
    expect(res.events.filter((e) => e.type === 'BoardDealt').map((e) => e.playerId)).toEqual(['p1']);
    expect(g.round.participants).toEqual(['p1']);
    // p2 shows up on a TICK during INTRO → dealt.
    g.present = ['p1', 'p2'];
    const tick = g.tick(g.now + 100);
    expect(tick.events.map((e) => e.type)).toEqual(['BoardDealt']);
    expect(g.round.participants).toEqual(['p1', 'p2']);
    // p3 (not present) acts during Q1 OPEN → dealt in and the answer counts.
    g.advanceTo('OPEN', 1);
    g.later(500);
    const act = g.answer('p3', 'a');
    expect(act.events.map((e) => e.type)).toEqual(['BoardDealt', 'AnswerAccepted']);
    expect(g.round.participants).toEqual(['p1', 'p2', 'p3']);
    expect(g.round.boards['p3']!.symmetry).toBe(2); // joinIndex 2 + round 0
  });

  it('window stays open through Q3 PENDING and closes the moment Q3 (index 2) opens', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('PENDING', 2);
    g.join('late1');
    expect(g.round.participants).toContain('late1');
    expect(privateView(g.state, 'late1')!.waitingForNextRound).toBe(false);
    g.advanceTo('OPEN', 2);
    g.join('late2');
    expect(g.round.participants).not.toContain('late2');
    expect(g.round.boards['late2']).toBeUndefined();
    const view = privateView(g.state, 'late2')!;
    expect(view.inRound).toBe(false);
    expect(view.waitingForNextRound).toBe(true);
    expect(view.board).toBeNull();
    g.later(100);
    expect(g.answer('late2', 'a').reply.error?.code).toBe('NOT_PARTICIPANT');
    // Presence after the window does not deal either.
    const tick = g.tick(g.now + 100);
    expect(tick.events.filter((e) => e.type === 'BoardDealt')).toEqual([]);
    // The next round includes them.
    g.packs['originals-v1'] = makePack(CATS, 3, 'p');
    g.runUntil((x) => x.state.round?.id === 'round-1');
    expect(g.round.participants).toContain('late2');
  });

  it('a JOIN during INTRO deals immediately with the next symmetry', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    const res = g.join('p2');
    expect(res.events.map((e) => e.type)).toEqual(['PlayerJoined', 'BoardDealt']);
    expect(g.round.boards['p2']!.symmetry).toBe(1);
  });
});

describe('pause, resume, advance, end', () => {
  it('PAUSE freezes transitions and rejects answers; RESUME shifts every deadline by the paused duration', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const { openedAt, closesAt, phaseEndsAt } = g.round;
    g.later(3_000);
    expect(g.send({ type: 'PAUSE', now: g.now }).events.map((e) => e.type)).toEqual(['SessionPaused']);
    expect(g.send({ type: 'PAUSE', now: g.now }).reply.error?.code).toBe('SESSION_NOT_RUNNING');
    expect(nextDeadline(g.state)).toBeNull();
    g.later(60_000);
    expect(g.answer('p1', 'a').reply.error?.code).toBe('SESSION_NOT_RUNNING');
    expect(g.tick().events).toEqual([]); // deadline passed long ago, but paused
    expect(g.round.phase).toBe('OPEN');
    const res = g.must({ type: 'RESUME', now: g.now });
    expect(res.events).toEqual([{ type: 'SessionResumed', at: g.now, pausedForMs: 60_000 }]);
    expect(g.round.openedAt).toBe(openedAt! + 60_000);
    expect(g.round.closesAt).toBe(closesAt! + 60_000);
    expect(g.round.phaseEndsAt).toBe(phaseEndsAt + 60_000);
    // Paused time does not count against the player: 3 s before + 1 s after = 4 s elapsed.
    g.later(1_000);
    g.answer('p1', 'a');
    expect(g.round.answers[g.question.id]!['p1']!.elapsedMs).toBe(4_000);
    expect(g.send({ type: 'RESUME', now: g.now }).reply.error?.code).toBe('NOT_PAUSED');
  });

  it('RESUME shifts the break between rounds too', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.runUntil((x) => x.state.round === null);
    const due = g.state.nextRoundAt!;
    g.must({ type: 'PAUSE', now: g.now + 1_000 });
    g.must({ type: 'RESUME', now: g.now + 11_000 });
    expect(g.state.nextRoundAt).toBe(due + 10_000);
  });

  it('ADVANCE cuts the current phase short (INTRO, PENDING, OPEN, LOCKED, REVEALED, ROUND_OVER) and the break', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    const phases: string[] = [];
    for (let i = 0; i < 6; i++) {
      g.later(100);
      g.must({ type: 'ADVANCE', now: g.now });
      phases.push(g.round.phase);
    }
    expect(phases).toEqual(['PENDING', 'OPEN', 'LOCKED', 'REVEALED', 'PENDING', 'OPEN']);
    expect(g.round.qIndex).toBe(1);
    // OPEN cut short: closesAt moves to the advance time, late answers are TOO_LATE.
    g.later(100);
    g.must({ type: 'ADVANCE', now: g.now });
    expect(g.round.closesAt).toBe(g.now);
    g.later(1);
    expect(g.answer('p1', 'a').reply.error?.code).toBe('TOO_LATE');
    // Skip to ROUND_OVER and cut it.
    while (g.state.round && g.round.phase !== 'ROUND_OVER') g.must({ type: 'ADVANCE', now: g.later(10).now });
    g.must({ type: 'ADVANCE', now: g.later(10).now });
    expect(g.state.round).toBeNull();
    expect(g.state.nextRoundAt).toBeGreaterThan(g.now);
    // Cutting the break makes the next round due at once.
    const res = g.must({ type: 'ADVANCE', now: g.later(10).now });
    expect(res.events.map((e) => e.type)).toEqual(['RoundDue']);
    // While awaiting START_ROUND, ADVANCE re-announces RoundDue (server retry hook).
    expect(g.must({ type: 'ADVANCE', now: g.later(10).now }).events.map((e) => e.type)).toEqual(['RoundDue']);
  });

  it('END_SESSION completes immediately, reports the cancelled round, and blocks further commands', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const res = g.must({ type: 'END_SESSION', now: g.later(10).now, idempotencyKey: 'end-1' });
    expect(res.events).toHaveLength(1);
    expect(res.events[0]).toMatchObject({ type: 'SessionCompleted', cancelledRoundId: 'r1' });
    expect(g.state.status).toBe('COMPLETED');
    expect(g.state.round).toBeNull();
    expect(nextDeadline(g.state)).toBeNull();
    expect(g.send({ type: 'END_SESSION', now: g.now, idempotencyKey: 'end-1' }).reply).toEqual({ ok: true, duplicate: true });
    expect(g.send({ type: 'END_SESSION', now: g.now }).reply.error?.code).toBe('SESSION_COMPLETED');
    expect(g.send({ type: 'TICK', now: g.now, present: [] }).reply.error?.code).toBe('SESSION_COMPLETED');
    expect(g.send({ type: 'JOIN', now: g.now, player: { id: 'x', nickname: 'Xavier' } }).reply.error?.code).toBe('SESSION_COMPLETED');
  });

  it('END_SESSION between rounds has no cancelledRoundId; a WAITING session can be ended', () => {
    const g = Game.create();
    const res = g.must({ type: 'END_SESSION', now: T0 });
    expect(res.events[0]).toEqual({ type: 'SessionCompleted', at: T0, standings: [] });
  });

  it('START_ROUND during a pause is refused; RESUME re-announces RoundDue', () => {
    const g = runningAlwaysOn(['p1']);
    expect(needsRound(g.state)).toBe(true);
    g.must({ type: 'PAUSE', now: g.later(5).now });
    expect(needsRound(g.state)).toBe(false);
    expect(g.send({ type: 'START_ROUND', now: g.now, round: boardSetup('r1') }).reply.error?.code).toBe('SESSION_NOT_RUNNING');
    const res = g.must({ type: 'RESUME', now: g.later(5).now });
    expect(res.events.map((e) => e.type)).toEqual(['SessionResumed', 'RoundDue']);
    g.startRound(boardSetup('r1'));
    expect(g.round.id).toBe('r1');
  });
});

describe('TICK catch-up and restart lateness', () => {
  it('chains transitions that are at most 2 s late from their deadlines', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const closesAt = g.round.closesAt!;
    // 1.9 s after close: lock at closesAt, LOCKED (1.5 s) also ended → reveal at closesAt + 1.5 s.
    const res = g.tick(closesAt + 1_900);
    expect(res.events.map((e) => e.type)).toEqual(['QuestionLocked', 'QuestionRevealed']);
    expect(res.events[0]!.at).toBe(closesAt);
    expect(res.events[1]!.at).toBe(closesAt + t.lockedMs);
    expect(g.round.phaseEndsAt).toBe(closesAt + t.lockedMs + t.revealMs);
  });

  it('after downtime (> 2 s late) the next phase starts at now — no fast-forward through questions', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    const restartAt = g.round.closesAt! + 10 * 60_000;
    const res = g.tick(restartAt);
    expect(res.events.map((e) => e.type)).toEqual(['QuestionLocked']);
    expect(res.events[0]!.at).toBe(restartAt);
    expect(g.round.phaseEndsAt).toBe(restartAt + t.lockedMs);
    expect(g.round.qIndex).toBe(0);
    // Same for a stale PENDING: it opens at now with a full window.
    g.advanceTo('PENDING', 1);
    const late = g.round.phaseEndsAt + 5_000;
    g.tick(late);
    expect(g.round.phase).toBe('OPEN');
    expect(g.round.openedAt).toBe(late);
    expect(g.round.closesAt).toBe(late + t.windowMs);
    expect(LATE_TRANSITION_MS).toBe(2_000);
  });

  it('a player command arriving after an unprocessed deadline catches the state up first', () => {
    const g = runningAlwaysOn(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('PENDING', 0);
    const opensAt = g.round.phaseEndsAt;
    // No TICK at opensAt; the answer arrives 1 s later and still counts (question opened at opensAt).
    g.at(opensAt + 1_000);
    const res = g.answer('p1', 'a', { questionId: 'r1-q0' });
    expect(res.events.map((e) => e.type)).toEqual(['QuestionOpened', 'AnswerAccepted', 'QuestionLocked']);
    expect(res.events[1]).toMatchObject({ openedAt: opensAt, elapsedMs: 1_000 });
  });
});

describe('players', () => {
  it('JOIN validates nicknames and uniqueness; duplicate JOIN is idempotent; LEFT players can come back', () => {
    const g = runningAlwaysOn([]);
    g.join('p1', '  Mike  ');
    expect(g.state.players['p1']!.nickname).toBe('Mike');
    expect(g.send({ type: 'JOIN', now: g.now, player: { id: 'p2', nickname: 'MIKE' } }).reply.error?.code).toBe('NICKNAME_TAKEN');
    expect(g.send({ type: 'JOIN', now: g.now, player: { id: 'p2', nickname: 'x' } }).reply.error?.code).toBe('NICKNAME_TAKEN');
    expect(g.send({ type: 'JOIN', now: g.now, player: { id: 'p1', nickname: 'Mike' } }).reply).toEqual({ ok: true, duplicate: true });
    g.must({ type: 'LEAVE', now: g.now, playerId: 'p1' });
    expect(g.state.players['p1']!.status).toBe('LEFT');
    expect(g.send({ type: 'LEAVE', now: g.now, playerId: 'p1' }).reply.duplicate).toBe(true);
    const back = g.must({ type: 'JOIN', now: g.now, player: { id: 'p1', nickname: 'Whatever' } });
    expect(back.events[0]).toMatchObject({ type: 'PlayerJoined', playerId: 'p1', nickname: 'Mike', joinIndex: 0 });
    expect(g.state.players['p1']!.status).toBe('ACTIVE');
  });

  it('KICK removes the player from the round, frees the name, and blocks them from coming back', () => {
    const g = runningAlwaysOn(['p1', 'p2']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('p1', 'a');
    g.must({ type: 'KICK_PLAYER', now: g.now, playerId: 'p1' });
    expect(g.round.participants).toEqual(['p2']);
    expect(g.round.answers[g.question.id]!['p1']).toBeUndefined();
    expect(g.send({ type: 'KICK_PLAYER', now: g.now, playerId: 'p1' }).reply.duplicate).toBe(true);
    expect(g.send({ type: 'JOIN', now: g.now, player: { id: 'p1', nickname: 'p1' } }).reply.error?.code).toBe('PLAYER_KICKED');
    expect(g.answer('p1', 'a').reply.error?.code).toBe('PLAYER_KICKED');
    g.must({ type: 'JOIN', now: g.now, player: { id: 'p3', nickname: 'p1' } }); // name is free again
    expect(publicView(g.state).leaderboard.map((s) => s.playerId)).not.toContain('p1');
  });

  it('a kick or leave of the last unanswered participant locks the question early', () => {
    const g = runningAlwaysOn(['p1', 'p2', 'p3']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('p1', 'a');
    g.answer('p2', 'a');
    const res = g.must({ type: 'LEAVE', now: g.later(100).now, playerId: 'p3' });
    expect(res.events.map((e) => e.type)).toEqual(['PlayerLeft', 'QuestionLocked']);
  });

  it('RENAME_PLAYER moderates and enforces uniqueness; SET_TEAM creates and switches teams', () => {
    const g = runningAlwaysOn(['p1', 'p2']);
    expect(g.send({ type: 'RENAME_PLAYER', now: g.now, playerId: 'p1', nickname: 'p2' }).reply.error?.code).toBe('NICKNAME_TAKEN');
    expect(g.send({ type: 'RENAME_PLAYER', now: g.now, playerId: 'p1', nickname: 'Sh1t Head' }).reply.error?.code).toBe('NICKNAME_TAKEN');
    expect(g.must({ type: 'RENAME_PLAYER', now: g.now, playerId: 'p1', nickname: 'Quiz Wiz' }).events).toEqual([
      { type: 'PlayerRenamed', at: g.now, playerId: 'p1', nickname: 'Quiz Wiz' },
    ]);
    const set = g.must({ type: 'SET_TEAM', now: g.now, playerId: 'p1', team: { id: 't1', name: 'Bar Flies' } });
    expect(set.events.map((e) => e.type)).toEqual(['TeamCreated', 'PlayerTeamChanged']);
    expect(g.must({ type: 'SET_TEAM', now: g.now, playerId: 'p1', team: { id: 't1', name: 'ignored' } }).events).toEqual([]);
    expect(g.send({ type: 'SET_TEAM', now: g.now, playerId: 'p2', team: { id: 't2', name: 'www.spam.com' } }).reply.ok).toBe(false);
    g.must({ type: 'SET_TEAM', now: g.now, playerId: 'p1', team: null });
    expect(g.state.players['p1']!.teamId).toBeNull();
  });
});

describe('purity', () => {
  it('reduce never mutates its input (deep-frozen states throughout) and errors return the same state object', () => {
    const g = runningAlwaysOn(['p1']);
    const before = g.state;
    const res = reduce(before, { type: 'SUBMIT_ANSWER', now: g.now, playerId: 'p1', questionId: 'x', optionId: 'a', idempotencyKey: 'k' });
    expect(res.reply.ok).toBe(false);
    expect(res.state).toBe(before);
    expect(res.events).toEqual([]);
    expect(Object.isFrozen(before)).toBe(true);
  });

  it('START_ROUND validates the setup', () => {
    const g = runningAlwaysOn(['p1']);
    const bad = boardSetup('r1');
    expect(g.send({ type: 'START_ROUND', now: g.now, round: { ...bad, kind: 'TABLE_WARS' } }).reply.error?.code).toBe('INVALID_ROUND');
    expect(g.send({ type: 'START_ROUND', now: g.now, round: { ...bad, questions: bad.questions.slice(0, 9) } }).reply.error?.code).toBe('INVALID_ROUND');
    const dupCat = { ...bad, questions: [...bad.questions.slice(0, 8), mcQuestion('x', 'pop'), bad.questions[9]!] };
    expect(g.send({ type: 'START_ROUND', now: g.now, round: dupCat }).reply.error?.code).toBe('INVALID_ROUND');
    const badAnswer = { ...bad, questions: bad.questions.map((q, i) => (i === 3 ? { ...q, correctOptionId: 'zz' } : q)) };
    expect(g.send({ type: 'START_ROUND', now: g.now, round: badAnswer }).reply.error?.code).toBe('INVALID_ROUND');
    g.startRound(bad);
    expect(g.send({ type: 'START_ROUND', now: g.now, round: bad }).reply).toEqual({ ok: true, duplicate: true });
    expect(g.send({ type: 'START_ROUND', now: g.now, round: boardSetup('r2') }).reply.error?.code).toBe('ROUND_NOT_EXPECTED');
  });
});

describe('Final Rush', () => {
  const PLAN: RoundPlanItem[] = [
    { kind: 'RUSH', packKey: 'test', label: 'The Rush', segment: 0 },
    { kind: 'FINAL_RUSH', packKey: 'test', label: 'Final Rush', segment: 1 },
  ];

  /** Round 1 gives p0..p6 distinct scores (p0 best); p7 leaves; then the Final Rush starts. */
  function finalRush(): Game {
    const g = Game.create({ mode: 'CHAMPIONSHIP', plan: PLAN });
    for (let i = 0; i < 8; i++) g.join(`p${i}`);
    g.start();
    g.startRound(boardSetup('r1'));
    // Player i answers the first (8 - i) questions correctly: strictly decreasing scores.
    g.strategy = ({ question, qIndex, playerId }) => (qIndex < 8 - Number(playerId.slice(1)) ? { optionId: question.correctOptionId, delayMs: 1_000 } : null);
    g.runUntil((x) => x.state.round === null);
    g.strategy = null;
    g.must({ type: 'LEAVE', now: g.now, playerId: 'p0' }); // the leader left: not a finalist
    g.runUntil((x) => needsRound(x.state));
    g.startRound(finalRushSetup('fr'));
    return g;
  }

  it('finalists are the top 5 ACTIVE players by session standings; only they answer', () => {
    const g = finalRush();
    expect(g.round.finalists).toEqual(['p1', 'p2', 'p3', 'p4', 'p5']);
    expect(g.round.participants).toEqual(['p1', 'p2', 'p3', 'p4', 'p5']);
    expect(g.eventsOf('BoardDealt').filter((b) => b.roundId === 'fr')).toEqual([]);
    g.advanceTo('OPEN', 0);
    g.later(500);
    expect(g.answer('p6', 'a').reply.error?.code).toBe('NOT_PARTICIPANT');
    expect(g.answer('p1', 'a').reply.ok).toBe(true);
    // No late join into the Final Rush.
    g.join('newbie');
    expect(g.round.participants).not.toContain('newbie');
    expect(privateView(g.state, 'newbie')!.waitingForNextRound).toBe(false);
  });

  it('PREDICT: non-finalists only, a finalist target, INTRO or Q1 PENDING, idempotent, changeable', () => {
    const g = finalRush();
    const predict = (playerId: string, finalistId: string, key: string) => g.send({ type: 'PREDICT', now: g.now, playerId, finalistId, idempotencyKey: key });
    expect(predict('p1', 'p2', 'k1').reply.error?.code).toBe('CANNOT_PREDICT'); // finalist
    expect(predict('p6', 'p7', 'k2').reply.error?.code).toBe('CANNOT_PREDICT'); // not a finalist
    expect(predict('p0', 'p1', 'k3').reply.error?.code).toBe('CANNOT_PREDICT'); // left the game
    expect(predict('ghost', 'p1', 'k4').reply.error?.code).toBe('UNKNOWN_PLAYER');
    const ok = predict('p6', 'p3', 'k5');
    expect(ok.events).toEqual([{ type: 'PredictionMade', at: g.now, roundId: 'fr', playerId: 'p6', finalistId: 'p3' }]);
    expect(predict('p6', 'p4', 'k5').reply).toEqual({ ok: true, duplicate: true });
    g.advanceTo('PENDING', 0);
    expect(predict('p6', 'p4', 'k6').reply.ok).toBe(true);
    expect(g.round.predictions).toEqual({ p6: 'p4' });
    g.advanceTo('OPEN', 0);
    expect(predict('p7', 'p4', 'k7').reply.error?.code).toBe('WRONG_PHASE');
    // Predictions never score.
    g.runUntil((x) => x.state.status === 'COMPLETED');
    expect(g.eventsOf('ScoreAwarded').some((e) => e.playerId === 'p6' && e.roundId === 'fr')).toBe(false);
    expect(g.events.at(-1)).toMatchObject({ type: 'SessionCompleted' });
  });

  it('PREDICT outside the Final Rush is refused', () => {
    const g = Game.create({ mode: 'CHAMPIONSHIP', plan: PLAN });
    g.join('p1');
    g.start();
    expect(g.send({ type: 'PREDICT', now: g.now, playerId: 'p1', finalistId: 'p1', idempotencyKey: 'k' }).reply.error?.code).toBe('NO_ROUND');
    g.startRound(boardSetup('r1'));
    expect(g.send({ type: 'PREDICT', now: g.now, playerId: 'p1', finalistId: 'p1', idempotencyKey: 'k' }).reply.error?.code).toBe('NOT_FINALIST_ROUND');
  });

  it('with fewer than 5 active players every active player is a finalist', () => {
    const g = Game.create({ mode: 'CHAMPIONSHIP', plan: [{ kind: 'FINAL_RUSH', packKey: 'test', label: 'Final Rush', segment: 0 }] });
    g.join('p1');
    g.join('p2');
    g.start();
    g.startRound(finalRushSetup('fr'));
    expect(g.round.finalists).toEqual(['p1', 'p2']);
  });
});

describe('power-up and answer commands outside a running round', () => {
  it('report SESSION_NOT_RUNNING before start and NO_ROUND between rounds', () => {
    const g = Game.create();
    g.join('p1');
    const usePu = () => g.send({ type: 'USE_POWERUP', now: g.now, playerId: 'p1', powerUp: 'DOUBLE_DOWN', idempotencyKey: `k${g.commands.length}` });
    expect(usePu().reply.error?.code).toBe('SESSION_NOT_RUNNING');
    expect(g.answer('p1', 'a', { questionId: 'q' }).reply.error?.code).toBe('SESSION_NOT_RUNNING');
    g.start();
    expect(g.answer('p1', 'a', { questionId: 'q' }).reply.error?.code).toBe('NO_ROUND');
    const s = structuredClone(g.state);
    s.wallets['p1']!.tokens = 1;
    g.state = s;
    expect(usePu().reply.error?.code).toBe('NO_ROUND');
  });
});
