import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { outgoingViolations } from '../src/outgoing';
import type { GameServer } from '../src/server';
import { joinAs, seedFixture, sleep, startServer, TestClient, type Envelope, type Fixture } from './helpers';

const TODAY = '2026-10-29';

describe('game server (socket integration)', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

  beforeAll(async () => {
    fx = await seedFixture();
    ({ server, url } = await startServer(fx));
    const actor = await server.rooms.getActor(fx.roomA.id);
    await actor!.ensureAlwaysOn(TODAY);
  });

  afterAll(async () => {
    await server?.stop();
    await fx?.db.$disconnect();
    // Every message sent during the whole suite matched the strict protocol (incl. no answer leaks).
    expect(outgoingViolations).toEqual([]);
  });

  async function tvClient(roomId: string, venueId: string, v = 1) {
    const tvToken = await server.tokens.signTv({ roomId, venueId, v });
    const tv = new TestClient(url, { tvToken });
    await tv.connected();
    return tv;
  }

  async function hostClient(venueId: string, userId: string, eventId: string) {
    const hostToken = await server.tokens.signHost({ sub: userId, venueId, role: 'OWNER' }, Date.now());
    return new TestClient(url, { hostToken, eventId });
  }

  it('joins with nickname validation, profanity filter and duplicate suffixing', async () => {
    const a = await joinAs(url, fx.venueA.joinCode, 'Mike');
    expect(a.res.ok).toBe(true);
    const b = await joinAs(url, fx.venueA.joinCode.toLowerCase(), 'mike');
    expect(b.res.ok && b.res.data?.nickname).toBe('mike 2');
    const bad = await joinAs(url, fx.venueA.joinCode, 'sh1thead');
    expect(bad.res.ok).toBe(false);
    if (!bad.res.ok) expect(bad.res.error.code).toBe('NICKNAME_PROFANE');
    const missing = await joinAs(url, 'NOPE99', 'Zed');
    expect(missing.res.ok).toBe(false);
    if (!missing.res.ok) expect(missing.res.error.code).toBe('NOT_FOUND');
    for (const c of [a, b, bad, missing]) c.client.close();
  });

  it('the same phone rejoining gets its existing player back', async () => {
    const deviceId = randomUUID();
    const first = await joinAs(url, fx.venueA.joinCode, 'Rejoiner', { deviceId });
    const second = await joinAs(url, fx.venueA.joinCode, 'Someone Else', { deviceId });
    expect(first.res.ok && second.res.ok).toBe(true);
    if (first.res.ok && second.res.ok) {
      expect(second.res.data!.playerSessionId).toBe(first.res.data!.playerSessionId);
      expect(second.res.data!.nickname).toBe('Rejoiner');
    }
    first.client.close();
    second.client.close();
  });

  it('plays a full scored round: board, lines, full board, persistence, ordered audit log', async () => {
    const tv = await tvClient(fx.roomA.id, fx.venueA.id);
    const ava = await joinAs(url, fx.venueA.joinCode, 'Ava');
    const ben = await joinAs(url, fx.venueA.joinCode, 'Ben');
    expect(ava.res.ok && ben.res.ok).toBe(true);
    if (!ava.res.ok || !ben.res.ok) return;
    const avaId = ava.res.data!.playerSessionId;
    const benId = ben.res.data!.playerSessionId;
    const sessionId = ava.res.data!.gameSessionId;

    // Use the next round that starts with both players present, so both are dealt in from question 1.
    const start = await ava.client.next((e) => e.type === 'round.start' && !!e.me?.inRound, 60_000, 'a fresh round');
    const roundId: string = start.public!.round!.id;

    // Answer every question as it opens: Ava always right, Ben always wrong.
    const answered = new Set<string>();
    const answerer = (c: TestClient, right: boolean) => {
      c.socket.onAny((type: string, env: Envelope) => {
        const q = env.public?.question;
        if (type !== 'question.open' || !q?.options || !env.me?.inRound) return;
        if (env.public!.round!.id !== roundId) return;
        const key = `${c.socket.id}:${q.id}`;
        if (answered.has(key)) return;
        answered.add(key);
        const correct = fx.answers.get(q.id)!;
        const optionId = right ? correct : q.options.find((o) => o.id !== correct)!.id;
        void c.emit('answer.submit', { questionId: q.id, optionId, idempotencyKey: randomUUID() });
      });
    };
    answerer(ava.client, true);
    answerer(ben.client, false);

    // Wait until a round we were in completes.
    const done = await ava.client.waitFor(
      (e) => e.type === 'leaderboard.updated' && e.public?.lastRound?.id === roundId,
      60_000,
      'round completion',
    );
    const me = done.me!;
    expect(me.totals.lines).toBe(8);
    expect(me.totals.fullBoards).toBe(1);
    expect(me.totals.correct).toBe(10);

    const actor = await server.rooms.getActor(fx.roomA.id);
    await actor!.drain();
    const db = fx.db;
    const avaScore = await db.scoreEvent.aggregate({ where: { playerSessionId: avaId, gameSessionId: sessionId, roundId: roundId! }, _sum: { points: true } });
    const roundPoints = done.public!.lastRound!.podium.find((p) => p.playerId === avaId)?.points;
    expect(avaScore._sum.points).toBe(roundPoints);
    expect(await db.scoreEvent.count({ where: { playerSessionId: avaId, roundId: roundId!, kind: 'LINE' } })).toBe(8);
    expect(await db.scoreEvent.count({ where: { playerSessionId: avaId, roundId: roundId!, kind: 'FULL_BOARD' } })).toBe(1);
    expect(await db.scoreEvent.count({ where: { playerSessionId: benId, roundId: roundId! } })).toBe(0);
    expect(await db.answerSubmission.count({ where: { roundId: roundId!, playerSessionId: { in: [avaId, benId] } } })).toBe(20);
    expect(await db.answerSubmission.count({ where: { roundId: roundId!, playerSessionId: avaId, isCorrect: true } })).toBe(10);
    const tiles = await db.boardTile.findMany({ where: { board: { playerSessionId: avaId, roundId: roundId! } } });
    expect(tiles).toHaveLength(9);
    expect(tiles.every((t) => t.activatedAt !== null)).toBe(true);
    expect((await db.round.findUnique({ where: { id: roundId! } }))?.status).toBe('COMPLETED');
    expect(await db.leaderboardSnapshot.count({ where: { scope: 'ROUND', scopeRef: roundId! } })).toBe(1);
    const audits = await db.auditEvent.findMany({ where: { gameSessionId: sessionId }, orderBy: { ordinal: 'asc' } });
    expect(audits.length).toBeGreaterThan(30);
    for (let i = 1; i < audits.length; i++) expect(audits[i]!.seq!).toBeGreaterThanOrEqual(audits[i - 1]!.seq!);

    // Public seq numbers only ever go up for a watcher.
    const seqs = ava.client.received.filter((e) => !['answer.result', 'board.updated', 'score.updated', 'session.token', 'error'].includes(e.type) && !e.snapshot).map((e) => e.seq);
    for (let i = 1; i < seqs.length; i++) expect(seqs[i]!).toBeGreaterThan(seqs[i - 1]!);

    // The TV never saw a correct answer before reveal.
    for (const e of tv.received) {
      if (e.type === 'question.open' || e.type === 'question.lock' || e.type === 'question.pending') {
        expect(JSON.stringify(e)).not.toContain('correctOptionId');
      }
    }
    for (const c of [tv, ava.client, ben.client]) c.close();
  }, 90_000);

  it('treats a repeated submit as idempotent and rejects a second different answer', async () => {
    const p = await joinAs(url, fx.venueA.joinCode, 'Dupe');
    // A second player who never answers keeps the question open (otherwise it locks early).
    const idle = await joinAs(url, fx.venueA.joinCode, 'Idle Ida');
    expect(p.res.ok && idle.res.ok).toBe(true);
    const open = await p.client.next((e) => e.type === 'question.open' && !!e.me?.inRound && (e.public?.participantCount ?? 0) >= 2, 60_000, 'an open question');
    const q = open.public!.question!;
    const key = randomUUID();
    const first = await p.client.emit('answer.submit', { questionId: q.id, optionId: q.options![0]!.id, idempotencyKey: key });
    const again = await p.client.emit('answer.submit', { questionId: q.id, optionId: q.options![0]!.id, idempotencyKey: key });
    const other = await p.client.emit('answer.submit', { questionId: q.id, optionId: q.options![1]!.id, idempotencyKey: randomUUID() });
    expect(first.ok).toBe(true);
    expect(again.ok).toBe(true); // same key → same success reply, no second submission
    expect(other.ok).toBe(false);
    if (!other.ok) expect(other.error.code).toBe('ALREADY_ANSWERED');
    p.client.close();
    idle.client.close();
  }, 70_000);

  it('reconnects with a rotated token and resyncs from a full snapshot', async () => {
    const p = await joinAs(url, fx.venueA.joinCode, 'Roamer');
    expect(p.res.ok).toBe(true);
    if (!p.res.ok) return;
    const t1 = p.res.data!.token;
    p.client.close();

    const r1 = new TestClient(url, { tokens: [t1] });
    await r1.connected();
    const rotated = await r1.waitFor((e) => e.type === 'session.token', 5_000, 'rotated token');
    const snap = await r1.waitFor((e) => e.type === 'room.state' && !!e.snapshot, 5_000, 'snapshot');
    expect(snap.snapshot.me.playerId).toBe(p.res.data!.playerSessionId);
    expect(snap.snapshot.mySessionActive).toBe(true);
    const state = await r1.emit('state.request', { lastSeq: 0 });
    expect(state.ok).toBe(true);
    const t2 = rotated.data.token as string;
    r1.close();

    // t2 rotates to t3; t1 is now two versions behind and must be refused.
    const r2 = new TestClient(url, { tokens: [t2] });
    await r2.connected();
    await r2.waitFor((e) => e.type === 'session.token', 5_000, 'second rotation');
    r2.close();
    const r3 = new TestClient(url, { tokens: [t1] });
    await r3.connected();
    const err = await r3.waitFor((e) => e.type === 'error', 5_000, 'expired token error');
    expect(err.data.code).toBe('UNAUTHORIZED');
    r3.close();
  });

  it('TV tokens are display-only', async () => {
    const tv = await tvClient(fx.roomA.id, fx.venueA.id);
    const snap = await tv.waitFor((e) => e.type === 'room.state' && !!e.snapshot, 5_000, 'tv snapshot');
    expect(snap.snapshot.me).toBeNull();
    const answer = await tv.emit('answer.submit', { questionId: randomUUID(), optionId: 'a', idempotencyKey: randomUUID() });
    expect(answer.ok).toBe(false);
    const start = await tv.emit('event.start', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(start.ok).toBe(false);
    if (!start.ok) expect(start.error.code).toBe('FORBIDDEN');
    const join = await tv.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Telly', deviceId: randomUUID(), idempotencyKey: randomUUID() });
    expect(join.ok).toBe(false);
    tv.close();

    // A revoked TV link (old version) cannot connect.
    const stale = new TestClient(url, { tvToken: await server.tokens.signTv({ roomId: fx.roomA.id, venueId: fx.venueA.id, v: 0 }) });
    await expect(stale.connected()).rejects.toThrow();
    stale.close();
  });

  it('isolates tenants: a host can never reach another venue’s events', async () => {
    // Host B presenting venue A's event at handshake is refused.
    const cross = await hostClient(fx.venueB.id, fx.hostB.id, fx.champA.id);
    await expect(cross.connected()).rejects.toThrow(/FORBIDDEN/);
    cross.close();
    // Host B bound to its own event cannot command venue A's event by id.
    const hostB = await hostClient(fx.venueB.id, fx.hostB.id, fx.champB.id);
    await hostB.connected();
    for (const ev of ['event.open', 'event.start', 'event.pause', 'event.end', 'round.advance']) {
      const r = await hostB.emit(ev, { eventId: fx.champA.id, idempotencyKey: randomUUID() });
      expect(r.ok).toBe(false);
      if (!r.ok) expect(r.error.code).toBe('FORBIDDEN');
    }
    const mod = await hostB.emit('nickname.moderate', { eventId: fx.champA.id, playerSessionId: randomUUID(), action: 'kick', idempotencyKey: randomUUID() });
    expect(mod.ok).toBe(false);
    // A guest token signed for venue A cannot be replayed against venue B's room.
    hostB.close();
  });

  it('rate-limits a socket that floods the server', async () => {
    const c = new TestClient(url);
    await c.connected();
    const results = await Promise.all(Array.from({ length: 40 }, (_, i) => c.emit('clock.sync', { t0: i })));
    expect(results.some((r) => !r.ok && r.error.code === 'RATE_LIMITED')).toBe(true);
    expect(results.filter((r) => r.ok).length).toBeGreaterThanOrEqual(16);
    c.close();
  });

  it('runs a host-controlled championship: lobby, start, pause/resume, moderation, end', async () => {
    const host = await hostClient(fx.venueA.id, fx.hostA.id, fx.champA.id);
    await host.connected();
    const tv = await tvClient(fx.roomA.id, fx.venueA.id);

    const opened = await host.emit('event.open', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(opened.ok).toBe(true);
    // The TV switches to the championship lobby.
    await tv.waitFor((e) => e.type === 'room.state' && e.snapshot?.activeEventKind === 'CHAMPIONSHIP', 5_000, 'tv switch');

    const p1 = await joinAs(url, fx.venueA.joinCode, 'Champ One', { teamName: 'Table Five' });
    const p2 = await joinAs(url, fx.venueA.joinCode, 'Champ Two', { teamName: 'table five' });
    expect(p1.res.ok && p2.res.ok).toBe(true);
    if (!p1.res.ok || !p2.res.ok) return;
    expect(p1.res.data!.gameSessionId).not.toBe((await server.rooms.getActor(fx.roomA.id))!.alwaysOnSession()?.id);

    const started = await host.emit('event.start', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(started.ok).toBe(true);
    await p1.client.waitFor((e) => e.type === 'round.start', 10_000, 'championship round start');
    expect(p1.client.me?.teamName).toBe('Table Five');

    const paused = await host.emit('event.pause', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(paused.ok).toBe(true);
    await p1.client.waitFor((e) => e.public?.status === 'PAUSED', 5_000, 'paused');
    const resumed = await host.emit('event.resume', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(resumed.ok).toBe(true);

    const renamed = await host.emit('nickname.moderate', { eventId: fx.champA.id, playerSessionId: p2.res.data!.playerSessionId, action: 'rename', nickname: 'Polite Name', idempotencyKey: randomUUID() });
    expect(renamed.ok).toBe(true);
    const kicked = await host.emit('nickname.moderate', { eventId: fx.champA.id, playerSessionId: p2.res.data!.playerSessionId, action: 'kick', idempotencyKey: randomUUID() });
    expect(kicked.ok).toBe(true);
    await p2.client.waitFor((e) => e.type === 'player.kicked', 5_000, 'kick notice');

    const sync = await host.emit('host.sync', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(sync.ok).toBe(true);

    const backToAlwaysOn = tv.next((e) => e.type === 'room.state' && e.snapshot?.activeEventKind === 'ALWAYS_ON', 5_000, 'tv back to always-on');
    const ended = await host.emit('event.end', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(ended.ok).toBe(true);
    const complete = await tv.waitFor((e) => e.type === 'event.complete', 5_000, 'event complete on TV');
    expect(complete.data.standings.length).toBeGreaterThan(0);
    await p1.client.waitFor((e) => e.type === 'event.complete', 5_000, 'event complete');
    // The finale stays up for the results window, then the TV returns to always-on.
    await backToAlwaysOn;

    const actor = await server.rooms.getActor(fx.roomA.id);
    await actor!.drain();
    const event = await fx.db.event.findUnique({ where: { id: fx.champA.id } });
    expect(event?.status).toBe('COMPLETED');
    expect(await fx.db.leaderboardSnapshot.count({ where: { scope: 'EVENT', scopeRef: fx.champA.id } })).toBe(1);
    const kickedRow = await fx.db.playerSession.findUnique({ where: { id: p2.res.data!.playerSessionId } });
    expect(kickedRow?.status).toBe('KICKED');
    expect(kickedRow?.nickname).toBe('Polite Name');
    for (const c of [host, tv, p1.client, p2.client]) c.close();
  }, 60_000);

  it('recovers live sessions after a restart', async () => {
    const actor = await server.rooms.getActor(fx.roomA.id);
    const before = actor!.alwaysOnSession()!;
    const p = await joinAs(url, fx.venueA.joinCode, 'Survivor');
    expect(p.res.ok).toBe(true);
    if (!p.res.ok) return;
    await sleep(300);
    await server.stop();

    ({ server, url } = await startServer(fx));
    const revived = await server.rooms.getActor(fx.roomA.id);
    expect(revived!.alwaysOnSession()?.id).toBe(before.id);
    expect(revived!.getState(before.id)!.players[p.res.data!.playerSessionId]?.nickname).toBe('Survivor');

    const back = new TestClient(url, { tokens: [p.res.data!.token] });
    await back.connected();
    const snap = await back.waitFor((e) => e.type === 'room.state' && !!e.snapshot, 5_000, 'snapshot after restart');
    expect(snap.snapshot.me.nickname).toBe('Survivor');
    back.close();
    p.client.close();
  }, 30_000);
});
