import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

import {
  BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_PATH,
  BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE,
  BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES,
  BOT_NAVIGATION_SABOT_SOURCE_PROFILES,
  BOT_NAVIGATION_TEAM_SEED_IDENTITY,
  BOT_NAVIGATION_TEAM_SEED_PATH,
  BOT_NAVIGATION_TEAM_SEED_SOURCE,
  BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY,
  botNavigationSourceProfileForMap,
} from '../server/bot-navigation.js';
import {
  botNavigationAttemptFailed,
  botNavigationAttemptIsExactPostWriteAbort,
  botNavigationAttemptIsKnownLeak,
  botNavigationXvfbArgs,
  stageBotNavigationCompilerProfile,
  stageBotNavigationCompilerSeed,
} from '../tools/bot-navigation-compiler.mjs';
import { buildBotNavigationSmokePlan } from '../tools/bot-navigation-smoke-plan.mjs';

test('bot navigation stages a private compiler-only info_player_team seed', async (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-botnav-compiler-'));
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));

  assert.equal(BOT_NAVIGATION_TEAM_SEED_PATH, 'baseoq4/def/000_q4web_botnav_compile_openq4.def',
    'the override must sort before stock defs and retain OpenQ4 override naming');
  const seedFile = await stageBotNavigationCompilerSeed(root);
  assert.equal(seedFile, path.join(root, BOT_NAVIGATION_TEAM_SEED_PATH));
  assert.equal(fs.readFileSync(seedFile, 'utf8'), BOT_NAVIGATION_TEAM_SEED_SOURCE);
  assert.equal(fs.lstatSync(seedFile).isFile(), true);
  assert.equal(fs.lstatSync(seedFile).isSymbolicLink(), false);
  assert.equal(fs.statSync(seedFile).size, BOT_NAVIGATION_TEAM_SEED_IDENTITY.size);
  assert.match(BOT_NAVIGATION_TEAM_SEED_SOURCE, /entityDef info_player_team/);
  assert.match(BOT_NAVIGATION_TEAM_SEED_SOURCE, /"mins"\s+"-15 -15 0"/);
  assert.match(BOT_NAVIGATION_TEAM_SEED_SOURCE, /"maxs"\s+"15 15 64"/);
  assert.match(BOT_NAVIGATION_TEAM_SEED_SOURCE, /"use_aas"\s+"aas32"/);
  assert.match(BOT_NAVIGATION_TEAM_SEED_SOURCE, /"spawnclass"\s+"idPlayerStart"/);
});

test('bot navigation compiler mounts the seed root as fs_savepath', () => {
  const args = botNavigationXvfbArgs({
    client: '/runtime/openQ4-client_x64',
    runtimeRoot: '/runtime',
    saveRoot: '/temporary/save',
    map: 'mp/q4ctf1',
  });
  const valueAfter = (name) => args[args.indexOf(name) + 1];
  assert.equal(args[0], '-a');
  assert.equal(args[1], '/runtime/openQ4-client_x64');
  assert.equal(valueAfter('fs_basepath'), '/runtime');
  assert.equal(valueAfter('fs_savepath'), '/temporary/save');
  assert.equal(valueAfter('fs_game'), 'baseoq4');
  assert.equal(valueAfter('+runAAS'), 'mp/q4ctf1');
});

test('bot navigation compiler stages the explicit playerclip collision profile', async (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-botnav-profiles-'));
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
  const profileRoot = path.join(root, 'playerclip');
  const staged = await stageBotNavigationCompilerProfile(
    profileRoot,
    BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE,
  );
  assert.equal(staged.profile.id, BOT_NAVIGATION_PLAYERCLIP_COMPILER_PROFILE.id);
  assert.equal(staged.files.length, 2);
  assert.equal(
    fs.readFileSync(path.join(profileRoot, BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_PATH), 'utf8'),
    BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE,
  );
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"mins"\s+"-15 -15 0"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"maxs"\s+"15 15 76"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"usePatches"\s+"1"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"playerClipSolid"\s+"1"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"allowSwimReachabilities"\s+"1"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"allowFlyReachabilities"\s+"0"/);
  assert.doesNotMatch(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /skipLeakingFloodOrigins/);
  assert.doesNotMatch(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /generateTacticalFeatures/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"fileExtension"\s+"aas32"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"maxStepHeight"\s+"17"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"maxBarrierHeight"\s+"49"/);
  assert.match(BOT_NAVIGATION_PLAYERCLIP_OVERRIDE_SOURCE, /"maxFallHeight"\s+"2048"/);
});

test('bot navigation compiler rejects verified source profiles as compiler overrides', async (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-botnav-source-profile-'));
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
  await assert.rejects(
    stageBotNavigationCompilerProfile(root, BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES[0]),
    /unknown bot navigation compiler profile/,
  );
});

test('OpenQ4 builds bind only the compiler-only playerclip patch', () => {
  const root = path.resolve(import.meta.dirname, '..');
  const patchName = '0032-include-playerclip-in-aas-build.patch';
  const patch = fs.readFileSync(path.join(root, 'port/native/patches', patchName), 'utf8');
  assert.match(patch, /playerClipSolid = false/);
  assert.match(patch, /dict->GetBool\( "playerClipSolid", "0", playerClipSolid \);/);
  assert.doesNotMatch(patch, /Missing 'playerClipSolid'/);
  assert.match(patch, /aasSettings->playerClipSolid/);
  assert.match(patch, /contents & CONTENTS_PLAYERCLIP/);
  assert.doesNotMatch(patch, /skipLeakingFloodOrigins|ClearOccupied_r|acceptedOrigins/);
  for (const script of [
    'tools/build-native.sh',
    'tools/build-wasm.sh',
    'tools/stage-source-offer.sh',
    'tools/native-source-identity.py',
  ]) {
    assert.match(fs.readFileSync(path.join(root, script), 'utf8'), new RegExp(patchName.replaceAll('.', '\\.')));
  }
});

test('bot navigation attempt validation catches silent leaks and unusable compiler runs', () => {
  assert.equal(botNavigationAttemptFailed({
    outputReady: true,
    status: 0,
    log: '[Remove Outside]\\nreached outside from entity 74 (info_player_deathmatch)',
  }), true);
  assert.equal(botNavigationAttemptFailed({
    outputReady: true,
    status: 0,
    log: 'maps/mp/q4dm1.map has no outside',
  }), true);
  assert.equal(botNavigationAttemptFailed({ outputReady: false, status: 0 }), true);
  assert.equal(botNavigationAttemptFailed({
    outputReady: true,
    status: 0,
    log: 'writing maps/mp/q4dm3.aas32',
  }), false);
});

test('bot navigation source fallback requires the exact successful leak failure', () => {
  const leakLog = [
    '^3WARNING: ^1reached outside from entity 74 (info_player_deathmatch)',
    'maps/mp/q4dm1.map has no outside',
  ].join('\n');
  assert.equal(botNavigationAttemptIsKnownLeak({
    log: leakLog,
    outputPresent: false,
    status: 0,
    signal: null,
    errorCode: null,
  }, 'mp/q4dm1'), true);
  for (const changed of [
    { outputPresent: true },
    { status: 1 },
    { signal: 'SIGKILL' },
    { errorCode: 'ETIMEDOUT' },
    { log: 'maps/mp/q4dm1.map has no outside' },
    { log: 'WARNING: reached outside from entity 74 (info_player_deathmatch)' },
  ]) {
    assert.equal(botNavigationAttemptIsKnownLeak({
      log: leakLog,
      outputPresent: false,
      status: 0,
      signal: null,
      errorCode: null,
      ...changed,
    }, 'mp/q4dm1'), false);
  }
  assert.equal(botNavigationAttemptIsKnownLeak({
    log: leakLog,
    outputPresent: false,
    status: 0,
    signal: null,
    errorCode: null,
  }, 'mp/q4dm2'), false);
  assert.equal(botNavigationAttemptIsKnownLeak({
    log: leakLog,
    outputPresent: false,
    status: 0,
    signal: null,
    errorCode: null,
  }, '../q4dm1'), false);
});

test('bot navigation post-write source fallback requires the exact parsed SIGABRT signature', () => {
  const map = 'mp/q4ctf1';
  const abortLog = (allocatorFailure = 'corrupted double-linked list') => [
    '[Write AAS]',
    `writing maps/${map}.aas32`,
    'done.',
    '    14 seconds to create AAS',
    allocatorFailure,
    'openQ4: fatal signal SIGABRT (6), exiting without unsafe engine shutdown',
  ].join('\n');
  const attempt = {
    log: abortLog(),
    outputReady: true,
    status: 134,
    signal: null,
    errorCode: null,
  };
  assert.equal(botNavigationAttemptIsExactPostWriteAbort(attempt, map), true);
  assert.equal(botNavigationAttemptIsExactPostWriteAbort({
    ...attempt,
    log: abortLog('corrupted size vs. prev_size'),
  }, map), true);
  for (const changed of [
    { outputReady: false },
    { status: 0 },
    { status: 139 },
    { signal: 'SIGABRT' },
    { errorCode: 'ETIMEDOUT' },
    { log: abortLog().replace('[Write AAS]', '[Write BSP]') },
    { log: abortLog().replace(`maps/${map}.aas32`, 'maps/mp/q4ctf2.aas32') },
    { log: abortLog().replace('done.', 'failed.') },
    { log: abortLog().replace('14 seconds to create AAS', '14 seconds') },
    { log: abortLog('segmentation fault') },
    { log: abortLog('corrupted size vs. prev_size in fastbins') },
    { log: abortLog().replace('SIGABRT (6)', 'SIGSEGV (11)') },
    { log: `${abortLog()}\nmaps/mp/q4ctf1.map has no outside` },
  ]) {
    assert.equal(botNavigationAttemptIsExactPostWriteAbort({ ...attempt, ...changed }, map), false);
  }
  assert.equal(botNavigationAttemptIsExactPostWriteAbort(attempt, 'mp/q4ctf2'), false);
  assert.equal(botNavigationAttemptIsExactPostWriteAbort(attempt, '../q4ctf1'), false);
});

test('bot navigation source profiles are map-specific and bind every archive layer', () => {
  const q4dm1 = botNavigationSourceProfileForMap('mp/q4dm1');
  assert.equal(q4dm1.kind, 'verified-aas-source');
  assert.equal(q4dm1.mapCrc, 1013305419);
  assert.equal(q4dm1.archive.path, 'q4max-aas-01.zip');
  assert.equal(q4dm1.archive.sha256,
    '4d1232e6e466d7074538cbfb76bc94e616e2e9fc34a114ceac4080016a6de86f');
  assert.equal(q4dm1.container.path, 'q4max/q4max-aas01-base.pk4');
  assert.equal(q4dm1.entry.path, 'maps/mp/q4dm1.maxaas01');
  assert.equal(q4dm1.transform.contentMutation, 'none');

  assert.equal(BOT_NAVIGATION_Q4MAX_SOURCE_PROFILES.length, 13);
  assert.deepEqual(BOT_NAVIGATION_SABOT_SOURCE_PROFILES.map(({ map }) => map), [
    'mp/q4ctf3',
    'mp/q4xdm13',
  ]);
  assert.equal(botNavigationSourceProfileForMap('mp/q4ctf3').archive.path, 'sabota11.zip');
  const q4xdm13 = botNavigationSourceProfileForMap('mp/q4xdm13');
  assert.equal(q4xdm13.archive.path, 'sabota11.zip');
  assert.equal(q4xdm13.mapCrc, 3840712584);
  assert.equal(q4xdm13.entry.path, 'maps/mp/q4xdm13.botaas32');
  assert.equal(q4xdm13.entry.size, 1549540);
  assert.equal(q4xdm13.entry.sha256,
    'be05bb365f4b84f36c4cd1b7d98decd2d92a507e87713a25f5c394c1b9577d8c');
  assert.equal(
    q4xdm13.selectionPolicy,
    BOT_NAVIGATION_VERIFIED_SOURCE_DIRECT_POLICY,
  );
  assert.equal(botNavigationSourceProfileForMap('mp/q4xctf6'), null);
  assert.equal(botNavigationSourceProfileForMap('mp/q4xtourney1'), null);
});

test('navigation smoke loads team-only maps without enabling unqualified team bots', () => {
  const plans = buildBotNavigationSmokePlan([
    'mp/q4dm1',
    'mp/q4ctf1',
    'mp/q4xctf6',
  ]);
  assert.deepEqual(plans, [
    { id: 'mp/q4ctf1', mode: 'Team DM', botCount: 0 },
    { id: 'mp/q4xctf6', mode: 'Team DM', botCount: 0 },
    { id: 'mp/q4dm1', mode: 'DM', botCount: 1 },
  ]);
});
