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

import {
  BOT_NAVIGATION_COMPILER_PROFILES,
  BOT_NAVIGATION_POLICY,
  BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES,
  BOT_NAVIGATION_SCHEMA,
  BOT_NAVIGATION_TEAM_SEED_IDENTITY,
  RETAIL_BOT_NAVIGATION_MAP_IDS,
  botNavigationMapIds,
  botReadyMapIds,
  loadBotNavigation,
  parseBotNavigationAas,
} from '../server/bot-navigation.js';

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

function aasSource(mapCrc, { special = false } = {}) {
  const reachability = special
    ? [
        '\t\t12288 0 (0 0 0) (0 0 0) 0 1',
        '\t\t{',
        '\t\t\t"moveDir" "127 0 0"',
        '\t\t}',
        '',
      ]
    : ['\t\t2 0 (0 0 0) (0 0 0) 0 1'];
  return Buffer.from([
    'DewmAAS "1.08"', '', String(mapCrc), '', 'settings', '{', '}', 'areas 2 {',
    '\t0 ( 0 0 0 0 0 0 0 0 ) 0 {', '\t}',
    '\t1 ( 0 0 0 0 0 0 0 0 ) 1 {', ...reachability, '\t}',
    '}', 'clusters 0 {', '}', '',
  ].join('\n'));
}

function buildFixture(t, options = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-bot-navigation-'));
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
  const stage = path.join(root, 'stage');
  const packDirectory = path.join(root, 'baseoq4');
  const manifestFile = path.join(root, 'retail-botnav-manifest.json');
  const clientFile = path.join(root, 'openQ4-client_x64');
  const gameLibFile = path.join(packDirectory, 'game-mp_x64.so');
  fs.mkdirSync(packDirectory, { recursive: true });

  const maps = RETAIL_BOT_NAVIGATION_MAP_IDS.map((id, index) => {
    const source = aasSource(index + 100);
    const entryPath = `maps/${id}.aas32`;
    const output = path.join(stage, entryPath);
    fs.mkdirSync(path.dirname(output), { recursive: true });
    fs.writeFileSync(output, source);
    return {
      id,
      path: entryPath,
      size: source.length,
      sha256: sha256(source),
      mapCrc: index + 100,
      areas: 2,
      reachabilities: 1,
      compilerProfile: {
        id: BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.id,
        sha256: BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.sha256,
      },
    };
  });

  let archiveEntries = maps.map(({ path: entry }) => entry);
  if (options.missingEntry) archiveEntries = archiveEntries.filter((entry) => entry !== maps[0].path);
  if (options.extraEntry) {
    fs.writeFileSync(path.join(stage, 'junk.txt'), 'undeclared');
    archiveEntries.push('junk.txt');
  }
  if (options.caseDuplicate) {
    archiveEntries = archiveEntries.slice(0, -1);
    const duplicate = maps[0].path.toUpperCase();
    const duplicateFile = path.join(stage, duplicate);
    fs.mkdirSync(path.dirname(duplicateFile), { recursive: true });
    fs.copyFileSync(path.join(stage, maps[0].path), duplicateFile);
    archiveEntries.push(duplicate);
  }
  if (options.symlink) {
    const target = path.join(stage, maps[0].path);
    fs.rmSync(target);
    fs.symlinkSync('../q4dm3.aas32', target);
  }

  const temporaryPack = path.join(root, 'navigation.pk4');
  const zipArgs = ['-qX', ...(options.symlink ? ['-y'] : []),
    ...(options.encrypted ? ['-P', 'test-password'] : []), '-9', temporaryPack, ...archiveEntries.sort()];
  const zipped = spawnSync('zip', zipArgs, { cwd: stage, encoding: 'utf8' });
  assert.equal(zipped.status, 0, zipped.stderr || zipped.error?.message);
  let packBytes = fs.readFileSync(temporaryPack);
  if (options.rawPack) packBytes = Buffer.from('this is not a ZIP archive');
  const packHash = sha256(packBytes);
  const packName = `q4retail_botnav-${packHash.slice(0, 12)}.pk4`;
  let packFile = path.join(packDirectory, packName);
  fs.writeFileSync(packFile, packBytes);

  const clientBytes = Buffer.from('fixture OpenQ4 client');
  const gameLibBytes = Buffer.from('fixture multiplayer GameLib');
  fs.writeFileSync(clientFile, clientBytes);
  fs.writeFileSync(gameLibFile, gameLibBytes);
  const manifest = {
    schema: BOT_NAVIGATION_SCHEMA,
    policy: BOT_NAVIGATION_POLICY,
    retail: { revision: `q4retail-${'a'.repeat(20)}`, fingerprint: 'a'.repeat(64) },
    engine: {
      client: { size: clientBytes.length, sha256: sha256(clientBytes) },
      gameLib: { size: gameLibBytes.length, sha256: sha256(gameLibBytes) },
    },
    compiler: {
      teamSpawnSeed: { ...BOT_NAVIGATION_TEAM_SEED_IDENTITY },
      profiles: BOT_NAVIGATION_COMPILER_PROFILES.map((profile) => ({
        ...profile,
        ...(profile.override ? { override: { ...profile.override } } : {}),
      })),
    },
    pack: { name: packName, size: packBytes.length, sha256: packHash },
    maps,
  };
  const saveManifest = () => fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
  const replacePack = (replacement) => {
    fs.rmSync(packFile, { force: true });
    const replacementHash = sha256(replacement);
    const replacementName = `q4retail_botnav-${replacementHash.slice(0, 12)}.pk4`;
    packFile = path.join(packDirectory, replacementName);
    fs.writeFileSync(packFile, replacement);
    manifest.pack = { name: replacementName, size: replacement.length, sha256: replacementHash };
    saveManifest();
  };
  saveManifest();
  const load = () => loadBotNavigation({ manifestFile, packDirectory, clientFile, gameLibFile });
  return { load, manifest, saveManifest, replacePack, packFile, clientFile, gameLibFile };
}

test('bot navigation opens a real PK4 and verifies the exact 22-map retail AAS catalog', (t) => {
  const fixture = buildFixture(t);
  const loaded = fixture.load();
  assert.equal(loaded.maps.size, 22);
  assert.deepEqual([...loaded.maps].sort(), [...RETAIL_BOT_NAVIGATION_MAP_IDS].sort());
  assert.equal(loaded.maps.has('mp/q4dm1'), true);
  assert.equal(loaded.maps.has('mp/q4ctf1'), true);

  const navigationMaps = botNavigationMapIds(loaded);
  const readyMaps = botReadyMapIds(loaded);
  assert.equal(navigationMaps.size, 23);
  assert.equal(navigationMaps.has('mp/q4ctf1'), true);
  assert.equal(readyMaps.size, 18);
  assert.equal(readyMaps.has('mp/q4dm1'), true);
  assert.equal(readyMaps.has('mp/q4ctf1'), false);
});

test('bot navigation rejects non-ZIP junk even when its outer hash is declared', (t) => {
  const fixture = buildFixture(t, { rawPack: true });
  assert.throws(fixture.load, /canonical ZIP end record/);
});

test('bot navigation rejects missing, extra, and case-insensitive duplicate ZIP entries', async (t) => {
  await t.test('missing', (t) => {
    const fixture = buildFixture(t, { missingEntry: true });
    assert.throws(fixture.load, /central directory|entry set/);
  });
  await t.test('extra', (t) => {
    const fixture = buildFixture(t, { extraEntry: true });
    assert.throws(fixture.load, /central directory|undeclared entry/);
  });
  await t.test('case-insensitive duplicate', (t) => {
    const fixture = buildFixture(t, { caseDuplicate: true });
    assert.throws(fixture.load, /duplicate case-insensitive entry/);
  });
});

test('bot navigation rejects encrypted entries and ZIP symlinks', async (t) => {
  await t.test('encrypted', (t) => {
    const fixture = buildFixture(t, { encrypted: true });
    assert.throws(fixture.load, /encrypted ZIP flags/);
  });
  await t.test('symlink', (t) => {
    const fixture = buildFixture(t, { symlink: true });
    assert.throws(fixture.load, /symlink or non-regular entry/);
  });
});

test('bot navigation verifies decompressed AAS SHA and parsed graph metadata', async (t) => {
  await t.test('ZIP CRC', (t) => {
    const fixture = buildFixture(t);
    const bytes = fs.readFileSync(fixture.packFile);
    const end = bytes.length - 22;
    const central = bytes.readUInt32LE(end + 16);
    const local = bytes.readUInt32LE(central + 42);
    const changedCrc = (bytes.readUInt32LE(central + 16) ^ 1) >>> 0;
    bytes.writeUInt32LE(changedCrc, central + 16);
    bytes.writeUInt32LE(changedCrc, local + 14);
    fixture.replacePack(bytes);
    assert.throws(fixture.load, /content differs from its CRC, size, or SHA-256/);
  });
  await t.test('entry SHA', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.maps[0].sha256 = 'f'.repeat(64);
    fixture.saveManifest();
    assert.throws(fixture.load, /content differs from its CRC, size, or SHA-256/);
  });
  await t.test('map CRC', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.maps[0].mapCrc += 1;
    fixture.saveManifest();
    assert.throws(fixture.load, /AAS metadata differs from the manifest/);
  });
  await t.test('areas and reachabilities', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.maps[0].reachabilities += 1;
    fixture.saveManifest();
    assert.throws(fixture.load, /AAS metadata differs from the manifest/);
  });
});

test('bot navigation parser accepts strict special reachability epairs', () => {
  assert.deepEqual(parseBotNavigationAas(aasSource(1234, { special: true }), 'mp/q4dm1'), {
    mapCrc: 1234,
    areas: 2,
    reachabilities: 1,
  });
  const malformed = Buffer.from(aasSource(1234, { special: true }).toString('utf8')
    .replace('"moveDir" "127 0 0"', 'moveDir = 127 0 0'));
  assert.throws(() => parseBotNavigationAas(malformed, 'mp/q4dm1'), /malformed special reachability epair/);
  const missing = Buffer.from(aasSource(1234, { special: true }).toString('utf8')
    .replace(/\n\t\t\{\n\t\t\t"moveDir" "127 0 0"\n\t\t\}\n/, '\n'));
  assert.throws(() => parseBotNavigationAas(missing, 'mp/q4dm1'), /has no epair block/);
});

test('bot navigation enforces current OpenQ4 client and GameLib identities', async (t) => {
  await t.test('client', (t) => {
    const fixture = buildFixture(t);
    fs.appendFileSync(fixture.clientFile, 'changed');
    assert.throws(fixture.load, /OpenQ4 client differs from the generator identity/);
  });
  await t.test('GameLib', (t) => {
    const fixture = buildFixture(t);
    fs.appendFileSync(fixture.gameLibFile, 'changed');
    assert.throws(fixture.load, /multiplayer GameLib differs from the generator identity/);
  });
});

test('bot navigation binds the compiler-only team spawn seed identity', async (t) => {
  await t.test('missing seed identity', (t) => {
    const fixture = buildFixture(t);
    delete fixture.manifest.compiler;
    fixture.saveManifest();
    assert.throws(fixture.load, /team-spawn seed identity/);
  });
  await t.test('changed seed identity', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.compiler.teamSpawnSeed.sha256 = 'f'.repeat(64);
    fixture.saveManifest();
    assert.throws(fixture.load, /team-spawn seed identity/);
  });
});

test('bot navigation binds the exact compiler profile catalog and each map profile', async (t) => {
  await t.test('missing profile catalog', (t) => {
    const fixture = buildFixture(t);
    delete fixture.manifest.compiler.profiles;
    fixture.saveManifest();
    assert.throws(fixture.load, /compiler profile catalog/);
  });
  await t.test('changed playerclip override identity', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.compiler.profiles[0].override.sha256 = 'f'.repeat(64);
    fixture.saveManifest();
    assert.throws(fixture.load, /compiler profile catalog/);
  });
  await t.test('changed verified source entry identity', (t) => {
    const fixture = buildFixture(t);
    const profile = fixture.manifest.compiler.profiles.find(
      ({ id }) => id === BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES[0].id,
    );
    profile.entry = { ...profile.entry, sha256: 'f'.repeat(64) };
    fixture.saveManifest();
    assert.throws(fixture.load, /compiler profile catalog/);
  });
  await t.test('unknown map profile', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.maps[0].compilerProfile.id = 'unknown-profile';
    fixture.saveManifest();
    assert.throws(fixture.load, /invalid map entry/);
  });
  await t.test('changed map profile identity', (t) => {
    const fixture = buildFixture(t);
    fixture.manifest.maps[0].compilerProfile = {
      id: BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.id,
      sha256: 'f'.repeat(64),
    };
    fixture.saveManifest();
    assert.throws(fixture.load, /invalid map entry/);
  });
  await t.test('source profile cannot be assigned to another map or artifact', (t) => {
    const fixture = buildFixture(t);
    const entry = fixture.manifest.maps.find(({ id }) => id === 'mp/q4dm1');
    const sourceProfile = BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES.find(
      ({ map }) => map === 'mp/q4dm11v1',
    );
    entry.compilerProfile = { id: sourceProfile.id, sha256: sourceProfile.sha256 };
    fixture.saveManifest();
    assert.throws(fixture.load, /invalid map entry: mp\/q4dm1/);
  });
});
