import assert from 'node:assert/strict';
import dgram from 'node:dgram';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { WebSocket } from 'ws';
import { createGateway } from '../server.js';
import { loadConfig } from '../server/config.js';
import { UdpBridge } from '../server/udp-bridge.js';

function waitEvent(emitter, event) {
  return new Promise((resolve, reject) => {
    const onError = (error) => { emitter.off(event, onEvent); reject(error); };
    const onEvent = (...args) => { emitter.off('error', onError); resolve(args); };
    emitter.once(event, onEvent);
    emitter.once('error', onError);
  });
}

function waitJson(ws, type, timeoutMs = 2_000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => finish(new Error(`timed out waiting for ${type}`)), timeoutMs);
    const onMessage = (data, isBinary) => {
      if (isBinary) return;
      let message;
      try { message = JSON.parse(Buffer.from(data).toString('utf8')); } catch { return; }
      if (message.t === type) finish(null, message);
    };
    const onClose = (code) => finish(new Error(`socket closed (${code}) while waiting for ${type}`));
    const onError = (error) => finish(error);
    const finish = (error, message) => {
      clearTimeout(timer);
      ws.off('message', onMessage);
      ws.off('close', onClose);
      ws.off('error', onError);
      if (error) reject(error); else resolve(message);
    };
    ws.on('message', onMessage);
    ws.once('close', onClose);
    ws.once('error', onError);
  });
}

function waitBinary(ws, timeoutMs = 2_000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => finish(new Error('timed out waiting for binary frame')), timeoutMs);
    const onMessage = (data, isBinary) => { if (isBinary) finish(null, Buffer.from(data)); };
    const onClose = (code) => finish(new Error(`socket closed (${code}) while waiting for binary frame`));
    const onError = (error) => finish(error);
    const finish = (error, message) => {
      clearTimeout(timer);
      ws.off('message', onMessage);
      ws.off('close', onClose);
      ws.off('error', onError);
      if (error) reject(error); else resolve(message);
    };
    ws.on('message', onMessage);
    ws.once('close', onClose);
    ws.once('error', onError);
  });
}

async function connectSocket(url) {
  const ws = new WebSocket(url);
  await waitEvent(ws, 'open');
  return ws;
}

async function connectVoice(url, activation = Buffer.from('accept')) {
  const ws = await connectSocket(url);
  const sessionPromise = waitJson(ws, 'voice-session');
  const replyPromise = waitBinary(ws);
  ws.send(activation, { binary: true });
  return { ws, session: await sessionPromise, reply: await replyPromise };
}

async function getJson(port, pathname) {
  const response = await fetch(`http://127.0.0.1:${port}${pathname}`);
  return response.json();
}

test('trusted proxies supply normalized client IPs without allowing direct XFF spoofing', () => {
  const config = loadConfig({ env: {}, rootDir: '/tmp/q4-proxy-test' });
  const bridge = new UdpBridge(config, { info() {}, warn() {}, error() {} });
  const request = (remoteAddress, forwarded = '') => ({
    socket: { remoteAddress },
    headers: { 'x-forwarded-for': forwarded },
  });

  assert.deepEqual([...config.trustedProxies], ['127.0.0.1', '::1', '10.6.0.105']);
  assert.equal(bridge.ipFor(request('10.6.0.105', '198.51.100.12, 10.6.0.105')), '198.51.100.12');
  assert.equal(bridge.ipFor(request('::ffff:10.6.0.105', '2001:0db8:0:0::12')), '2001:db8::12');
  assert.equal(bridge.ipFor(request('::ffff:10.0.0.150', '198.51.100.99')), '10.0.0.150');
  assert.equal(bridge.ipFor(request('10.6.0.105', 'malformed, 198.51.100.99')), '10.6.0.105');

  const custom = loadConfig({
    env: { Q4_TRUSTED_PROXIES: '::ffff:192.0.2.10,0:0:0:0:0:0:0:1' },
    rootDir: '/tmp/q4-proxy-custom-test',
  });
  assert.deepEqual([...custom.trustedProxies], ['192.0.2.10', '::1']);
  assert.throws(() => loadConfig({
    env: { Q4_TRUSTED_PROXIES: '10.6.0.105,not-an-ip' },
    rootDir: '/tmp/q4-proxy-invalid-test',
  }), /invalid trusted proxy IP/);
});

test('real WebSocket to UDP roundtrip and client registration', async (t) => {
  const udp = dgram.createSocket('udp4');
  udp.on('message', (message, rinfo) => udp.send(Buffer.concat([Buffer.from('udp:'), message]), rinfo.port, rinfo.address));
  udp.bind(0, '127.0.0.1');
  await waitEvent(udp, 'listening');
  const udpPort = udp.address().port;

  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-ws-'));
  for (const dir of ['public', 'engine', 'content', 'replays']) fs.mkdirSync(path.join(root, dir));
  const gateway = createGateway({
    rootDir: root,
    publicDir: path.join(root, 'public'), engineDir: path.join(root, 'engine'),
    contentDir: path.join(root, 'content'), replayDir: path.join(root, 'replays'),
    host: '127.0.0.1', port: 0, gamePort: udpPort, allowedPorts: [udpPort],
    spawnDedicated: false, idleMs: 1_000, pingMs: 50,
  });
  await gateway.listen();
  const port = gateway.address().port;
  t.after(async () => {
    await gateway.close();
    udp.close();
    fs.rmSync(root, { recursive: true, force: true });
  });

  const ws = new WebSocket(`ws://127.0.0.1:${port}/game/${udpPort}`);
  const voiceSession = waitJson(ws, 'voice-session');
  const binaryReply = waitBinary(ws);
  await waitEvent(ws, 'open');
  assert.equal((await getJson(port, '/healthz')).clients, 1);

  ws.send(Buffer.from('quake4'), { binary: true });
  assert.equal((await binaryReply).toString(), 'udp:quake4');
  assert.match((await voiceSession).selfId, /^[0-9a-f-]{36}$/);

  const status = await getJson(port, '/api/status');
  assert.equal(status.clients, 1);
  assert.equal(status.connections[0].port, udpPort);
  assert.equal(status.connections[0].rxFrames, 1);
  assert.equal(status.connections[0].txFrames, 1);
  assert.equal(status.connections[0].voiceEligible, true);

  await new Promise((resolve) => setTimeout(resolve, 1_500));
  assert.equal(ws.readyState, WebSocket.OPEN);
  assert.equal((await getJson(port, '/healthz')).clients, 1,
    'transport pong heartbeats must keep a loading browser connected');

  ws.close();
  await waitEvent(ws, 'close');
  await new Promise((resolve) => setTimeout(resolve, 20));
  assert.equal((await getJson(port, '/healthz')).clients, 0);

  const denied = new WebSocket(`ws://127.0.0.1:${port}/game/${udpPort + 1}`);
  const [, response] = await waitEvent(denied, 'unexpected-response');
  assert.equal(response.statusCode, 403);
  response.destroy();
});

test('voice requires authoritative in-band acceptance on the main server and never reaches UDP', async (t) => {
  const gameUdp = dgram.createSocket('udp4');
  const replayUdp = dgram.createSocket('udp4');
  const udpFrames = { game: 0, replay: 0 };
  const connectionless = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74]);
  const installServer = (udp, kind) => udp.on('message', (message, rinfo) => {
    udpFrames[kind] += 1;
    const response = message.toString() === 'reject'
      ? connectionless
      : Buffer.concat([Buffer.from([0x01, 0x00, 0x00, 0x00]), message]);
    udp.send(response, rinfo.port, rinfo.address);
  });
  installServer(gameUdp, 'game');
  installServer(replayUdp, 'replay');
  gameUdp.bind(0, '127.0.0.1');
  replayUdp.bind(0, '127.0.0.1');
  await Promise.all([waitEvent(gameUdp, 'listening'), waitEvent(replayUdp, 'listening')]);
  const gamePort = gameUdp.address().port;
  const replayPort = replayUdp.address().port;
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-ws-voice-'));
  for (const dir of ['public', 'engine', 'content', 'replays']) fs.mkdirSync(path.join(root, dir));
  const replayManager = {
    ownsPort: (port) => port === replayPort,
    touch() {},
    status: () => [],
    async close() {},
  };
  const gateway = createGateway({ rootDir: root, host: '127.0.0.1', port: 0,
    publicDir: path.join(root, 'public'), engineDir: path.join(root, 'engine'),
    contentDir: path.join(root, 'content'), replayDir: path.join(root, 'replays'),
    gamePort, allowedPorts: [gamePort], replayManager, spawnDedicated: false });
  await gateway.listen();
  t.after(async () => {
    await gateway.close();
    gameUdp.close();
    replayUdp.close();
    fs.rmSync(root, { recursive: true, force: true });
  });

  const base = `ws://127.0.0.1:${gateway.address().port}/game`;
  const unaccepted = await connectSocket(`${base}/${gamePort}`);
  unaccepted.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'offer' } }));
  const [unacceptedCode] = await waitEvent(unaccepted, 'close');
  assert.equal(unacceptedCode, 1008);
  assert.equal(udpFrames.game, 0, 'an unaccepted text frame never enters UDP');

  const rejected = await connectSocket(`${base}/${gamePort}`);
  const rejectedReply = waitBinary(rejected);
  const rejectedSession = waitJson(rejected, 'voice-session', 100);
  rejected.send(Buffer.from('reject'), { binary: true });
  assert.deepEqual(await rejectedReply, connectionless);
  await assert.rejects(rejectedSession, /timed out waiting for voice-session/);
  rejected.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'offer' } }));
  const [rejectedCode] = await waitEvent(rejected, 'close');
  assert.equal(rejectedCode, 1008);

  const replay = await connectSocket(`${base}/${replayPort}`);
  const replayReply = waitBinary(replay);
  const replaySession = waitJson(replay, 'voice-session', 100);
  replay.send(Buffer.from('accept-replay'), { binary: true });
  assert.match((await replayReply).toString('hex'), /^01000000/);
  await assert.rejects(replaySession, /timed out waiting for voice-session/);
  replay.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'offer' } }));
  const [replayCode] = await waitEvent(replay, 'close');
  assert.equal(replayCode, 1008);

  const a = await connectVoice(`${base}/${gamePort}?room=${gamePort}`, Buffer.from('accept-a'));
  assert.equal(a.session.eligible, true);
  assert.equal(a.session.room, String(gamePort));
  assert.deepEqual(a.session.peerIds, []);
  const aSawJoin = waitJson(a.ws, 'voice-peer');
  const b = await connectVoice(`${base}/${gamePort}`, Buffer.from('accept-b'));
  assert.equal((await aSawJoin).id, b.session.selfId);
  assert.deepEqual(b.session.peerIds, [a.session.selfId]);

  const udpBeforeVoice = { ...udpFrames };
  const directed = waitJson(b.ws, 'voice-signal');
  a.ws.send(JSON.stringify({
    t: 'voice-signal',
    to: b.session.selfId,
    from: 'spoofed-by-client',
    payload: { t: 'ice', candidate: { candidate: 'candidate:unit-test' } },
  }));
  assert.deepEqual(await directed, {
    t: 'voice-signal',
    from: a.session.selfId,
    payload: { t: 'ice', candidate: { candidate: 'candidate:unit-test' } },
  });

  const broadcast = waitJson(b.ws, 'voice-signal');
  a.ws.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'renegotiate' } }));
  assert.equal((await broadcast).from, a.session.selfId);
  await new Promise((resolve) => setTimeout(resolve, 50));
  assert.deepEqual(udpFrames, udpBeforeVoice, 'voice text is never forwarded to either UDP server');

  const status = await getJson(gateway.address().port, '/api/status');
  const sender = status.connections.find((connection) => connection.id === a.session.selfId);
  const recipient = status.connections.find((connection) => connection.id === b.session.selfId);
  assert.equal(sender.voiceRxFrames, 2);
  assert.equal(recipient.voiceTxFrames, 2);
  assert.equal(sender.voiceEligible, true);
  assert.equal(recipient.voiceEligible, true);

  const peerLeft = waitJson(a.ws, 'voice-peer');
  b.ws.close();
  assert.deepEqual(await peerLeft, { t: 'voice-peer', id: b.session.selfId, state: 'left' });
});

test('malformed and unsupported text frames are rejected', async (t) => {
  const udp = dgram.createSocket('udp4');
  udp.on('message', (message, rinfo) => {
    udp.send(Buffer.concat([Buffer.from([0x01, 0x00, 0x00, 0x00]), message]), rinfo.port, rinfo.address);
  });
  udp.bind(0, '127.0.0.1');
  await waitEvent(udp, 'listening');
  const udpPort = udp.address().port;
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-ws-text-'));
  for (const dir of ['public', 'engine', 'content', 'replays']) fs.mkdirSync(path.join(root, dir));
  const gateway = createGateway({ rootDir: root, host: '127.0.0.1', port: 0,
    publicDir: path.join(root, 'public'), engineDir: path.join(root, 'engine'),
    contentDir: path.join(root, 'content'), replayDir: path.join(root, 'replays'),
    gamePort: udpPort, allowedPorts: [udpPort], spawnDedicated: false });
  await gateway.listen();
  t.after(async () => { await gateway.close(); udp.close(); fs.rmSync(root, { recursive: true, force: true }); });

  const malformed = await connectVoice(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  malformed.ws.send('not JSON');
  const [malformedCode] = await waitEvent(malformed.ws, 'close');
  assert.equal(malformedCode, 1007);

  const unsupported = await connectVoice(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  unsupported.ws.send(JSON.stringify({ t: 'chat', message: 'not supported here' }));
  const [unsupportedCode] = await waitEvent(unsupported.ws, 'close');
  assert.equal(unsupportedCode, 1003);

  const invalidRoom = new WebSocket(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}?room=not%20valid`);
  const [, response] = await waitEvent(invalidRoom, 'unexpected-response');
  assert.equal(response.statusCode, 400);
  response.destroy();

  const privateRoom = new WebSocket(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}?room=private`);
  const [, privateResponse] = await waitEvent(privateRoom, 'unexpected-response');
  assert.equal(privateResponse.statusCode, 400);
  privateResponse.destroy();
});

test('bridge enforces frame-size and per-second rate limits', async (t) => {
  const udp = dgram.createSocket('udp4');
  udp.on('message', (message, rinfo) => {
    udp.send(Buffer.concat([Buffer.from([0x01, 0x00, 0x00, 0x00]), message]), rinfo.port, rinfo.address);
  });
  udp.bind(0, '127.0.0.1');
  await waitEvent(udp, 'listening');
  const udpPort = udp.address().port;
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-ws-limits-'));
  for (const dir of ['public', 'engine', 'content', 'replays']) fs.mkdirSync(path.join(root, dir));
  const gateway = createGateway({ rootDir: root, host: '127.0.0.1', port: 0,
    publicDir: path.join(root, 'public'), engineDir: path.join(root, 'engine'),
    contentDir: path.join(root, 'content'), replayDir: path.join(root, 'replays'),
    gamePort: udpPort, allowedPorts: [udpPort], spawnDedicated: false,
    maxFrameBytes: 512, maxFramesPerSecond: 1, maxBytesPerSecond: 1024,
    voiceMaxMessageBytes: 128, voiceMaxMessagesPerSecond: 1, voiceMaxBytesPerSecond: 1024 });
  await gateway.listen();
  t.after(async () => { await gateway.close(); udp.close(); fs.rmSync(root, { recursive: true, force: true }); });

  const oversized = new WebSocket(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  await waitEvent(oversized, 'open');
  oversized.send(Buffer.alloc(513));
  const [sizeCode] = await waitEvent(oversized, 'close');
  assert.equal(sizeCode, 1009);

  const flooded = new WebSocket(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  await waitEvent(flooded, 'open');
  flooded.send(Buffer.from('one'));
  flooded.send(Buffer.from('two'));
  const [rateCode] = await waitEvent(flooded, 'close');
  assert.equal(rateCode, 1008);

  const oversizedVoice = await connectVoice(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  oversizedVoice.ws.send(JSON.stringify({ t: 'voice-signal', payload: { sdp: 'x'.repeat(160) } }));
  const [voiceSizeCode] = await waitEvent(oversizedVoice.ws, 'close');
  assert.equal(voiceSizeCode, 1009);

  const voiceFlood = await connectVoice(`ws://127.0.0.1:${gateway.address().port}/game/${udpPort}`);
  voiceFlood.ws.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'one' } }));
  voiceFlood.ws.send(JSON.stringify({ t: 'voice-signal', payload: { t: 'two' } }));
  const [voiceRateCode] = await waitEvent(voiceFlood.ws, 'close');
  assert.equal(voiceRateCode, 1008);
});
