/**
 * Review findings (races and session lifecycle) against the real server + Postgres. Each `it.fails`
 * asserts the CORRECT behaviour and currently fails; flip it to `it` once fixed.
 */
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameServer } from '../src/server';
import { seedFixture, startServer, TestClient, type Fixture } from './helpers';

const TODAY = '2026-10-29';

async function anonClients(url: string, n: number): Promise<TestClient[]> {
  const clients = Array.from({ length: n }, () => new TestClient(url));
  await Promise.all(clients.map((c) => c.connected()));
  return clients;
}

describe('review: concurrent joins', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

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

  // stop() drains the write-behind; after the team race that means waiting out the poison-batch retries.
  afterAll(async () => {
    await server?.stop();
    await fx?.db.$disconnect();
  }, 180_000);

  // RoomActor.join reads h.devices (line 749), then awaits createPlayer (a DB insert, line 758) and
  // only records the device afterwards (line 764). Two joins from one phone that overlap (double tap,
  // second tab, client retry with a new key) both miss the device map and create two players.
  it('simultaneous joins from the same device resolve to one player', async () => {
    // A cold server serialises the very first join on connection setup; warm it up first.
    const warm = await anonClients(url, 1);
    await warm[0]!.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Warm Up', teamName: null, deviceId: randomUUID(), idempotencyKey: randomUUID() });
    warm[0]!.close();
    const distinctPerTrial: number[] = [];
    // The first burst can serialise while the DB pool opens connections, so try several bursts.
    for (let trial = 0; trial < 4; trial++) {
      const deviceId = randomUUID();
      const clients = await anonClients(url, 3);
      const acks = await Promise.all(
        clients.map((c, i) =>
          c.emit<{ playerSessionId: string }>('room.join', { venueCode: fx.venueA.joinCode, nickname: `Twin ${trial}${i}`, teamName: null, deviceId, idempotencyKey: randomUUID() }),
        ),
      );
      for (const c of clients) c.close();
      expect(acks.every((a) => a.ok)).toBe(true);
      distinctPerTrial.push(new Set(acks.map((a) => (a.ok ? a.data!.playerSessionId : null))).size);
    }
    expect(distinctPerTrial).toEqual([1, 1, 1, 1]);
  });

  // RoomActor.join computes teamFor() (line 743: an existing team by name, else a NEW random id) and
  // only dispatches JOIN after the createPlayer await. Friends at one table who join at the same
  // moment with the same new team name each mint their own team id, so the engine ends up with two
  // teams called "Table Nine" (and the second TeamCreated is skipped by the Team(gameSessionId,
  // nameKey) unique index, so its TeamMembership insert then violates the teamId FK → poison batch).
  it('simultaneous joins with the same new team name land on the same team', async () => {
    const warm = await anonClients(url, 1);
    await warm[0]!.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Warm Again', teamName: null, deviceId: randomUUID(), idempotencyKey: randomUUID() });
    warm[0]!.close();
    const actor = (await server.rooms.getActor(fx.roomA.id))!;
    // Up to 3 tables of 2 friends; stop at the first split so only one poisoned batch has to drain.
    for (let trial = 0; trial < 3; trial++) {
      const name = `Table ${['Nine', 'Ten', 'Eleven'][trial]}`;
      const clients = await anonClients(url, 2);
      const acks = await Promise.all(
        clients.map((c, i) =>
          c.emit<{ playerSessionId: string; gameSessionId: string }>('room.join', {
            venueCode: fx.venueA.joinCode,
            nickname: `Mate ${trial}${i}`,
            teamName: name,
            deviceId: randomUUID(),
            idempotencyKey: randomUUID(),
          }),
        ),
      );
      for (const c of clients) c.close();
      expect(acks.every((a) => a.ok)).toBe(true);
      const state = actor.getState(acks[0]!.ok ? acks[0]!.data!.gameSessionId : '')!;
      const teamIds = new Set(acks.map((a) => (a.ok ? state.players[a.data!.playerSessionId]?.teamId : null)));
      expect(Object.values(state.teams).filter((t) => t.name === name)).toHaveLength(1);
      expect(teamIds.size).toBe(1);
    }
  });
});

describe('review: always-on lifecycle', () => {
  let fx: Fixture;
  let server: GameServer;

  beforeAll(async () => {
    fx = await seedFixture();
    ({ server } = await startServer(fx));
  });

  afterAll(async () => {
    await server?.stop();
    await fx?.db.$disconnect();
  });

  // ensureAlwaysOn (RoomActor.ts:238-243) returns early when tonight's ALWAYS_ON event is COMPLETED, and
  // the Event(roomId, kind, businessDate) unique key means a second one can never be created. Any
  // END of tonight's always-on — closing a split schedule's first slot (lunch), an admin toggling
  // always-on off/on, a host event.end — leaves the room without games until the 5 am rollover.
  // Fixed by pausing (not ending) tonight's session at closing time: reopening the same business
  // date resumes the same session and its nightly leaderboard.
  it('always-on can start again on the same business date after closing', async () => {
    const actor = (await server.rooms.getActor(fx.roomA.id))!;
    await actor.ensureAlwaysOn(TODAY);
    const first = actor.alwaysOnSession()!.id;
    await actor.pauseAlwaysOn(); // e.g. the venue's 11:00–14:00 slot closes
    await actor.drain();
    expect(actor.alwaysOnSession()!.state.status).toBe('PAUSED');
    await actor.ensureAlwaysOn(TODAY); // the 17:00–23:00 slot opens
    const again = actor.alwaysOnSession();
    expect(again).not.toBeNull();
    expect(again!.id).toBe(first);
    expect(again!.state.status).toBe('RUNNING');
    expect(actor.activeSession()?.id).toBe(again!.id);
  });
});

describe('review: overlapping championships', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

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

  afterAll(async () => {
    await server?.stop();
    await fx?.db.$disconnect();
  });

  // openEvent (RoomActor.ts:305) activates the new lobby unconditionally. The scheduler auto-opens a
  // lobby 15 min before scheduledAt (RoomManager.ts:127-134), so a 7 pm and an 8 pm show (or a host
  // opening the wrong event) hijack the room while the first show is still running: buildRound
  // (line 421) refuses to start the running show's next round, and when the second show ends
  // onSessionCompleted falls back to always-on, orphaning the first show for good.
  it('opening a second lobby does not take the room away from a running championship', async () => {
    const plan = [{ kind: 'RUSH', packKey: fx.packKey, label: 'The Rush', segment: 0 }];
    const late = await fx.db.event.create({
      data: { venueId: fx.venueA.id, roomId: fx.roomA.id, kind: 'CHAMPIONSHIP', name: 'Late Show', scheduledAt: new Date(Date.now() + 10 * 60_000), config: { plan, mergeSolos: false } },
    });
    const hostToken = await server.tokens.signHost({ sub: fx.hostA.id, venueId: fx.venueA.id, role: 'OWNER' }, Date.now());
    const host = new TestClient(url, { hostToken, eventId: fx.champA.id });
    await host.connected();
    expect((await host.emit('event.start', { eventId: fx.champA.id, idempotencyKey: randomUUID() })).ok).toBe(true);

    const actor = (await server.rooms.getActor(fx.roomA.id))!;
    expect(actor.activeSession()?.event.id).toBe(fx.champA.id);
    await actor.openEvent(late.id); // what runScheduler() does 15 minutes before the late show
    const active = actor.activeSession()?.event.id;
    host.close();
    expect(active).toBe(fx.champA.id);
  });
});

describe('review: graceful shutdown', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

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

  afterAll(async () => {
    await server?.stop();
    await fx?.db.$disconnect();
  });

  // server.ts:108-112 drains the actors (rooms.stop) BEFORE io.close(), and nothing marks an actor as
  // closed for commands (only armTimer checks `stopped`). Sockets stay connected after the final
  // drain, so mutations are still reduced and acked ok:true even though index.ts exits right after —
  // the player is told their action counted, and it is lost.
  it('after the final drain, mutations are refused instead of acked and lost', async () => {
    const phone = new TestClient(url);
    await phone.connected();
    const join = await phone.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Late Owl', teamName: null, deviceId: randomUUID(), idempotencyKey: randomUUID() });
    expect(join.ok).toBe(true);
    await server.rooms.stop(); // what server.stop() does first on SIGTERM
    const team = await phone.emit('team.set', { teamName: 'Night Shift', idempotencyKey: randomUUID() });
    phone.close();
    expect(team.ok).toBe(false);
  });
});
