/**
 * Review findings (security / protocol). Each `it.fails` demonstrates a confirmed defect: the test
 * asserts the CORRECT behaviour and currently fails. Flip it to `it` once the fix lands.
 */
import { randomUUID } from 'node:crypto';
import { HOST_ACK_DATA, MAX_CONNECTIONS_PER_IP, ackSchema } from '@pintrush/protocol';
import { io as ioClient, type Socket } from 'socket.io-client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameServer } from '../src/server';
import { joinAs, seedFixture, startServer, TestClient, type Fixture } from './helpers';

const TODAY = '2026-10-29';

/** Open a raw socket and resolve with 'connected' or the connect_error message. */
function tryConnect(url: string, opts: { auth?: Record<string, unknown>; headers?: Record<string, string> } = {}): Promise<{ socket: Socket; outcome: string }> {
  return new Promise((resolve) => {
    const socket = ioClient(url, {
      auth: opts.auth ?? {},
      transports: ['websocket'],
      reconnection: false,
      forceNew: true,
      ...(opts.headers ? { extraHeaders: opts.headers } : {}),
    });
    socket.once('connect', () => resolve({ socket, outcome: 'connected' }));
    socket.once('connect_error', (err) => resolve({ socket, outcome: err.message }));
  });
}

async function inBatches<T>(n: number, size: number, fn: (i: number) => Promise<T>): Promise<T[]> {
  const out: T[] = [];
  for (let start = 0; start < n; start += size) {
    out.push(...(await Promise.all(Array.from({ length: Math.min(size, n - start) }, (_, k) => fn(start + k)))));
  }
  return out;
}

describe('review: per-IP connection cap', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

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

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

  // io.ts:51-52 counts the IP before auth and only releases it on 'disconnect'. Socket.IO never emits
  // 'disconnect' for a socket whose middleware called next(err) (namespace.js _add → _cleanup + _error),
  // so every refused handshake leaks one slot. 300 refusals lock the IP (a whole bar behind one NAT) out
  // until the process restarts.
  it('refused handshakes do not permanently consume the per-IP connection slots', async () => {
    const refused = await inBatches(MAX_CONNECTIONS_PER_IP, 50, async () => {
      const r = await tryConnect(url, { auth: { tvToken: 'not-a-real-token' } });
      r.socket.close();
      return r.outcome;
    });
    expect(refused.every((o) => o === 'UNAUTHORIZED')).toBe(true);
    // Nothing is connected any more, so a brand-new anonymous phone from the same IP must get in.
    const phone = await tryConnect(url);
    phone.socket.close();
    expect(phone.outcome).toBe('connected');
  }, 60_000);
});

describe('review: X-Forwarded-For behind a trusted proxy', () => {
  let fx: Fixture;
  let server: GameServer;
  let url: string;

  beforeAll(async () => {
    fx = await seedFixture();
    ({ server, url } = await startServer(fx, { config: { trustProxyHops: 1 } }));
  });

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

  // io.ts:26-33 takes the LEFT-most X-Forwarded-For entry, which is whatever the client sent; a proxy
  // appends the address it actually saw on the right. Rotating a fake left-most value bypasses the
  // cap (and lets an attacker exhaust any victim IP's slots by spoofing it).
  it('the per-IP cap keys on the proxy-observed client address, not a client-supplied one', async () => {
    const realClient = '203.0.113.7';
    const sockets: Socket[] = [];
    try {
      const outcomes = await inBatches(MAX_CONNECTIONS_PER_IP + 1, 50, async (i) => {
        const r = await tryConnect(url, { headers: { 'x-forwarded-for': `10.${(i >> 8) & 255}.${i & 255}.1, ${realClient}` } });
        sockets.push(r.socket);
        return r.outcome;
      });
      expect(outcomes.filter((o) => o === 'connected')).toHaveLength(MAX_CONNECTIONS_PER_IP);
      expect(outcomes.filter((o) => o === 'RATE_LIMITED')).toHaveLength(1);
    } finally {
      for (const s of sockets) s.close();
    }
  }, 60_000);
});

describe('review: access policy and protocol shapes', () => {
  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();
  });

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

  // ARCHITECTURE §4 / protocol EVENT_ACCESS: anonymous sockets may only call room.join and clock.sync.
  // io.ts:159-163 attaches the socket to the room BEFORE actor.join() validates anything, so a join
  // that fails (bad nickname) leaves an anonymous socket subscribed to the room's live stream, and
  // state.request (io.ts:167-171) only checks that an actor is set.
  it('an anonymous socket whose join failed cannot read room state', async () => {
    const anon = new TestClient(url);
    await anon.connected();
    const join = await anon.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'sh1thead', teamName: null, deviceId: randomUUID(), idempotencyKey: randomUUID() });
    expect(join.ok).toBe(false);
    const state = await anon.emit('state.request', {});
    anon.close();
    expect(state.ok).toBe(false);
  });

  // protocol acks.ts HOST_ACK_DATA: event.open / event.start carry NO data (NoAckDataSchema = z.never()),
  // but RoomActor.hostCommand (RoomActor.ts:862, 865) replies { sessionId }.
  it('event.open ack matches HOST_ACK_DATA', async () => {
    const host = await hostClient(fx.venueA.id, fx.hostA.id, fx.champA.id);
    const ack = await host.emit('event.open', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    expect(ack.ok).toBe(true);
    // Leave the lobby closed again so other tests in this file see always-on.
    await host.emit('event.end', { eventId: fx.champA.id, idempotencyKey: randomUUID() });
    host.close();
    expect(ackSchema(HOST_ACK_DATA['event.open']).safeParse(ack).success).toBe(true);
  });

  // RoomActor.join (RoomActor.ts:725-729) replays a cached join result by idempotency key alone: it
  // never re-checks that the player still exists unkicked, so a kicked phone that retries its join
  // gets ok:true and its socket is re-identified as the kicked player (with a revoked token).
  it('a kicked player cannot re-attach by replaying their join idempotency key', async () => {
    const key = randomUUID();
    const deviceId = randomUUID();
    const phone = new TestClient(url);
    await phone.connected();
    const first = await phone.emit<{ playerSessionId: string; gameSessionId: string }>('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Kickable', teamName: null, deviceId, idempotencyKey: key });
    expect(first.ok).toBe(true);
    if (!first.ok) return;
    const actor = (await server.rooms.getActor(fx.roomA.id))!;
    const alwaysOnEvent = actor.alwaysOnSession()!.event.id;
    const host = await hostClient(fx.venueA.id, fx.hostA.id, alwaysOnEvent);
    const kicked = await host.emit('nickname.moderate', { eventId: alwaysOnEvent, playerSessionId: first.data!.playerSessionId, action: 'kick', idempotencyKey: randomUUID() });
    expect(kicked.ok).toBe(true);
    await phone.waitFor((e) => e.type === 'player.kicked', 5_000, 'kick notice');
    const replay = await phone.emit('room.join', { venueCode: fx.venueA.joinCode, nickname: 'Kickable', teamName: null, deviceId, idempotencyKey: key });
    host.close();
    phone.close();
    expect(replay.ok).toBe(false);
  });
});
