import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { inflateRawSync } from 'node:zlib';

import { MULTIPLAYER_MAPS } from '../public/js/menu-config.js';

export const BOT_NAVIGATION_SCHEMA = 3;
export const BOT_NAVIGATION_POLICY = 'operator-derived-server-only-v3';
export const BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY =
  'verified-source-direct-v1';
export const BOT_NAVIGATION_TEAM_SEED_PATH = 'baseoq4/def/000_q4web_botnav_compile_openq4.def';
export const BOT_NAVIGATION_TEAM_SEED_SOURCE = [
  '// Compiler-only AAS seed. This file is never included in the staged navigation PK4.',
  'entityDef info_player_team {',
  '\t"mins"\t\t"-15 -15 0"',
  '\t"maxs"\t\t"15 15 64"',
  '\t"use_aas"\t"aas32"',
  '\t"spawnclass"\t"idPlayerStart"',
  '\t"q4web_botnav_compile_seed"\t"1"',
  '}',
  '',
].join('\n');
export const BOT_NAVIGATION_TEAM_SEED_IDENTITY = Object.freeze({
  path: BOT_NAVIGATION_TEAM_SEED_PATH,
  size: Buffer.byteLength(BOT_NAVIGATION_TEAM_SEED_SOURCE),
  sha256: createHash('sha256').update(BOT_NAVIGATION_TEAM_SEED_SOURCE).digest('hex'),
});
export const BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_PATH =
  'baseoq4/def/001_q4web_botnav_playerclip_aas32_openq4.def';
function playerclipOverrideSource() {
  return [
    '// Compiler-only player-sized aas32 profile. Never include this file in the staged PK4.',
    'entityDef aas32 {',
    '\t"mins"\t\t"-15 -15 0"',
    '\t"maxs"\t\t"15 15 76"',
    '\t"usePatches"\t\t"1"',
    '\t"writeBrushMap"\t\t"0"',
    '\t"playerFlood"\t\t"1"',
    '\t"playerClipSolid"\t"1"',
    '\t"allowSwimReachabilities"\t"1"',
    '\t"allowFlyReachabilities"\t"0"',
    '\t"generateAllFaces"\t"0"',
    '\t"fileExtension"\t\t"aas32"',
    '\t"gravity"\t\t"0 0 -1050"',
    '\t"maxStepHeight"\t\t"17"',
    '\t"maxBarrierHeight"\t"49"',
    '\t"maxWaterJumpHeight"\t"20"',
    '\t"maxFallHeight"\t\t"2048"',
    '\t"minFloorCos"\t\t"0.6999999881"',
    '\t"tt_barrierJump"\t\t"100"',
    '\t"tt_startCrouching"\t"100"',
    '\t"tt_waterJump"\t\t"100"',
    '\t"tt_startWalkOffLedge"\t"100"',
    '\t"debugColor"\t\t"1.0 0.5 0.0"',
    '}',
    '',
  ].join('\n');
}
export const BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE = playerclipOverrideSource();
export const BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_IDENTITY = Object.freeze({
  path: BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_PATH,
  size: Buffer.byteLength(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE),
  sha256: createHash('sha256').update(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE).digest('hex'),
});

function canonicalJson(value) {
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort().map((key) =>
      `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}

function deepFreeze(value) {
  if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
  for (const child of Object.values(value)) deepFreeze(child);
  return Object.freeze(value);
}

function compilerProfile(descriptor) {
  const sha256 = createHash('sha256').update(canonicalJson(descriptor)).digest('hex');
  return deepFreeze({
    ...descriptor,
    sha256,
  });
}

export const BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE = compilerProfile({
  id: 'retail-playerclip-aas32-v1',
  kind: 'compiler-override',
  fileExtension: 'aas32',
  definition: 'entityDef aas32',
  collisionPolicy: 'solid-playerclip',
  override: BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_IDENTITY,
});

const Q4MAX_AAS01_ARCHIVE = Object.freeze({
  path: 'q4max-aas-01.zip',
  size: 25635781,
  sha256: '4d1232e6e466d7074538cbfb76bc94e616e2e9fc34a114ceac4080016a6de86f',
});
const Q4MAX_AAS01_CONTAINER = Object.freeze({
  path: 'q4max/q4max-aas01-base.pk4',
  size: 22557881,
  sha256: '672816ddea64dc1d3be25521834586afbf5c817d825b10fea53d7a0094d7b944',
});
const Q4MAX_AAS01_MAPS = Object.freeze([
  {
    map: 'mp/q4dm11v1',
    mapCrc: 1963940422,
    entry: {
      path: 'maps/mp/q4dm11v1.maxaas01',
      size: 805752,
      sha256: 'cfd2b714261a30ccdadc44ea5a89d08c6ba0d7bf19d4e2be72244a77f0e78530',
    },
  },
  {
    map: 'mp/q4dm1',
    mapCrc: 1013305419,
    entry: {
      path: 'maps/mp/q4dm1.maxaas01',
      size: 2101753,
      sha256: 'f24006e9299e8be153afeb5438655abe0cd2b04708223488ce9ca0e70dace401',
    },
  },
  {
    map: 'mp/q4dm2',
    mapCrc: 553320228,
    entry: {
      path: 'maps/mp/q4dm2.maxaas01',
      size: 1250455,
      sha256: 'be7489c6533af46fdb2587331316c69c123013abe579e7d4310873c3ee2c96e5',
    },
  },
  {
    map: 'mp/q4dm3',
    mapCrc: 2726522086,
    entry: {
      path: 'maps/mp/q4dm3.maxaas01',
      size: 1193438,
      sha256: '90d7cb492e879c8fd305757565b15ff561703690f74cd7a6bf4ddf451bd24a11',
    },
  },
  {
    map: 'mp/q4dm4',
    mapCrc: 852697483,
    entry: {
      path: 'maps/mp/q4dm4.maxaas01',
      size: 4162897,
      sha256: 'c078b1576da19c15998b9ec781ca5b1d06e891e1ceb00e97515b8809ab709d67',
    },
  },
  {
    map: 'mp/q4dm5',
    mapCrc: 3902103635,
    entry: {
      path: 'maps/mp/q4dm5.maxaas01',
      size: 3739451,
      sha256: 'b900713cff8c541e39f4b272c8526f6a55935e50be3bd9a63da37a820567144a',
    },
  },
  {
    map: 'mp/q4dm6',
    mapCrc: 3634486240,
    entry: {
      path: 'maps/mp/q4dm6.maxaas01',
      size: 1510073,
      sha256: '29da6604eab691c3fa6341b8381331ec0e0d51e5b1d11d68b10ffcf1c854f1c5',
    },
  },
  {
    map: 'mp/q4dm7',
    mapCrc: 1173483175,
    entry: {
      path: 'maps/mp/q4dm7.maxaas01',
      size: 2808467,
      sha256: '4884d4e533eced4584ace4ce273b830e6fcf081ee57bde8cf3b7831ec6955b1e',
    },
  },
  {
    map: 'mp/q4dm8',
    mapCrc: 2059896742,
    entry: {
      path: 'maps/mp/q4dm8.maxaas01',
      size: 1308751,
      sha256: '49a481a0c5850f23abfbdeb54013faeebc6430972b63a549127894818e026486',
    },
  },
  {
    map: 'mp/q4ctf1',
    mapCrc: 1162835683,
    entry: {
      path: 'maps/mp/q4ctf1.maxaas01',
      size: 3789049,
      sha256: 'aec7d08bf2002bf736666509f908b631e443663a028182bd22b747290c447860',
    },
  },
  {
    map: 'mp/q4ctf2',
    mapCrc: 2890158923,
    entry: {
      path: 'maps/mp/q4ctf2.maxaas01',
      size: 5375788,
      sha256: 'f7f8cd6714265e7764fd7188d09124cb2e942b19e0d93e7ba5bf420173f850e6',
    },
  },
  {
    map: 'mp/q4ctf4',
    mapCrc: 502054961,
    entry: {
      path: 'maps/mp/q4ctf4.maxaas01',
      size: 4214107,
      sha256: '0dd60373b8539885b2129a79942b3c18b558dfa6fa388893852e8daa9b5cc893',
    },
  },
  {
    map: 'mp/q4ctf5',
    mapCrc: 1928454761,
    entry: {
      path: 'maps/mp/q4ctf5.maxaas01',
      size: 4402320,
      sha256: '33021ed63a502413732f00b8c4fdd24b7f2f57b389d0ab22797013f7873eac5f',
    },
  },
]);
const SABOT_A11_ARCHIVE = Object.freeze({
  path: 'sabota11.zip',
  size: 21341561,
  sha256: '624618da8af8be35e9b9e9ff08991dc89a5ed468a94b7492e65045e41a12110f',
});
const SABOT_A11_CONTAINER = Object.freeze({
  path: 'sabot-aas.pk4',
  size: 16912549,
  sha256: 'c89b090b70a1d1c81ba934910cae20cb5dbc3b323a460c37930d926d87374d1f',
});
const SABOT_A11_MAPS = Object.freeze([
  {
    map: 'mp/q4ctf3',
    mapCrc: 2964200501,
    entry: {
      path: 'maps/mp/q4ctf3.botaas32',
      size: 3304653,
      sha256: '2ebef1e159fed3b2c76672020f2e7fdc98be99e6392ba1d4a565c2803b2a2ded',
    },
  },
  {
    map: 'mp/q4xdm13',
    mapCrc: 3840712584,
    selectionPolicy: BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY,
    entry: {
      path: 'maps/mp/q4xdm13.botaas32',
      size: 1549540,
      sha256: 'be05bb365f4b84f36c4cd1b7d98decd2d92a507e87713a25f5c394c1b9577d8c',
    },
  },
]);

function sourceProfile(prefix, archive, container, source) {
  const slug = source.map.slice(source.map.lastIndexOf('/') + 1);
  return compilerProfile({
    id: `${prefix}-${slug}-source-v1`,
    kind: 'verified-aas-source',
    map: source.map,
    mapCrc: source.mapCrc,
    ...(source.selectionPolicy ? { selectionPolicy: source.selectionPolicy } : {}),
    archive,
    container,
    entry: source.entry,
    transform: {
      id: 'copy-bytes-rename-entry-v1',
      outputExtension: 'aas32',
      contentMutation: 'none',
    },
  });
}

export const BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES = Object.freeze(Q4MAX_AAS01_MAPS.map((source) =>
  sourceProfile('q4max-aas01', Q4MAX_AAS01_ARCHIVE, Q4MAX_AAS01_CONTAINER, source)));
export const BOT_NAVIGATION_SABOT_SOURCE_PROFILES = Object.freeze(SABOT_A11_MAPS.map((source) =>
  sourceProfile('sabot-a11', SABOT_A11_ARCHIVE, SABOT_A11_CONTAINER, source)));
export const BOT_NAVIGATION_SOURCE_PROFILES = Object.freeze([
  ...BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES,
  ...BOT_NAVIGATION_SABOT_SOURCE_PROFILES,
]);
export const BOT_NAVIGATION_COMPILER_PROFILES = Object.freeze([
  BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  ...BOT_NAVIGATION_SOURCE_PROFILES,
]);

const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const PACK_NAME_PATTERN = /^q4retail_botnav-[a-f0-9]{12}\.pk4$/;
const ZIP_LOCAL_FILE = 0x04034b50;
const ZIP_CENTRAL_FILE = 0x02014b50;
const ZIP_END = 0x06054b50;
const ZIP_ALLOWED_FLAGS = 0x0806; // UTF-8 plus the two DEFLATE level bits.
const ZIP_METHOD_STORED = 0;
const ZIP_METHOD_DEFLATE = 8;
const MAX_PACK_BYTES = 128 * 1024 * 1024;
const MAX_AAS_BYTES = 32 * 1024 * 1024;
const MAX_TOTAL_AAS_BYTES = 256 * 1024 * 1024;

export const RETAIL_BOT_NAVIGATION_MAP_IDS = Object.freeze(MULTIPLAYER_MAPS
  .filter((map) => map.content === 'retail')
  .map((map) => map.id));

const retailNavigationMaps = new Set(RETAIL_BOT_NAVIGATION_MAP_IDS);
const compilerProfilesById = new Map(BOT_NAVIGATION_COMPILER_PROFILES
  .map((profile) => [profile.id, profile]));
const sourceProfilesByMap = new Map(BOT_NAVIGATION_SOURCE_PROFILES
  .map((profile) => [profile.map, profile]));

export function botNavigationSourceProfileForMap(map) {
  return sourceProfilesByMap.get(map) || null;
}

function fail(message) {
  throw new Error(`bot navigation: ${message}`);
}

function regularFileIdentity(file, label, { read = false, maximumSize = Number.MAX_SAFE_INTEGER } = {}) {
  let descriptor;
  try {
    const before = fs.lstatSync(file);
    if (!before.isFile() || before.isSymbolicLink() || before.size <= 0 || before.size > maximumSize) {
      fail(`${label} is not an approved regular file`);
    }
    descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
    const pinned = fs.fstatSync(descriptor);
    if (!pinned.isFile() || pinned.dev !== before.dev || pinned.ino !== before.ino || pinned.size !== before.size) {
      fail(`${label} changed while it was being opened`);
    }

    const hash = createHash('sha256');
    const chunks = read ? [] : null;
    const buffer = Buffer.allocUnsafe(Math.min(1024 * 1024, pinned.size));
    let offset = 0;
    while (offset < pinned.size) {
      const bytesRead = fs.readSync(descriptor, buffer, 0, Math.min(buffer.length, pinned.size - offset), offset);
      if (bytesRead <= 0) fail(`${label} ended before its declared size`);
      const chunk = buffer.subarray(0, bytesRead);
      hash.update(chunk);
      if (chunks) chunks.push(Buffer.from(chunk));
      offset += bytesRead;
    }
    const after = fs.fstatSync(descriptor);
    if (after.dev !== pinned.dev || after.ino !== pinned.ino || after.size !== pinned.size ||
        after.mtimeMs !== pinned.mtimeMs || after.ctimeMs !== pinned.ctimeMs) {
      fail(`${label} changed while it was being read`);
    }
    return {
      size: pinned.size,
      sha256: hash.digest('hex'),
      ...(chunks ? { bytes: Buffer.concat(chunks, pinned.size) } : {}),
    };
  } catch (error) {
    if (String(error?.message || '').startsWith('bot navigation:')) throw error;
    fail(`${label} cannot be read: ${error?.message || error}`);
  } finally {
    if (descriptor !== undefined) fs.closeSync(descriptor);
  }
}

function artifactIdentity(entry, label) {
  if (!entry || typeof entry !== 'object' || Array.isArray(entry) ||
      !Number.isSafeInteger(entry.size) || entry.size <= 0 ||
      !SHA256_PATTERN.test(String(entry.sha256 || ''))) {
    fail(`invalid ${label} identity`);
  }
  return { size: entry.size, sha256: entry.sha256 };
}

function cloneCompilerProfile(profile) {
  return JSON.parse(JSON.stringify(profile));
}

function compilerProfileReference(profile) {
  return { id: profile.id, sha256: profile.sha256 };
}

function verifyArtifact(entry, file, label) {
  const actual = regularFileIdentity(file, label);
  if (actual.size !== entry.size || actual.sha256 !== entry.sha256) {
    fail(`${label} differs from the generator identity`);
  }
}

function crc32(bytes) {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc ^= byte;
    for (let bit = 0; bit < 8; bit += 1) {
      crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
    }
  }
  return (crc ^ 0xffffffff) >>> 0;
}

function decodeZipName(bytes) {
  if (bytes.length === 0 || [...bytes].some((byte) => byte < 0x20 || byte > 0x7e)) {
    fail('pack contains a non-ASCII or empty entry name');
  }
  return bytes.toString('ascii');
}

function validateZipFlags(flags, method, name) {
  if ((flags & ~ZIP_ALLOWED_FLAGS) !== 0 || (flags & 0x0001) !== 0 ||
      (method === ZIP_METHOD_STORED && (flags & 0x0006) !== 0)) {
    fail(`pack entry uses unsupported or encrypted ZIP flags: ${name}`);
  }
  if (method !== ZIP_METHOD_STORED && method !== ZIP_METHOD_DEFLATE) {
    fail(`pack entry uses an unsupported compression method: ${name}`);
  }
}

function parseAreas(source, map) {
  let text;
  try {
    text = new TextDecoder('utf-8', { fatal: true }).decode(source);
  } catch {
    fail(`${map}: AAS is not valid UTF-8 text`);
  }
  if (/[^\x09\x0a\x0d\x20-\x7e]/.test(text) || !text.endsWith('}\n')) {
    fail(`${map}: AAS graph is truncated or contains binary data`);
  }
  const header = /^DewmAAS "1\.08"\r?\n\r?\n(\d+)\r?\n\r?\n/.exec(text);
  const mapCrc = Number(header?.[1]);
  if (!Number.isInteger(mapCrc) || mapCrc < 0 || mapCrc > 0xffffffff) {
    fail(`${map}: AAS has no valid DewmAAS 1.08 map CRC`);
  }

  const lines = text.replaceAll('\r\n', '\n').split('\n');
  const areaHeaders = [];
  for (let index = 0; index < lines.length; index += 1) {
    const match = /^areas (\d+) \{$/.exec(lines[index]);
    if (match) areaHeaders.push({ index, count: Number(match[1]) });
  }
  if (areaHeaders.length !== 1 || !Number.isSafeInteger(areaHeaders[0].count) || areaHeaders[0].count < 2) {
    fail(`${map}: AAS has no unique, valid areas block`);
  }

  const areas = areaHeaders[0].count;
  let cursor = areaHeaders[0].index + 1;
  let reachabilities = 0;
  const scalar = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?';
  const areaPattern = /^\s*(\d+)\s+\(\s*-?\d+(?:\s+-?\d+){7}\s*\)\s+(\d+)\s+\{$/;
  const reachabilityPattern = new RegExp(
    `^\\s*\\d+\\s+\\d+\\s+\\(\\s*${scalar}(?:\\s+${scalar}){2}\\s*\\)` +
    `\\s+\\(\\s*${scalar}(?:\\s+${scalar}){2}\\s*\\)\\s+\\d+\\s+\\d+\\s*$`
  );
  const specialReachabilityPattern = /^\s*\d+\s+/;
  const epairPattern = /^\s*"(?:[^"\\]|\\.)*"\s+"(?:[^"\\]|\\.)*"\s*$/;
  for (let expectedArea = 0; expectedArea < areas; expectedArea += 1) {
    const area = areaPattern.exec(lines[cursor] || '');
    const index = Number(area?.[1]);
    const declaredReachabilities = Number(area?.[2]);
    if (index !== expectedArea || !Number.isSafeInteger(declaredReachabilities) || declaredReachabilities < 0) {
      fail(`${map}: AAS area ${expectedArea} is malformed or out of sequence`);
    }
    cursor += 1;
    let records = 0;
    while (cursor < lines.length && !/^\s*\}$/.test(lines[cursor])) {
      const record = lines[cursor].trim();
      if (!record || !reachabilityPattern.test(record)) {
        fail(`${map}: AAS area ${expectedArea} contains a malformed reachability`);
      }
      records += 1;
      cursor += 1;
      const travelType = Number(record.match(specialReachabilityPattern)?.[0]);
      if ((travelType & 4096) !== 0) {
        if (!/^\s*\{$/.test(lines[cursor] || '')) {
          fail(`${map}: AAS area ${expectedArea} special reachability has no epair block`);
        }
        cursor += 1;
        let epairs = 0;
        while (cursor < lines.length && !/^\s*\}$/.test(lines[cursor])) {
          if (!epairPattern.test(lines[cursor])) {
            fail(`${map}: AAS area ${expectedArea} contains a malformed special reachability epair`);
          }
          epairs += 1;
          cursor += 1;
        }
        if (epairs < 1 || cursor >= lines.length) {
          fail(`${map}: AAS area ${expectedArea} special reachability epair block is incomplete`);
        }
        cursor += 1;
        while (lines[cursor] === '') cursor += 1;
      }
    }
    if (records !== declaredReachabilities || cursor >= lines.length) {
      fail(`${map}: AAS area ${expectedArea} reachability count differs from its records`);
    }
    reachabilities += records;
    if (!Number.isSafeInteger(reachabilities)) fail(`${map}: AAS reachability count overflows`);
    cursor += 1;
  }
  if (lines[cursor] !== '}' || reachabilities < 1) {
    fail(`${map}: AAS areas block is incomplete`);
  }
  return { mapCrc, areas, reachabilities };
}

export function parseBotNavigationAas(source, map) {
  if (!Buffer.isBuffer(source)) fail(`${map}: AAS parser requires a Buffer`);
  return parseAreas(source, map);
}

function readPackEntries(bytes, entriesByPath) {
  if (bytes.length < 22 || bytes.readUInt32LE(bytes.length - 22) !== ZIP_END ||
      bytes.readUInt16LE(bytes.length - 2) !== 0) {
    fail('pack has no canonical ZIP end record');
  }
  const end = bytes.length - 22;
  const disk = bytes.readUInt16LE(end + 4);
  const centralDisk = bytes.readUInt16LE(end + 6);
  const diskEntries = bytes.readUInt16LE(end + 8);
  const entryCount = bytes.readUInt16LE(end + 10);
  const centralSize = bytes.readUInt32LE(end + 12);
  const centralOffset = bytes.readUInt32LE(end + 16);
  if (disk !== 0 || centralDisk !== 0 || diskEntries !== entryCount || entryCount !== entriesByPath.size ||
      entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff ||
      centralOffset + centralSize !== end) {
    fail('pack has a split, ZIP64, non-canonical, or incomplete central directory');
  }

  const archiveEntries = [];
  const names = new Set();
  const exactNames = new Set();
  const expectedByFoldedName = new Map([...entriesByPath]
    .map(([name, entry]) => [name.toLowerCase(), entry]));
  let cursor = centralOffset;
  for (let index = 0; index < entryCount; index += 1) {
    if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== ZIP_CENTRAL_FILE) {
      fail('pack central directory is malformed');
    }
    const versionMadeBy = bytes.readUInt16LE(cursor + 4);
    const flags = bytes.readUInt16LE(cursor + 8);
    const method = bytes.readUInt16LE(cursor + 10);
    const expectedCrc = bytes.readUInt32LE(cursor + 16);
    const compressedSize = bytes.readUInt32LE(cursor + 20);
    const uncompressedSize = bytes.readUInt32LE(cursor + 24);
    const nameLength = bytes.readUInt16LE(cursor + 28);
    const extraLength = bytes.readUInt16LE(cursor + 30);
    const commentLength = bytes.readUInt16LE(cursor + 32);
    const startDisk = bytes.readUInt16LE(cursor + 34);
    const externalAttributes = bytes.readUInt32LE(cursor + 38);
    const localOffset = bytes.readUInt32LE(cursor + 42);
    const recordEnd = cursor + 46 + nameLength + extraLength + commentLength;
    if (recordEnd > end || extraLength !== 0 || commentLength !== 0 || startDisk !== 0 ||
        compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) {
      fail('pack contains an unsupported central directory record');
    }
    const nameBytes = bytes.subarray(cursor + 46, cursor + 46 + nameLength);
    const name = decodeZipName(nameBytes);
    const foldedName = name.toLowerCase();
    if (names.has(foldedName)) fail(`pack contains a duplicate case-insensitive entry: ${name}`);
    names.add(foldedName);
    exactNames.add(name);
    validateZipFlags(flags, method, name);

    const host = versionMadeBy >>> 8;
    const unixType = (externalAttributes >>> 16) & 0xf000;
    if (host === 3 && unixType !== 0 && unixType !== 0x8000) {
      fail(`pack contains a symlink or non-regular entry: ${name}`);
    }
    const manifestEntry = entriesByPath.get(name) || expectedByFoldedName.get(foldedName);
    if (!manifestEntry) fail(`pack contains an undeclared entry: ${name}`);
    if (uncompressedSize !== manifestEntry.size || uncompressedSize > MAX_AAS_BYTES) {
      fail(`pack entry size differs from the manifest: ${name}`);
    }
    archiveEntries.push({
      name,
      nameBytes,
      flags,
      method,
      expectedCrc,
      compressedSize,
      uncompressedSize,
      localOffset,
      manifestEntry,
    });
    cursor = recordEnd;
  }
  if (cursor !== end || names.size !== entriesByPath.size ||
      [...entriesByPath].some(([name]) => !exactNames.has(name))) {
    fail('pack entry set differs from the exact manifest map set');
  }

  const ranges = [];
  for (const entry of archiveEntries) {
    const offset = entry.localOffset;
    if (offset + 30 > centralOffset || bytes.readUInt32LE(offset) !== ZIP_LOCAL_FILE) {
      fail(`pack local record is missing: ${entry.name}`);
    }
    const flags = bytes.readUInt16LE(offset + 6);
    const method = bytes.readUInt16LE(offset + 8);
    const localCrc = bytes.readUInt32LE(offset + 14);
    const compressedSize = bytes.readUInt32LE(offset + 18);
    const uncompressedSize = bytes.readUInt32LE(offset + 22);
    const nameLength = bytes.readUInt16LE(offset + 26);
    const extraLength = bytes.readUInt16LE(offset + 28);
    const dataStart = offset + 30 + nameLength + extraLength;
    const dataEnd = dataStart + compressedSize;
    if (flags !== entry.flags || method !== entry.method || localCrc !== entry.expectedCrc ||
        compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize ||
        extraLength !== 0 || nameLength !== entry.nameBytes.length || dataEnd > centralOffset ||
        !bytes.subarray(offset + 30, offset + 30 + nameLength).equals(entry.nameBytes)) {
      fail(`pack local record differs from its central record: ${entry.name}`);
    }
    const compressed = bytes.subarray(dataStart, dataEnd);
    let source;
    try {
      source = method === ZIP_METHOD_STORED
        ? Buffer.from(compressed)
        : inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize });
    } catch (error) {
      fail(`pack entry cannot be decompressed: ${entry.name}: ${error?.message || error}`);
    }
    if (source.length !== entry.manifestEntry.size || crc32(source) !== entry.expectedCrc ||
        createHash('sha256').update(source).digest('hex') !== entry.manifestEntry.sha256) {
      fail(`pack entry content differs from its CRC, size, or SHA-256: ${entry.name}`);
    }
    const graph = parseBotNavigationAas(source, entry.manifestEntry.id);
    if (graph.mapCrc !== entry.manifestEntry.mapCrc || graph.areas !== entry.manifestEntry.areas ||
        graph.reachabilities !== entry.manifestEntry.reachabilities) {
      fail(`pack AAS metadata differs from the manifest: ${entry.name}`);
    }
    ranges.push({ start: offset, end: dataEnd });
  }

  ranges.sort((left, right) => left.start - right.start);
  let expectedOffset = 0;
  for (const range of ranges) {
    if (range.start !== expectedOffset || range.end <= range.start) {
      fail('pack contains overlapping records or unindexed data');
    }
    expectedOffset = range.end;
  }
  if (expectedOffset !== centralOffset) fail('pack contains data outside its declared entries');
}

export function validateBotNavigationManifest(manifest, options = {}) {
  if (manifest?.schema !== BOT_NAVIGATION_SCHEMA || manifest?.policy !== BOT_NAVIGATION_POLICY) {
    fail('invalid schema or policy');
  }
  if (!manifest.retail || typeof manifest.retail !== 'object' ||
      !SHA256_PATTERN.test(String(manifest.retail.fingerprint || '')) ||
      !/^q4retail-[a-f0-9]{20}$/.test(String(manifest.retail.revision || ''))) {
    fail('invalid retail source identity');
  }
  if (options.retailCatalog &&
      (manifest.retail.fingerprint !== options.retailCatalog.manifest.fingerprint ||
       manifest.retail.revision !== options.retailCatalog.manifest.revision)) {
    fail('retail source identity differs from the active catalog');
  }

  if (!manifest.engine || typeof manifest.engine !== 'object' || Array.isArray(manifest.engine)) {
    fail('native generator identity is missing');
  }
  const engine = {
    client: artifactIdentity(manifest.engine.client, 'OpenQ4 client'),
    gameLib: artifactIdentity(manifest.engine.gameLib, 'multiplayer GameLib'),
  };
  const teamSpawnSeed = manifest.compiler?.teamSpawnSeed;
  if (!teamSpawnSeed || teamSpawnSeed.path !== BOT_NAVIGATION_TEAM_SEED_IDENTITY.path ||
      teamSpawnSeed.size !== BOT_NAVIGATION_TEAM_SEED_IDENTITY.size ||
      teamSpawnSeed.sha256 !== BOT_NAVIGATION_TEAM_SEED_IDENTITY.sha256) {
    fail('compiler team-spawn seed identity is missing or invalid');
  }
  const profiles = manifest.compiler?.profiles;
  if (!Array.isArray(profiles) ||
      canonicalJson(profiles) !== canonicalJson(BOT_NAVIGATION_COMPILER_PROFILES)) {
    fail('compiler profile catalog is missing or invalid');
  }

  const pack = manifest.pack;
  if (!pack || !SHA256_PATTERN.test(String(pack.sha256 || '')) ||
      !Number.isSafeInteger(pack.size) || pack.size <= 0 || pack.size > MAX_PACK_BYTES ||
      !PACK_NAME_PATTERN.test(String(pack.name || '')) ||
      pack.name !== `q4retail_botnav-${pack.sha256.slice(0, 12)}.pk4`) {
    fail('invalid pack identity');
  }
  if (!Array.isArray(manifest.maps) || manifest.maps.length !== retailNavigationMaps.size) {
    fail(`map catalog must contain exactly ${retailNavigationMaps.size} retail navigation maps`);
  }

  const maps = new Set();
  const entriesByPath = new Map();
  let totalBytes = 0;
  for (const entry of manifest.maps) {
    const id = String(entry?.id || '');
    const relative = `maps/${id}.aas32`;
    const profile = compilerProfilesById.get(String(entry?.compilerProfile?.id || ''));
    const invalidSourceBinding = profile?.kind === 'verified-aas-source' &&
      (profile.map !== id || profile.mapCrc !== entry.mapCrc ||
       profile.entry.size !== entry.size || profile.entry.sha256 !== entry.sha256);
    if (!retailNavigationMaps.has(id) || maps.has(id) || entry.path !== relative ||
        !SHA256_PATTERN.test(String(entry.sha256 || '')) ||
        !Number.isSafeInteger(entry.size) || entry.size <= 16 || entry.size > MAX_AAS_BYTES ||
        !Number.isInteger(entry.mapCrc) || entry.mapCrc < 0 || entry.mapCrc > 0xffffffff ||
        !Number.isInteger(entry.areas) || entry.areas < 2 ||
        !Number.isInteger(entry.reachabilities) || entry.reachabilities < 1 ||
        !profile ||
        canonicalJson(entry.compilerProfile) !== canonicalJson(compilerProfileReference(profile)) ||
        invalidSourceBinding) {
      fail(`invalid map entry: ${id || '<unnamed>'}`);
    }
    maps.add(id);
    entriesByPath.set(relative, entry);
    totalBytes += entry.size;
  }
  if (maps.size !== retailNavigationMaps.size ||
      [...retailNavigationMaps].some((id) => !maps.has(id)) || totalBytes > MAX_TOTAL_AAS_BYTES) {
    fail('map catalog differs from the exact retail navigation map set');
  }

  const resolvedPackDirectory = options.packDirectory && path.resolve(options.packDirectory);
  const clientFile = options.clientFile || (resolvedPackDirectory &&
    path.join(path.dirname(resolvedPackDirectory), 'openQ4-client_x64'));
  const gameLibFile = options.gameLibFile || (resolvedPackDirectory &&
    path.join(resolvedPackDirectory, 'game-mp_x64.so'));
  if (clientFile || gameLibFile) {
    if (!clientFile || !gameLibFile) fail('both native generator artifacts must be verified together');
    verifyArtifact(engine.client, path.resolve(clientFile), 'OpenQ4 client');
    verifyArtifact(engine.gameLib, path.resolve(gameLibFile), 'multiplayer GameLib');
  }

  if (resolvedPackDirectory) {
    const packPath = path.join(resolvedPackDirectory, pack.name);
    const actual = regularFileIdentity(packPath, 'staged pack', { read: true, maximumSize: MAX_PACK_BYTES });
    if (actual.size !== pack.size || actual.sha256 !== pack.sha256) {
      fail('staged pack is missing or differs from its manifest');
    }
    readPackEntries(actual.bytes, entriesByPath);
  }

  return {
    manifest: {
      schema: BOT_NAVIGATION_SCHEMA,
      policy: BOT_NAVIGATION_POLICY,
      retail: { ...manifest.retail },
      engine,
      compiler: {
        teamSpawnSeed: { ...BOT_NAVIGATION_TEAM_SEED_IDENTITY },
        profiles: BOT_NAVIGATION_COMPILER_PROFILES.map(cloneCompilerProfile),
      },
      pack: { ...pack },
      maps: manifest.maps.map((entry) => ({
        ...entry,
        compilerProfile: { ...entry.compilerProfile },
      })),
    },
    maps,
  };
}

export function loadBotNavigation({
  manifestFile,
  packDirectory,
  retailCatalog = null,
  clientFile = null,
  gameLibFile = null,
}) {
  let source;
  try {
    source = fs.readFileSync(manifestFile, 'utf8');
  } catch (error) {
    if (error?.code === 'ENOENT') return null;
    throw error;
  }
  return validateBotNavigationManifest(JSON.parse(source), {
    packDirectory,
    retailCatalog,
    clientFile,
    gameLibFile,
  });
}

export function botReadyMapIds(botNavigation) {
  const navigationMaps = botNavigationMapIds(botNavigation);
  return new Set(MULTIPLAYER_MAPS
    .filter((map) => navigationMaps.has(map.id) && map.botModes.length > 0)
    .map((map) => map.id));
}

export function botNavigationMapIds(botNavigation) {
  return new Set(['mp/q4web_arena', ...(botNavigation?.maps || [])]);
}
