import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';

import { DEFAULT_MATCH, buildDedicatedMatchCommands, normalizeMatchRequest } from '../public/js/menu-config.js';
import { DedicatedSupervisor } from '../server/supervisor.js';

const READY_MARKER = 'net_LANServer is enabled. Not sending heartbeats';

class FakeChild extends EventEmitter {
  constructor(pid = 1234) {
    super();
    this.pid = pid;
    this.stdin = new PassThrough();
    this.stdout = new PassThrough();
    this.stderr = new PassThrough();
    this.written = '';
    this.verificationWrites = [];
    this.kills = [];
    this.exitOnKill = true;
    this.stdin.on('data', (chunk) => {
      const text = String(chunk);
      if (/^echo Q4_GATEWAY_READY_\d+_BEGIN\nserverInfo\necho Q4_GATEWAY_READY_\d+_END\n$/.test(text)) {
        this.verificationWrites.push(text);
      } else {
        this.written += text;
      }
    });
  }

  kill(signal) {
    this.kills.push(signal);
    if (this.exitOnKill) queueMicrotask(() => this.emit('exit', null, signal));
    return true;
  }
}

function config(overrides = {}) {
  return {
    enabled: true,
    command: '/test/openQ4-ded_x64',
    args: ['+spawnServer'],
    cwd: '/test',
    port: 28004,
    backoffBaseMs: 1_000,
    backoffMaxMs: 30_000,
    stableMs: 60_000,
    stopTimeoutMs: 50,
    matchStartTimeoutMs: 1_000,
    ...overrides,
  };
}

function createSupervisor(t, overrides = {}, dependencyOverrides = {}) {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-supervisor-'));
  t.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
  const children = [];
  const spawns = [];
  const reloadConfigFile = overrides.reloadConfigFile
    || path.join(temporary, 'save/baseoq4/autoexec.cfg');
  const supervisor = new DedicatedSupervisor(config({ reloadConfigFile, ...overrides }), {
    info() {},
    warn() {},
  }, {
    initialMatch: DEFAULT_MATCH,
    ...dependencyOverrides,
    spawnProcess(command, args, options) {
      spawns.push({ command, args, options });
      const child = new FakeChild(1000 + children.length);
      children.push(child);
      return child;
    },
  });
  supervisor.start();
  t.after(() => supervisor.stop());
  return { supervisor, children, spawns, reloadConfigFile };
}

function lastSetValue(text, name, fallback) {
  const expression = new RegExp(`^set ${name} (?:"([^"]*)"|(\\S+))$`, 'gm');
  let value = fallback;
  for (const match of text.matchAll(expression)) value = match[1] ?? match[2];
  return value;
}

function ready(child, map = DEFAULT_MATCH.map, prefix = '', overrides = {}) {
  child.stdout.write(`Map: ${map}\n${prefix}${READY_MARKER}\n`);
  const request = child.verificationWrites.at(-1);
  assert.ok(request, 'readiness marker must request native serverInfo');
  const begin = request.match(/^echo (Q4_GATEWAY_READY_\d+_BEGIN)$/m)?.[1];
  const end = request.match(/^echo (Q4_GATEWAY_READY_\d+_END)$/m)?.[1];
  assert.ok(begin && end, 'native serverInfo request must be delimited');
  const mode = lastSetValue(child.written, 'si_gameType', DEFAULT_MATCH.mode);
  const botCount = lastSetValue(child.written, 'bot_minplayers', String(DEFAULT_MATCH.botCount));
  const fragLimit = lastSetValue(child.written, 'si_fragLimit', String(DEFAULT_MATCH.fragLimit));
  const captureLimit = lastSetValue(child.written, 'si_captureLimit', fragLimit);
  const timeLimit = lastSetValue(child.written, 'si_timeLimit', String(DEFAULT_MATCH.timeLimit));
  child.stdout.write([
    begin,
    `server 'test' IP = 127.0.0.1:${overrides.port ?? 28004}`,
    `si_map = ${overrides.map ?? map}`,
    `si_gametype = ${overrides.mode ?? mode}`,
    `bot_minplayers = ${overrides.botCount ?? botCount}`,
    `si_fragLimit = ${overrides.fragLimit ?? fragLimit}`,
    `si_captureLimit = ${overrides.captureLimit ?? captureLimit}`,
    `si_timeLimit = ${overrides.timeLimit ?? timeLimit}`,
    end,
    '',
  ].join('\n'));
}

function tick() {
  return new Promise((resolve) => setImmediate(resolve));
}

test('status separates process readiness from each match load', async (t) => {
  let now = 1_000;
  const { supervisor, children } = createSupervisor(t, {}, { now: () => now });
  const child = children[0];

  assert.deepEqual(supervisor.status(), {
    enabled: true,
    running: true,
    ready: false,
    pid: child.pid,
    command: '/test/openQ4-ded_x64',
    startedAt: '1970-01-01T00:00:01.000Z',
    processReadyAt: null,
    matchLoadStartedAt: '1970-01-01T00:00:01.000Z',
    matchReadyAt: null,
    readyAt: null,
    readyDurationMs: null,
    restartAttempt: 0,
    restartPending: false,
    lastExit: null,
    currentMatch: DEFAULT_MATCH,
    pendingMatch: null,
    verifiedMatch: null,
    verifiedPort: null,
    verifiedAt: null,
  });

  now = 1_400;
  ready(child);
  let status = supervisor.status();
  assert.equal(status.processReadyAt, '1970-01-01T00:00:01.400Z');
  assert.equal(status.matchReadyAt, '1970-01-01T00:00:01.400Z');
  assert.equal(status.readyAt, status.matchReadyAt);
  assert.equal(status.readyDurationMs, 400);
  assert.deepEqual(status.verifiedMatch, DEFAULT_MATCH);
  assert.equal(status.verifiedPort, 28004);
  assert.equal(status.verifiedAt, '1970-01-01T00:00:01.400Z');

  now = 1_800;
  ready(child);
  status = supervisor.status();
  assert.equal(status.processReadyAt, '1970-01-01T00:00:01.400Z');
  assert.equal(status.matchReadyAt, '1970-01-01T00:00:01.400Z',
    'a duplicate readiness marker must not move the completed match timestamp');

  const requested = normalizeMatchRequest({
    map: 'mp/q4web_arena',
    mode: 'Team DM',
    botCount: 4,
    fragLimit: 20,
    timeLimit: 10,
  });
  now = 2_000;
  const changing = supervisor.configureMatch(requested);
  await tick();
  status = supervisor.status();
  assert.equal(status.processReadyAt, '1970-01-01T00:00:01.400Z');
  assert.equal(status.matchLoadStartedAt, '1970-01-01T00:00:02.000Z');
  assert.equal(status.matchReadyAt, null);
  assert.equal(status.readyAt, null);
  assert.equal(status.readyDurationMs, null);
  assert.equal(status.verifiedMatch, null);
  assert.equal(status.verifiedPort, null);
  assert.equal(status.verifiedAt, null);

  now = 2_600;
  ready(child, requested.map);
  await changing;
  status = supervisor.status();
  assert.equal(status.processReadyAt, '1970-01-01T00:00:01.400Z');
  assert.equal(status.matchReadyAt, '1970-01-01T00:00:02.600Z');
  assert.equal(status.readyAt, status.matchReadyAt);
  assert.equal(status.readyDurationMs, 600);
  assert.deepEqual(status.verifiedMatch, requested);
  assert.equal(status.verifiedPort, 28004);
  assert.equal(status.verifiedAt, '1970-01-01T00:00:02.600Z');
});

test('readiness is fail-closed until exact native serverInfo is delimited and verified', async (t) => {
  const { supervisor, children } = createSupervisor(t);
  const child = children[0];

  child.stdout.write(`Map: ${DEFAULT_MATCH.map}\n${READY_MARKER}\n`);
  assert.equal(supervisor.status().ready, false);
  assert.equal(child.verificationWrites.length, 1);

  const request = child.verificationWrites[0];
  const begin = request.match(/^echo (Q4_GATEWAY_READY_\d+_BEGIN)$/m)[1];
  const end = request.match(/^echo (Q4_GATEWAY_READY_\d+_END)$/m)[1];
  child.stdout.write(`${begin}\nserver 'test' IP = 127.0.0.1:28004\nsi_map = ${DEFAULT_MATCH.map}\n`);
  assert.equal(supervisor.status().ready, false);
  child.stdout.write([
    `si_gametype = ${DEFAULT_MATCH.mode}`,
    `bot_minplayers = ${DEFAULT_MATCH.botCount}`,
    `si_fragLimit = ${DEFAULT_MATCH.fragLimit}`,
    `si_captureLimit = ${DEFAULT_MATCH.fragLimit}`,
    `si_timeLimit = ${DEFAULT_MATCH.timeLimit}`,
    end,
    '',
  ].join('\n'));
  assert.equal(supervisor.status().ready, true);
  assert.deepEqual(supervisor.status().verifiedMatch, DEFAULT_MATCH);
});

test('native serverInfo mismatch rejects readiness and restarts the child', async (t) => {
  const { supervisor, children } = createSupervisor(t);
  const child = children[0];
  ready(child, DEFAULT_MATCH.map, '', { port: 28005 });
  await tick();
  assert.deepEqual(child.kills, ['SIGTERM']);
  assert.equal(supervisor.status().ready, false);
  assert.equal(supervisor.status().verifiedMatch, null);
  assert.equal(supervisor.status().verifiedPort, null);
});

test('initial readiness watchdog is generation-bound and cancelled by readiness or stop', async (t) => {
  await t.test('unready child is terminated and scheduled for restart', async (t) => {
    const { supervisor, children } = createSupervisor(t, {
      matchStartTimeoutMs: 10,
      backoffBaseMs: 1_000,
    });
    await new Promise((resolve) => setTimeout(resolve, 25));
    assert.deepEqual(children[0].kills, ['SIGTERM']);
    assert.equal(supervisor.status().running, false);
    assert.equal(supervisor.status().restartPending, true);
    assert.equal(supervisor.status().processReadyAt, null);
    assert.equal(supervisor.status().matchLoadStartedAt, null);
  });

  await t.test('valid initial readiness cancels the watchdog', async (t) => {
    const { supervisor, children } = createSupervisor(t, { matchStartTimeoutMs: 10 });
    ready(children[0]);
    await new Promise((resolve) => setTimeout(resolve, 25));
    assert.deepEqual(children[0].kills, []);
    assert.equal(supervisor.status().ready, true);
  });

  await t.test('stop cancels the watchdog without starting a restart', async (t) => {
    const { supervisor, children } = createSupervisor(t, { matchStartTimeoutMs: 10 });
    await supervisor.stop();
    await new Promise((resolve) => setTimeout(resolve, 25));
    assert.deepEqual(children[0].kills, ['SIGTERM']);
    assert.equal(children.length, 1);
    assert.equal(supervisor.status().restartPending, false);
  });
});

test('dedicated supervisor opens a command pipe and commits a match only after renewed readiness', async (t) => {
  const { supervisor, children, spawns } = createSupervisor(t);
  const child = children[0];
  assert.deepEqual(spawns[0].options.stdio, ['pipe', 'pipe', 'pipe']);

  ready(child);
  assert.equal(supervisor.status().ready, true);
  assert.deepEqual(supervisor.status().currentMatch, DEFAULT_MATCH);

  const match = {
    map: 'mp/q4web_arena',
    mode: 'Team DM',
    botCount: 4,
    fragLimit: 30,
    timeLimit: 20,
  };
  const normalizedMatch = normalizeMatchRequest(match);
  const changing = supervisor.configureMatch(match);
  await tick();
  assert.equal(supervisor.status().ready, false);
  assert.deepEqual(supervisor.status().pendingMatch, normalizedMatch);
  assert.equal(child.written, `${buildDedicatedMatchCommands(match).join('\n')}\n`);

  ready(child, normalizedMatch.map, 'map load complete: ');
  assert.deepEqual(await changing, { changed: true, match: normalizedMatch });
  assert.equal(supervisor.status().ready, true);
  assert.deepEqual(supervisor.status().currentMatch, normalizedMatch);
  assert.equal(supervisor.status().pendingMatch, null);

  const unchanged = await supervisor.configureMatch(match);
  assert.deepEqual(unchanged, { changed: false, match: normalizedMatch });
  assert.equal(child.written, `${buildDedicatedMatchCommands(match).join('\n')}\n`);
});

test('managed autoexec preserves critical server cvars across every ReloadEngine', async (t) => {
  const args = [
    '+set', 's_noSound', '1',
    '+set', 'net_ip', '127.0.0.1',
    '+set', 'net_port', '28014',
    '+set', 'net_LANServer', '1',
    '+set', 'net_serverClientTimeout', '180',
    '+set', 'si_pure', '0',
    '+set', 'net_serverAllowDataChecksumMismatch', '1',
    '+set', 'fs_caseSensitiveOS', '0',
    '+set', 'net_serverDemo', '1',
    '+set', 'net_serverDemoPath', '/tmp/q4-replays',
    '+set', 'si_name', 'Quake 4 Web',
    '+set', 'si_maxPlayers', '12',
    '+set', 'si_warmup', '0',
    '+set', 'bot_debug', '0',
    '+spawnServer',
  ];
  const { supervisor, children, reloadConfigFile } = createSupervisor(t, { args });
  const child = children[0];
  const initial = fs.readFileSync(reloadConfigFile, 'utf8');
  assert.match(initial, /^set net_ip "127\.0\.0\.1"$/m);
  assert.match(initial, /^set net_port "28014"$/m);
  assert.match(initial, /^set net_LANServer "1"$/m);
  assert.match(initial, /^set net_serverClientTimeout "180"$/m);
  assert.match(initial, /^set net_serverDemo "1"$/m);
  assert.match(initial, /^set net_serverDemoPath "\/tmp\/q4-replays"$/m);
  assert.match(initial, /^set si_name "Quake 4 Web"$/m);
  assert.match(initial, /^set si_maxPlayers "12"$/m);
  assert.match(initial, /^set bot_minplayers "8"$/m);
  assert.match(initial, /^set si_map "mp\/q4web_arena"$/m);
  assert.match(initial, /^set si_gameType "DM"$/m);

  ready(child);
  const requested = normalizeMatchRequest({
    map: 'mp/q4dm1',
    mode: 'DM',
    botCount: 0,
    fragLimit: 25,
    timeLimit: 15,
  });
  let configAtCommandWrite = '';
  child.stdin.once('data', () => {
    configAtCommandWrite = fs.readFileSync(reloadConfigFile, 'utf8');
  });
  const changing = supervisor.configureMatch(requested);
  await tick();
  assert.match(configAtCommandWrite, /^set net_port "28014"$/m);
  assert.match(configAtCommandWrite, /^set bot_minplayers "0"$/m);
  assert.match(configAtCommandWrite, /^set si_fragLimit "25"$/m);
  assert.match(configAtCommandWrite, /^set si_captureLimit "25"$/m);
  assert.match(configAtCommandWrite, /^set si_timeLimit "15"$/m);
  assert.match(configAtCommandWrite, /^set si_map "mp\/q4dm1"$/m);
  assert.match(configAtCommandWrite, /^set si_gameType "DM"$/m);
  assert.equal(fs.readdirSync(path.dirname(reloadConfigFile))
    .some((name) => name.endsWith('.tmp')), false);

  ready(child, requested.map);
  assert.deepEqual(await changing, { changed: true, match: requested });
});

test('a failed autoexec replacement cannot send a match command or change public state', async (t) => {
  const { supervisor, children, reloadConfigFile } = createSupervisor(t);
  const child = children[0];
  ready(child);
  fs.rmSync(path.dirname(reloadConfigFile), { recursive: true, force: true });
  fs.writeFileSync(path.dirname(reloadConfigFile), 'blocks directory creation');

  await assert.rejects(supervisor.configureMatch({
    ...DEFAULT_MATCH,
    botCount: 0,
  }), (error) => error.code === 'MATCH_RELOAD_CONFIG_WRITE_FAILED');
  assert.equal(child.written, '');
  assert.equal(supervisor.status().ready, true);
  assert.deepEqual(supervisor.status().currentMatch, DEFAULT_MATCH);
  assert.equal(supervisor.status().pendingMatch, null);
});

test('match changes are serialized and each waits for its own readiness marker', async (t) => {
  const { supervisor, children } = createSupervisor(t);
  const child = children[0];
  ready(child);

  const first = {
    map: 'mp/q4web_arena', mode: 'Tourney', botCount: 2, fragLimit: 15, timeLimit: 10,
  };
  const second = {
    map: 'mp/q4web_arena', mode: 'DM', botCount: 6, fragLimit: 40, timeLimit: 25,
  };
  const firstChange = supervisor.configureMatch(first);
  const secondChange = supervisor.configureMatch(second);
  await tick();
  const firstPayload = `${buildDedicatedMatchCommands(first).join('\n')}\n`;
  assert.equal(child.written, firstPayload);
  assert.deepEqual(supervisor.status().pendingMatch, normalizeMatchRequest(first));

  ready(child);
  assert.deepEqual(await firstChange, { changed: true, match: normalizeMatchRequest(first) });
  await tick();
  assert.equal(child.written, firstPayload + `${buildDedicatedMatchCommands(second).join('\n')}\n`);
  assert.deepEqual(supervisor.status().pendingMatch, normalizeMatchRequest(second));

  ready(child);
  assert.deepEqual(await secondChange, { changed: true, match: normalizeMatchRequest(second) });
  assert.deepEqual(supervisor.status().currentMatch, normalizeMatchRequest(second));
});

test('readiness for a different map cannot commit the requested match', async (t) => {
  const { supervisor, children } = createSupervisor(t);
  const child = children[0];
  ready(child);

  const requested = normalizeMatchRequest({
    map: 'mp/q4dm11v1', mode: 'DM', botCount: 0, fragLimit: 15, timeLimit: 10,
  });
  const changing = supervisor.configureMatch(requested);
  await tick();

  ready(child, 'mp/q4web_arena');
  await tick();
  assert.equal(supervisor.status().ready, false);
  assert.deepEqual(supervisor.status().pendingMatch, requested);
  assert.deepEqual(supervisor.status().currentMatch, DEFAULT_MATCH);

  ready(child, requested.map);
  assert.deepEqual(await changing, { changed: true, match: requested });
  assert.equal(supervisor.status().ready, true);
  assert.deepEqual(supervisor.status().currentMatch, requested);
});

test('exit and timeout reject a pending match without reporting it as current', async (t) => {
  await t.test('process exit', async (t) => {
    const { supervisor, children } = createSupervisor(t);
    const child = children[0];
    ready(child);
    const match = {
      map: 'mp/q4web_arena', mode: 'Tourney', botCount: 0, fragLimit: 10, timeLimit: 5,
    };
    const changing = supervisor.configureMatch(match);
    await tick();
    const processReadyAt = supervisor.status().processReadyAt;
    child.emit('exit', 1, null);
    await assert.rejects(changing, (error) => error.code === 'MATCH_SERVER_EXITED');
    assert.equal(supervisor.status().currentMatch, null);
    assert.equal(supervisor.status().pendingMatch, null);
    assert.equal(supervisor.status().processReadyAt, processReadyAt);
  });

  await t.test('readiness timeout', async (t) => {
    const { supervisor, children } = createSupervisor(t, { matchStartTimeoutMs: 10 });
    ready(children[0]);
    const match = {
      map: 'mp/q4web_arena', mode: 'Tourney', botCount: 0, fragLimit: 10, timeLimit: 5,
    };
    await assert.rejects(supervisor.configureMatch(match), (error) =>
      error.code === 'MATCH_START_TIMEOUT' && error.statusCode === 504);
    assert.equal(supervisor.status().ready, false);
    assert.equal(supervisor.status().pendingMatch, null);
    assert.equal(supervisor.status().currentMatch, null);
    assert.deepEqual(children[0].kills, ['SIGTERM']);
    ready(children[0]);
    assert.equal(supervisor.status().ready, false, 'a late readiness marker from the failed child must be ignored');
  });

  await t.test('readiness timeout escalates when the child ignores SIGTERM', async (t) => {
    const { supervisor, children } = createSupervisor(t, {
      matchStartTimeoutMs: 10,
      stopTimeoutMs: 10,
    });
    const child = children[0];
    child.exitOnKill = false;
    ready(child);
    await assert.rejects(supervisor.configureMatch({
      map: 'mp/q4web_arena', mode: 'DM', botCount: 8, fragLimit: 20, timeLimit: 5,
    }), (error) => error.code === 'MATCH_START_TIMEOUT');
    await new Promise((resolve) => setTimeout(resolve, 25));
    assert.deepEqual(child.kills, ['SIGTERM', 'SIGKILL']);
    assert.equal(supervisor.status().ready, false);
    assert.equal(supervisor.status().currentMatch, null);
  });
});

test('invalid match input cannot write console commands', async (t) => {
  const { supervisor, children } = createSupervisor(t);
  const child = children[0];
  await assert.rejects(supervisor.configureMatch({ ...DEFAULT_MATCH, fragLimit: 20 }), (error) =>
    error.code === 'MATCH_SERVER_NOT_READY');
  assert.equal(child.written, '');
  ready(child);

  await assert.rejects(supervisor.configureMatch({
    map: 'mp/q4web_arena\nquit',
    mode: 'DM',
    botCount: 8,
    fragLimit: 10,
    timeLimit: 0,
  }), (error) => error.code === 'MATCH_MAP_UNKNOWN');
  assert.equal(child.written, '');
  assert.equal(supervisor.status().ready, true);
});
