import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';

const root = new URL('../', import.meta.url);
const rootPath = fileURLToPath(root);
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');

test('source offer uses pinned trees and clean Git archives', async () => {
  const [stage, build, nativeBuild, nativeIdentity] = await Promise.all([
    readFile(new URL('tools/stage-source-offer.sh', root), 'utf8'),
    readFile(new URL('tools/build-wasm.sh', root), 'utf8'),
    readFile(new URL('tools/build-native.sh', root), 'utf8'),
    readFile(new URL('tools/native-source-identity.py', root), 'utf8'),
  ]);
  assert.match(stage, /git -C "\$\{OPENQ4_VENDOR\}" archive --format=tar "\$\{OPENQ4_PIN\}"/);
  assert.match(stage, /git -C "\$\{GAMELIBS_VENDOR\}" archive --format=tar "\$\{GAMELIBS_PIN\}"/);
  assert.match(stage, /commit=%s\\ntree=%s\\nexport-tree=%s/);
  assert.match(stage, /OPENQ4_EXPORT_TREE=[0-9a-f]{40}/);
  assert.match(stage, /content\/baseoq4\/pak0/);
  assert.match(stage, /content\/baseoq4\/pak1/);
  assert.match(stage, /content-src/);
  assert.match(stage, /sha256sum --strict -c SHA256SUMS/);
  assert.match(stage, /manifest\.get\("schema"\) != 2/);
  assert.match(stage, /patch_manifest_sha256/);
  assert.match(stage, /runtime_source_sha256/);
  assert.match(stage, /private\/generated top-level path in source offer/);
  assert.match(stage, /absolute symlink in source offer/);
  assert.match(stage, /generated Python cache in source offer/);
  assert.match(stage, /generated Python bytecode in source offer/);
  assert.match(stage, /\.pk4/);
  assert.match(stage, /\.git/);

  const patchList = (source, variable) => {
    const body = source.match(new RegExp(`${variable}=\\(([\\s\\S]*?)\\n\\)`));
    assert.ok(body, `missing ${variable}`);
    return [...body[1].matchAll(/(\d{4}-[^/"']+\.patch)/g)].map((match) => match[1]);
  };
  assert.deepEqual(
    patchList(stage, 'OPENQ4_PATCH_FILES'),
    patchList(build, 'OPENQ4_PATCH_FILES'),
  );
  assert.deepEqual(
    patchList(stage, 'GAMELIBS_PATCH_FILES'),
    patchList(build, 'GAMELIBS_PATCH_FILES'),
  );
  for (const source of [stage, build, nativeBuild]) {
    assert.equal(
      patchList(source, 'OPENQ4_PATCH_FILES')
        .filter((name) => name === '0024-replace-replay-anchor-with-retail-decl.patch').length,
      1,
    );
  }
  const nativeIdentityPatches = [...nativeIdentity.matchAll(/"(\d{4}-[^"/]+\.patch)"/g)]
    .map((match) => match[1]);
  assert.deepEqual(nativeIdentityPatches, [
    ...patchList(nativeBuild, 'OPENQ4_PATCH_FILES'),
    ...patchList(nativeBuild, 'GAMELIBS_PATCH_FILES'),
  ]);
  assert.equal(nativeIdentityPatches.filter((name) => name === '0028-flush-dedicated-stdout.patch').length, 1);
  assert.match(nativeIdentity, /"patch", "--batch", "--forward"/);
  assert.match(nativeIdentity, /native_patch_manifest_sha256/);
  assert.match(nativeIdentity, /native_openq4_patched_diff_sha256/);
  assert.match(nativeIdentity, /native_gamelibs_patched_diff_sha256/);
  assert.match(stage, /OpenQ4 source patch redistributes a pruned runtime-pack file/);
});

test('real project staging and archive listing exclude Python cache artifacts', async (t) => {
  const temporary = await mkdtemp(path.join(os.tmpdir(), 'q4-source-stage-'));
  t.after(() => rm(temporary, { recursive: true, force: true }));
  const fixture = path.join(temporary, 'fixture');
  const staged = path.join(temporary, 'staged');
  const archive = path.join(temporary, 'source.tar');

  const files = [
    '.gitignore', 'AGENTS.md', 'DEPLOY.md', 'SOURCE_BUILD.md',
    'mods/README.md', 'package.json', 'package-lock.json', 'server.js',
    'content-src/keep.txt', 'deploy/keep.txt', 'mods/maps/keep.txt',
    'port/keep.txt', 'public/keep.txt', 'server/keep.txt', 'tests/keep.txt',
    'tools/keep.py', 'tools/loose.pyo', 'tests/extension.pyd',
    'tools/__pycache__/cached.cpython-312.pyc'
  ];
  for (const relative of files) {
    const destination = path.join(fixture, relative);
    await mkdir(path.dirname(destination), { recursive: true });
    await writeFile(destination, `fixture:${relative}\n`);
  }

  execFileSync('bash', [
    path.join(rootPath, 'tools/stage-source-offer.sh'),
    '--stage-project-files-only', fixture, staged
  ], { cwd: rootPath, stdio: 'pipe' });
  assert.equal(await readFile(path.join(staged, 'tools/keep.py'), 'utf8'), 'fixture:tools/keep.py\n');

  execFileSync('tar', ['-cf', archive, '-C', staged, '.']);
  const listing = execFileSync('tar', ['-tf', archive], { encoding: 'utf8' });
  assert.doesNotMatch(listing, /(^|\/)__pycache__(\/|$)|\.py[cod]$/m);

  const injectedCache = path.join(staged, 'tools/__pycache__/late.py');
  await mkdir(path.dirname(injectedCache), { recursive: true });
  await writeFile(injectedCache, 'late generated cache');
  const cacheAudit = spawnSync('python3', [
    path.join(rootPath, 'tools/audit-source-offer-tree.py'), staged
  ], { cwd: rootPath, encoding: 'utf8' });
  assert.notEqual(cacheAudit.status, 0);
  assert.match(cacheAudit.stderr, /generated Python cache in source offer/);

  await rm(path.dirname(injectedCache), { recursive: true });
  await writeFile(path.join(staged, 'tools/late.PYC'), 'late generated bytecode');
  const bytecodeAudit = spawnSync('python3', [
    path.join(rootPath, 'tools/audit-source-offer-tree.py'), staged
  ], { cwd: rootPath, encoding: 'utf8' });
  assert.notEqual(bytecodeAudit.status, 0);
  assert.match(bytecodeAudit.stderr, /generated Python bytecode in source offer/);
});

test('source-only build authenticates snapshot bytes before applying patches', async () => {
  const build = await readFile(new URL('tools/build-wasm.sh', root), 'utf8');
  assert.match(build, /--verify-sources-only/);
  assert.match(build, /OPENQ4_TREE="[0-9a-f]{40}"/);
  assert.match(build, /OPENQ4_EXPORT_TREE="[0-9a-f]{40}"/);
  assert.match(build, /GAMELIBS_TREE="[0-9a-f]{40}"/);
  assert.match(build, /export-tree=\(\[0-9a-f\]\{40\}\)/);
  assert.match(build, /q4\.sourceExportTree/);
  assert.match(build, /OPENQ4_SOURCE_DATE_EPOCH="[0-9]+"/);
  assert.match(build, /export SOURCE_DATE_EPOCH="\$\{OPENQ4_SOURCE_DATE_EPOCH\}"/);
  assert.match(build, /actual_tree="\$\(git -C "\$\{destination\}" write-tree\)"/);
  assert.match(build, /materialized snapshot tree mismatch/);
  assert.doesNotMatch(build, /openq4_patch_excludes/);
  assert.match(
    build,
    /git -C "\$\{SOURCE_ROOT\}" apply --check/,
  );
  assert.match(build, /git -C "\$\{GAMELIBS_ROOT\}" apply --check/);
  assert.match(build, /if \(\(VERIFY_SOURCES_ONLY\)\); then/);
});

test('source-offer tamper test exercises a real extracted snapshot', async () => {
  const sourceTest = await readFile(new URL('tools/test-source-offer.sh', root), 'utf8');
  assert.match(sourceTest, /tar -xJf/);
  assert.match(sourceTest, /source-offer tamper test/);
  assert.match(sourceTest, /tools\/build-wasm\.sh --clean --verify-sources-only/);
  assert.match(sourceTest, /Tampered source snapshot unexpectedly passed verification/);
  assert.match(sourceTest, /Q4_EXPECT_OPENQ4_DIFF_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_GAMELIBS_DIFF_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_PATCH_MANIFEST_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_RUNTIME_SOURCE_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_NATIVE_PATCH_MANIFEST_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_NATIVE_OPENQ4_DIFF_SHA256/);
  assert.match(sourceTest, /Q4_EXPECT_NATIVE_GAMELIBS_DIFF_SHA256/);
  assert.match(sourceTest, /tools\/native-source-identity\.py --root \./);
  assert.match(sourceTest, /stdout flush patch 0028/);
  assert.match(sourceTest, /is required and must be a lowercase SHA-256 digest/);
  assert.match(sourceTest, /grep -Fqx/);
  assert.match(sourceTest, /Source offer contains a patch against a pruned runtime-pack file/);
  assert.match(sourceTest, /Source offer contains removed upstream runtime declaration text/);
});

test('source-offer release manifest binds browser artifacts and the native source chain', async (t) => {
  const temporary = await mkdtemp(path.join(os.tmpdir(), 'q4-source-identity-'));
  t.after(() => rm(temporary, { recursive: true, force: true }));
  const fixtureRoot = path.join(temporary, 'release');
  const engine = path.join(fixtureRoot, 'engine');
  const nativeBuildInfo = path.join(fixtureRoot, 'runtime/native/build-info');
  const sourceRoot = path.join(temporary, 'q4-web-source');
  const archive = path.join(temporary, 'openq4-web-source.tar.xz');
  const identityTool = path.join(rootPath, 'tools/source-offer-identity.py');
  await mkdir(engine, { recursive: true });
  await mkdir(nativeBuildInfo, { recursive: true });
  await mkdir(sourceRoot, { recursive: true });

  const openq4 = '1'.repeat(40);
  const gamelibs = '2'.repeat(40);
  const patchManifest = '3'.repeat(64);
  const runtimeSource = '4'.repeat(64);
  const nativePatchManifest = '5'.repeat(64);
  const nativeOpenq4Diff = '6'.repeat(64);
  const nativeGamelibsDiff = '7'.repeat(64);
  const build = `openq4-${openq4.slice(0, 12)}-gamelibs-${gamelibs.slice(0, 12)}`
    + `-p${patchManifest.slice(0, 12)}-r${runtimeSource.slice(0, 12)}`;
  const buildInfo = [
    `openq4=${openq4}`,
    `gamelibs=${gamelibs}`,
    `patch_manifest_sha256=${patchManifest}`,
    `runtime_source_sha256=${runtimeSource}`,
    `build=${build}`,
    'linkage=emscripten-main-module-plus-side-module',
    'emscripten=fixture',
    '',
  ].join('\n');
  const engineManifest = `${JSON.stringify({ schema: 2, build, variants: {} }, null, 2)}\n`;
  const artifact = Buffer.from('frozen engine fixture\n');
  await writeFile(path.join(engine, 'build-info.txt'), buildInfo);
  await writeFile(path.join(engine, 'engine-manifest.json'), engineManifest);
  await writeFile(path.join(engine, 'fixture.wasm'), artifact);
  await writeFile(path.join(engine, 'SHA256SUMS'), [
    `${sha256(artifact)}  fixture.wasm`,
    `${sha256(Buffer.from(engineManifest))}  engine-manifest.json`,
    `${sha256(Buffer.from(buildInfo))}  build-info.txt`,
    '',
  ].join('\n'));
  await writeFile(path.join(nativeBuildInfo, 'build.txt'), [
    `openq4=${openq4}`,
    `gamelibs=${gamelibs}`,
    `native_patch_manifest_sha256=${nativePatchManifest}`,
    '',
  ].join('\n'));

  const nativeArgs = [
    '--native-patch-manifest', nativePatchManifest,
    '--native-openq4-diff', nativeOpenq4Diff,
    '--native-gamelibs-diff', nativeGamelibsDiff,
  ];

  const writeArgs = [
    identityTool, 'write', '--root', fixtureRoot,
    '--output', path.join(sourceRoot, 'SOURCE_MANIFEST.json'),
    '--openq4', openq4, '--gamelibs', gamelibs,
    '--patch-manifest', patchManifest, '--runtime-source', runtimeSource,
    ...nativeArgs,
  ];
  execFileSync('python3', writeArgs, { encoding: 'utf8' });
  execFileSync('tar', ['-cJf', archive, '-C', temporary, 'q4-web-source']);
  await writeFile(`${archive}.sha256`, `${sha256(await readFile(archive))}  ${path.basename(archive)}\n`);

  const verified = spawnSync('python3', [
    identityTool, 'verify', '--root', fixtureRoot, '--archive', archive,
    ...nativeArgs,
  ], { encoding: 'utf8' });
  assert.equal(verified.status, 0, verified.stderr);
  assert.match(verified.stdout, /Source offer release identity verified/);
  const releaseIdentity = JSON.parse(await readFile(path.join(sourceRoot, 'SOURCE_MANIFEST.json'), 'utf8'));
  assert.equal(releaseIdentity.schema, 2);
  assert.deepEqual(releaseIdentity.nativeSource, {
    schema: 1,
    openq4,
    gamelibs,
    patchManifestSha256: nativePatchManifest,
    openq4PatchedDiffSha256: nativeOpenq4Diff,
    gamelibsPatchedDiffSha256: nativeGamelibsDiff,
    patches: [],
  });

  const stale = JSON.parse(await readFile(path.join(sourceRoot, 'SOURCE_MANIFEST.json'), 'utf8'));
  stale.source.patchManifestSha256 = '5'.repeat(64);
  await writeFile(path.join(sourceRoot, 'SOURCE_MANIFEST.json'), `${JSON.stringify(stale)}\n`);
  execFileSync('tar', ['-cJf', archive, '-C', temporary, 'q4-web-source']);
  await writeFile(`${archive}.sha256`, `${sha256(await readFile(archive))}  ${path.basename(archive)}\n`);
  const staleResult = spawnSync('python3', [
    identityTool, 'verify', '--root', fixtureRoot, '--archive', archive,
    ...nativeArgs,
  ], { encoding: 'utf8' });
  assert.notEqual(staleResult.status, 0);
  assert.match(staleResult.stderr, /does not match the current frozen release/);

  await writeFile(`${archive}.sha256`, `${'0'.repeat(64)}  ${path.basename(archive)}\n`);
  const badChecksum = spawnSync('python3', [
    identityTool, 'verify', '--root', fixtureRoot, '--archive', archive,
    ...nativeArgs,
  ], { encoding: 'utf8' });
  assert.notEqual(badChecksum.status, 0);
  assert.match(badChecksum.stderr, /source archive checksum mismatch/);
});

test('publish identity hashes each native patch member inside the source archive', async (t) => {
  const temporary = await mkdtemp(path.join(os.tmpdir(), 'q4-native-source-member-'));
  t.after(() => rm(temporary, { recursive: true, force: true }));
  const sourceRoot = path.join(temporary, 'q4-web-source');
  const patchPath = 'port/native/patches/0028-flush-dedicated-stdout.patch';
  const patchFile = path.join(sourceRoot, patchPath);
  const archive = path.join(temporary, 'source.tar.xz');
  const patchBytes = Buffer.from('fixture native stdout patch\n');
  await mkdir(path.dirname(patchFile), { recursive: true });
  await writeFile(patchFile, patchBytes);
  execFileSync('tar', ['-cJf', archive, '-C', temporary, 'q4-web-source']);

  const identity = JSON.stringify({ patches: [{
    tree: 'openq4', path: patchPath, sha256: sha256(patchBytes),
  }] });
  const verifier = [
    'import importlib.util, json, pathlib, sys',
    'spec = importlib.util.spec_from_file_location("source_identity", sys.argv[1])',
    'module = importlib.util.module_from_spec(spec)',
    'spec.loader.exec_module(module)',
    'module.validate_archived_native_patches(pathlib.Path(sys.argv[2]), json.loads(sys.argv[3]))',
  ].join('; ');
  const valid = spawnSync('python3', [
    '-c', verifier, path.join(rootPath, 'tools/source-offer-identity.py'), archive, identity,
  ], { encoding: 'utf8' });
  assert.equal(valid.status, 0, valid.stderr);

  await writeFile(patchFile, 'tampered native stdout patch\n');
  execFileSync('tar', ['-cJf', archive, '-C', temporary, 'q4-web-source']);
  const tampered = spawnSync('python3', [
    '-c', verifier, path.join(rootPath, 'tools/source-offer-identity.py'), archive, identity,
  ], { encoding: 'utf8' });
  assert.notEqual(tampered.status, 0);
  assert.match(tampered.stderr, /native patch checksum mismatch/);
});

test('publish validates source identity before evidence, health, or routing', async () => {
  const [installer, stage, identity] = await Promise.all([
    readFile(new URL('deploy/install.sh', root), 'utf8'),
    readFile(new URL('tools/stage-source-offer.sh', root), 'utf8'),
    readFile(new URL('tools/source-offer-identity.py', root), 'utf8'),
  ]);
  const publish = installer.match(/\n  publish\)([\s\S]*?)\n\s*;;/)?.[1];
  assert.ok(publish, 'missing publish mode');
  const sourceGate = publish.indexOf('source-offer-identity.py');
  const evidenceGate = publish.indexOf('verify-e2e-evidence.mjs');
  const healthGate = publish.indexOf('curl -fsS');
  const routeGate = publish.indexOf('caddy_route_watchdog.sh');
  assert.ok(sourceGate >= 0 && sourceGate < evidenceGate);
  assert.ok(evidenceGate < healthGate && healthGate < routeGate);
  assert.match(stage, /SOURCE_MANIFEST\.json/);
  assert.match(stage, /"\$\{SOURCE_IDENTITY_TOOL\}" write/);
  assert.match(identity, /validate_engine_checksums/);
  assert.match(identity, /current_native_identity/);
  assert.match(identity, /validate_native_build_info/);
  assert.match(identity, /validate_archived_native_patches/);
  assert.match(identity, /SOURCE_MANIFEST_MEMBER/);
  assert.match(identity, /source archive checksum mismatch/);
});

test('pre-deploy regenerates and verifies the source offer against clean source hashes', async () => {
  const deploy = await readFile(new URL('DEPLOY.md', root), 'utf8');
  const section = deploy.match(/## Pre-deploy verification([\s\S]*?)## First installation/)?.[1];
  assert.ok(section, 'missing pre-deploy verification section');

  const cleanVerify = 'tools/build-wasm.sh --clean --verify-sources-only';
  const nativeVerify = 'python3 tools/native-source-identity.py --root .';
  const stage = 'tools/stage-source-offer.sh';
  const verifyOffer = 'tools/test-source-offer.sh';
  const cleanVerifyIndex = section.indexOf(cleanVerify);
  const nativeVerifyIndex = section.indexOf(nativeVerify);
  const stageIndex = section.indexOf(stage);
  const verifyOfferIndex = section.indexOf(verifyOffer);
  assert.notEqual(cleanVerifyIndex, -1, 'missing clean source verification');
  assert.notEqual(nativeVerifyIndex, -1, 'missing native source verification');
  assert.notEqual(stageIndex, -1, 'missing source-offer regeneration');
  assert.notEqual(verifyOfferIndex, -1, 'missing extracted source-offer verification');
  assert.ok(cleanVerifyIndex < nativeVerifyIndex && nativeVerifyIndex < stageIndex);
  assert.ok(stageIndex < verifyOfferIndex);
  assert.match(section, /openq4_patched_diff_sha256=/);
  assert.match(section, /gamelibs_patched_diff_sha256=/);
  assert.match(section, /patch_manifest_sha256=/);
  assert.match(section, /free_runtime_source_sha256=/);
  assert.match(section, /Q4_EXPECT_OPENQ4_DIFF_SHA256="\$\{openq4_diff\}"/);
  assert.match(section, /Q4_EXPECT_GAMELIBS_DIFF_SHA256="\$\{gamelibs_diff\}"/);
  assert.match(section, /Q4_EXPECT_PATCH_MANIFEST_SHA256="\$\{patch_manifest\}"/);
  assert.match(section, /Q4_EXPECT_RUNTIME_SOURCE_SHA256="\$\{runtime_source\}"/);
  assert.match(section, /native_patch_manifest_sha256=/);
  assert.match(section, /native_openq4_patched_diff_sha256=/);
  assert.match(section, /native_gamelibs_patched_diff_sha256=/);
  assert.match(section, /Q4_EXPECT_NATIVE_PATCH_MANIFEST_SHA256="\$\{native_patch_manifest\}"/);
  assert.match(section, /Q4_EXPECT_NATIVE_OPENQ4_DIFF_SHA256="\$\{native_openq4_diff\}"/);
  assert.match(section, /Q4_EXPECT_NATIVE_GAMELIBS_DIFF_SHA256="\$\{native_gamelibs_diff\}"/);
});
