import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

const root = path.resolve(import.meta.dirname, '..');
const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8');

test('production unit uses the q4 identity and exposes only required write paths', () => {
  const unit = read('deploy/q4.service');
  assert.match(unit, /^User=q4$/m);
  assert.match(unit, /^Group=q4$/m);
  assert.match(unit, /^NoNewPrivileges=true$/m);
  assert.match(unit, /^PrivateTmp=true$/m);
  assert.match(unit, /^ProtectSystem=strict$/m);
  assert.match(unit, /^ProtectHome=true$/m);
  assert.match(unit, /^CapabilityBoundingSet=$/m);
  assert.match(unit, /^Environment=Q4_REPLAY_HOME_ROOT=\/srv\/q4\/runtime\/replay-instances$/m);
  assert.match(unit, /^Environment=Q4_REPLAY_START_TIMEOUT_MS=120000$/m);
  assert.match(unit, /^Environment=Q4_TRUSTED_PROXIES=127\.0\.0\.1,::1,10\.6\.0\.105$/m);
  assert.match(unit, /^Environment=Q4_RETAIL_DELIVERY=operator-authorized$/m);
  assert.match(unit, /^Environment=Q4_RETAIL_DIR=\/srv\/q4\/assets-src\/gog\/q4base$/m);
  assert.match(unit, /^Environment=Q4_RETAIL_MANIFEST=\/srv\/q4\/runtime\/retail-manifest\.json$/m);
  assert.match(unit, /^Environment=Q4_RETAIL_MAX_CONCURRENT=16$/m);
  assert.match(unit, /^Environment=Q4_RETAIL_MAX_PER_IP=2$/m);

  const writePaths = [...unit.matchAll(/^ReadWritePaths=(.+)$/gm)].map((match) => match[1]);
  assert.deepEqual(writePaths, [
    '/srv/q4/replays',
    '/srv/q4/runtime/native/home',
    '/srv/q4/runtime/replay-instances',
  ]);
});

test('Caddy watchdog restores a route only after gateway and dedicated readiness', () => {
  const watchdog = read('tools/caddy_route_watchdog.sh');
  assert.match(watchdog, /'"status":"ok"'/);
  assert.match(watchdog, /'"dedicated":true'/);
  assert.match(watchdog, /'"retail":true'/);
  assert.match(watchdog, /curl -fsS --max-time 5 -X POST/);
  const existingRouteCheck = watchdog.indexOf('grep -q "\\"$HOST\\""');
  const post = watchdog.indexOf('curl -fsS --max-time 5 -X POST');
  assert.notEqual(existingRouteCheck, -1);
  assert.ok(existingRouteCheck < post, 'watchdog must check for an existing route before POST');
});

test('installer keeps local start separate from explicit health-gated publication', () => {
  const installer = read('deploy/install.sh');
  const localStart = installer.match(/  start-local\)([\s\S]*?)    ;;/)?.[1];
  const publish = installer.match(/  publish\)([\s\S]*?)    ;;/)?.[1];
  assert.ok(localStart, 'missing --start-local mode');
  assert.ok(publish, 'missing --publish mode');
  assert.match(installer, /usage: \$0 \[--start-local\|--publish\]/);

  const removeCron = localStart.indexOf('rm -f "$WATCHDOG_CRON"');
  const startService = localStart.indexOf('systemctl enable --now q4');
  assert.notEqual(removeCron, -1);
  assert.ok(removeCron < startService, '--start-local must remove an old cron before start');
  assert.doesNotMatch(localStart, /q4-caddy-watchdog\.cron|caddy_route_watchdog/);

  const retailVerify = installer.indexOf('node "$ROOT/tools/stage-retail-manifest.mjs" --verify');
  const evidence = publish.indexOf('node "$ROOT/tools/verify-e2e-evidence.mjs"');
  const evidenceGlobal = installer.indexOf('node "$ROOT/tools/verify-e2e-evidence.mjs"');
  const health = publish.indexOf('health=$(curl -fsS --max-time 5 "$HEALTH_URL")');
  const statusReady = publish.indexOf('"status"[[:space:]]*:[[:space:]]*"ok"');
  const dedicatedReady = publish.indexOf('"dedicated"[[:space:]]*:[[:space:]]*true');
  const retailReady = publish.indexOf('"retail"[[:space:]]*:[[:space:]]*true');
  const watchdog = publish.indexOf('"$ROOT/tools/caddy_route_watchdog.sh"');
  const installCron = publish.indexOf('"$ROOT/deploy/q4-caddy-watchdog.cron"');
  for (const [name, index] of Object.entries({
    retailVerify,
    evidence,
    health,
    statusReady,
    dedicatedReady,
    retailReady,
    watchdog,
    installCron,
  })) assert.notEqual(index, -1, `missing publish ${name} gate`);
  assert.ok(retailVerify < evidenceGlobal, 'retail bytes must be fully hashed before release evidence is checked');
  assert.ok(evidence < health, 'release-bound E2E evidence must be checked before health');
  assert.ok(health < statusReady && health < dedicatedReady && health < retailReady);
  assert.ok(statusReady < watchdog && dedicatedReady < watchdog && retailReady < watchdog);
  assert.ok(watchdog < installCron, 'immediate idempotent route check must precede cron enablement');
  assert.doesNotMatch(installer, /-X POST/);
  assert.equal((installer.match(/deploy\/q4-caddy-watchdog\.cron/g) || []).length, 1);

  const deploy = read('DEPLOY.md');
  const fullE2E = deploy.indexOf('npm run test:e2e:full');
  const publication = deploy.indexOf('sudo deploy/install.sh --publish');
  assert.notEqual(fullE2E, -1);
  assert.ok(fullE2E < publication, 'documented full E2E must finish before publication');
  assert.doesNotMatch(deploy, /curl[^\n]*-X POST[\s\S]*caddy-route\.json/);
});

test('safe restart waits for dedicated map readiness with configurable timing', (t) => {
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-safe-restart-'));
  t.after(() => fs.rmSync(temp, { recursive: true, force: true }));
  const bin = path.join(temp, 'bin');
  fs.mkdirSync(bin);
  const state = path.join(temp, 'restarted');
  const count = path.join(temp, 'count');
  const systemctlLog = path.join(temp, 'systemctl.log');

  fs.writeFileSync(path.join(bin, 'curl'), `#!/bin/sh
if [ ! -e "$Q4_TEST_STATE" ]; then
  case " $* " in *' -f'*|*' -fsS'*) exit 22 ;; esac
  printf '%s\\n' '{"status":"degraded","clients":0,"dedicated":false,"retail":true,"retailDownloads":0,"retailBusy":false}'
  exit 0
fi
n=0; [ ! -r "$Q4_TEST_COUNT" ] || n=$(cat "$Q4_TEST_COUNT")
n=$((n + 1)); printf '%s\\n' "$n" >"$Q4_TEST_COUNT"
if [ "$n" -lt 3 ]; then
  printf '%s\\n' '{"status":"degraded","clients":0,"dedicated":false,"retail":true,"retailDownloads":0,"retailBusy":false}'
else
  printf '%s\\n' '{"status":"ok","clients":0,"dedicated":true,"retail":true,"retailDownloads":0,"retailBusy":false}'
fi
`);
  fs.writeFileSync(path.join(bin, 'systemctl'), `#!/bin/sh
printf '%s\\n' "$*" >"$Q4_TEST_SYSTEMCTL_LOG"
: >"$Q4_TEST_STATE"
`);
  fs.writeFileSync(path.join(bin, 'sleep'), '#!/bin/sh\nexit 0\n');
  for (const name of ['curl', 'systemctl', 'sleep']) fs.chmodSync(path.join(bin, name), 0o755);

  const result = spawnSync(path.join(root, 'tools/safe_restart.sh'), ['1'], {
    encoding: 'utf8',
    env: {
      ...process.env,
      PATH: `${bin}:${process.env.PATH}`,
      Q4_TEST_STATE: state,
      Q4_TEST_COUNT: count,
      Q4_TEST_SYSTEMCTL_LOG: systemctlLog,
      Q4_RESTART_READY_TIMEOUT_SECONDS: '6',
      Q4_RESTART_READY_INTERVAL_SECONDS: '2',
    },
  });
  assert.equal(result.status, 0, result.stderr);
  assert.equal(fs.readFileSync(systemctlLog, 'utf8').trim(), 'restart q4');
  assert.equal(fs.readFileSync(count, 'utf8').trim(), '3');
  assert.match(result.stdout, /gateway and dedicated map are ready/);

  const restart = read('tools/safe_restart.sh');
  assert.match(restart, /"retailDownloads"/);
  assert.match(restart, /"retailBusy"/);
  assert.match(restart, /"retail"\[\[:space:\]\]\*:\[\[:space:\]\]\*true/);
});

test('native bot and replay smoke rotates the bundled AAS-ready arena', () => {
  const smoke = read('port/native/smoke-dedicated.sh');
  assert.match(smoke, /\+set s_noSound 1/);
  assert.match(smoke, /\+set si_map mp\/q4web_arena/);
  assert.match(smoke, /MAX_PLAYERS=12/);
  assert.match(smoke, /BOT_MINPLAYERS=8/);
  assert.match(smoke, /RECORD_SECONDS="\$\{Q4_SMOKE_RECORD_SECONDS:-70\}"/);
  assert.match(smoke, /\+set si_maxPlayers "\$\{MAX_PLAYERS\}"/);
  assert.match(smoke, /\+set bot_minplayers "\$\{BOT_MINPLAYERS\}"/);
  assert.match(smoke, /wait_for_bot_population "\$\{initial_population_line\}"/);
  assert.match(smoke, /wait_for_bot_population "\$\{survival_population_line\}"/);
  assert.match(smoke, /wait_for_bot_population "\$\{rotation_population_line\}"/);
  assert.match(smoke, /name ~ \/\^major_\*\$\//);
  assert.doesNotMatch(smoke, /printf 'addbot /);
  assert.match(smoke, /loading maps\/mp\/q4web_arena\.aas32/);
  assert.match(smoke, /wait_for_log_count "msec to load mp\/q4web_arena" 2/);
  assert.match(smoke, /wait_for_log_count "recording full-state server demo[^\n]+" 2/);
  assert.doesNotMatch(smoke, /mp\/q4dm[12]/);

  const selector = read('port/native/select-server-demo.sh');
  assert.match(selector, /-printf '%s %T@ %p\\n'/);
  assert.match(selector, /sort -k1,1nr -k2,2nr/);
  assert.match(selector, /head -c 4/);
  assert.match(selector, /json\.load\(stream\)/);
  assert.match(selector, /metadata\.get\("complete"\) is not True/);
  assert.match(selector, /duration < minimum/);

  const playback = read('port/native/smoke-server-demo-playback.sh');
  assert.match(playback, /DEMO_SELECTOR.*"\$\{OUT_ROOT\}\/replays"/s);

  const join = read('port/native/smoke-server-demo-join.sh');
  assert.match(join, /DEMO_SELECTOR.*runtime\/native-smoke\/replays.*"\$\{ROOT\}\/replays"/s);
  assert.match(join, /^MIN_DEMO_DURATION_MS=60000$/m);
  assert.match(join, /Q4_DEMO_MIN_DURATION_MS="\$\{MIN_DEMO_DURATION_MS\}"/);
});

test('server demo join smoke proves two ordinary synchronized viewers', () => {
  const join = read('port/native/smoke-server-demo-join.sh');

  assert.match(join, /Usage: \$0 \[demo\.q4sd\]/);
  assert.match(join, /DEMO_FILE="\$\(realpath -- "\$1"\)"/);
  assert.match(join, /rm -rf -- "\$\{OUT_ROOT\}\/server-home" "\$\{OUT_ROOT\}\/viewer1-home" "\$\{OUT_ROOT\}\/viewer2-home"/);
  assert.match(join, /\+set timescale 1/);
  assert.match(join, /\+set g_repeaterReliableOnly 0/);
  assert.doesNotMatch(join, /Q4_REPLAY_TIMESCALE|PLAYBACK_TIMESCALE|Q4_REPLAY_LATE_JOIN_DELAY/);
  assert.equal([...join.matchAll(/\+set net_verbose 2/g)].length, 2);

  assert.match(join, /wait_for_log[^\n]+'viewer 0 connected\.'/);
  assert.match(join, /wait_for_log[^\n]+'viewer 1 connected\.'/);
  assert.doesNotMatch(join, /wait_for_log[^\n]+connected \(repeater\)\./);
  assert.ok(join.includes('viewer [0-9]+ connected \\(repeater\\)\\.'));

  assert.match(join, /^FIRST_VIEWER_ADVANCE_MS=5000$/m);
  assert.match(join, /wait_for_snapshot_at_least[\s\S]+first_time \+ FIRST_VIEWER_ADVANCE_MS/);
  assert.match(join, /^SYNC_TOLERANCE_MS=1000$/m);
  assert.match(join, /second_init.*first_init.*viewer1_join_init.*first_init/s);
  assert.match(join, /join_delta="\$\(snapshot_delta "\$\{viewer1_join_time\}" "\$\{second_time\}"\)"/);
  assert.match(join, /join_delta > SYNC_TOLERANCE_MS/);

  assert.match(join, /^POST_JOIN_OBSERVE_SECONDS=5$/m);
  assert.match(join, /^POST_JOIN_MIN_ADVANCE_MS=3000$/m);
  assert.match(join, /observe_live_replay "\$\{POST_JOIN_OBSERVE_SECONDS\}"/);
  assert.match(join, /viewer1_advance < POST_JOIN_MIN_ADVANCE_MS \|\| viewer2_advance < POST_JOIN_MIN_ADVANCE_MS/);
  assert.match(join, /final_delta > SYNC_TOLERANCE_MS/);
  assert.match(join, /^MIN_POST_JOIN_SNAPSHOTS=2$/m);
  assert.match(join, /viewer1_new_snapshots < MIN_POST_JOIN_SNAPSHOTS \|\| viewer2_new_snapshots < MIN_POST_JOIN_SNAPSHOTS/);

  assert.equal([...join.matchAll(/wait_for_clean_exit "\$\{/g)].length, 3);
  assert.match(join, /Replay UDP port \$\{PORT\} remained in use after shutdown/);
  assert.match(join, /grep -Eiq "\$\{FAILURE_PATTERN\}" "\$\{log\}"/);
});

test('native capacity smoke gates eight bots plus two real clients', () => {
  const smoke = read('port/native/smoke-capacity.sh');

  assert.match(smoke, /OUT_ROOT="\$\{ROOT\}\/runtime\/native-capacity-smoke"/);
  assert.match(smoke, /\+set net_ip 127\.0\.0\.1/);
  assert.match(smoke, /\+set si_map mp\/q4web_arena/);
  assert.match(smoke, /\+set si_maxPlayers 12/);
  assert.match(smoke, /\+set bot_minplayers 8/);
  assert.match(smoke, /Q4NativeHuman1/);
  assert.match(smoke, /Q4NativeHuman2/);
  assert.match(smoke, /received first snapshot/);
  assert.match(smoke, /bots-initial\.serverinfo/);
  assert.match(smoke, /bots-sustained\.serverinfo/);
  assert.match(smoke, /humans-initial\.serverinfo/);
  assert.match(smoke, /humans-sustained\.serverinfo/);
  assert.match(smoke, /assert_population[^\n]+ 8 8 0/);
  assert.match(smoke, /assert_population[^\n]+ 8 10 1/);
  assert.match(smoke, /SUSTAIN_SECONDS[^\n]+30/);
  assert.equal([...smoke.matchAll(/sleep "\$\{SUSTAIN_SECONDS\}"/g)].length, 2);
  assert.match(smoke, /Q4_CAPACITY_\$\{tag\}_BEGIN/);
  assert.match(smoke, /Q4_CAPACITY_\$\{tag\}_END/);
  assert.match(smoke, /major_\*, ping/);
  assert.match(smoke, /CRASH_PATTERN=.*segmentation fault/);
  assert.doesNotMatch(smoke, /systemctl (?:start|restart)/);
});

test('server demo selector keeps directory priority before recording size', (t) => {
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-demo-selector-'));
  t.after(() => fs.rmSync(temp, { recursive: true, force: true }));
  const smoke = path.join(temp, 'smoke');
  const legacy = path.join(temp, 'legacy');
  fs.mkdirSync(smoke);
  fs.mkdirSync(legacy);

  const writeDemo = (directory, name, bytes, {
    complete = true,
    durationMs = 120_000,
    header = 'Q4SD'
  } = {}) => {
    const file = path.join(directory, name);
    const payload = Buffer.alloc(bytes, 0x41);
    payload.write(header, 0, 'ascii');
    fs.writeFileSync(file, payload);
    fs.writeFileSync(`${file}.meta.json`, JSON.stringify({ complete, durationMs }));
    return file;
  };

  const expected = writeDemo(smoke, 'match-large.q4sd', 128, { durationMs: 6_000 });
  writeDemo(smoke, 'match-small.q4sd', 64, { durationMs: 5_000 });
  writeDemo(smoke, 'invalid-header.q4sd', 1024, { header: 'NOPE' });
  writeDemo(smoke, 'incomplete.q4sd', 2048, { complete: false });
  fs.writeFileSync(path.join(smoke, 'unfinished.q4sd.part'), Buffer.alloc(4096));
  const fallback = writeDemo(legacy, 'legacy-huge.q4sd', 8192);

  const selector = path.join(root, 'port/native/select-server-demo.sh');
  const selected = execFileSync(selector, [smoke, legacy], { encoding: 'utf8' }).trim();
  assert.equal(selected, expected);

  const selectedByDuration = execFileSync(selector, [smoke, legacy], {
    encoding: 'utf8',
    env: { ...process.env, Q4_DEMO_MIN_DURATION_MS: '60000' }
  }).trim();
  assert.equal(selectedByDuration, fallback);

  for (const entry of fs.readdirSync(smoke)) fs.rmSync(path.join(smoke, entry));
  const selectedFallback = execFileSync(selector, [smoke, legacy], { encoding: 'utf8' }).trim();
  assert.equal(selectedFallback, fallback);
});

test('deployment shell files are syntactically valid', () => {
  for (const relative of [
    'deploy/install.sh',
    'tools/safe_restart.sh',
    'tools/caddy_route_watchdog.sh',
    'port/native/smoke-dedicated.sh',
    'port/native/select-server-demo.sh',
    'port/native/smoke-server-demo-playback.sh',
    'port/native/smoke-server-demo-join.sh',
    'port/native/smoke-capacity.sh',
  ]) {
    execFileSync('/bin/bash', ['-n', path.join(root, relative)]);
  }
});
