import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

import {
  extractDelimitedLogBlock,
  firstSnapshotEvidence,
  makeReleaseIdentity,
  parseServerInfoBlock,
  readReleaseIdentity,
  retainCurrentPhaseEvidence,
  sameReleaseIdentity
} from '../tools/e2e-evidence.mjs';

const root = new URL('../', import.meta.url);

function engineManifest(seed = 'a') {
  const variant = (mode, offset) => ({
    sha256: {
      script: (offset === 0 ? seed : String.fromCharCode(seed.charCodeAt(0) + offset)).repeat(64),
      wasm: String.fromCharCode(seed.charCodeAt(0) + offset + 1).repeat(64)
    },
    gameLib: {
      filename: `game-${mode}_wasm.wasm`,
      sha256: String.fromCharCode(seed.charCodeAt(0) + offset + 2).repeat(64)
    }
  });
  return {
    schema: 2,
    build: `engine-${seed}`,
    variants: {
      singleplayer: variant('sp', 0),
      multiplayer: variant('mp', 3),
      replay: variant('mp', 3)
    }
  };
}

function contentManifest(seed = '1') {
  return {
    schema: 2,
    build: `content-${seed}`,
    runtimeSourceSha256: seed.repeat(64),
    files: [{ name: 'pak0.pk4', sha256: seed.repeat(64) }]
  };
}

function retailManifest(seed = '2') {
  const fingerprint = seed.repeat(64);
  const sha256 = String.fromCharCode(seed.charCodeAt(0) + 1).repeat(64);
  return {
    schema: 1,
    policy: 'operator-authorized-retail-v1',
    revision: `q4retail-${fingerprint.slice(0, 20)}`,
    fingerprint,
    totalBytes: 123,
    files: [{
      name: 'pak001.pk4',
      size: 123,
      sha256,
      url: `/retail/q4base/${sha256}-pak001.pk4`
    }]
  };
}

function nativeIdentity(seed = '4') {
  return {
    schema: 1,
    dedicated: { filename: 'openQ4-ded_x64', sha256: seed.repeat(64) },
    multiplayerGameLib: { filename: 'game-mp_x64.so', sha256: String(Number(seed) + 1).repeat(64) },
    buildInfo: { filename: 'build.txt', sha256: String(Number(seed) + 2).repeat(64) }
  };
}

function identity(
  engine = engineManifest(),
  content = contentManifest(),
  retail = retailManifest(),
  native = nativeIdentity()
) {
  return makeReleaseIdentity(
    Buffer.from(`${JSON.stringify(engine)}\n`),
    Buffer.from(`${JSON.stringify(content)}\n`),
    Buffer.from(`${JSON.stringify(retail)}\n`),
    native
  );
}

test('release identity binds exact engine, content, retail and native artifacts', async () => {
  const release = identity();
  assert.equal(release.engine.build, 'engine-a');
  assert.equal(release.engine.variants.singleplayer.scriptSha256, 'a'.repeat(64));
  assert.equal(release.engine.variants.multiplayer.gameLib.sha256, 'f'.repeat(64));
  assert.equal(release.content.build, 'content-1');
  assert.equal(release.retail.revision, `q4retail-${'2'.repeat(20)}`);
  assert.equal(release.retail.files[0].sha256, '3'.repeat(64));
  assert.equal(release.native.dedicated.sha256, '4'.repeat(64));
  assert.equal(release.native.multiplayerGameLib.sha256, '5'.repeat(64));
  assert.equal(release.native.buildInfo.sha256, '6'.repeat(64));
  assert.match(release.engine.manifestSha256, /^[a-f0-9]{64}$/);
  assert.match(release.content.manifestSha256, /^[a-f0-9]{64}$/);
  assert.match(release.retail.manifestSha256, /^[a-f0-9]{64}$/);
  assert.match(release.identitySha256, /^[a-f0-9]{64}$/);

  const fetchedRelease = makeReleaseIdentity(
    new TextEncoder().encode(`${JSON.stringify(engineManifest())}\n`).buffer,
    new TextEncoder().encode(`${JSON.stringify(contentManifest())}\n`).buffer,
    new TextEncoder().encode(`${JSON.stringify(retailManifest())}\n`).buffer,
    nativeIdentity()
  );
  assert.equal(sameReleaseIdentity(fetchedRelease, release), true,
    'fetch ArrayBuffers and local file Buffers must produce the same release identity');

  const samePayloadDifferentBytes = makeReleaseIdentity(
    Buffer.from(JSON.stringify(engineManifest(), null, 2)),
    Buffer.from(JSON.stringify(contentManifest(), null, 2)),
    Buffer.from(JSON.stringify(retailManifest(), null, 2)),
    nativeIdentity()
  );
  assert.equal(samePayloadDifferentBytes.engine.build, release.engine.build);
  assert.equal(samePayloadDifferentBytes.content.build, release.content.build);
  assert.equal(samePayloadDifferentBytes.retail.revision, release.retail.revision);
  assert.equal(sameReleaseIdentity(samePayloadDifferentBytes, release), false,
    'manifest byte changes must invalidate preserved evidence even when build labels are unchanged');

  const changedRetail = identity(engineManifest(), contentManifest(), retailManifest('4'));
  assert.equal(changedRetail.engine.manifestSha256, release.engine.manifestSha256);
  assert.equal(changedRetail.content.manifestSha256, release.content.manifestSha256);
  assert.equal(sameReleaseIdentity(changedRetail, release), false,
    'retail manifest changes must invalidate preserved evidence');

  const staged = await readReleaseIdentity();
  assert.match(staged.identitySha256, /^[a-f0-9]{64}$/);
});

test('readReleaseIdentity hashes the actual dedicated, GameLib and build-info files', async (t) => {
  const directory = await mkdtemp(path.join(os.tmpdir(), 'q4-native-release-'));
  t.after(() => rm(directory, { recursive: true, force: true }));
  const files = {
    dedicated: path.join(directory, 'openQ4-ded_x64'),
    multiplayerGameLib: path.join(directory, 'game-mp_x64.so'),
    buildInfo: path.join(directory, 'build.txt')
  };
  const original = {
    dedicated: Buffer.from('dedicated-v1'),
    multiplayerGameLib: Buffer.from('game-mp-v1'),
    buildInfo: Buffer.from('build-info-v1\n')
  };
  const options = {
    nativeDedicated: files.dedicated,
    nativeGameLib: files.multiplayerGameLib,
    nativeBuildInfo: files.buildInfo
  };

  for (const name of Object.keys(files)) await writeFile(files[name], original[name]);
  const baseline = await readReleaseIdentity(options);
  for (const name of Object.keys(files)) {
    for (const reset of Object.keys(files)) await writeFile(files[reset], original[reset]);
    await writeFile(files[name], Buffer.from(`${name}-v2`));
    const changed = await readReleaseIdentity(options);
    assert.notEqual(changed.native[name].sha256, baseline.native[name].sha256, `${name} hash did not change`);
    assert.equal(sameReleaseIdentity(changed, baseline), false, `${name} did not invalidate release evidence`);
    for (const unchanged of Object.keys(files).filter((entry) => entry !== name)) {
      assert.equal(changed.native[unchanged].sha256, baseline.native[unchanged].sha256,
        `${name} unexpectedly changed ${unchanged}`);
    }
  }
});

test('only phase evidence carrying the exact current release identity survives', () => {
  const current = identity();
  const priorEngine = identity(engineManifest('2'), contentManifest());
  const priorContent = identity(engineManifest(), contentManifest('2'));
  const priorRetail = identity(engineManifest(), contentManifest(), retailManifest('4'));
  const previous = {
    evidence: { campaign: 'keep', multiplayer: 'old-engine', replay: 'old-content', mobile: 'old-retail' },
    phaseChecks: {
      campaign: { releaseIdentity: current, baseUrl: 'https://q4.test', gamePort: 28004 },
      multiplayer: { releaseIdentity: priorEngine, baseUrl: 'https://q4.test', gamePort: 28004 },
      replay: { releaseIdentity: priorContent, baseUrl: 'https://q4.test', gamePort: 28004 },
      mobile: { releaseIdentity: priorRetail, baseUrl: 'https://q4.test', gamePort: 28004 }
    }
  };
  const retained = retainCurrentPhaseEvidence(previous, current, ['campaign', 'multiplayer', 'replay', 'mobile'], {
    baseUrl: 'https://q4.test', gamePort: 28004
  });
  assert.deepEqual(retained.evidence, { campaign: 'keep' });
  assert.deepEqual(Object.keys(retained.phaseChecks), ['campaign']);
  assert.deepEqual(retained.invalidatedPhases.map(({ phase, reason }) => [phase, reason]), [
    ['multiplayer', 'release-identity-changed'],
    ['replay', 'release-identity-changed'],
    ['mobile', 'release-identity-changed']
  ]);

  const changedTarget = retainCurrentPhaseEvidence(previous, current, ['campaign'], {
    baseUrl: 'https://q4.test', gamePort: 28005
  });
  assert.deepEqual(changedTarget.evidence, {});
  assert.equal(changedTarget.invalidatedPhases[0].reason, 'e2e-target-changed');
});

test('serverInfo parsing consumes only the uniquely delimited fresh response', () => {
  const begin = 'Q4_E2E_SERVERINFO_1234_ABCD_BEGIN';
  const end = 'Q4_E2E_SERVERINFO_1234_ABCD_END';
  const logs = [
    'client  0: stale-human, ping = 1, rate = 16000',
    'bot_minplayers = 99',
    begin,
    'server IP = 127.0.0.1:28004',
    'bot_minplayers = 8',
    'si_maxPlayers = 12',
    'client  0: major_, ping = 0, rate = 16000',
    'client  8: Q4E2E-1, ping = 12, rate = 16000',
    'client  9: Q4E2E-2, ping = 13, rate = 16000',
    '0: server 127.0.0.1:28004 - protocol 1.4 - Q4 Web Arena',
    end,
    'client 10: later-noise, ping = 2, rate = 16000'
  ];
  const block = extractDelimitedLogBlock(logs, begin, end);
  const parsed = parseServerInfoBlock(block);
  assert.deepEqual(parsed.clients.map(({ slot, name }) => [slot, name]), [
    [0, 'major_'],
    [8, 'Q4E2E-1'],
    [9, 'Q4E2E-2']
  ]);
  assert.equal(parsed.botMinPlayers, 8);
  assert.equal(parsed.maxPlayers, 12);
  assert.match(parsed.terminal, /^0: server /);
  assert.throws(() => extractDelimitedLogBlock(logs.filter((line) => line !== end), begin, end), /missing fresh log marker/);
});

test('first snapshot line is retained as the exact in-game transition evidence', () => {
  const evidence = firstSnapshotEvidence([
    'old menu output',
    'received first snapshot, gameInitId = 7, gameFrame 314 gameTime 5024'
  ]);
  assert.deepEqual(evidence, {
    line: 'received first snapshot, gameInitId = 7, gameFrame 314 gameTime 5024',
    gameInitId: 7,
    gameFrame: 314,
    gameTime: 5024,
    clientState: 'CS_INGAME',
    serverStateRequiredToSendSnapshot: 'SCS_INGAME'
  });
  assert.equal(firstSnapshotEvidence(['received first snapshot someday']), null);
});

test('full E2E gate enables verbose startup before connect and records release-bound checks', async () => {
  const source = await readFile(new URL('tools/e2e-full.mjs', root), 'utf8');
  assert.match(source, /args\.splice\(connectIndex, 0, '\+set', 'net_verbose', '1'\)/);
  assert.match(source, /ui_autoJoin: '1',[\s\S]*ui_joined: '1',[\s\S]*ui_spectate: 'Play'/);
  assert.match(source, /await waitForFirstSnapshot\(page/);
  assert.match(source, /const freshServerInfo = await captureServerInfo/);
  assert.match(source, /extractDelimitedLogBlock\(logs, begin, end\)/);
  assert.match(source, /phaseChecks\[phase\] = \{[^}]*releaseIdentity/);
  assert.match(source, /await assertReleaseIdentityCurrent\(\)/);
  assert.match(source, /fetch\(`\$\{baseUrl\}\/retail\/manifest\.json`/);
  assert.match(source, /fetch\(`\$\{baseUrl\}\/api\/status`/,
    'deployed release verification must read the gateway startup identity');
  assert.match(source, /makeReleaseIdentity\(engineBytes, contentBytes, retailBytes, deployedStatus\.native\)/,
    'deployed release verification must bind the running native artifacts');
  assert.match(source, /performance\.setResourceTimingBufferSize\(4096\)/,
    'campaign E2E must retain engine timings after hundreds of bounded retail range requests');
  assert.match(source, /E2E strict decoder rejects resizable or shared ArrayBuffer input/,
    'full E2E must emulate browsers that reject resizable WebAssembly memory in TextDecoder');
  assert.match(source, /Module\.HEAPU8\.buffer\.resizable === true/,
    'full E2E must prove the engine heap is not backed by a resizable ArrayBuffer');
  assert.match(source, /unsupported syscall: __syscall_m\(\?:un\)\?lock/,
    'full E2E must reject unsupported browser memory-lock syscall warnings');
  assert.match(source, /InvalidGUI:\|Couldn't load \(\?:gui\|model\|image\)/,
    'full E2E must reject missing multiplayer GUI and renderer assets');
  assert.match(source, /const imported = await assertAutomaticRetailInstalled\(page\)/);
  assert.match(source, /await waitForLauncher\(page, 'campaign'\)[\s\S]*#campaignPlayButton/,
    'campaign E2E must enter the engine through the HTML launcher');
  assert.match(source, /await waitForLauncher\(page, 'multiplayer'\)[\s\S]*#multiplayerModel[\s\S]*#multiplayerPlayButton/,
    'multiplayer E2E must submit the HTML map and player selection');
  assert.match(source, /Q4_E2E_MULTIPLAYER_MAP[\s\S]*data-map="\$\{multiplayerMap\}"/,
    'multiplayer E2E must support a selected retail map without bypassing the HTML launcher');
  assert.match(source, /url\.pathname === '\/api\/match'[\s\S]*matchResponse\.status, 200/,
    'multiplayer E2E must observe a successful match configuration response');
  assert.match(source, /await waitForLauncher\(page, 'replays'\)[\s\S]*#replayPlayButton/,
    'replay E2E must start its first viewer from the HTML replay browser');
  assert.doesNotMatch(source, /Q4App\.startEngine\(\{ type: 'singleplayer' \}\)/,
    'campaign E2E must not bypass the HTML launcher');
  const commandReadyWait = source.indexOf("line.includes('Q4_E2E_COMMAND_READY')");
  assert.match(source.slice(commandReadyWait, commandReadyWait + 220), /timeout: engineTimeout/,
    'campaign command probe must tolerate the blocking retail map load');
  const mapReadyWait = source.indexOf("line.includes('Q4_E2E_MAP_READY')");
  assert.match(source.slice(mapReadyWait, mapReadyWait + 220), /timeout: engineTimeout/,
    'campaign map precache must use the engine watchdog instead of a shorter fixed timeout');
  assert.doesNotMatch(source, /setInputFiles|Q4_E2E_RETAIL/,
    'full E2E must install the deployed server retail catalog without local file selection');
  assert.match(source, /schema: 4/);
  assert.match(source, /releaseComplete = validPhases\.every/);
  assert.doesNotMatch(source, /Q4_(?:MP_LOAD|ASYNCIFY(?:_TRACE|_NODE)?)/,
    'temporary multiplayer/Asyncify diagnostics must not ship in the release E2E gate');
  const activePlayerProbe = source.indexOf('const activePlayers = [];');
  const focusForProbe = source.indexOf('await page.bringToFront();', activePlayerProbe);
  const visibilityForProbe = source.indexOf("document.visibilityState === 'visible'", focusForProbe);
  const capturePlayState = source.indexOf('activePlayers.push(await captureClientPlayState(page));', activePlayerProbe);
  assert.ok(activePlayerProbe >= 0 && activePlayerProbe < focusForProbe
    && focusForProbe < visibilityForProbe && visibilityForProbe < capturePlayState,
  'multiplayer E2E must foreground and observe each RAF-driven client before querying its play state');
  assert.match(source.slice(visibilityForProbe, capturePlayState), /polling: 100/,
    'the foreground visibility gate must not depend on requestAnimationFrame');
  assert.doesNotMatch(source, /Promise\.all\(pages\.map\(\(page\) => captureClientPlayState\(page\)\)\)/,
    'play-state probes must not race foreground focus across browser tabs');
  const captureFunction = source.indexOf('async function captureClientPlayState(page)');
  const captureFunctionEnd = source.indexOf('\nasync function ', captureFunction + 1);
  assert.match(source.slice(captureFunction, captureFunctionEnd), /polling: 100/,
    'the client-state marker wait must not depend on requestAnimationFrame');
  assert.match(source.slice(captureFunction, captureFunctionEnd), /timeout: engineTimeout/,
    'the client-state marker must allow a foregrounded engine to finish map precache');
  const focusFirstClient = source.indexOf('await pages[0].bringToFront();');
  const findCanvasPoint = source.indexOf('document.elementFromPoint(x, y) === canvas');
  const clickCanvas = source.indexOf('await pages[0].mouse.click(pointerLockPoint.x, pointerLockPoint.y);');
  const acquirePointer = source.indexOf("document.pointerLockElement === document.querySelector('#gameCanvas')");
  assert.ok(focusFirstClient >= 0 && focusFirstClient < findCanvasPoint && findCanvasPoint < clickCanvas
    && clickCanvas < acquirePointer,
  'multiplayer E2E must focus the first client, click an unobscured canvas point, and acquire pointer lock before movement');
  const releasePointer = source.indexOf('await page.evaluate(() => document.exitPointerLock?.());');
  const waitForRelease = source.indexOf('await page.waitForFunction(() => document.pointerLockElement === null);');
  const activateVoice = source.indexOf('page.mouse.click(voicePoint.x, voicePoint.y)');
  assert.ok(releasePointer >= 0 && releasePointer < waitForRelease && waitForRelease < activateVoice,
    'multiplayer E2E must release SDL pointer lock before activating the voice toolbar');
  assert.match(source.slice(waitForRelease, activateVoice), /document\.elementFromPoint[\s\S]*button\.contains\(target\)/,
    'multiplayer E2E must click a currently exposed pixel inside the voice button');
  const voiceBlockEnd = source.indexOf('\n    if (index === 0)', activateVoice);
  const voiceBlock = source.slice(activateVoice, voiceBlockEnd);
  assert.match(voiceBlock, /externallyTimed\([\s\S]*voice client \$\{index \+ 1\} enabled state/,
    'voice activation and enabled-state checks must have a Node-side watchdog');
  assert.match(voiceBlock, /externallyTimed\(page\.evaluate\([\s\S]*voice client \$\{index \+ 1\} diagnostics/,
    'voice failure diagnostics must have a Node-side watchdog');
  const mobileFunction = source.indexOf('async function mobileSmoke()');
  const mobileFunctionEnd = source.indexOf('\nasync function ', mobileFunction + 1);
  const mobileBlock = source.slice(mobileFunction, mobileFunctionEnd);
  assert.match(mobileBlock, /baselineConnectionIds[\s\S]*waitForStatus\([\s\S]*!baselineConnectionIds\.has\(entry\.id\)/,
    'mobile E2E must wait for its own newly-created bridge instead of racing a status snapshot');
  const replayFunction = source.indexOf('async function replaySmoke(context)');
  const replayFunctionEnd = source.indexOf('\nasync function ', replayFunction + 1);
  const replayBlock = source.slice(replayFunction, replayFunctionEnd);
  assert.match(replayBlock, /enableFirstSnapshotEvidence\(page\)[\s\S]*waitForFirstSnapshot\(page, `replay \$\{index\}`/,
    'each replay viewer must prove the first CS_INGAME snapshot transition');
  assert.match(replayBlock, /secondViewer\.firstSnapshot\.gameTime >= firstViewer\.firstSnapshot\.gameTime \+ 3_000/,
    'the late replay viewer must join the already-advanced live timeline');
});
