import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
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';

const root = new URL('../', import.meta.url);
const tool = path.resolve(new URL('tools/stage-retail-bundles.py', root).pathname);
const names = [
  ...Array.from({ length: 22 }, (_, index) => `pak${String(index + 1).padStart(3, '0')}.pk4`),
  'zpak_english.pk4',
];
const coverageScenarios = [
  'fresh-process-per-map',
  'balanced-machine-spec-2',
  'all-player-models',
  'all-mp-weapons-items-projectiles',
  'dm-tourney-team-ctf-gui-audio',
];

function sha256(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

function makePk4(file, entries) {
  const stage = fs.mkdtempSync(path.join(path.dirname(file), '.zip-stage-'));
  try {
    const members = [];
    for (const [name, contents] of Object.entries(entries)) {
      const target = path.join(stage, ...name.split('/'));
      fs.mkdirSync(path.dirname(target), { recursive: true });
      fs.writeFileSync(target, contents);
      members.push(name);
    }
    const result = spawnSync('zip', ['-qX', '-9', file, ...members.sort()], {
      cwd: stage,
      encoding: 'utf8',
    });
    assert.equal(result.status, 0, result.stderr);
  } finally {
    fs.rmSync(stage, { recursive: true, force: true });
  }
}

function retailFingerprint(files) {
  const hash = createHash('sha256');
  for (const file of [...files].sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
    hash.update(`${file.name}\0${file.size}\0${file.sha256}\n`);
  }
  return hash.digest('hex');
}

function traceLine(member, archive) {
  return `idFileSystem::OpenFileRead: ${member} (found in '${archive}')\n`;
}

function fixture({ dependencies = false } = {}) {
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-retail-bundles-'));
  const retailDir = path.join(directory, 'retail');
  const profileDir = path.join(directory, 'profiles');
  fs.mkdirSync(retailDir);
  fs.mkdirSync(profileDir);
  for (const [index, name] of names.entries()) {
    const entries = { [`fixture/${name}.txt`]: name };
    if (name === 'pak001.pk4') {
      entries['shared/contract.txt'] = 'old-contract';
      entries['models/player.md5mesh'] = 'player';
      entries['textures/q4dm3.dds'] = 'dm3';
      if (dependencies) {
        entries['def/hidden.def'] = `
          entityDef hidden_entity {
            "model" "models/hidden.md5mesh"
            "fx" "effects/hidden"
            "snd" "hidden_sound"
            "gui" "guis/hidden.gui"
          }
        `;
        entries['effects/hidden.fx'] = `
          effect effects/hidden {
            material "textures/hidden/fx"
          }
        `;
        entries['guis/hidden.gui'] = `
          windowDef Desktop {
            background "textures/hidden/gui"
          }
        `;
        entries['guides/hidden.guide'] = `
          guide hiddenGuide(TextureParm, EnvParm) {
            diffusemap TextureParm_d.tga
            bumpmap TextureParm_local.tga
            cubeMap EnvParm
          }
        `;
        entries['materials/hidden.mtr'] = `
          guide textures/hidden/wall hiddenGuide("textures/hidden/wall", "gfx/env/hidden")
          textures/hidden/model { map textures/hidden/model.tga }
          textures/hidden/fx { map textures/hidden/fx.tga }
          textures/hidden/gui { map textures/hidden/gui.tga }
        `;
        entries['models/hidden.md5mesh'] = 'shader "textures/hidden/model"';
        entries['sound/hidden.sndshd'] = `
          sound hidden_sound {
            sound/hidden/cue
          }
        `;
        for (const stem of [
          'textures/hidden/wall_d',
          'textures/hidden/wall_local',
          'textures/hidden/model',
          'textures/hidden/fx',
          'textures/hidden/gui',
        ]) {
          entries[`${stem}.dds`] = `dds:${stem}`;
          entries[`${stem}.tga`] = `tga:${stem}`;
        }
        for (const suffix of ['nx', 'ny', 'nz', 'px', 'py', 'pz']) {
          entries[`gfx/env/hidden_${suffix}.dds`] = `cube-dds:${suffix}`;
          entries[`gfx/env/hidden_${suffix}.tga`] = `cube-tga:${suffix}`;
        }
        entries['sound/hidden/cue.ogg'] = 'hidden-audio';
      }
    }
    if (name === 'pak016.pk4') {
      entries['maps/mp/q4dm3.cm'] = 'collision';
      entries['maps/mp/q4dm3.proc'] = dependencies
        ? 'render-geometry "textures/hidden/wall"'
        : 'render-geometry';
      entries['maps/mp/q4dm3.map'] = dependencies
        ? `
          Version 3
          { "classname" "worldspawn" }
          { "classname" "hidden_entity" }
        `
        : 'map-entities';
    }
    if (name === 'pak019.pk4') entries['SHARED/Contract.txt'] = 'new-contract';
    makePk4(path.join(retailDir, name), entries);
    assert.ok(index >= 0);
  }
  const files = names.map((name) => {
    const file = path.join(retailDir, name);
    const bytes = fs.readFileSync(file);
    return { name, size: bytes.length, sha256: sha256(bytes) };
  });
  const fingerprint = retailFingerprint(files);
  const retailManifest = {
    schema: 1,
    policy: 'operator-authorized-retail-v1',
    revision: `q4retail-${fingerprint.slice(0, 20)}`,
    fingerprint,
    totalBytes: files.reduce((sum, entry) => sum + entry.size, 0),
    files,
  };
  const retailManifestFile = path.join(directory, 'retail-manifest.json');
  fs.writeFileSync(retailManifestFile, `${JSON.stringify(retailManifest)}\n`);

  const pak001 = path.join(retailDir, 'pak001.pk4');
  const pak016 = path.join(retailDir, 'pak016.pk4');
  const pak019 = path.join(retailDir, 'pak019.pk4');
  fs.writeFileSync(path.join(profileDir, 'q4web.log'), [
    traceLine('SHARED/Contract.txt', pak019),
    traceLine('models/player.md5mesh', pak001),
  ].join(''));
  fs.writeFileSync(path.join(profileDir, 'q4dm3.log'), [
    traceLine('SHARED/Contract.txt', pak019),
    traceLine('models/player.md5mesh', pak001),
    traceLine('textures/q4dm3.dds', pak001),
    traceLine('maps/mp/q4dm3.cm', pak016),
    traceLine('maps/mp/q4dm3.map', pak016),
    traceLine('maps/mp/q4dm3.proc', pak016),
  ].join(''));
  const profileManifest = {
    schema: 1,
    policy: 'openq4-fs-debug-working-set-v1',
    sourceRevision: retailManifest.revision,
    sourceFingerprint: retailManifest.fingerprint,
    performancePreset: 'balanced',
    maxMachineSpec: 2,
    coverage: 'beta',
    coverageScenarios,
    commonTraces: [],
    maps: [
      { id: 'mp/q4web_arena', retailGeometry: false, traces: ['q4web.log'] },
      { id: 'mp/q4dm3', traces: ['q4dm3.log'] },
    ],
  };
  const profileManifestFile = path.join(profileDir, 'manifest.json');
  fs.writeFileSync(profileManifestFile, `${JSON.stringify(profileManifest)}\n`);
  return { directory, retailDir, retailManifestFile, profileManifestFile, profileManifest };
}

function runBuilder(fx, output) {
  const manifest = path.join(fx.directory, 'bundle-manifest.json');
  return spawnSync('python3', [
    tool,
    '--retail-dir', fx.retailDir,
    '--retail-manifest', fx.retailManifestFile,
    '--profiles', fx.profileManifestFile,
    '--output', output,
    '--manifest', manifest,
  ], { encoding: 'utf8' });
}

function zipList(file) {
  const result = spawnSync('unzip', ['-Z1', file], { encoding: 'utf8' });
  if (result.status === 1 && /(Empty zipfile|zipfile is empty)/i.test(result.stdout + result.stderr)) return [];
  assert.equal(result.status, 0, result.stderr);
  return result.stdout.trim().split('\n').filter(Boolean);
}

test('retail bundle builder is deterministic, preserves winners, and partitions shared paths', () => {
  const fx = fixture();
  try {
    const output = path.join(fx.directory, 'output');
    const manifestFile = path.join(fx.directory, 'bundle-manifest.json');
    const first = runBuilder(fx, output);
    assert.equal(first.status, 0, first.stderr);
    const manifest = JSON.parse(fs.readFileSync(manifestFile));
    assert.equal(manifest.policy, 'operator-authorized-retail-bundles-v1');
    assert.equal(manifest.performancePreset, 'balanced');
    assert.equal(manifest.maxMachineSpec, 2);
    assert.deepEqual(manifest.maps.map(({ id }) => id), ['mp/q4dm3', 'mp/q4web_arena']);
    assert.equal(manifest.totalBytes, manifest.bundles.reduce((sum, entry) => sum + entry.size, 0));
    const before = new Map(manifest.bundles.map((entry) => [entry.name, fs.readFileSync(path.join(output, entry.name))]));

    const second = runBuilder(fx, output);
    assert.equal(second.status, 0, second.stderr);
    assert.deepEqual(JSON.parse(fs.readFileSync(manifestFile)), manifest);
    for (const [name, bytes] of before) assert.deepEqual(fs.readFileSync(path.join(output, name)), bytes, name);

    const common = manifest.bundles.find(({ id }) => id === 'common');
    const dm3 = manifest.bundles.find(({ id }) => id === 'map/mp/q4dm3');
    const web = manifest.bundles.find(({ id }) => id === 'map/mp/q4web_arena');
    assert.equal(common.name, 'pak001.pk4');
    assert.deepEqual(zipList(path.join(output, common.name)), [
      'models/', 'SHARED/', 'models/player.md5mesh', 'SHARED/Contract.txt',
    ]);
    assert.deepEqual(zipList(path.join(output, dm3.name)), [
      'maps/', 'maps/mp/', 'textures/',
      'maps/mp/q4dm3.cm', 'maps/mp/q4dm3.map', 'maps/mp/q4dm3.proc', 'textures/q4dm3.dds',
    ]);
    assert.deepEqual(zipList(path.join(output, web.name)), []);
    const winner = spawnSync('unzip', ['-p', path.join(output, common.name), 'SHARED/Contract.txt'], { encoding: 'utf8' });
    assert.equal(winner.stdout, 'new-contract');
    const all = manifest.bundles.flatMap((entry) => zipList(path.join(output, entry.name)).map((name) => name.toLowerCase()));
    assert.equal(new Set(all).size, all.length, 'bundles must have disjoint case-insensitive members');

    const verified = spawnSync('python3', [
      tool, '--verify',
      '--retail-dir', fx.retailDir,
      '--retail-manifest', fx.retailManifestFile,
      '--output', output,
      '--manifest', manifestFile,
    ], { encoding: 'utf8' });
    assert.equal(verified.status, 0, verified.stderr);

    const commonPath = path.join(output, common.name);
    const commonBytes = fs.readFileSync(commonPath);
    const removeDirectory = spawnSync('zip', ['-qd', commonPath, 'models/'], { encoding: 'utf8' });
    assert.equal(removeDirectory.status, 0, removeDirectory.stderr);
    const missingDirectoryManifest = structuredClone(manifest);
    const changedCommon = missingDirectoryManifest.bundles.find(({ id }) => id === 'common');
    const changedBytes = fs.readFileSync(commonPath);
    changedCommon.size = changedBytes.length;
    changedCommon.sha256 = sha256(changedBytes);
    changedCommon.url = `/retail/bundles/${changedCommon.sha256}-${changedCommon.name}`;
    missingDirectoryManifest.totalBytes = missingDirectoryManifest.bundles
      .reduce((sum, entry) => sum + entry.size, 0);
    fs.writeFileSync(manifestFile, `${JSON.stringify(missingDirectoryManifest)}\n`);
    const missingDirectory = spawnSync('python3', [
      tool, '--verify',
      '--retail-dir', fx.retailDir,
      '--retail-manifest', fx.retailManifestFile,
      '--output', output,
      '--manifest', manifestFile,
    ], { encoding: 'utf8' });
    assert.notEqual(missingDirectory.status, 0);
    assert.match(missingDirectory.stderr, /bundle directory tree mismatch/);
    fs.writeFileSync(commonPath, commonBytes);
    fs.writeFileSync(manifestFile, `${JSON.stringify(manifest)}\n`);

    const invalidManifest = structuredClone(manifest);
    const invalidCommon = invalidManifest.bundles.find(({ id }) => id === 'common');
    const invalidName = 'pak900_q4retail_common.pk4';
    fs.renameSync(path.join(output, invalidCommon.name), path.join(output, invalidName));
    invalidCommon.name = invalidName;
    invalidCommon.url = `/retail/bundles/${invalidCommon.sha256}-${invalidName}`;
    fs.writeFileSync(manifestFile, `${JSON.stringify(invalidManifest)}\n`);
    const invalid = spawnSync('python3', [
      tool, '--verify',
      '--retail-dir', fx.retailDir,
      '--retail-manifest', fx.retailManifestFile,
      '--output', output,
      '--manifest', manifestFile,
    ], { encoding: 'utf8' });
    assert.notEqual(invalid.status, 0);
    assert.match(invalid.stderr, /exactly one common bundle named pak001\.pk4/);
  } finally {
    fs.rmSync(fx.directory, { recursive: true, force: true });
  }
});

test('retail bundle builder rejects a profile without required map geometry', () => {
  const fx = fixture();
  try {
    fx.profileManifest.maps[1].traces = ['q4web.log'];
    fs.writeFileSync(fx.profileManifestFile, `${JSON.stringify(fx.profileManifest)}\n`);
    const result = runBuilder(fx, path.join(fx.directory, 'bad-output'));
    assert.notEqual(result.status, 0);
    assert.match(result.stderr, /lacks required cm geometry/);
  } finally {
    fs.rmSync(fx.directory, { recursive: true, force: true });
  }
});

test('retail bundle builder requires the source map for static entity dependency closure', () => {
  const fx = fixture();
  try {
    const profileDirectory = path.dirname(fx.profileManifestFile);
    const withoutMap = fs.readFileSync(path.join(profileDirectory, 'q4dm3.log'), 'utf8')
      .split('\n')
      .filter((line) => !line.includes('maps/mp/q4dm3.map'))
      .join('\n');
    fs.writeFileSync(path.join(profileDirectory, 'q4dm3-no-map.log'), withoutMap);
    fx.profileManifest.maps[1].traces = ['q4dm3-no-map.log'];
    fs.writeFileSync(fx.profileManifestFile, `${JSON.stringify(fx.profileManifest)}\n`);
    const result = runBuilder(fx, path.join(fx.directory, 'bad-output'));
    assert.notEqual(result.status, 0);
    assert.match(result.stderr, /lacks required map geometry/);
  } finally {
    fs.rmSync(fx.directory, { recursive: true, force: true });
  }
});

test('retail bundle builder closes hidden renderer, model, GUI, effect, cubemap, and audio dependencies', () => {
  const fx = fixture({ dependencies: true });
  try {
    const output = path.join(fx.directory, 'output');
    const result = runBuilder(fx, output);
    assert.equal(result.status, 0, result.stderr);
    const manifest = JSON.parse(fs.readFileSync(path.join(fx.directory, 'bundle-manifest.json')));
    const dm3 = manifest.bundles.find(({ id }) => id === 'map/mp/q4dm3');
    const members = new Set(zipList(path.join(output, dm3.name)));
    for (const expected of [
      'def/hidden.def',
      'effects/hidden.fx',
      'guis/hidden.gui',
      'guides/hidden.guide',
      'materials/hidden.mtr',
      'models/hidden.md5mesh',
      'sound/hidden.sndshd',
      'sound/hidden/cue.ogg',
      'textures/hidden/wall_d.dds',
      'textures/hidden/wall_d.tga',
      'textures/hidden/wall_local.dds',
      'textures/hidden/wall_local.tga',
      'textures/hidden/model.dds',
      'textures/hidden/model.tga',
      'textures/hidden/fx.dds',
      'textures/hidden/fx.tga',
      'textures/hidden/gui.dds',
      'textures/hidden/gui.tga',
      ...['nx', 'ny', 'nz', 'px', 'py', 'pz'].flatMap((suffix) => [
        `gfx/env/hidden_${suffix}.dds`,
        `gfx/env/hidden_${suffix}.tga`,
      ]),
    ]) {
      assert.ok(members.has(expected), `missing statically closed dependency: ${expected}`);
    }
  } finally {
    fs.rmSync(fx.directory, { recursive: true, force: true });
  }
});
