#!/usr/bin/env node

import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

import { loadBotNavigation } from '../server/bot-navigation.js';
import { loadRetailCatalog } from '../server/retail.js';
import {
  BotMotionGate,
  parseBotStatusBlock,
} from './bot-navigation-motion-gate.mjs';
import { buildBotNavigationSmokePlan } from './bot-navigation-smoke-plan.mjs';

const root = path.resolve(import.meta.dirname, '..');
const runtimeRoot = path.resolve(process.env.Q4_NATIVE_RUNTIME || path.join(root, 'runtime/native'));
const manifestFile = path.resolve(process.env.Q4_BOT_NAVIGATION_MANIFEST ||
  path.join(root, 'runtime/retail-botnav-manifest.json'));
const packDirectory = path.resolve(process.env.Q4_BOT_NAVIGATION_PACK_DIR || path.join(runtimeRoot, 'baseoq4'));
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 logFile = path.resolve(process.env.Q4_BOT_NAVIGATION_SMOKE_LOG || path.join(root, 'runtime/retail-botnav-smoke.log'));
const port = Number(process.env.Q4_BOT_NAVIGATION_SMOKE_PORT || 27974);
const timeoutMs = Number(process.env.Q4_BOT_NAVIGATION_SMOKE_TIMEOUT_MS || 120_000);
const motionTimeoutMs = Number(process.env.Q4_BOT_NAVIGATION_MOTION_TIMEOUT_MS || 45_000);

if (!Number.isInteger(port) || port < 1024 || port > 65535 ||
    !Number.isInteger(timeoutMs) || timeoutMs < 10_000 || timeoutMs > 300_000 ||
    !Number.isInteger(motionTimeoutMs) || motionTimeoutMs < 5_000 || motionTimeoutMs > 300_000) {
  throw new Error(
    'invalid Q4_BOT_NAVIGATION_SMOKE_PORT, Q4_BOT_NAVIGATION_SMOKE_TIMEOUT_MS, ' +
    'or Q4_BOT_NAVIGATION_MOTION_TIMEOUT_MS'
  );
}

const retailCatalog = loadRetailCatalog({
  enabled: true,
  directory: retailDirectory,
  manifestFile: retailManifestFile,
});
const botNavigation = loadBotNavigation({ manifestFile, packDirectory, retailCatalog });
if (!botNavigation) throw new Error(`bot navigation manifest is missing: ${manifestFile}`);
const plans = buildBotNavigationSmokePlan(botNavigation.manifest.maps.map((entry) => entry.id));
const temporary = await mkdtemp(path.join(root, 'runtime/retail-botnav-smoke-'));
const reloadConfigFile = path.join(temporary, 'save/baseoq4/autoexec.cfg');
await mkdir(path.dirname(logFile), { recursive: true });
await mkdir(path.dirname(reloadConfigFile), { recursive: true });

function reloadConfig(plan) {
  return [
    'set net_ip 127.0.0.1',
    `set net_port ${port}`,
    'set net_LANServer 1',
    'set si_pure 0',
    'set net_serverAllowDataChecksumMismatch 1',
    'set net_serverDemo 0',
    'set si_name "Q4RetailBotNavigationSmoke"',
    'set si_maxPlayers 4',
    'set si_warmup 0',
    `set bot_minplayers ${plan.botCount}`,
    `set si_map ${plan.id}`,
    `set si_gameType "${plan.mode}"`,
    '',
  ].join('\n');
}

async function stageReloadConfig(plan) {
  await writeFile(reloadConfigFile, reloadConfig(plan), { mode: 0o600 });
}

let output = '';
let exit = null;
const firstPlan = plans[0];
await stageReloadConfig(firstPlan);
const child = spawn(path.join(runtimeRoot, 'openQ4-ded_x64'), [
  '+set', 'fs_basepath', runtimeRoot,
  '+set', 'fs_cdpath', runtimeRoot,
  '+set', 'fs_savepath', path.join(temporary, 'save'),
  '+set', 'fs_game', 'baseoq4',
  '+set', 'fs_caseSensitiveOS', '0',
  '+set', 's_noSound', '1',
  '+set', 'net_ip', '127.0.0.1',
  '+set', 'net_port', String(port),
  '+set', 'net_LANServer', '1',
  '+set', 'si_pure', '0',
  '+set', 'net_serverAllowDataChecksumMismatch', '1',
  '+set', 'net_serverDemo', '0',
  '+set', 'si_name', 'Q4RetailBotNavigationSmoke',
  '+set', 'si_maxPlayers', '4',
  '+set', 'si_warmup', '0',
  '+spawnServer', firstPlan.id,
], {
  cwd: runtimeRoot,
  env: { ...process.env, HOME: path.join(temporary, 'home') },
  stdio: ['pipe', 'pipe', 'pipe'],
});

for (const stream of [child.stdout, child.stderr]) {
  stream.setEncoding('utf8');
  stream.on('data', (chunk) => { output += chunk; });
}
child.once('exit', (code, signal) => { exit = { code, signal }; });
child.once('error', (error) => { exit = { error }; });

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitFor(predicate, offset, label, limit = timeoutMs) {
  const deadline = Date.now() + limit;
  while (Date.now() < deadline) {
    const text = output.slice(offset);
    if (predicate(text)) return text;
    if (exit) throw new Error(`${label}: dedicated server exited (${exit.error?.message || exit.signal || exit.code})`);
    await sleep(100);
  }
  throw new Error(`${label}: timed out after ${limit}ms`);
}

let botStatusAttempt = 0;

async function requestBotStatus(map, label, includeServerInfo = false) {
  const begin = `BOTNAV_${map.replaceAll('/', '_')}_${++botStatusAttempt}_BEGIN`;
  const end = `${begin}_END`;
  const offset = output.length;
  child.stdin.write([
    `echo ${begin}`,
    ...(includeServerInfo ? ['serverInfo'] : []),
    'botStatus',
    `echo ${end}`,
    '',
  ].join('\n'));
  const text = await waitFor((value) => value.includes(end), offset, `${map} ${label}`, 10_000);
  const beginIndex = text.indexOf(begin);
  const endIndex = text.indexOf(end, beginIndex + begin.length);
  if (beginIndex < 0 || endIndex < 0) throw new Error(`${map}: incomplete ${label} block`);
  const block = text.slice(beginIndex + begin.length, endIndex);
  return { block, status: parseBotStatusBlock(block) };
}

function assertServerConfiguration(plan, block) {
  const portMatch = block.match(/^server .* IP = .*:(\d+)\s*$/m);
  const values = new Map();
  for (const line of block.split(/\r?\n/)) {
    const separator = line.indexOf(' = ');
    if (separator > 0) values.set(line.slice(0, separator).toLowerCase(), line.slice(separator + 3).trim());
  }

  const actualPort = Number(portMatch?.[1]);
  if (actualPort !== port) {
    throw new Error(`${plan.id}: ReloadEngine changed net_port from ${port} to ${portMatch?.[1] || 'unknown'}`);
  }
  if (values.get('si_gametype') !== plan.mode) {
    throw new Error(
      `${plan.id}: ReloadEngine changed si_gameType from ${plan.mode} to ` +
      `${values.get('si_gametype') || 'unknown'}`
    );
  }
  if (values.get('bot_minplayers') !== String(plan.botCount)) {
    throw new Error(
      `${plan.id}: ReloadEngine changed bot_minplayers from ${plan.botCount} to ` +
      `${values.get('bot_minplayers') || 'unknown'}`
    );
  }
}

async function waitForBot(map) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const result = await requestBotStatus(map, 'bot status', true);
    const { summary } = result.status;
    if (summary.slots === 1 && summary.entities === 1 && summary.active === 1 &&
        result.status.bots.size === 1 && /^client +\d+:/m.test(result.block)) return result;
    await sleep(500);
  }
  throw new Error(`${map}: bot did not become active`);
}

async function waitForBotMotion(map, initialStatus) {
  const gate = new BotMotionGate();
  const initialBot = [...initialStatus.bots.values()][0];
  if (initialBot) gate.observe(initialBot);

  const deadline = Date.now() + motionTimeoutMs;
  while (Date.now() < deadline) {
    const { status } = await requestBotStatus(map, 'bot movement');
    const { summary } = status;
    if (summary.slots !== 1 || summary.entities !== 1 || summary.active !== 1 ||
        status.bots.size !== 1) {
      await sleep(250);
      continue;
    }
    const evidence = gate.observe([...status.bots.values()][0]);
    if (evidence) return evidence;
    await sleep(250);
  }
  throw new Error(`${map}: bot did not prove XY movement: ${JSON.stringify(gate.describe())}`);
}

try {
  for (let index = 0; index < plans.length; index += 1) {
    const plan = plans[index];
    const map = plan.id;
    const offset = index === 0 ? 0 : output.length;
    if (index > 0) {
      await stageReloadConfig(plan);
      child.stdin.write([
        `set bot_minplayers ${plan.botCount}`,
        `set si_gameType "${plan.mode}"`,
        `set si_map ${map}`,
        `spawnServer ${map}`,
        '',
      ].join('\n'));
    }
    const loaded = await waitFor((text) =>
      text.includes(`Map: ${map}`) &&
      text.includes(`loading maps/${map}.aas32`) &&
      text.includes(`msec to load ${map}`) &&
      text.includes('net_LANServer is enabled. Not sending heartbeats'), offset, `${map} load`);
    if (/no valid AAS|Expected one of these AAS files|fatal signal|double free|segmentation fault/i.test(loaded)) {
      throw new Error(`${map}: runtime reported invalid navigation or a fatal failure`);
    }
    const configuration = await requestBotStatus(map, 'server configuration', true);
    assertServerConfiguration(plan, configuration.block);
    if (plan.botCount > 0) {
      const active = configuration.status.summary.active === 1 &&
        configuration.status.bots.size === 1 &&
        /^client +\d+:/m.test(configuration.block)
        ? configuration
        : await waitForBot(map);
      if (plan.mode === 'DM') {
        const movement = await waitForBotMotion(map, active.status);
        process.stdout.write(
          `PASS ${map}: selected map, AAS loaded, DM bot moved ` +
          `${movement.distanceXY.toFixed(1)} XY units in area ${movement.area}\n`
        );
      } else {
        process.stdout.write(`PASS ${map}: selected map, AAS loaded, bot active\n`);
      }
    } else {
      process.stdout.write(`PASS ${map}: selected map, AAS loaded; team bot AI remains disabled\n`);
    }
  }
  child.stdin.write('quit\n');
  await waitFor(() => exit !== null, output.length, 'dedicated shutdown', 15_000);
  if (exit?.code !== 0) throw new Error(`dedicated shutdown failed (${exit?.signal || exit?.code})`);
} finally {
  if (!exit) {
    child.kill('SIGTERM');
    const stopDeadline = Date.now() + 5_000;
    while (!exit && Date.now() < stopDeadline) await sleep(100);
    if (!exit) child.kill('SIGKILL');
    const killDeadline = Date.now() + 2_000;
    while (!exit && Date.now() < killDeadline) await sleep(100);
  }
  await writeFile(logFile, output.replaceAll('\r', ''), { mode: 0o600 });
  await rm(temporary, { recursive: true, force: true });
  retailCatalog.close();
}

process.stdout.write(`Retail bot navigation smoke passed for ${plans.length} maps; evidence: ${logFile}\n`);
