#!/usr/bin/env node

import { createHash, randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import {
  chmod,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  readdir,
  rename,
  rm,
  utimes,
  writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

import {
  BOT_NAVIGATION_COMPILER_PROFILES,
  BOT_NAVIGATION_POLICY,
  BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  BOT_NAVIGATION_SCHEMA,
  BOT_NAVIGATION_TEAM_SEED_IDENTITY,
  BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY,
  RETAIL_BOT_NAVIGATION_MAP_IDS,
  botNavigationSourceProfileForMap,
  loadBotNavigation,
  parseBotNavigationAas,
  validateBotNavigationManifest,
} from '../server/bot-navigation.js';
import { loadRetailCatalog } from '../server/retail.js';
import {
  botNavigationAttemptFailed,
  botNavigationAttemptIsExactPostWriteAbort,
  botNavigationAttemptIsKnownLeak,
  botNavigationXvfbArgs,
  stageBotNavigationCompilerProfile,
} from './bot-navigation-compiler.mjs';
import { loadBotNavigationSource } from './bot-navigation-source.mjs';

const root = path.resolve(import.meta.dirname, '..');
const args = process.argv.slice(2);
const qualifiedMaps = RETAIL_BOT_NAVIGATION_MAP_IDS;
const verifyOnly = args.length === 1 && args[0] === '--verify';
const probeMap = args.length === 2 && args[0] === '--probe' ? args[1] : null;
const probeSourceMap = args.length === 2 && args[0] === '--probe-source' ? args[1] : null;
if ((args.length > 0 && !verifyOnly && !probeMap && !probeSourceMap) ||
    ((probeMap || probeSourceMap) && !qualifiedMaps.includes(probeMap || probeSourceMap))) {
  throw new Error(
    'usage: node tools/stage-retail-botnav.mjs [--verify | --probe <retail-map> | --probe-source <retail-map>]',
  );
}

const runtimeRoot = path.resolve(process.env.Q4_NATIVE_RUNTIME || path.join(root, 'runtime/native'));
const client = path.resolve(process.env.Q4_CLIENT_BIN || path.join(runtimeRoot, 'openQ4-client_x64'));
const gameLib = path.join(runtimeRoot, 'baseoq4/game-mp_x64.so');
const packDirectory = path.resolve(process.env.Q4_BOT_NAVIGATION_PACK_DIR || path.join(runtimeRoot, 'baseoq4'));
const manifestFile = path.resolve(process.env.Q4_BOT_NAVIGATION_MANIFEST ||
  path.join(root, 'runtime/retail-botnav-manifest.json'));
const retailDirectory = path.resolve(process.env.Q4_RETAIL_DIR || path.join(root, 'assets-src/gog/q4base'));
const retailManifestFile = path.resolve(process.env.Q4_RETAIL_MANIFEST || path.join(root, 'runtime/retail-manifest.json'));
const logDirectory = path.resolve(process.env.Q4_BOT_NAVIGATION_LOG_DIR || path.join(root, 'runtime/retail-botnav-logs'));
const sourceDirectory = path.resolve(process.env.Q4_BOT_NAVIGATION_SOURCE_DIR ||
  path.join(root, 'build/native-aas'));

async function sha256(file) {
  const hash = createHash('sha256');
  for await (const chunk of createReadStream(file)) hash.update(chunk);
  return hash.digest('hex');
}

async function regularFile(file) {
  const info = await lstat(file).catch(() => null);
  return info?.isFile() && !info.isSymbolicLink() ? info : null;
}

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

async function compileAttempt(temporary, map, profile) {
  const slug = map.replaceAll('/', '_');
  const attemptRoot = path.join(temporary, 'attempts', slug, profile.id);
  const saveRoot = path.join(attemptRoot, 'save');
  const home = path.join(attemptRoot, 'home');
  const output = path.join(saveRoot, 'baseoq4', 'maps', `${map}.aas32`);
  await rm(attemptRoot, { recursive: true, force: true });
  await stageBotNavigationCompilerProfile(saveRoot, profile);
  await Promise.all([mkdir(path.dirname(output), { recursive: true }), mkdir(home, { recursive: true })]);

  const result = spawnSync('xvfb-run', botNavigationXvfbArgs({
    client,
    runtimeRoot,
    saveRoot,
    map,
  }), {
    cwd: runtimeRoot,
    encoding: 'utf8',
    env: { ...process.env, HOME: home },
    maxBuffer: 256 * 1024 * 1024,
    timeout: 180_000,
  });
  let log = `${result.stdout || ''}\n${result.stderr || ''}\n` +
    `stage-retail-botnav: status=${result.status} signal=${result.signal || 'none'} error=${result.error?.message || 'none'}\n`;
  const info = await regularFile(output);
  let graph = null;
  let source = null;
  let validationError = null;
  if (info) {
    try {
      source = await readFile(output);
      graph = parseBotNavigationAas(source, map);
    } catch (error) {
      validationError = error;
      log += `stage-retail-botnav: output validation failed: ${error?.message || error}\n`;
    }
  }
  const logFile = path.join(logDirectory, `${slug}.${profile.id}.log`);
  await writeFile(logFile, log, { mode: 0o600 });
  const failed = botNavigationAttemptFailed({
    log,
    outputReady: Boolean(info && graph && source),
    status: result.status,
    signal: result.signal || null,
    errorCode: result.error?.code || null,
  });
  return {
    profile,
    log,
    logFile,
    failed,
    outputPresent: Boolean(info),
    outputReady: Boolean(info && graph && source),
    status: result.status,
    signal: result.signal || null,
    errorCode: result.error?.code || null,
    validationError,
    id: map,
    path: `maps/${map}.aas32`,
    ...(info && source && graph ? {
      size: info.size,
      sha256: createHash('sha256').update(source).digest('hex'),
      ...graph,
    } : {}),
    output,
  };
}

async function writeCombinedLog(map, attempts) {
  const slug = map.replaceAll('/', '_');
  const combined = attempts.map((attempt) =>
    `===== ${attempt.profile.id} =====\n${attempt.log}`).join('\n');
  await writeFile(path.join(logDirectory, `${slug}.log`), combined, { mode: 0o600 });
}

async function sourceAttempt(temporary, map, profile) {
  const slug = map.replaceAll('/', '_');
  const output = path.join(temporary, 'source-output', 'maps', `${map}.aas32`);
  let log = `bot-navigation-source: profile=${profile?.id || 'none'} map=${map}\n`;
  let validationError = null;
  let source = null;
  let graph = null;
  try {
    if (!profile || profile.map !== map) throw new Error(`${map}: no approved source profile`);
    ({ source } = await loadBotNavigationSource(profile, {
      sourceDirectory,
      temporaryDirectory: temporary,
    }));
    graph = parseBotNavigationAas(source, map);
    if (graph.mapCrc !== profile.mapCrc) {
      throw new Error(`${map}: source map CRC ${graph.mapCrc} differs from approved ${profile.mapCrc}`);
    }
    await mkdir(path.dirname(output), { recursive: true });
    await writeFile(output, source, { flag: 'wx', mode: 0o600 });
    log += `bot-navigation-source: verified size=${source.length} sha256=${profile.entry.sha256} ` +
      `mapCrc=${graph.mapCrc} areas=${graph.areas} reachabilities=${graph.reachabilities}\n`;
  } catch (error) {
    validationError = error;
    log += `bot-navigation-source: failed: ${error?.message || error}\n`;
  }
  const logFile = path.join(logDirectory, `${slug}.${profile?.id || 'unapproved-source'}.log`);
  await writeFile(logFile, log, { mode: 0o600 });
  return {
    profile,
    log,
    logFile,
    failed: Boolean(validationError),
    outputPresent: Boolean(source),
    outputReady: Boolean(source && graph && !validationError),
    status: validationError ? 1 : 0,
    signal: null,
    errorCode: null,
    validationError,
    id: map,
    path: `maps/${map}.aas32`,
    ...(source && graph && !validationError ? {
      size: source.length,
      sha256: createHash('sha256').update(source).digest('hex'),
      ...graph,
    } : {}),
    output,
  };
}

async function compileMap(temporary, map) {
  const attempts = [];
  const sourceProfile = botNavigationSourceProfileForMap(map);
  let selected;
  if (sourceProfile?.selectionPolicy === BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY) {
    selected = await sourceAttempt(temporary, map, sourceProfile);
    attempts.push(selected);
  } else {
    const primary = await compileAttempt(temporary, map, BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE);
    attempts.push(primary);
    selected = primary;
    if (primary.failed) {
      const knownLeak = botNavigationAttemptIsKnownLeak(primary, map);
      const exactPostWriteAbort = botNavigationAttemptIsExactPostWriteAbort(primary, map);
      if (!knownLeak && !exactPostWriteAbort) {
        await writeCombinedLog(map, attempts);
        throw new Error(`${map}: primary AAS compiler profile failed without an approved fail-closed signature; ` +
          `${primary.validationError?.message || `inspect ${primary.logFile}`}`);
      }
      if (!sourceProfile) {
        await writeCombinedLog(map, attempts);
        throw new Error(`${map}: approved compiler failure has no CRC-bound source fallback`);
      }
      selected = await sourceAttempt(temporary, map, sourceProfile);
      attempts.push(selected);
    }
  }

  await writeCombinedLog(map, attempts);
  if (selected.failed) {
    const detail = attempts.map(({ profile, logFile, validationError }) =>
      `${profile.id}: ${validationError?.message || `inspect ${logFile}`}`).join('; ');
    throw new Error(`${map}: eligible AAS compiler profiles failed; ${detail}`);
  }
  return {
    id: selected.id,
    path: selected.path,
    size: selected.size,
    sha256: selected.sha256,
    mapCrc: selected.mapCrc,
    areas: selected.areas,
    reachabilities: selected.reachabilities,
    compilerProfile: compilerProfileReference(selected.profile),
    output: selected.output,
  };
}

function runZip(output, entries, cwd) {
  const result = spawnSync('zip', ['-qX', '-9', output, ...entries], {
    cwd,
    encoding: 'utf8',
    maxBuffer: 16 * 1024 * 1024,
  });
  if (result.error || result.status !== 0) {
    throw new Error(`zip failed: ${result.error?.message || result.stderr || `exit ${result.status}`}`);
  }
}

const retailCatalog = loadRetailCatalog({
  enabled: true,
  directory: retailDirectory,
  manifestFile: retailManifestFile,
});

try {
  if (verifyOnly) {
    const staged = loadBotNavigation({
      manifestFile,
      packDirectory,
      retailCatalog,
      clientFile: client,
      gameLibFile: gameLib,
    });
    if (!staged) throw new Error(`bot navigation manifest is missing: ${manifestFile}`);
    const profileCounts = Object.fromEntries(BOT_NAVIGATION_COMPILER_PROFILES.map((profile) => [
      profile.id,
      staged.manifest.maps.filter((map) => map.compilerProfile.id === profile.id).length,
    ]));
    process.stdout.write(`Verified ${staged.manifest.pack.name}: ${staged.maps.size} retail navigation maps; ` +
      `${JSON.stringify(profileCounts)}\n`);
    process.exitCode = 0;
  } else {
    if (!probeSourceMap && (!await regularFile(client) || !await regularFile(gameLib))) {
      throw new Error('native OpenQ4 client or multiplayer GameLib is missing; run tools/build-native.sh first');
    }
    await Promise.all([mkdir(path.dirname(manifestFile), { recursive: true }), mkdir(packDirectory, { recursive: true }),
      mkdir(logDirectory, { recursive: true })]);
    const temporary = await mkdtemp(path.join(root, 'runtime/.retail-botnav-build-'));
    try {
      if (probeSourceMap) {
        const profile = botNavigationSourceProfileForMap(probeSourceMap);
        process.stdout.write(`Probing approved bot navigation source for ${probeSourceMap}...\n`);
        const map = await sourceAttempt(temporary, probeSourceMap, profile);
        await writeCombinedLog(probeSourceMap, [map]);
        if (map.failed) throw map.validationError;
        process.stdout.write(`Source probe PASS ${probeSourceMap}: ${map.areas} areas, ` +
          `${map.reachabilities} reachabilities, profile=${map.compilerProfile?.id || profile.id}\n`);
        process.exitCode = 0;
      } else if (probeMap) {
        process.stdout.write(`Probing bot navigation for ${probeMap}...\n`);
        const map = await compileMap(temporary, probeMap);
        process.stdout.write(`Probe PASS ${probeMap}: ${map.areas} areas, ${map.reachabilities} reachabilities, ` +
          `profile=${map.compilerProfile.id}\n`);
        process.exitCode = 0;
      } else {
        const maps = [];
        for (const map of qualifiedMaps) {
          process.stdout.write(`Compiling bot navigation for ${map}...\n`);
          maps.push(await compileMap(temporary, map));
        }

        const stage = path.join(temporary, 'pack');
        const fixedTime = new Date('2000-01-01T00:00:00.000Z');
        for (const map of maps) {
          const target = path.join(stage, map.path);
          await mkdir(path.dirname(target), { recursive: true });
          await copyFile(map.output, target);
          await chmod(target, 0o644);
          await utimes(target, fixedTime, fixedTime);
        }
        const temporaryPack = path.join(temporary, 'retail-botnav.pk4');
        const entries = maps.map((map) => map.path).sort((left, right) => left.localeCompare(right, 'en'));
        runZip(temporaryPack, entries, stage);
        const packHash = await sha256(temporaryPack);
        const packName = `q4retail_botnav-${packHash.slice(0, 12)}.pk4`;
        const packInfo = await lstat(temporaryPack);
        const stagedPack = path.join(packDirectory, packName);
        const packTemporary = `${stagedPack}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
        await copyFile(temporaryPack, packTemporary);
        await chmod(packTemporary, 0o644);
        await rename(packTemporary, stagedPack);

        const manifest = {
          schema: BOT_NAVIGATION_SCHEMA,
          policy: BOT_NAVIGATION_POLICY,
          retail: {
            revision: retailCatalog.manifest.revision,
            fingerprint: retailCatalog.manifest.fingerprint,
          },
          engine: {
            client: { size: (await lstat(client)).size, sha256: await sha256(client) },
            gameLib: { size: (await lstat(gameLib)).size, sha256: await sha256(gameLib) },
          },
          compiler: {
            teamSpawnSeed: { ...BOT_NAVIGATION_TEAM_SEED_IDENTITY },
            profiles: BOT_NAVIGATION_COMPILER_PROFILES.map((profile) => ({
              ...profile,
              ...(profile.override ? { override: { ...profile.override } } : {}),
            })),
          },
          pack: { name: packName, size: packInfo.size, sha256: packHash },
          maps: maps.map(({ output: _output, ...map }) => map),
        };
        validateBotNavigationManifest(manifest, {
          packDirectory,
          retailCatalog,
          clientFile: client,
          gameLibFile: gameLib,
        });

        const manifestTemporary = `${manifestFile}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
        await writeFile(manifestTemporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
        await chmod(manifestTemporary, 0o644);
        await rename(manifestTemporary, manifestFile);

        for (const name of await readdir(packDirectory)) {
          if (/^q4retail_botnav-[a-f0-9]{12}\.pk4$/.test(name) && name !== packName) {
            await rm(path.join(packDirectory, name), { force: true });
          }
        }
        const fallbackMaps = maps
          .filter((map) => {
            const profile = BOT_NAVIGATION_COMPILER_PROFILES.find(
              ({ id }) => id === map.compilerProfile.id,
            );
            return profile?.kind === 'verified-aas-source';
          })
          .map((map) => map.id);
        process.stdout.write(`Staged ${packName}: ${maps.length} maps, ${packInfo.size} bytes; ` +
          `verified-source-fallback=${fallbackMaps.length} [${fallbackMaps.join(', ')}]\n`);
      }
    } finally {
      if (process.env.Q4_BOT_NAVIGATION_KEEP_BUILD !== '1') await rm(temporary, { recursive: true, force: true });
    }
  }
} finally {
  retailCatalog.close();
}
