import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { WebSocket } from 'ws';

import { DEFAULT_MATCH } from '../public/js/menu-config.js';
import {
  BOT_NAVIGATION_COMPILER_PROFILES,
  BOT_NAVIGATION_POLICY,
  BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  BOT_NAVIGATION_SCHEMA,
  BOT_NAVIGATION_TEAM_SEED_IDENTITY,
  RETAIL_BOT_NAVIGATION_MAP_IDS,
} from '../server/bot-navigation.js';
import { createGateway } from '../server.js';

const DEFAULT_RESERVATION_TOKEN = 'a'.repeat(64);

function request(port, pathname, options = {}) {
  const body = options.body === undefined
    ? null
    : Buffer.isBuffer(options.body) ? options.body : Buffer.from(String(options.body));
  return new Promise((resolve, reject) => {
    const req = http.request({
      host: '127.0.0.1',
      port,
      path: pathname,
      method: options.method || 'GET',
      headers: {
        ...(body ? { 'Content-Length': String(body.length) } : {}),
        ...(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(body);
  });
}

function fakeSupervisor(initialMatch = DEFAULT_MATCH) {
  const calls = [];
  const state = {
    enabled: true,
    running: true,
    ready: true,
    pid: 1234,
    currentMatch: initialMatch ? { ...initialMatch } : null,
    pendingMatch: null,
  };
  return {
    calls,
    state,
    start() {},
    async stop() {},
    status() { return { ...state, currentMatch: state.currentMatch && { ...state.currentMatch } }; },
    async configureMatch(match) {
      calls.push({ ...match });
      state.currentMatch = { ...match };
      return { changed: true, match: { ...match } };
    },
  };
}

async function fixture(t, options = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-match-server-'));
  const dirs = {};
  for (const name of ['public', 'engine', 'content', 'replays']) {
    dirs[name] = path.join(root, name);
    fs.mkdirSync(dirs[name]);
  }
  fs.writeFileSync(path.join(dirs.public, 'index.html'), '<!doctype html><title>q4</title>');
  const botNavigationPackDirectory = path.join(root, 'runtime/native/baseoq4');
  const botNavigationManifestFile = path.join(root, 'runtime/retail-botnav-manifest.json');
  const botNavigationClientFile = path.join(root, 'runtime/native/openQ4-client_x64');
  const botNavigationGameLibFile = path.join(root, 'runtime/native/baseoq4/game-mp_x64.so');
  if (options.botNavigationMap) {
    fs.mkdirSync(botNavigationPackDirectory, { recursive: true });
    const stage = path.join(root, 'bot-navigation-stage');
    const maps = RETAIL_BOT_NAVIGATION_MAP_IDS.map((id, index) => {
      const source = Buffer.from([
        'DewmAAS "1.08"', '', String(index + 1), '', 'settings', '{', '}', 'areas 2 {',
        '\t0 ( 0 0 0 0 0 0 0 0 ) 0 {', '\t}',
        '\t1 ( 0 0 0 0 0 0 0 0 ) 1 {', '\t\t2 0 (0 0 0) (0 0 0) 0 1', '\t}',
        '}', 'clusters 0 {', '}', '',
      ].join('\n'));
      const entryPath = `maps/${id}.aas32`;
      const output = path.join(stage, entryPath);
      fs.mkdirSync(path.dirname(output), { recursive: true });
      fs.writeFileSync(output, source);
      return {
        id,
        path: entryPath,
        size: source.length,
        sha256: createHash('sha256').update(source).digest('hex'),
        mapCrc: index + 1,
        areas: 2,
        reachabilities: 1,
        compilerProfile: {
          id: BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.id,
          sha256: BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.sha256,
        },
      };
    });
    const temporaryPack = path.join(root, 'bot-navigation.pk4');
    const zipped = spawnSync('zip', ['-qX', '-9', temporaryPack, ...maps.map(({ path: entry }) => entry).sort()], {
      cwd: stage,
      encoding: 'utf8',
    });
    assert.equal(zipped.status, 0, zipped.stderr || zipped.error?.message);
    const bytes = fs.readFileSync(temporaryPack);
    const sha256 = createHash('sha256').update(bytes).digest('hex');
    const name = `q4retail_botnav-${sha256.slice(0, 12)}.pk4`;
    fs.copyFileSync(temporaryPack, path.join(botNavigationPackDirectory, name));
    const clientBytes = Buffer.from('test-only-openq4-client');
    const gameLibBytes = Buffer.from('test-only-openq4-game-lib');
    fs.writeFileSync(botNavigationClientFile, clientBytes);
    fs.writeFileSync(botNavigationGameLibFile, gameLibBytes);
    fs.writeFileSync(botNavigationManifestFile, JSON.stringify({
      schema: BOT_NAVIGATION_SCHEMA,
      policy: BOT_NAVIGATION_POLICY,
      retail: { revision: `q4retail-${'a'.repeat(20)}`, fingerprint: 'a'.repeat(64) },
      engine: {
        client: { size: clientBytes.length, sha256: createHash('sha256').update(clientBytes).digest('hex') },
        gameLib: { size: gameLibBytes.length, sha256: createHash('sha256').update(gameLibBytes).digest('hex') },
      },
      compiler: {
        teamSpawnSeed: { ...BOT_NAVIGATION_TEAM_SEED_IDENTITY },
        profiles: BOT_NAVIGATION_COMPILER_PROFILES.map((profile) => ({
          ...profile,
          ...(profile.override ? { override: { ...profile.override } } : {}),
        })),
      },
      pack: { name, size: bytes.length, sha256 },
      maps,
    }));
  }
  const supervisor = options.supervisor || fakeSupervisor();
  const replayManager = {
    status: () => [],
    ownsPort: () => false,
    touch() {},
    async close() {},
  };
  const gateway = createGateway({
    rootDir: root,
    publicDir: dirs.public,
    engineDir: dirs.engine,
    contentDir: dirs.content,
    replayDir: dirs.replays,
    voicechatFile: path.join(dirs.public, 'missing-voicechat.js'),
    host: '127.0.0.1',
    port: 0,
    spawnDedicated: false,
    replayPlaybackEnabled: false,
    serveRetail: false,
    nativeIdentity: null,
    botNavigationManifestFile,
    botNavigationPackDirectory,
    botNavigationClientFile,
    botNavigationGameLibFile,
    supervisor,
    replayManager,
    matchRateLimitMs: options.matchRateLimitMs ?? 10_000,
    matchReservationMs: options.matchReservationMs ?? 0,
    allowedOrigins: options.allowedOrigins,
    now: options.now,
  });
  await gateway.listen();
  t.after(async () => {
    await gateway.close();
    fs.rmSync(root, { recursive: true, force: true });
  });
  return { gateway, supervisor, port: gateway.address().port };
}

function matchRequest(port, match, options = {}) {
  const reservationToken = options.reservationToken === undefined
    ? DEFAULT_RESERVATION_TOKEN
    : options.reservationToken;
  return request(port, '/api/match', {
    method: 'POST',
    body: JSON.stringify(match),
    headers: {
      Origin: `http://127.0.0.1:${port}`,
      'Content-Type': 'application/json; charset=utf-8',
      ...(reservationToken ? { 'X-Q4-Match-Reservation': reservationToken } : {}),
      ...(options.headers || {}),
    },
  });
}

function cancelMatchReservation(port, reservationToken, options = {}) {
  return request(port, '/api/match/reservation', {
    method: 'DELETE',
    headers: {
      ...(options.origin === null ? {} : { Origin: options.origin || `http://127.0.0.1:${port}` }),
      ...(reservationToken ? { 'X-Q4-Match-Reservation': reservationToken } : {}),
    },
  });
}

function websocketAttempt(port, options = {}) {
  const wsOptions = {};
  if (options.origin !== null) {
    wsOptions.origin = options.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}${options.path || '/game/28004'}`, wsOptions);
    const settle = (result) => {
      if (settled) return;
      settled = true;
      resolve(result);
    };
    ws.once('open', () => settle({ status: 101, ws }));
    ws.once('unexpected-response', (_request, response) => {
      const status = response.statusCode;
      response.resume();
      response.once('end', () => settle({ 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();
  });
}

test('POST /api/match normalizes a match, waits for the supervisor, and publishes current status', async (t) => {
  const supervisor = fakeSupervisor(null);
  const { port } = await fixture(t, { supervisor });
  const requested = {
    map: 'mp/q4web_arena',
    mode: 'DM',
    botCount: 5,
    fragLimit: 25,
    timeLimit: 20,
  };
  const response = await matchRequest(port, requested);
  assert.equal(response.status, 200);
  assert.deepEqual(JSON.parse(response.body), {
    ok: true,
    changed: true,
    port: 28004,
    match: requested,
  });
  assert.deepEqual(supervisor.calls, [requested]);

  const status = JSON.parse((await request(port, '/api/status')).body);
  assert.deepEqual(status.match, { current: requested, pending: null });
  assert.deepEqual(status.dedicated.currentMatch, requested);
});

test('retail bots require a verified server-only navigation pack', async (t) => {
  await t.test('missing pack is rejected while the map remains playable without bots', async (t) => {
    const supervisor = fakeSupervisor(null);
    const { port } = await fixture(t, { supervisor, matchRateLimitMs: 0 });
    const match = {
      map: 'mp/q4dm1', mode: 'DM', botCount: 4, fragLimit: 15, timeLimit: 10,
    };
    const blocked = await matchRequest(port, match);
    assert.equal(blocked.status, 409);
    assert.equal(JSON.parse(blocked.body).code, 'MATCH_BOT_NAVIGATION_UNAVAILABLE');
    assert.equal(supervisor.calls.length, 0);

    const humansOnly = await matchRequest(port, { ...match, botCount: 0 });
    assert.equal(humansOnly.status, 200);
    assert.deepEqual(supervisor.calls, [{ ...match, botCount: 0 }]);
  });

  await t.test('verified exact pack enables the per-mode retail bot catalog', async (t) => {
    const supervisor = fakeSupervisor(null);
    const map = 'mp/q4dm1';
    const { port } = await fixture(t, { supervisor, botNavigationMap: map, matchRateLimitMs: 0 });
    const requested = { map, mode: 'DM', botCount: 4, fragLimit: 15, timeLimit: 10 };
    assert.equal((await matchRequest(port, requested)).status, 200);
    assert.deepEqual(supervisor.calls, [requested]);

    const status = JSON.parse((await request(port, '/api/status')).body);
    const q4dm1 = status.catalog.maps.find((entry) => entry.id === map);
    assert.equal(q4dm1.botNavigationReady, true);
    assert.deepEqual(q4dm1.botModes, ['DM']);
    assert.equal(q4dm1.botReady, true);

    const q4dm3 = status.catalog.maps.find((entry) => entry.id === 'mp/q4dm3');
    assert.equal(q4dm3.botNavigationReady, true);
    assert.deepEqual(q4dm3.botModes, ['DM']);
    assert.equal(q4dm3.botReady, true);

    const q4ctf1 = status.catalog.maps.find((entry) => entry.id === 'mp/q4ctf1');
    assert.equal(q4ctf1.botNavigationReady, true);
    assert.deepEqual(q4ctf1.botModes, []);
    assert.equal(q4ctf1.botReady, false);

    const teamRequest = { ...requested, map: 'mp/q4ctf1', mode: 'Team DM', botCount: 4 };
    assert.equal((await matchRequest(port, teamRequest)).status, 200);
    assert.deepEqual(supervisor.calls, [requested, { ...teamRequest, botCount: 0 }]);
  });
});

test('match mutation requires same-origin strict bounded JSON', async (t) => {
  const { port, supervisor } = await fixture(t);

  const missingOrigin = await request(port, '/api/match', {
    method: 'POST',
    body: JSON.stringify(DEFAULT_MATCH),
    headers: { 'Content-Type': 'application/json' },
  });
  assert.equal(missingOrigin.status, 403);
  assert.equal(JSON.parse(missingOrigin.body).code, 'MATCH_ORIGIN_FORBIDDEN');

  const missingToken = await request(port, '/api/match', {
    method: 'POST',
    body: JSON.stringify(DEFAULT_MATCH),
    headers: {
      Origin: `http://127.0.0.1:${port}`,
      'Content-Type': 'application/json',
    },
  });
  assert.equal(missingToken.status, 400);
  assert.equal(JSON.parse(missingToken.body).code, 'MATCH_RESERVATION_TOKEN_INVALID');

  const crossOrigin = await matchRequest(port, DEFAULT_MATCH, { headers: { Origin: 'https://example.invalid' } });
  assert.equal(crossOrigin.status, 403);

  const wrongScheme = await matchRequest(port, DEFAULT_MATCH, {
    headers: { Origin: `https://127.0.0.1:${port}` },
  });
  assert.equal(wrongScheme.status, 403);

  const wrongType = await request(port, '/api/match', {
    method: 'POST',
    body: JSON.stringify(DEFAULT_MATCH),
    headers: { Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'text/plain' },
  });
  assert.equal(wrongType.status, 415);
  assert.equal(JSON.parse(wrongType.body).code, 'MATCH_CONTENT_TYPE');

  const malformed = await request(port, '/api/match', {
    method: 'POST',
    body: '{"map":',
    headers: { Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'application/json' },
  });
  assert.equal(malformed.status, 400);
  assert.equal(JSON.parse(malformed.body).code, 'MATCH_JSON_INVALID');

  const oversized = await request(port, '/api/match', {
    method: 'POST',
    body: JSON.stringify({ ...DEFAULT_MATCH, padding: 'x'.repeat(8 * 1024) }),
    headers: { Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'application/json' },
  });
  assert.equal(oversized.status, 413);
  assert.equal(JSON.parse(oversized.body).code, 'MATCH_BODY_TOO_LARGE');

  const unknownMap = await matchRequest(port, { ...DEFAULT_MATCH, map: 'mp/not_a_real_map' });
  assert.equal(unknownMap.status, 400);
  assert.equal(JSON.parse(unknownMap.body).code, 'MATCH_MAP_UNKNOWN');
  assert.equal(supervisor.calls.length, 0);

  const wrongMethod = await request(port, '/api/match');
  assert.equal(wrongMethod.status, 405);
  assert.equal(wrongMethod.headers.allow, 'POST');
});

test('active match players block changes, while replay viewers do not', async (t) => {
  const supervisor = fakeSupervisor();
  const { port, gateway } = await fixture(t, { supervisor, matchRateLimitMs: 0 });
  const requested = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 5 };

  gateway.bridge.clients.set('player', {
    id: 'player', port: gateway.config.gamePort, closed: false,
  });
  const blocked = await matchRequest(port, requested);
  assert.equal(blocked.status, 409);
  assert.deepEqual(JSON.parse(blocked.body), {
    error: 'cannot change match while players are connected',
    code: 'MATCH_PLAYERS_ACTIVE',
    players: 1,
  });
  assert.equal(supervisor.calls.length, 0);

  gateway.bridge.clients.delete('player');
  gateway.bridge.clients.set('replay-viewer', {
    id: 'replay-viewer', port: 28040, closed: false,
  });
  const allowed = await matchRequest(port, requested);
  assert.equal(allowed.status, 200);
  assert.deepEqual(supervisor.calls, [requested]);
});

test('an identical ready match is idempotent even with players, and real changes are rate limited per IP', async (t) => {
  let now = 50_000;
  const supervisor = fakeSupervisor(DEFAULT_MATCH);
  const { port, gateway } = await fixture(t, { supervisor, now: () => now });
  gateway.bridge.clients.set('player', {
    id: 'player', port: gateway.config.gamePort, closed: false,
  });

  const unchanged = await matchRequest(port, DEFAULT_MATCH);
  assert.equal(unchanged.status, 200);
  assert.equal(JSON.parse(unchanged.body).changed, false);
  assert.equal(supervisor.calls.length, 0);

  gateway.bridge.clients.delete('player');
  const first = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 5 };
  assert.equal((await matchRequest(port, first)).status, 200);
  const second = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 10 };
  const limited = await matchRequest(port, second);
  assert.equal(limited.status, 429);
  assert.equal(limited.headers['retry-after'], '10');
  assert.equal(JSON.parse(limited.body).code, 'MATCH_RATE_LIMIT');

  now += 10_000;
  assert.equal((await matchRequest(port, second)).status, 200);
  assert.deepEqual(supervisor.calls, [first, second]);
});

test('supervisor configuration failures preserve their public status and code', async (t) => {
  const supervisor = fakeSupervisor(null);
  supervisor.configureMatch = async () => {
    const error = new Error('map load timed out');
    error.code = 'MATCH_START_TIMEOUT';
    error.statusCode = 504;
    throw error;
  };
  const { port } = await fixture(t, { supervisor });
  const response = await matchRequest(port, DEFAULT_MATCH);
  assert.equal(response.status, 504);
  assert.deepEqual(JSON.parse(response.body), {
    error: 'map load timed out',
    code: 'MATCH_START_TIMEOUT',
  });
});

test('concurrent match changes are rejected instead of being queued behind a joining player', async (t) => {
  const supervisor = fakeSupervisor();
  let release;
  supervisor.configureMatch = (match) => {
    supervisor.calls.push({ ...match });
    return new Promise((resolve) => {
      release = () => {
        supervisor.state.currentMatch = { ...match };
        resolve({ changed: true, match: { ...match } });
      };
    });
  };
  const { port } = await fixture(t, { supervisor, matchRateLimitMs: 0 });
  const firstMatch = { ...DEFAULT_MATCH, fragLimit: 15 };
  const secondMatch = { ...DEFAULT_MATCH, fragLimit: 20 };
  const first = matchRequest(port, firstMatch);
  while (!release) await new Promise((resolve) => setImmediate(resolve));

  const staleIdempotent = await matchRequest(port, DEFAULT_MATCH);
  assert.equal(staleIdempotent.status, 409);
  assert.equal(JSON.parse(staleIdempotent.body).code, 'MATCH_CHANGE_IN_PROGRESS');

  const concurrent = await matchRequest(port, secondMatch);
  assert.equal(concurrent.status, 409);
  assert.equal(JSON.parse(concurrent.body).code, 'MATCH_CHANGE_IN_PROGRESS');
  assert.deepEqual(supervisor.calls, [firstMatch]);

  release();
  assert.equal((await first).status, 200);
});

test('game WebSockets are rejected while a match mutation or supervisor change is pending', async (t) => {
  const supervisor = fakeSupervisor();
  let release;
  supervisor.configureMatch = (match) => {
    supervisor.calls.push({ ...match });
    return new Promise((resolve) => {
      release = () => {
        supervisor.state.currentMatch = { ...match };
        resolve({ changed: true, match: { ...match } });
      };
    });
  };
  const { port } = await fixture(t, { supervisor, matchRateLimitMs: 0 });
  const requested = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 5 };
  const mutation = matchRequest(port, requested);
  while (!release) await new Promise((resolve) => setImmediate(resolve));

  const duringMutation = await websocketAttempt(port);
  assert.equal(duringMutation.status, 503);

  release();
  assert.equal((await mutation).status, 200);

  supervisor.state.pendingMatch = { ...requested, fragLimit: requested.fragLimit + 5 };
  const whilePending = await websocketAttempt(port);
  assert.equal(whilePending.status, 503);

  supervisor.state.pendingMatch = null;
  const accepted = await websocketAttempt(port);
  assert.equal(accepted.status, 101);
  await closeWebSocket(accepted.ws);
});

test('a freshly configured match is reserved until its player joins or the grace period expires', async (t) => {
  let now = 10_000;
  const supervisor = fakeSupervisor();
  const { port } = await fixture(t, {
    supervisor,
    matchRateLimitMs: 0,
    matchReservationMs: 5_000,
    now: () => now,
  });
  const first = { ...DEFAULT_MATCH, fragLimit: 15 };
  const second = { ...DEFAULT_MATCH, fragLimit: 20 };
  const ownerToken = '1'.repeat(64);
  const otherToken = '2'.repeat(64);
  assert.equal((await matchRequest(port, first, { reservationToken: ownerToken })).status, 200);

  const reserved = await matchRequest(port, second, { reservationToken: otherToken });
  assert.equal(reserved.status, 409);
  assert.equal(JSON.parse(reserved.body).code, 'MATCH_JOIN_RESERVED');

  now += 4_000;
  assert.equal((await matchRequest(port, first, { reservationToken: ownerToken })).status, 200,
    'only the owner can refresh the current match reservation');

  now += 1_000;
  const refreshed = await matchRequest(port, second, { reservationToken: otherToken });
  assert.equal(refreshed.status, 409, 'an idempotent launch refreshes the join reservation');
  assert.equal(refreshed.headers['retry-after'], '4');

  now += 4_000;
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 200);
  assert.deepEqual(supervisor.calls, [first, second]);
});

test('the reservation owner can replace its match immediately while other launchers remain blocked', async (t) => {
  const supervisor = fakeSupervisor();
  const { port } = await fixture(t, {
    supervisor,
    matchRateLimitMs: 60_000,
    matchReservationMs: 5_000,
    now: () => 10_000,
  });
  const ownerToken = '5'.repeat(64);
  const otherToken = '6'.repeat(64);
  const first = { ...DEFAULT_MATCH, fragLimit: 15 };
  const replacement = { ...DEFAULT_MATCH, fragLimit: 20 };
  const blocked = { ...DEFAULT_MATCH, fragLimit: 25 };

  assert.equal((await matchRequest(port, first, { reservationToken: ownerToken })).status, 200);
  assert.equal((await matchRequest(port, replacement, { reservationToken: ownerToken })).status, 200,
    'the owner token must bypass its own grace reservation and rate limit');
  const denied = await matchRequest(port, blocked, { reservationToken: otherToken });
  assert.equal(denied.status, 409);
  assert.equal(JSON.parse(denied.body).code, 'MATCH_JOIN_RESERVED');
  assert.deepEqual(supervisor.calls, [first, replacement]);
});

test('same-origin owner cancellation is idempotent and also cancels a pending mutation reservation', async (t) => {
  const supervisor = fakeSupervisor();
  const { port } = await fixture(t, {
    supervisor,
    matchRateLimitMs: 0,
    matchReservationMs: 5_000,
    now: () => 10_000,
  });
  const ownerToken = '7'.repeat(64);
  const otherToken = '8'.repeat(64);
  const first = { ...DEFAULT_MATCH, fragLimit: 15 };
  const second = { ...DEFAULT_MATCH, fragLimit: 20 };

  assert.equal((await matchRequest(port, first, { reservationToken: ownerToken })).status, 200);
  assert.equal((await cancelMatchReservation(port, null)).status, 400);
  assert.equal((await cancelMatchReservation(port, ownerToken, { origin: 'https://example.invalid' })).status, 403);
  const wrong = await cancelMatchReservation(port, otherToken);
  assert.deepEqual(JSON.parse(wrong.body), { ok: true, released: false });
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 409);
  const released = await cancelMatchReservation(port, ownerToken);
  assert.deepEqual(JSON.parse(released.body), { ok: true, released: true });
  assert.deepEqual(JSON.parse((await cancelMatchReservation(port, ownerToken)).body), { ok: true, released: false });
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 200);

  let finishMutation;
  supervisor.configureMatch = (match) => {
    supervisor.calls.push({ ...match });
    return new Promise((resolve) => {
      finishMutation = () => {
        supervisor.state.currentMatch = { ...match };
        resolve({ changed: true, match: { ...match } });
      };
    });
  };
  const third = { ...DEFAULT_MATCH, fragLimit: 25 };
  const fourth = { ...DEFAULT_MATCH, fragLimit: 30 };
  const nextToken = '9'.repeat(64);
  const pending = matchRequest(port, third, { reservationToken: otherToken });
  while (!finishMutation) await new Promise((resolve) => setImmediate(resolve));
  assert.deepEqual(JSON.parse((await cancelMatchReservation(port, otherToken)).body), { ok: true, released: true });
  finishMutation();
  assert.equal((await pending).status, 200);
  supervisor.configureMatch = async (match) => {
    supervisor.calls.push({ ...match });
    supervisor.state.currentMatch = { ...match };
    return { changed: true, match: { ...match } };
  };
  assert.equal((await matchRequest(port, fourth, { reservationToken: nextToken })).status, 200,
    'a cancellation received during loading must suppress the later reservation');
});

test('an idempotent request with an active player does not create an abandoned join reservation', async (t) => {
  const supervisor = fakeSupervisor(DEFAULT_MATCH);
  const { port, gateway } = await fixture(t, {
    supervisor,
    matchRateLimitMs: 0,
    matchReservationMs: 5_000,
    now: () => 10_000,
  });
  gateway.bridge.clients.set('player', {
    id: 'player', port: gateway.config.gamePort, closed: false,
  });

  const unchanged = await matchRequest(port, DEFAULT_MATCH);
  assert.equal(unchanged.status, 200);
  assert.equal(JSON.parse(unchanged.body).changed, false);

  gateway.bridge.clients.delete('player');
  const changedMatch = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 5 };
  assert.equal((await matchRequest(port, changedMatch)).status, 200);
  assert.deepEqual(supervisor.calls, [changedMatch]);
});

test('only the owner game WebSocket releases a fresh-match reservation', async (t) => {
  const supervisor = fakeSupervisor();
  const { port, gateway } = await fixture(t, {
    supervisor,
    matchRateLimitMs: 0,
    matchReservationMs: 5_000,
    allowedOrigins: ['https://trusted.example'],
    now: () => 10_000,
  });
  const first = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 5 };
  const second = { ...DEFAULT_MATCH, fragLimit: DEFAULT_MATCH.fragLimit + 10 };
  const ownerToken = '3'.repeat(64);
  const otherToken = '4'.repeat(64);
  assert.equal((await matchRequest(port, first, { reservationToken: ownerToken })).status, 200);
  assert.doesNotMatch(String((await request(port, '/api/status')).body), new RegExp(ownerToken),
    'reservation capabilities must never be exposed by public status');

  const crossOrigin = await websocketAttempt(port, {
    origin: 'https://example.invalid',
    path: `/game/28004?reservation=${ownerToken}`,
  });
  assert.equal(crossOrigin.status, 403);
  const stillReserved = await matchRequest(port, second, { reservationToken: otherToken });
  assert.equal(stillReserved.status, 409);
  assert.equal(JSON.parse(stillReserved.body).code, 'MATCH_JOIN_RESERVED');

  const trustedCrossOrigin = await websocketAttempt(port, {
    origin: 'https://trusted.example',
    path: `/game/28004?reservation=${ownerToken}`,
  });
  assert.equal(trustedCrossOrigin.status, 101);
  await closeWebSocket(trustedCrossOrigin.ws);
  while (gateway.bridge.clients.size > 0) await new Promise((resolve) => setImmediate(resolve));
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 409,
    'an allowed cross-origin WebSocket must not release the launcher reservation');

  const missingOrigin = await websocketAttempt(port, {
    origin: null,
    path: `/game/28004?reservation=${ownerToken}`,
  });
  assert.equal(missingOrigin.status, 101);
  await closeWebSocket(missingOrigin.ws);
  while (gateway.bridge.clients.size > 0) await new Promise((resolve) => setImmediate(resolve));
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 409,
    'a WebSocket without Origin must not release the launcher reservation');

  const wrongOwner = await websocketAttempt(port, { path: `/game/28004?reservation=${otherToken}` });
  assert.equal(wrongOwner.status, 101);
  await closeWebSocket(wrongOwner.ws);
  while (gateway.bridge.clients.size > 0) await new Promise((resolve) => setImmediate(resolve));
  assert.equal((await matchRequest(port, second, { reservationToken: otherToken })).status, 409,
    'a same-origin WebSocket with the wrong token must not release the reservation');

  const joined = await websocketAttempt(port, { path: `/game/28004?reservation=${ownerToken}` });
  assert.equal(joined.status, 101);
  await closeWebSocket(joined.ws);
  while (gateway.bridge.clients.size > 0) await new Promise((resolve) => setImmediate(resolve));

  const changed = await matchRequest(port, second, { reservationToken: otherToken });
  assert.equal(changed.status, 200);
  assert.deepEqual(supervisor.calls, [first, second]);
});
