import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

import {
  BOT_MOTION_MIN_XY,
  BOT_MOTION_TELEPORT_XY,
  BotMotionGate,
  parseBotStatusBlock,
} from '../tools/bot-navigation-motion-gate.mjs';

const root = new URL('../', import.meta.url);
const statusPatchName = '0033-report-bot-navigation-status.patch';
const lifecyclePatchName = '0034-enter-bots-through-player-lifecycle.patch';

function line({
  client = 0,
  health = 100,
  spectating = 0,
  respawning = 0,
  teleporting = 0,
  spawnAt = 1000,
  minRespawnTime = 0,
  area = 7,
  x = 10,
  y = 20,
  z = 30,
  forward = 127,
  right = 0,
} = {}) {
  return [
    `botNavigation client=${client} health=${health} spectating=${spectating}`,
    `respawning=${respawning} teleporting=${teleporting} spawnAt=${spawnAt}`,
    `minRespawnTime=${minRespawnTime} area=${area}`,
    `origin=${x.toFixed(3)},${y.toFixed(3)},${z.toFixed(3)}`,
    `forward=${forward} right=${right}`,
  ].join(' ');
}

function sample(overrides = {}) {
  const status = parseBotStatusBlock(`botStatus slots=1 entities=1 active=1\n${line(overrides)}\n`);
  return status.bots.get(overrides.client ?? 0);
}

function patchList(source, variable) {
  const body = source.match(new RegExp(`${variable}=\\(([\\s\\S]*?)\\n\\)`));
  assert.ok(body, `missing ${variable}`);
  return [...body[1].matchAll(/(\d{4}-[^/"']+\.patch)/g)].map((match) => match[1]);
}

test('bot navigation parser accepts the exact bounded telemetry contract', () => {
  const parsed = parseBotStatusBlock([
    'unrelated console output',
    'botStatus slots=1 entities=1 active=1',
    line({ x: -12.5, y: 42.25, z: 8, forward: -127, right: 64 }),
  ].join('\r\n'));
  assert.deepEqual(parsed.summary, { slots: 1, entities: 1, active: 1 });
  assert.deepEqual(parsed.bots.get(0), {
    client: 0,
    health: 100,
    spectating: false,
    respawning: false,
    teleporting: false,
    spawnAt: 1000,
    minRespawnTime: 0,
    area: 7,
    origin: [-12.5, 42.25, 8],
    forward: -127,
    right: 64,
  });
});

test('bot navigation parser rejects malformed, duplicate, and unbounded data', () => {
  assert.throws(
    () => parseBotStatusBlock(`botStatus slots=1 entities=1 active=1\n${line()}\n${line()}\n`),
    /duplicate botNavigation client/
  );
  assert.throws(
    () => parseBotStatusBlock(`botStatus slots=1 entities=1 active=1\n${line().replace('area=7', 'area=-1')}\n`),
    /malformed botNavigation/
  );
  assert.throws(
    () => parseBotStatusBlock(`botStatus slots=1 entities=1 active=1\n${line().replace('forward=127', 'forward=128')}\n`),
    /invalid bot forward input/
  );
  assert.throws(
    () => parseBotStatusBlock(`botStatus slots=1 entities=1 active=1\n${line().replace('10.000', '10000001.000')}\n`),
    /invalid bot origin x/
  );
  assert.throws(
    () => parseBotStatusBlock(`botStatus slots=1 entities=0 active=1\n${line()}\n`),
    /differs from entity count/
  );
});

test('motion gate requires commanded, same-life XY displacement above the conservative threshold', () => {
  assert.equal(BOT_MOTION_MIN_XY, 64);
  const gate = new BotMotionGate();
  assert.equal(gate.observe(sample({ x: 0, y: 0, forward: 0 })), null);
  assert.equal(gate.observe(sample({ x: 1, y: 1, forward: 0 })), null);
  assert.equal(gate.observe(sample({ x: 63, y: 0, forward: 127 })), null);
  const evidence = gate.observe(sample({ x: 65, y: 0, forward: 0 }));
  assert.equal(evidence.client, 0);
  assert.ok(evidence.distanceXY >= BOT_MOTION_MIN_XY);
  assert.equal(evidence.area, 7);
});

test('motion gate resets baselines across respawn and teleport boundaries', () => {
  const gate = new BotMotionGate();
  gate.observe(sample({ x: 0, y: 0 }));
  gate.observe(sample({ x: 40, y: 0 }));
  assert.equal(gate.observe(sample({ x: 120, y: 0, respawning: 1 })), null);
  assert.equal(gate.observe(sample({ x: 120, y: 0, minRespawnTime: 5000 })), null);
  assert.equal(gate.observe(sample({ x: 170, y: 0, minRespawnTime: 5000 })), null);
  assert.equal(gate.observe(sample({ x: 900, y: 0, minRespawnTime: 5000 })), null);
  assert.equal(gate.describe().lastResetReason, 'teleport-discontinuity');
  assert.ok(BOT_MOTION_TELEPORT_XY < 900 - 170);
  assert.equal(gate.observe(sample({ x: 900, y: 0, minRespawnTime: 5000, teleporting: 1 })), null);
  assert.equal(gate.describe().lastResetReason, 'teleporting');
});

test('motion gate rejects PM_SPECTATOR movement until the bot enters normal play', () => {
  const gate = new BotMotionGate();
  assert.equal(gate.observe(sample({ x: 0, y: 0, spectating: 1 })), null);
  assert.equal(gate.observe(sample({ x: 500, y: 0, spectating: 1 })), null);
  assert.equal(gate.describe().samples, 0);
  assert.equal(gate.describe().lastResetReason, 'inactive-or-respawning');

  assert.equal(gate.observe(sample({ x: 500, y: 0, spectating: 0 })), null);
  const evidence = gate.observe(sample({ x: 565, y: 0, spectating: 0 }));
  assert.ok(evidence);
  assert.ok(evidence.distanceXY >= BOT_MOTION_MIN_XY);
});

test('native patch reports navigation telemetry without changing the botStatus summary', async () => {
  const [patch, summaryPatch] = await Promise.all([
    readFile(new URL(`port/native/patches/${statusPatchName}`, root), 'utf8'),
    readFile(new URL('port/native/patches/0019-report-registered-bot-status.patch', root), 'utf8'),
  ]);
  assert.match(summaryPatch, /"botStatus slots=%d entities=%d active=%d\\n"/);
  assert.doesNotMatch(patch, /^[-+].*"botStatus slots=/m);
  assert.match(patch, /gameLocal\.PrintBotNavigationStatus\(\);/);
  assert.match(patch, /botNavigation client=%d health=%d spectating=%d respawning=%d teleporting=%d/);
  assert.match(patch, /spawnAt=%d minRespawnTime=%d area=%d origin=%\.3f,%\.3f,%\.3f forward=%d right=%d/);
  assert.match(patch, /botAAS->AdjustPositionAndGetArea\( areaOrigin \)/);
  assert.match(patch, /bot->IsRespawning\(\)/);
  assert.match(patch, /\( bot->IsInTeleport\(\) \|\| bot->pfl\.teleport \)/);
  assert.match(patch, /bot->spawnedTime/);
  assert.match(patch, /bot->minRespawnTime/);
  assert.match(patch, /bot->usercmd\.forwardmove/);
  assert.match(patch, /bot->usercmd\.rightmove/);
});

test('native bot lifecycle enters real bot clients into play without changing fake-client semantics', async () => {
  const [patch, mapChangePatch, gameLocal] = await Promise.all([
    readFile(new URL(`port/native/patches/${lifecyclePatchName}`, root), 'utf8'),
    readFile(new URL('port/native/patches/0018-preserve-bot-userinfo-across-map-change.patch', root), 'utf8'),
    readFile(new URL('vendor/openq4-game/src/mpgame/Game_local.cpp', root), 'utf8'),
  ]);
  const setUserInfo = gameLocal.slice(
    gameLocal.indexOf('const idDict* idGameLocal::SetUserInfo'),
    gameLocal.indexOf('idGameLocal::GetUserInfo', gameLocal.indexOf('const idDict* idGameLocal::SetUserInfo')),
  );
  assert.ok(setUserInfo.indexOf('UserInfoChanged()') < setUserInfo.indexOf('mpGame.EnterGame( clientNum )'));
  assert.match(
    mapChangePatch,
    /\+\t\t\tgame->ServerClientBegin\( i, true, botCharacter \);\n\+\t\t\tSendUserInfoBroadcast\( i, botUserInfo\[i\], true \);/
  );

  const botClientStart = patch.indexOf('+\tif ( IsBot() ) {');
  const botClientEnd = patch.indexOf('+\t\treturn false;\n+\t}', botClientStart);
  assert.ok(botClientStart >= 0 && botClientEnd > botClientStart,
    'lifecycle patch must add a dedicated bot-client userinfo branch');
  const botClientBlock = patch.slice(botClientStart, botClientEnd);
  assert.match(botClientBlock, /\+\t\tinitialJoinPending = false;/);
  assert.match(botClientBlock, /\+\t\tinitialJoinMenuPending = false;/);
  assert.match(botClientBlock, /\+\t\twantSpectate = false;/);
  assert.match(
    botClientBlock,
    /\+\t\tif \( !gameLocal\.isClient && spectating \) \{\n\+\t\t\tforceRespawn = true;/
  );
  assert.doesNotMatch(botClientBlock, /^\+\s*wantSpectate = true;/m);
  assert.match(
    patch,
    / \tif \( IsFakeClient\(\) \) \{\n \t\tshowWeaponViewModel = cvarSystem->GetCVarBool\( "ui_showGun" \);\n \t\twantSpectate = true;/
  );
});

test('every reported player field is public in the pinned GameLib header', async () => {
  const [playerHeader, entityHeader] = await Promise.all([
    readFile(new URL('vendor/openq4-game/src/mpgame/Player.h', root), 'utf8'),
    readFile(new URL('vendor/openq4-game/src/mpgame/Entity.h', root), 'utf8'),
  ]);
  const playerPublic = playerHeader.slice(
    playerHeader.indexOf('class idPlayer : public idActor'),
    playerHeader.indexOf('\nprotected:', playerHeader.indexOf('class idPlayer : public idActor')),
  );
  for (const field of [
    'usercmd_t',
    'playerFlags_s',
    'spectating;',
    'spawnedTime;',
    'minRespawnTime;',
    'IsRespawning',
    'IsInTeleport',
  ]) {
    assert.ok(playerPublic.includes(field), `${field} is not in idPlayer's public surface`);
  }
  const entityPublic = entityHeader.slice(
    entityHeader.indexOf('class idEntity : public idClass'),
    entityHeader.indexOf('\nprotected:', entityHeader.indexOf('class idEntity : public idClass')),
  );
  assert.match(entityPublic, /int\s+health;/);
});

test('all release/source identity paths include navigation status after the bot fixes', async () => {
  const [native, wasm, sourceOffer, identity, smoke] = await Promise.all([
    readFile(new URL('tools/build-native.sh', root), 'utf8'),
    readFile(new URL('tools/build-wasm.sh', root), 'utf8'),
    readFile(new URL('tools/stage-source-offer.sh', root), 'utf8'),
    readFile(new URL('tools/native-source-identity.py', root), 'utf8'),
    readFile(new URL('tools/smoke-retail-botnav.mjs', root), 'utf8'),
  ]);
  for (const script of [native, wasm, sourceOffer]) {
    const patches = patchList(script, 'GAMELIBS_PATCH_FILES');
    const statusIndex = patches.indexOf(statusPatchName);
    const lifecycleIndex = patches.indexOf(lifecyclePatchName);
    assert.equal(patches.filter((name) => name === statusPatchName).length, 1);
    assert.equal(patches.filter((name) => name === lifecyclePatchName).length, 1);
    assert.equal(lifecycleIndex, statusIndex + 1);
    assert.ok(!patchList(script, 'OPENQ4_PATCH_FILES').includes(statusPatchName));
    assert.ok(!patchList(script, 'OPENQ4_PATCH_FILES').includes(lifecyclePatchName));
  }
  assert.match(identity, new RegExp(`"${statusPatchName.replaceAll('.', '\\.')}"`));
  assert.match(identity, new RegExp(`"${lifecyclePatchName.replaceAll('.', '\\.')}"`));
  assert.match(smoke, /parseBotStatusBlock/);
  assert.match(smoke, /new BotMotionGate\(\)/);
  assert.match(smoke, /if \(plan\.mode === 'DM'\)/);
  assert.doesNotMatch(smoke, /waitForBotMotion\(map[\s\S]*?plan\.botCount === 0/);
});

test('retail bot navigation smoke preserves server configuration across ReloadEngine', async () => {
  const smoke = await readFile(new URL('tools/smoke-retail-botnav.mjs', root), 'utf8');
  const spawnIndex = smoke.indexOf("const child = spawn(path.join(runtimeRoot, 'openQ4-ded_x64')");
  const transitionIndex = smoke.indexOf('if (index > 0)');

  assert.match(smoke, /reloadConfigFile = path\.join\(temporary, 'save\/baseoq4\/autoexec\.cfg'\)/);
  assert.match(smoke, /`set net_port \$\{port\}`/);
  assert.match(smoke, /`set bot_minplayers \$\{plan\.botCount\}`/);
  assert.match(smoke, /`set si_gameType "\$\{plan\.mode\}"`/);
  assert.ok(smoke.indexOf('await stageReloadConfig(firstPlan)') < spawnIndex);
  assert.ok(smoke.indexOf('await stageReloadConfig(plan)', transitionIndex) <
    smoke.indexOf('child.stdin.write([', transitionIndex));
  assert.match(smoke, /actualPort !== port/);
  assert.match(smoke, /values\.get\('si_gametype'\) !== plan\.mode/);
  assert.match(smoke, /values\.get\('bot_minplayers'\) !== String\(plan\.botCount\)/);
  assert.ok(smoke.indexOf('assertServerConfiguration(plan, configuration.block)') >
    smoke.indexOf("await requestBotStatus(map, 'server configuration', true)"));
});
