import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createGateway } from '../server.js';

async function request(port, pathname, options = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({ host: '127.0.0.1', port, path: pathname, method: options.method || 'GET', headers: options.headers }, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
    });
    req.on('error', reject);
    req.end();
  });
}

async function fixture(t, overrides = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-gateway-'));
  const dirs = Object.fromEntries(['public', 'engine', 'content', 'replays'].map((name) => {
    const dir = path.join(root, name); fs.mkdirSync(dir); return [name, dir];
  }));
  fs.writeFileSync(path.join(dirs.public, 'index.html'), '<!doctype html><title>Q4</title>');
  fs.writeFileSync(path.join(dirs.public, 'app.js'), 'globalThis.Q4=true;');
  fs.writeFileSync(path.join(dirs.engine, 'openq4.wasm'), Buffer.from('0123456789abcdef'));
  fs.writeFileSync(path.join(dirs.content, 'pak001.pk4'), Buffer.from('PK4-CONTENT'));
  fs.writeFileSync(path.join(dirs.replays, 'match one.q4sd'), Buffer.from('replay-data'));
  fs.writeFileSync(path.join(dirs.replays, 'match one.q4sd.meta.json'), JSON.stringify({ map: 'q4dm1', mode: 'DM' }));
  fs.writeFileSync(path.join(root, 'secret.txt'), 'DO-NOT-SERVE');
  fs.symlinkSync(path.join(root, 'secret.txt'), path.join(dirs.public, 'leak'));
  const gateway = createGateway({
    rootDir: root,
    publicDir: dirs.public,
    engineDir: dirs.engine,
    contentDir: dirs.content,
    replayDir: dirs.replays,
    host: '127.0.0.1',
    port: 0,
    spawnDedicated: false,
    crossOriginIsolation: true,
    ...overrides,
  });
  await gateway.listen();
  t.after(async () => {
    await gateway.close();
    fs.rmSync(root, { recursive: true, force: true });
  });
  return { gateway, port: gateway.address().port, dirs };
}

test('HTTP health, status and static mount contracts', async (t) => {
  const { port } = await fixture(t);
  const health = await request(port, '/healthz');
  assert.equal(health.status, 200);
  assert.deepEqual(JSON.parse(health.body), {
    status: 'ok', service: 'q4', port, clients: 0, players: 0,
    gamePort: 28004, dedicated: false, retail: false, downloads: 0,
    retailDownloads: 0, retailBusy: false, uptime: 0,
  });
  assert.equal(health.headers['cross-origin-resource-policy'], 'cross-origin');

  const status = await request(port, '/api/status');
  assert.equal(status.status, 200);
  assert.equal(JSON.parse(status.body).udp.defaultPort, 28004);
  assert.equal(JSON.parse(status.body).replays, 1);

  const index = await request(port, '/');
  assert.equal(index.status, 200);
  assert.match(index.headers['content-type'], /^text\/html/);
  assert.equal(index.headers['cache-control'], 'no-cache');
  assert.equal(index.headers['cross-origin-opener-policy'], 'same-origin');

  const wasm = await request(port, '/engine/openq4.wasm');
  assert.equal(wasm.status, 200);
  assert.equal(wasm.headers['content-type'], 'application/wasm');
  assert.equal(wasm.headers['accept-ranges'], 'bytes');
  assert.match(wasm.headers['cache-control'], /must-revalidate/);

  const pk4 = await request(port, '/content/pak001.pk4');
  assert.equal(pk4.status, 200);
  assert.equal(pk4.headers['content-type'], 'application/octet-stream');

  const head = await request(port, '/app.js', { method: 'HEAD' });
  assert.equal(head.status, 200);
  assert.equal(head.body.length, 0);
  assert.equal(Number(head.headers['content-length']), 'globalThis.Q4=true;'.length);
  assert.equal(head.headers['cache-control'], 'no-cache');
});

test('health is degraded while an enabled authoritative server is down', async (t) => {
  const { port } = await fixture(t, {
    spawnDedicated: true,
    dedicatedCommand: '/definitely/missing/openq4-dedicated',
    backoffBaseMs: 10_000,
  });
  await new Promise((resolve) => setTimeout(resolve, 30));
  const health = await request(port, '/healthz');
  assert.equal(health.status, 503);
  assert.equal(JSON.parse(health.body).status, 'degraded');
  assert.equal(JSON.parse(health.body).dedicated, false);
});

test('byte ranges, validators and unsatisfiable ranges', async (t) => {
  const { port } = await fixture(t);
  const partial = await request(port, '/engine/openq4.wasm', { headers: { Range: 'bytes=2-5' } });
  assert.equal(partial.status, 206);
  assert.equal(partial.body.toString(), '2345');
  assert.equal(partial.headers['content-range'], 'bytes 2-5/16');
  assert.equal(partial.headers['content-length'], '4');

  const suffix = await request(port, '/engine/openq4.wasm', { headers: { Range: 'bytes=-4' } });
  assert.equal(suffix.status, 206);
  assert.equal(suffix.body.toString(), 'cdef');

  const invalid = await request(port, '/engine/openq4.wasm', { headers: { Range: 'bytes=99-100' } });
  assert.equal(invalid.status, 416);
  assert.equal(invalid.headers['content-range'], 'bytes */16');

  const first = await request(port, '/engine/openq4.wasm');
  const cached = await request(port, '/engine/openq4.wasm', { headers: { 'If-None-Match': first.headers.etag } });
  assert.equal(cached.status, 304);
  assert.equal(cached.body.length, 0);
});

test('path traversal and malformed encoded paths never escape a mount', async (t) => {
  const { port } = await fixture(t);
  for (const pathname of [
    '/engine/%2e%2e/secret.txt',
    '/engine/%2e%2e%2fsecret.txt',
    '/content/%2e%2e%5csecret.txt',
    '/%2e%2e/secret.txt',
    '/engine/%00openq4.wasm',
    '/leak',
  ]) {
    const response = await request(port, pathname);
    assert.ok([400, 404].includes(response.status), `${pathname}: ${response.status}`);
    assert.doesNotMatch(response.body.toString(), /DO-NOT-SERVE/);
  }
});

test('replay list exposes metadata, deep link and ranged download', async (t) => {
  const { port } = await fixture(t);
  const list = await request(port, '/api/replays');
  const payload = JSON.parse(list.body);
  assert.equal(payload.replays.length, 1);
  const replay = payload.replays[0];
  assert.equal(replay.name, 'match one.q4sd');
  assert.deepEqual(replay.metadata, { map: 'q4dm1', mode: 'DM' });
  assert.match(replay.deepLink, /^\/\?replay=/);
  assert.equal(replay.watchUrl, `/api/replays/${replay.id}/watch`);

  const meta = await request(port, replay.metadataUrl);
  assert.equal(meta.status, 200);
  assert.equal(JSON.parse(meta.body).metadata.map, 'q4dm1');

  const download = await request(port, replay.downloadUrl, { headers: { Range: 'bytes=0-5' } });
  assert.equal(download.status, 206);
  assert.equal(download.body.toString(), 'replay');
  assert.match(download.headers['content-disposition'], /^attachment;/);
});

test('watch API returns a stable dynamic connection contract', async (t) => {
  const calls = [];
  const fakeManager = {
    async watch(replay) {
      calls.push(replay);
      return { instance: { port: 28040 }, reused: false };
    },
    ownsPort: (port) => port === 28040,
    touch() {},
    status: () => [{ port: 28040, ready: true, ending: false }],
    async close() {},
  };
  const { port } = await fixture(t, { replayManager: fakeManager });
  const list = JSON.parse((await request(port, '/api/replays')).body);
  const replay = list.replays[0];
  const crossSite = await request(port, replay.watchUrl, {
    method: 'POST',
    headers: { Origin: 'https://attacker.example' },
  });
  assert.equal(crossSite.status, 403);
  assert.equal(calls.length, 0);
  const response = await request(port, replay.watchUrl, { method: 'POST' });
  assert.equal(response.status, 200);
  assert.deepEqual(JSON.parse(response.body), {
    port: 28040,
    connectUrl: '/game/28040',
    replay: 'match one.q4sd',
    replayId: replay.id,
    map: 'q4dm1',
    reused: false,
  });
  assert.equal(calls[0].path, path.join(path.dirname(calls[0].path), 'match one.q4sd'));

  const status = JSON.parse((await request(port, '/api/status')).body);
  assert.deepEqual(status.replayInstances, [{ port: 28040, ready: true, ending: false }]);
  const wrongMethod = await request(port, replay.watchUrl);
  assert.equal(wrongMethod.status, 405);
  assert.equal(wrongMethod.headers.allow, 'POST');
});

test('watch API limits only new replay allocations per trusted client IP', async (t) => {
  const calls = [];
  const instances = [];
  let clock = 1_000_000;
  const fakeManager = {
    async watch(replay) {
      calls.push(replay.id);
      const existing = instances.find((instance) => instance.replayId === replay.id && !instance.ending);
      if (existing) return { instance: existing, reused: true };
      const instance = {
        port: 28040 + instances.length,
        replayId: replay.id,
        ready: true,
        ending: false,
      };
      instances.push(instance);
      return { instance, reused: false };
    },
    ownsPort: (port) => instances.some((instance) => instance.port === port),
    touch() {},
    status: () => instances.map((instance) => ({ ...instance })),
    async close() {},
  };
  const { port, dirs } = await fixture(t, {
    replayManager: fakeManager,
    replayAllocationLimit: 2,
    replayAllocationWindowMs: 10_000,
    now: () => clock,
  });
  for (const name of [
    'match two.q4sd',
    'match three.q4sd',
    'match four.q4sd',
    'match five.q4sd',
    'match six.q4sd',
  ]) {
    fs.writeFileSync(path.join(dirs.replays, name), Buffer.from(`replay:${name}`));
  }
  const listed = JSON.parse((await request(port, '/api/replays')).body).replays;
  const byName = new Map(listed.map((replay) => [replay.name, replay]));

  const first = await request(port, byName.get('match one.q4sd').watchUrl, { method: 'POST' });
  const second = await request(port, byName.get('match two.q4sd').watchUrl, { method: 'POST' });
  assert.equal(first.status, 200);
  assert.equal(second.status, 200);
  assert.equal(calls.length, 2);

  const limited = await request(port, byName.get('match three.q4sd').watchUrl, { method: 'POST' });
  assert.equal(limited.status, 429);
  assert.equal(limited.headers['retry-after'], '10');
  assert.equal(JSON.parse(limited.body).code, 'REPLAY_ALLOCATION_RATE_LIMIT');
  assert.equal(calls.length, 2, 'a limited allocation never reaches the replay manager');

  const reused = await request(port, byName.get('match one.q4sd').watchUrl, { method: 'POST' });
  assert.equal(reused.status, 200);
  assert.equal(JSON.parse(reused.body).reused, true);
  assert.equal(calls.length, 3, 'an active replay remains watchable after the new-allocation limit');

  const listAfterLimit = await request(port, '/api/replays');
  const downloadAfterLimit = await request(port, byName.get('match three.q4sd').downloadUrl);
  assert.equal(listAfterLimit.status, 200);
  assert.equal(downloadAfterLimit.status, 200);
  assert.equal(downloadAfterLimit.body.toString(), 'replay:match three.q4sd');

  const otherIpHeaders = { 'X-Forwarded-For': '198.51.100.42' };
  const otherIpFirst = await request(port, byName.get('match three.q4sd').watchUrl, {
    method: 'POST', headers: otherIpHeaders,
  });
  const otherIpSecond = await request(port, byName.get('match four.q4sd').watchUrl, {
    method: 'POST', headers: otherIpHeaders,
  });
  const otherIpLimited = await request(port, byName.get('match five.q4sd').watchUrl, {
    method: 'POST', headers: otherIpHeaders,
  });
  assert.equal(otherIpFirst.status, 200);
  assert.equal(otherIpSecond.status, 200);
  assert.equal(otherIpLimited.status, 429, 'the trusted second client IP has an independent full window');

  clock += 10_001;
  const afterWindow = await request(port, byName.get('match six.q4sd').watchUrl, { method: 'POST' });
  assert.equal(afterWindow.status, 200, 'expired rolling-window entries are pruned');
});
