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

import { MULTIPLAYER_MAPS } from '../public/js/menu-config.js';
import {
  CAPTURE_SERVER_READY_MARKER,
  MAX_CAPTURE_ATTEMPTS,
  MAX_CAPTURE_JOBS,
  RetryableCaptureError,
  aggregateModeCaptures,
  buildCapturedMapProfiles,
  buildCaptureLaunchPaths,
  buildCapturePlan,
  buildClientArguments,
  buildModeCapturePlans,
  buildServerArguments,
  copyCommonTraces,
  createExactRoots,
  parseCaptureArgs,
  promoteCaptureAttemptLogs,
  publishCaptureStage,
  runCaptureAttempts,
  serverSettledMarker,
  stageCaptureSupplements,
  validateCapturedTrace,
  validateCapturedServerEvidence,
  validateSupplementLog,
  waitForCaptureSettle,
  waitForCaptureServerReady,
} from '../tools/capture-retail-bundle-profiles.mjs';

const fixedNow = new Date('2026-07-16T12:34:56.000Z');

function options(args = [], directory = '/tmp/q4-capture-test') {
  return parseCaptureArgs(args, { cwd: directory, now: fixedNow, pid: 4242 });
}

function valueAfter(args, name) {
  const index = args.indexOf(name);
  assert.notEqual(index, -1, `missing ${name}`);
  return args[index + 1];
}

test('capture plan is derived from the live multiplayer catalog and supports an exact subset', () => {
  const all = buildCapturePlan(options());
  assert.equal(all.maps.length, MULTIPLAYER_MAPS.length);
  assert.equal(all.allCatalogMaps, true);
  assert.deepEqual(all.maps.map(({ id }) => id), MULTIPLAYER_MAPS.map(({ id }) => id));
  for (const [index, map] of all.maps.entries()) {
    assert.deepEqual([...map.modes], [...MULTIPLAYER_MAPS[index].modes]);
    assert.equal(map.port, 27700 + index);
    assert.equal(map.retailGeometry, MULTIPLAYER_MAPS[index].content === 'retail');
  }

  const subset = buildCapturePlan(options(['--maps', 'mp/q4dm3,mp/q4ctf1', '--jobs', '2']));
  assert.equal(subset.jobs, 2);
  assert.equal(subset.allCatalogMaps, false);
  assert.deepEqual(subset.maps.map(({ id }) => id), ['mp/q4dm3', 'mp/q4ctf1']);
  assert.deepEqual([...subset.maps[1].modes], [...MULTIPLAYER_MAPS.find(({ id }) => id === 'mp/q4ctf1').modes]);
});

test('capture arguments reject unsafe concurrency, unknown maps, reserved ports, and active output', () => {
  assert.equal(MAX_CAPTURE_JOBS, 2);
  assert.equal(MAX_CAPTURE_ATTEMPTS, 3);
  assert.throws(() => options(['--jobs', '3']), /--jobs must be between 1 and 2/);
  assert.throws(() => buildCapturePlan(options(['--maps', 'mp/not_a_map'])), /unknown multiplayer map/);
  assert.throws(() => buildCapturePlan(options(['--maps', 'mp/q4dm3', '--port-base', '28004'])), /reserved/);
  assert.throws(() => options(['--output', '/srv/q4/runtime/retail-bundle-profiles']), /must not overlap/);
  assert.throws(() => options(['--output', '/srv/q4/runtime/retail-bundle-profiles/new']), /must not overlap/);
  assert.throws(() => options(['--supplement', 'mp/q4dm3=/tmp/not allowed.log']), /safely named/);
  assert.throws(() => options(['--supplement', 'mp/q4dm3=../browser.log']), /parent traversal/);
  assert.throws(() => options([
    '--supplement', 'mp/q4dm3=/tmp/a/browser.log',
    '--supplement', 'mp/q4dm3=/tmp/b/browser.log',
  ]), /duplicate supplement target/);
  assert.throws(() => buildCapturePlan(options([
    '--maps', 'mp/q4dm3',
    '--supplement', 'mp/q4web_arena=/tmp/browser.log',
  ])), /outside the capture plan/);
});

test('native command contracts isolate paths and force fs_debug balanced machine spec 2', () => {
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  const context = {
    sourceRoot: '/stage/source',
    cdRoot: '/stage/cd',
    saveRoot: '/stage/save',
    map,
    mode: map.modes[0],
    port: map.port,
  };
  const server = buildServerArguments(context);
  const client = buildClientArguments(context);
  assert.equal(valueAfter(server, 'fs_basepath'), '/stage/source');
  assert.equal(valueAfter(server, 'fs_cdpath'), '/stage/cd');
  assert.equal(valueAfter(server, 'fs_savepath'), '/stage/save');
  assert.equal(valueAfter(server, 'si_map'), map.id);
  assert.equal(valueAfter(server, 'si_gameType'), map.modes[0]);
  assert.equal(server.at(-2), '+spawnServer');
  assert.equal(server.at(-1), map.id);

  assert.equal(valueAfter(client, 'fs_debug'), '1');
  assert.equal(valueAfter(client, 'net_verbose'), '1');
  assert.equal(valueAfter(client, 'sys_allowMultipleInstances'), '1');
  assert.equal(valueAfter(client, 'fs_validateOfficialPaks'), '0');
  assert.equal(valueAfter(client, 'com_machineSpec'), '2');
  assert.deepEqual(client.slice(client.indexOf('+applyPerformancePreset'), client.indexOf('+applyPerformancePreset') + 2),
    ['+applyPerformancePreset', 'balanced']);
  assert.equal(client.at(-2), '+connect');
  assert.equal(client.at(-1), `127.0.0.1:${map.port}`);

  const launch = buildCaptureLaunchPaths('/stage/run', '/srv/q4/runtime/native');
  assert.equal(launch.cwd, '/stage/run');
  assert.equal(launch.client, '/srv/q4/runtime/native/openQ4-client_x64');
  assert.equal(launch.dedicated, '/srv/q4/runtime/native/openQ4-ded_x64');
  assert.notEqual(launch.cwd, '/srv/q4/runtime/native');
});

test('every map mode has a deterministic isolated run directory and log pair', () => {
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  assert.deepEqual(buildModeCapturePlans(map), [
    {
      mode: 'DM',
      slug: 'dm',
      runDirectory: 'runs/mp_q4dm3/dm',
      trace: 'maps/mp_q4dm3__dm.log',
      serverEvidence: 'evidence/mp_q4dm3__dm.server.log',
    },
    {
      mode: 'Team DM',
      slug: 'team_dm',
      runDirectory: 'runs/mp_q4dm3/team_dm',
      trace: 'maps/mp_q4dm3__team_dm.log',
      serverEvidence: 'evidence/mp_q4dm3__team_dm.server.log',
    },
    {
      mode: 'Tourney',
      slug: 'tourney',
      runDirectory: 'runs/mp_q4dm3/tourney',
      trace: 'maps/mp_q4dm3__tourney.log',
      serverEvidence: 'evidence/mp_q4dm3__tourney.server.log',
    },
  ]);
});

test('server readiness waits for map load and LAN listener marker from the same log offset', async () => {
  const calls = [];
  const server = {
    async waitFor(text, offset, timeoutMs) {
      calls.push({ text, offset, timeoutMs });
    },
  };
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  await waitForCaptureServerReady(server, map, 1234, 240_000);
  assert.deepEqual(calls, [
    { text: 'msec to load mp/q4dm3', offset: 1234, timeoutMs: 240_000 },
    { text: CAPTURE_SERVER_READY_MARKER, offset: 1234, timeoutMs: 240_000 },
  ]);
  assert.equal(CAPTURE_SERVER_READY_MARKER, 'net_LANServer is enabled. Not sending heartbeats');
});

test('capture settle monitors both child processes instead of sleeping blindly', async () => {
  const server = { label: 'server', exit: null };
  const client = { label: 'client', exit: null };
  let clock = 0;
  const cleanSleeps = [];
  await waitForCaptureSettle(server, client, 250, {
    pollMs: 100,
    now: () => clock,
    sleepFn: async (ms) => {
      cleanSleeps.push(ms);
      clock += ms;
    },
  });
  assert.deepEqual(cleanSleeps, [100, 100, 50]);

  clock = 0;
  server.exit = null;
  await assert.rejects(waitForCaptureSettle(server, client, 1_000, {
    pollMs: 100,
    now: () => clock,
    sleepFn: async (ms) => {
      clock += ms;
      server.exit = { code: 139, signal: null };
    },
  }), (error) => error instanceof RetryableCaptureError && /server.*exit 139/.test(error.message));
});

test('capture retries only retryable runtime failures and preserves attempt evidence', async () => {
  const delays = [];
  const result = await runCaptureAttempts(async (attempt) => {
    if (attempt < 3) {
      const error = new RetryableCaptureError(`runtime failure ${attempt}`);
      error.attemptRecord = Object.freeze({ attempt, outcome: 'failed' });
      throw error;
    }
    return {
      value: 'complete',
      attemptRecord: Object.freeze({ attempt, outcome: 'completed' }),
    };
  }, {
    retryDelayMs: 25,
    sleepFn: async (ms) => delays.push(ms),
  });
  assert.equal(result.result.value, 'complete');
  assert.deepEqual(result.attempts, [
    { attempt: 1, outcome: 'failed' },
    { attempt: 2, outcome: 'failed' },
    { attempt: 3, outcome: 'completed' },
  ]);
  assert.deepEqual(delays, [25, 25]);

  let structuralAttempts = 0;
  await assert.rejects(runCaptureAttempts(async () => {
    structuralAttempts += 1;
    throw new Error('foreign PK4');
  }, { sleepFn: async () => assert.fail('structural failures must not sleep or retry') }), /foreign PK4/);
  assert.equal(structuralAttempts, 1);
});

test('server evidence rejects runtime crashes but records a known post-shutdown SIGSEGV', () => {
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  const mode = map.modes[0];
  const boundary = serverSettledMarker(map, mode);
  const base = [
    `123 msec to load ${map.id}`,
    CAPTURE_SERVER_READY_MARKER,
    boundary,
  ].join('\n');
  const clean = validateCapturedServerEvidence(base, map, mode, {
    stopResult: {
      commandSent: true,
      commandOffset: base.length,
      exit: { code: 0, signal: null },
    },
  });
  assert.equal(clean.teardownFatal, false);

  const runtimeFatal = [
    `123 msec to load ${map.id}`,
    CAPTURE_SERVER_READY_MARKER,
    'openQ4: fatal signal SIGSEGV (11), exiting without unsafe engine shutdown',
    boundary,
  ].join('\n');
  assert.throws(() => validateCapturedServerEvidence(runtimeFatal, map, mode, {
    stopResult: {
      commandSent: true,
      commandOffset: runtimeFatal.length,
      exit: { code: 139, signal: null },
    },
  }), RetryableCaptureError);

  const teardownFatal = `${base}\nopenQ4: fatal signal SIGSEGV (11), exiting without unsafe engine shutdown`;
  const classified = validateCapturedServerEvidence(teardownFatal, map, mode, {
    stopResult: {
      commandSent: true,
      commandOffset: base.length,
      exit: { code: 139, signal: null },
    },
  });
  assert.equal(classified.teardownFatal, true);
  assert.deepEqual(classified.exit, { code: 139, signal: null });

  assert.throws(() => validateCapturedServerEvidence(base, map, mode, {
    stopResult: {
      commandSent: false,
      commandOffset: null,
      exit: { code: 0, signal: null },
    },
  }), RetryableCaptureError);
});

test('capture roots expose baseoq4 for pre-init without adding a cwd q4base', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-roots-'));
  const runRoot = path.join(temporary, 'run');
  const retailDir = path.join(temporary, 'retail');
  const nativeRuntime = path.join(temporary, 'native');
  fs.mkdirSync(path.join(nativeRuntime, 'baseoq4'), { recursive: true });
  fs.mkdirSync(retailDir, { recursive: true });
  fs.writeFileSync(path.join(nativeRuntime, 'baseoq4/mod.json'), '{}\n');
  fs.writeFileSync(path.join(retailDir, 'pak001.pk4'), 'retail');
  try {
    const roots = await createExactRoots(runRoot, { retailDir, nativeRuntime }, {
      files: [{ name: 'pak001.pk4' }],
    });
    assert.equal(roots.sourceRoot, path.join(runRoot, 'source'));
    assert.equal(fs.lstatSync(path.join(runRoot, 'baseoq4')).isSymbolicLink(), true);
    assert.equal(fs.realpathSync(path.join(runRoot, 'baseoq4')), path.join(nativeRuntime, 'baseoq4'));
    assert.equal(fs.lstatSync(path.join(runRoot, 'cd/baseoq4')).isSymbolicLink(), true);
    assert.equal(fs.lstatSync(path.join(runRoot, 'source/q4base/pak001.pk4')).isSymbolicLink(), true);
    assert.equal(fs.existsSync(path.join(runRoot, 'q4base')), false);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('each mode trace requires isolated exact retail reads, geometry, and only its mode marker', () => {
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  const expectedQ4base = '/capture/source/q4base';
  const archive = `${expectedQ4base}/pak016.pk4`;
  const mode = 'DM';
  const marker = `Q4_PROFILE_${map.slug}_DM_READY`;
  const trace = [
    "idFileSystem::OpenFileRead: default.cfg (found in '/capture/cd/baseoq4/pak0.pk4')",
    `idFileSystem::OpenFileRead: maps/${map.id}.proc (found in '${archive}')`,
    `idFileSystem::OpenFileRead: maps/${map.id}.cm (found in '${archive}')`,
    `idFileSystem::OpenFileRead: maps/${map.id}.map (found in '${archive}')`,
    `glprogs/heatHazeWithMask.vfpidFileSystem::OpenFileRead: glprogs/heatHazeWithMask.vfp (found in '${archive}')`,
    marker,
  ].join('\n');
  const validationOptions = { expectedQ4base, expectedModes: [mode] };
  assert.equal(validateCapturedTrace(trace, map, validationOptions).successfulRetailReads, 4);
  assert.throws(() => validateCapturedTrace(
    trace.replace(`maps/${map.id}.cm`, 'maps/mp/other.cm'), map, validationOptions),
    /missing successful .*\.cm read/);
  assert.throws(() => validateCapturedTrace(
    trace.replace(marker, ''), map, validationOptions),
    /completion marker/);
  assert.throws(() => validateCapturedTrace(
    `${trace}\nQ4_PROFILE_${map.slug}_TEAM_DM_READY`, map, validationOptions),
    /unexpected Team DM completion marker/);
  assert.throws(() => validateCapturedTrace(
    `${trace}\nsegmentation fault`, map, validationOptions), /fatal runtime marker/);
  assert.throws(() => validateCapturedTrace(
    trace.replace(archive, '/srv/q4/runtime/native/q4base/pak016.pk4'), map, validationOptions),
    /unexpected q4base/);
  assert.throws(() => validateCapturedTrace([
    trace,
    "idFileSystem::OpenFileRead: materials/gui.mtr (found in '/srv/q4/runtime/native/q4base/pak025.pk4')",
  ].join('\n'), map, validationOptions), /unexpected q4base/);
  assert.throws(() => validateCapturedTrace([
    trace,
    `idFileSystem::OpenFileRead: materials/gui.mtr (found in '${expectedQ4base}/pak025.pk4')`,
  ].join('\n'), map, validationOptions), /unapproved PK4/);
});

test('mode capture aggregation requires exact complete catalog coverage and preserves every log', () => {
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  const captures = buildModeCapturePlans(map).map((plan, index) => ({
    mode: plan.mode,
    trace: plan.trace,
    serverEvidence: plan.serverEvidence,
    port: map.port,
    successfulRetailReads: index + 1,
    sha256: String(index).padStart(64, '0'),
  }));
  const aggregate = aggregateModeCaptures(map, [...captures].reverse());
  assert.deepEqual([...aggregate.modes], [...map.modes]);
  assert.deepEqual([...aggregate.traces], buildModeCapturePlans(map).map(({ trace }) => trace));
  assert.deepEqual([...aggregate.modeCaptures].map(({ mode }) => mode), [...map.modes]);
  assert.equal(aggregate.successfulRetailReads, 6);
  assert.throws(() => aggregateModeCaptures(map, captures.slice(1)), /count does not match/);
  assert.throws(() => aggregateModeCaptures(map, [captures[0], captures[0], captures[2]]),
    /invalid or duplicate mode capture/);
});

test('only the successful attempt is promoted into canonical profile paths', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-attempts-'));
  const stage = path.join(temporary, 'stage');
  const map = buildCapturePlan(options(['--maps', 'mp/q4dm3'])).maps[0];
  const modePlan = buildModeCapturePlans(map)[0];
  const attemptsDirectory = path.join(stage, 'evidence', 'attempts');
  const successfulClient = path.join(attemptsDirectory, 'attempt-02.client.log');
  const successfulServer = path.join(attemptsDirectory, 'attempt-02.server.log');
  const failedClient = path.join(attemptsDirectory, 'attempt-01.client.log');
  const failedServer = path.join(attemptsDirectory, 'attempt-01.server.log');
  fs.mkdirSync(path.join(stage, 'maps'), { recursive: true });
  fs.mkdirSync(attemptsDirectory, { recursive: true });
  fs.writeFileSync(failedClient, 'failed client\n');
  fs.writeFileSync(failedServer, 'failed server\n');
  fs.writeFileSync(successfulClient, 'successful client\n');
  fs.writeFileSync(successfulServer, 'successful server\n');
  try {
    const promoted = await promoteCaptureAttemptLogs(stage, modePlan, {
      clientLog: successfulClient,
      serverLog: successfulServer,
    });
    assert.deepEqual(promoted, {
      trace: modePlan.trace,
      serverEvidence: modePlan.serverEvidence,
    });
    assert.equal(fs.readFileSync(path.join(stage, modePlan.trace), 'utf8'), 'successful client\n');
    assert.equal(fs.readFileSync(path.join(stage, modePlan.serverEvidence), 'utf8'), 'successful server\n');
    assert.equal(fs.existsSync(failedClient), true);
    assert.equal(fs.existsSync(failedServer), true);
    assert.equal(fs.existsSync(successfulClient), false);
    assert.equal(fs.existsSync(successfulServer), false);
    const profiles = buildCapturedMapProfiles([{
      id: map.id,
      retailGeometry: map.retailGeometry,
      traces: [promoted.trace],
    }], []);
    assert.deepEqual(profiles[0].traces, [modePlan.trace]);
    assert.equal(profiles[0].traces.some((tracePath) => tracePath.includes('attempt')), false);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('explicit q4web browser and assets supplements are copied, hashed, and added to map traces', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-supplements-'));
  const browser = path.join(temporary, 'mp_q4web_arena_browser.log');
  const assets = path.join(temporary, 'mp_q4web_arena.assets');
  const stage = path.join(temporary, 'stage');
  const revision = 'q4retail-test';
  const browserQ4base = `/opfs/q4-retail/revisions/${revision}/runtime/q4base`;
  fs.writeFileSync(browser, [
    `Loaded pk4 ${browserQ4base}/pak001.pk4 with checksum 0x1234`,
    `idFileSystem::OpenFileRead: materials/gui.mtr (found in '${browserQ4base}/pak001.pk4')`,
  ].join('\n'));
  fs.writeFileSync(assets, 'guis/mpmain.gui\n');
  try {
    const parsed = options([
      '--maps', 'mp/q4web_arena',
      '--supplement', `mp/q4web_arena=${browser}`,
      '--supplement', `mp/q4web_arena=${assets}`,
    ], temporary);
    const plan = buildCapturePlan(parsed);
    assert.deepEqual(plan.supplements.map(({ name }) => name),
      ['mp_q4web_arena.assets', 'mp_q4web_arena_browser.log']);
    assert.equal(validateSupplementLog(fs.readFileSync(browser, 'utf8'),
      plan.maps[0], { expectedQ4base: browserQ4base }).successfulRetailReads, 1);

    fs.mkdirSync(stage);
    const records = await stageCaptureSupplements(plan, stage, { revision });
    assert.equal(records.length, 2);
    for (const record of records) {
      const staged = path.join(stage, ...record.trace.split('/'));
      assert.equal(fs.readFileSync(staged).equals(fs.readFileSync(record.source)), true);
      assert.match(record.sha256, /^[a-f0-9]{64}$/);
      assert.equal(record.size, fs.statSync(record.source).size);
    }
    const maps = buildCapturedMapProfiles([{
      id: 'mp/q4web_arena',
      retailGeometry: false,
      traces: [
        'maps/mp_q4web_arena__dm.log',
        'maps/mp_q4web_arena__team_dm.log',
        'maps/mp_q4web_arena__tourney.log',
      ],
    }], records);
    assert.deepEqual(maps[0].traces, [
      'maps/mp_q4web_arena__dm.log',
      'maps/mp_q4web_arena__team_dm.log',
      'maps/mp_q4web_arena__tourney.log',
      ...records.map(({ trace }) => trace),
    ]);
    assert.equal(maps[0].traces.some((trace) => trace.endsWith('_client.log')), false);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('common profiles preserve explicit browser runtime asset roots', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-common-assets-'));
  const profileDirectory = path.join(temporary, 'profile');
  const stage = path.join(temporary, 'stage');
  const revision = 'q4retail-test';
  const fingerprint = 'a'.repeat(64);
  fs.mkdirSync(path.join(profileDirectory, 'common'), { recursive: true });
  fs.writeFileSync(path.join(profileDirectory, 'common/startup.log'),
    "idFileSystem::OpenFileRead: default.cfg (found in '/retail/pak001.pk4')\n");
  fs.writeFileSync(path.join(profileDirectory, 'common/browser.assets'), [
    '# Browser-only roots not reached by the native capture.',
    'guis/cursor.gui',
    'guis/weapons/machinegun_ammo.gui',
    '',
  ].join('\n'));
  fs.writeFileSync(path.join(profileDirectory, 'manifest.json'), `${JSON.stringify({
    schema: 1,
    policy: 'openq4-fs-debug-working-set-v1',
    sourceRevision: revision,
    sourceFingerprint: fingerprint,
    performancePreset: 'balanced',
    maxMachineSpec: 2,
    coverageScenarios: [
      'fresh-process-per-map',
      'balanced-machine-spec-2',
      'all-player-models',
      'all-mp-weapons-items-projectiles',
      'dm-tourney-team-ctf-gui-audio',
    ],
    commonTraces: ['common/startup.log', 'common/browser.assets'],
  })}\n`);
  try {
    const result = await copyCommonTraces({
      commonProfile: path.join(profileDirectory, 'manifest.json'),
    }, stage, { revision, fingerprint });
    assert.deepEqual(result.traces, [
      'common/01-startup.log',
      'common/02-browser.assets',
    ]);
    assert.match(fs.readFileSync(path.join(stage, result.traces[1]), 'utf8'),
      /guis\/cursor\.gui/);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('supplements reject symlinks and browser logs reading a foreign q4base', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-bad-supplement-'));
  const revision = 'q4retail-test';
  const assets = path.join(temporary, 'real.assets');
  const linked = path.join(temporary, 'linked.assets');
  const foreign = path.join(temporary, 'foreign.log');
  fs.writeFileSync(assets, 'textures/example.dds\n');
  fs.symlinkSync(assets, linked);
  fs.writeFileSync(foreign, [
    "Loaded pk4 /srv/q4/runtime/native/q4base/pak025.pk4 with checksum 0x1234",
    "idFileSystem::OpenFileRead: materials/gui.mtr (found in '/srv/q4/runtime/native/q4base/pak025.pk4')",
  ].join('\n'));
  try {
    const symlinkPlan = buildCapturePlan(options([
      '--maps', 'mp/q4web_arena',
      '--supplement', `mp/q4web_arena=${linked}`,
    ], temporary));
    await assert.rejects(
      stageCaptureSupplements(symlinkPlan, path.join(temporary, 'symlink-stage'), { revision }),
      /symlink/);

    const foreignPlan = buildCapturePlan(options([
      '--maps', 'mp/q4web_arena',
      '--supplement', `mp/q4web_arena=${foreign}`,
    ], temporary));
    await assert.rejects(
      stageCaptureSupplements(foreignPlan, path.join(temporary, 'foreign-stage'), { revision }),
      /unexpected q4base/);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('manifest and profile directory are published atomically only after a complete stage', async () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-publish-'));
  const stage = path.join(temporary, '.capture-stage');
  const output = path.join(temporary, 'capture-complete');
  fs.mkdirSync(path.join(stage, 'maps'), { recursive: true });
  fs.writeFileSync(path.join(stage, 'maps/mp_q4dm3.log'), 'trace\n');
  const manifest = {
    schema: 1,
    policy: 'openq4-fs-debug-working-set-v1',
    maps: [{ id: 'mp/q4dm3', traces: ['maps/mp_q4dm3.log'] }],
  };
  try {
    assert.equal(fs.existsSync(path.join(stage, 'manifest.json')), false);
    assert.equal(fs.existsSync(output), false);
    await publishCaptureStage(stage, output, manifest);
    assert.equal(fs.existsSync(stage), false);
    assert.deepEqual(JSON.parse(fs.readFileSync(path.join(output, 'manifest.json'))), manifest);
    await assert.rejects(publishCaptureStage(path.join(temporary, 'missing-stage'), output, manifest),
      /output already exists/);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});

test('dry-run prints the live plan without creating its output or starting OpenQ4', () => {
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-profile-dry-run-'));
  const output = path.join(temporary, 'profiles');
  try {
    const result = spawnSync(process.execPath, [
      path.resolve('tools/capture-retail-bundle-profiles.mjs'),
      '--maps', 'mp/q4web_arena,mp/q4dm3',
      '--jobs', '2',
      '--output', output,
      '--native-runtime', path.join(temporary, 'missing-native-runtime'),
      '--supplement', `mp/q4web_arena=${path.join(temporary, 'mp_q4web_arena_browser.log')}`,
      '--supplement', `mp/q4web_arena=${path.join(temporary, 'mp_q4web_arena.assets')}`,
      '--dry-run',
    ], { cwd: path.resolve('.'), encoding: 'utf8' });
    assert.equal(result.status, 0, result.stderr);
    const plan = JSON.parse(result.stdout);
    assert.deepEqual(plan.maps.map(({ id }) => id), ['mp/q4web_arena', 'mp/q4dm3']);
    assert.equal(plan.jobs, 2);
    assert.equal(plan.maxAttempts, 3);
    assert.deepEqual(plan.maps[1].captures.map(({ mode, trace }) => ({ mode, trace })), [
      { mode: 'DM', trace: 'maps/mp_q4dm3__dm.log' },
      { mode: 'Team DM', trace: 'maps/mp_q4dm3__team_dm.log' },
      { mode: 'Tourney', trace: 'maps/mp_q4dm3__tourney.log' },
    ]);
    assert.deepEqual(plan.supplements.map(({ map, trace }) => ({ map, trace })), [
      {
        map: 'mp/q4web_arena',
        trace: 'supplements/mp_q4web_arena/mp_q4web_arena.assets',
      },
      {
        map: 'mp/q4web_arena',
        trace: 'supplements/mp_q4web_arena/mp_q4web_arena_browser.log',
      },
    ]);
    assert.equal(fs.existsSync(output), false);

    const help = spawnSync(process.execPath, [
      path.resolve('tools/capture-retail-bundle-profiles.mjs'), '--help',
    ], { cwd: path.resolve('.'), encoding: 'utf8' });
    assert.equal(help.status, 0, help.stderr);
    assert.match(help.stdout, /--supplement <map=file>/);
    assert.match(help.stdout, /retried up to 3 times/);
  } finally {
    fs.rmSync(temporary, { recursive: true, force: true });
  }
});
