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 { WebSocket } from 'ws';

import { createGateway } from '../server.js';
import { replayId } from '../server/replays.js';

const CLAIM_HEADER = 'X-Q4-Replay-Claim';

function token(character) {
  return character.repeat(64);
}

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();
  });
}

function websocketAttempt(port, pathname, origin = `http://127.0.0.1:${port}`) {
  return new Promise((resolve, reject) => {
    let settled = false;
    const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`, origin === null ? {} : { origin });
    const finish = (result) => {
      if (settled) return;
      settled = true;
      resolve(result);
    };
    ws.once('open', () => finish({ status: 101, ws }));
    ws.once('unexpected-response', (_req, response) => {
      const status = response.statusCode;
      response.resume();
      response.once('end', () => finish({ status, ws }));
    });
    ws.once('error', (error) => {
      if (!settled) reject(error);
    });
  });
}

function closeWebSocket(ws) {
  if (ws.readyState === WebSocket.CLOSED) return Promise.resolve();
  return new Promise((resolve) => {
    ws.once('close', resolve);
    ws.close();
  });
}

async function fixture(t) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-replay-claims-'));
  const publicDir = path.join(root, 'public');
  const engineDir = path.join(root, 'engine');
  const contentDir = path.join(root, 'content');
  const replayDir = path.join(root, 'replays');
  for (const dir of [publicDir, engineDir, contentDir, replayDir]) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(publicDir, 'index.html'), '<!doctype html><title>q4</title>');
  const replayName = 'claim-test.q4sd';
  const replayPath = path.join(replayDir, replayName);
  const replayContents = Buffer.alloc(20);
  replayContents.write('Q4SD', 0, 'ascii');
  replayContents.writeInt32LE(1, 4);
  replayContents.writeInt32LE(3, 8);
  replayContents.writeInt32LE(60_000, 12);
  replayContents.writeInt32LE(0, 16);
  fs.writeFileSync(replayPath, replayContents);
  fs.writeFileSync(`${replayPath}.meta.json`, JSON.stringify({
    format: 'openq4-server-demo',
    version: 1,
    complete: true,
    durationMs: 60_000,
  }));
  const id = replayId(replayName);

  const pending = new Map();
  const calls = { watch: [], cancel: [], claim: [] };
  const replayManager = {
    status() {
      return pending.size ? [{ replayId: id, ending: false, port: 28040 }] : [];
    },
    ownsPort(port) { return Number(port) === 28040 && pending.size > 0; },
    canClaim(port, claimToken) { return Number(port) === 28040 && pending.has(claimToken); },
    claim(port, claimToken) {
      calls.claim.push({ port, claimToken });
      if (!this.canClaim(port, claimToken)) return false;
      pending.delete(claimToken);
      return true;
    },
    cancelClaim(replay, claimToken) {
      calls.cancel.push({ replay, claimToken });
      return pending.delete(claimToken);
    },
    async watch(replay, claimToken) {
      calls.watch.push({ replay: replay.id, claimToken });
      pending.set(claimToken, replay.id);
      return {
        instance: { port: 28040 },
        reused: calls.watch.length > 1,
      };
    },
    async close() {},
  };
  const supervisor = {
    start() {},
    async stop() {},
    status() {
      return {
        enabled: false,
        running: false,
        ready: false,
        currentMatch: null,
        pendingMatch: null,
      };
    },
  };
  const gateway = createGateway({
    rootDir: root,
    publicDir,
    engineDir,
    contentDir,
    replayDir,
    host: '127.0.0.1',
    port: 0,
    spawnDedicated: false,
    replayPlaybackEnabled: false,
    serveRetail: false,
    nativeIdentity: null,
    voicechatFile: path.join(publicDir, 'missing-voicechat.js'),
    allowedOrigins: ['https://trusted.example'],
    replayManager,
    supervisor,
  });
  await gateway.listen();
  const port = gateway.address().port;
  t.after(async () => {
    await gateway.close();
    fs.rmSync(root, { recursive: true, force: true });
  });
  return { gateway, port, id, replayPath, calls, pending };
}

test('replay watch and cancellation require a same-origin unexposed capability', async (t) => {
  const { port, id, replayPath, calls, pending } = await fixture(t);
  const pathname = `/api/replays/${id}/watch`;
  const owner = token('a');
  const other = token('b');

  assert.equal((await request(port, pathname, { method: 'POST' })).status, 403);
  assert.equal((await request(port, pathname, {
    method: 'POST',
    headers: { Origin: `http://127.0.0.1:${port}` },
  })).status, 400);
  assert.equal(calls.watch.length, 0);

  const watched = await request(port, pathname, {
    method: 'POST',
    headers: { Origin: `http://127.0.0.1:${port}`, [CLAIM_HEADER]: owner },
  });
  assert.equal(watched.status, 200);
  assert.equal(calls.watch.length, 1);
  assert.doesNotMatch(String(watched.body), new RegExp(owner));
  assert.doesNotMatch(String((await request(port, '/api/status')).body), new RegExp(owner));

  assert.equal((await request(port, pathname, {
    method: 'DELETE',
    headers: { Origin: 'https://trusted.example', [CLAIM_HEADER]: owner },
  })).status, 403, 'an allowlisted cross-origin caller still cannot spend the capability');
  const wrong = await request(port, pathname, {
    method: 'DELETE',
    headers: { Origin: `http://127.0.0.1:${port}`, [CLAIM_HEADER]: other },
  });
  assert.deepEqual(JSON.parse(wrong.body), { ok: true, released: false });
  assert.equal(pending.has(owner), true);

  fs.unlinkSync(replayPath);
  const released = await request(port, pathname, {
    method: 'DELETE',
    headers: { Origin: `http://127.0.0.1:${port}`, [CLAIM_HEADER]: owner },
  });
  assert.deepEqual(JSON.parse(released.body), { ok: true, released: true });
  const repeated = await request(port, pathname, {
    method: 'DELETE',
    headers: { Origin: `http://127.0.0.1:${port}`, [CLAIM_HEADER]: owner },
  });
  assert.deepEqual(JSON.parse(repeated.body), { ok: true, released: false });
});

test('only the matching same-origin replay WebSocket consumes a pending claim', async (t) => {
  const { gateway, port, id, calls, pending } = await fixture(t);
  const pathname = `/api/replays/${id}/watch`;
  const owner = token('c');
  const other = token('d');
  assert.equal((await request(port, pathname, {
    method: 'POST',
    headers: { Origin: `http://127.0.0.1:${port}`, [CLAIM_HEADER]: owner },
  })).status, 200);

  assert.equal((await websocketAttempt(port, '/game/28040')).status, 403);
  assert.equal((await websocketAttempt(port, `/game/28040?replayClaim=${other}`)).status, 403);
  assert.equal((await websocketAttempt(
    port,
    `/game/28040?replayClaim=${owner}`,
    'https://trusted.example',
  )).status, 403);
  assert.equal(calls.claim.length, 0);
  assert.equal(pending.has(owner), true);

  const connected = await websocketAttempt(port, `/game/28040?replayClaim=${owner}`);
  assert.equal(connected.status, 101);
  await closeWebSocket(connected.ws);
  while (gateway.bridge.clients.size > 0) await new Promise((resolve) => setImmediate(resolve));
  assert.deepEqual(calls.claim, [{ port: 28040, claimToken: owner }]);
  assert.equal(pending.has(owner), false);
  assert.equal((await websocketAttempt(port, `/game/28040?replayClaim=${owner}`)).status, 403,
    'the same capability cannot open a second WebSocket');
});
