#!/usr/bin/env node

import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

import { MULTIPLAYER_MAPS } from '../public/js/menu-config.js';
import { readReleaseIdentity } from './e2e-evidence.mjs';
import {
  assertNoPayload,
  assertPayloadBatch,
  assertRenderEvidence,
  buildBundleScenarioPlan,
} from './retail-bundle-map-gate.mjs';
import { readRetailBundleManifestIdentity } from './verify-retail-bundle-cache-evidence.mjs';

const MAP_GATE_CODE_PATHS = Object.freeze({
  index: new URL('../public/index.html', import.meta.url),
  styles: new URL('../public/css/app.css', import.meta.url),
  importer: new URL('../public/js/importer.js', import.meta.url),
  sha256: new URL('../public/js/sha256.js', import.meta.url),
  app: new URL('../public/js/app.js', import.meta.url),
  identity: new URL('../public/js/identity.js', import.meta.url),
  menu: new URL('../public/js/menu.js', import.meta.url),
  menuConfig: new URL('../public/js/menu-config.js', import.meta.url),
  input: new URL('../public/js/input.js', import.meta.url),
  engineLoader: new URL('../public/js/engine-loader.js', import.meta.url),
  engineAdapter: new URL('../public/js/engine-adapter.js', import.meta.url),
  voiceHud: new URL('../public/js/voicehud.js', import.meta.url),
  voiceChat: new URL('../public/js/voicechat.js', import.meta.url),
  serviceWorker: new URL('../public/sw.js', import.meta.url),
  retailGateway: new URL('../server/retail-bundles.js', import.meta.url),
  retailCatalog: new URL('../server/retail.js', import.meta.url),
  serverConfig: new URL('../server/config.js', import.meta.url),
  serverFiles: new URL('../server/files.js', import.meta.url),
  nativeIdentity: new URL('../server/native-identity.js', import.meta.url),
  dedicatedSupervisor: new URL('../server/supervisor.js', import.meta.url),
  udpBridge: new URL('../server/udp-bridge.js', import.meta.url),
  botNavigation: new URL('../server/bot-navigation.js', import.meta.url),
  server: new URL('../server.js', import.meta.url),
  e2eEvidence: new URL('./e2e-evidence.mjs', import.meta.url),
  bundleIdentity: new URL('./verify-retail-bundle-cache-evidence.mjs', import.meta.url),
  mapGate: new URL('./e2e-retail-bundle-maps.mjs', import.meta.url),
  mapGateHelpers: new URL('./retail-bundle-map-gate.mjs', import.meta.url),
  mapGateDiagnostics: new URL('./retail-bundle-map-diagnostics.mjs', import.meta.url),
  mapGateVerifier: new URL('./verify-retail-bundle-maps-evidence.mjs', import.meta.url),
  packageManifest: new URL('../package.json', import.meta.url),
  packageLock: new URL('../package-lock.json', import.meta.url),
});

function exactStringArray(value, label, { empty = false } = {}) {
  assert.ok(Array.isArray(value) && (empty || value.length > 0), `${label} must be an array`);
  assert.ok(value.every((entry) => typeof entry === 'string' && entry.length > 0),
    `${label} must contain strings`);
  assert.equal(new Set(value).size, value.length, `${label} must not contain duplicates`);
  return value;
}

function assertSameSet(actual, expected, label) {
  exactStringArray(actual, label, { empty: expected.length === 0 });
  assert.deepEqual([...actual].sort(), [...expected].sort(), `${label} does not match the bundle graph`);
}

function parseTimestamp(value, label) {
  assert.equal(typeof value, 'string', `${label} must be an ISO timestamp`);
  const parsed = Date.parse(value);
  assert.ok(Number.isFinite(parsed), `${label} must be an ISO timestamp`);
  return parsed;
}

function assertSnapshot(snapshot, label) {
  assert.ok(snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot),
    `${label}: first snapshot evidence is missing`);
  assert.equal(snapshot.clientState, 'CS_INGAME', `${label}: client did not reach CS_INGAME`);
  assert.equal(snapshot.serverStateRequiredToSendSnapshot, 'SCS_INGAME',
    `${label}: server did not send the snapshot from SCS_INGAME`);
  for (const field of ['gameInitId', 'gameFrame', 'gameTime']) {
    assert.ok(Number.isInteger(snapshot[field]), `${label}: ${field} is invalid`);
  }
}

function assertEngineScenarioEvidence(
  entry,
  expected,
  label,
  { firstForMap = expected.firstForMap } = {}
) {
  assert.equal(entry.scenario, expected.scenario, `${label}: wrong scenario id`);
  assert.equal(entry.map, expected.id, `${label}: wrong map`);
  assert.equal(entry.mode, expected.mode, `${label}: wrong game mode`);
  assert.equal(entry.firstForMap, firstForMap, `${label}: wrong first-map marker`);
  assert.deepEqual(entry.match, {
    map: expected.id,
    mode: expected.mode,
    botCount: 0,
    fragLimit: 10,
    timeLimit: 0,
  }, `${label}: launcher match does not match the planned map`);
  assert.equal(entry.serverMap, expected.id, `${label}: dedicated server loaded another map`);
  assertSnapshot(entry.firstSnapshot, label);
  assert.deepEqual(entry.criticalLogs, [], `${label}: engine reported critical diagnostics`);
  assertRenderEvidence(entry.render, label);
  assert.ok(Number.isSafeInteger(entry.bridge?.rxFrames) && entry.bridge.rxFrames > 0,
    `${label}: browser bridge received no frames`);
  assert.ok(Number.isSafeInteger(entry.bridge?.txFrames) && entry.bridge.txFrames > 0,
    `${label}: browser bridge sent no frames`);
}

export async function readRetailBundleMapsGateIdentity({
  manifestPath,
  codePaths = MAP_GATE_CODE_PATHS,
  menuMaps = MULTIPLAYER_MAPS,
  releaseIdentity,
} = {}) {
  const [bundleIdentity, release] = await Promise.all([
    readRetailBundleManifestIdentity({ manifestPath, codePaths }),
    releaseIdentity ? Promise.resolve(releaseIdentity) : readReleaseIdentity(),
  ]);
  return {
    ...bundleIdentity,
    releaseIdentity: release,
    menuMaps: menuMaps.map((map) => ({
      id: map.id,
      label: map.label,
      modes: [...map.modes],
    })),
  };
}

export function verifyRetailBundleMapsEvidence(evidence, current) {
  assert.ok(evidence && typeof evidence === 'object' && !Array.isArray(evidence),
    'retail bundle map evidence must be a JSON object');
  assert.equal(evidence.schema, 2, 'retail bundle map evidence must use schema 2');
  assert.equal(evidence.status, 'passed', 'retail bundle map E2E did not pass');
  assert.equal(evidence.revision, current.catalog.revision,
    'retail bundle map evidence revision does not match the current manifest');
  assert.equal(evidence.sourceRevision, current.catalog.sourceRevision,
    'retail bundle map evidence source revision does not match the current manifest');
  assert.equal(evidence.manifestSha256, current.manifestSha256,
    'retail bundle map evidence does not match the current manifest bytes');
  assert.equal(evidence.manifestBytes, current.manifestBytes,
    'retail bundle map evidence does not match the current manifest size');
  assert.equal(evidence.runtimeIdentitySha256, current.identitySha256,
    'retail bundle map evidence does not match the current gate/runtime identity');
  assert.deepEqual(evidence.codeSha256, current.codeSha256,
    'retail bundle map evidence code hashes do not match the current gate/runtime');
  assert.equal(evidence.releaseIdentitySha256, current.releaseIdentity.identitySha256,
    'retail bundle map evidence does not match the current engine/content/native release');

  const startedAt = parseTimestamp(evidence.startedAt, 'startedAt');
  const checkedAt = parseTimestamp(evidence.checkedAt, 'checkedAt');
  assert.ok(checkedAt >= startedAt, 'checkedAt precedes startedAt');
  assert.doesNotThrow(() => new URL(evidence.baseUrl), 'baseUrl must be an absolute URL');

  const plan = buildBundleScenarioPlan(current.catalog, current.menuMaps);
  assert.ok(Array.isArray(evidence.scenarios), 'per-scenario engine evidence is missing');
  assert.equal(evidence.scenarios.length, plan.length,
    'per-scenario engine evidence does not cover every multiplayer map/mode pair');

  for (let index = 0; index < plan.length; index += 1) {
    const expected = plan[index];
    const entry = evidence.scenarios[index];
    const label = `scenario ${expected.id} / ${expected.mode}`;
    assertEngineScenarioEvidence(entry, expected, label);
    assertSameSet(entry.newBundles, expected.newBundles.map(({ id }) => id),
      `${label} downloaded bundles`);
    assertSameSet(entry.installedMaps, expected.installedMapIds, `${label} retained maps`);
    assertSameSet(entry.installedBundles, expected.installedBundleIds,
      `${label} retained bundles`);
    const phaseRequests = evidence.payloadRequests.filter(({ phase }) => phase === expected.scenario);
    assert.equal(entry.requests, phaseRequests.length, `${label}: request count is inconsistent`);
    if (expected.newBundles.length > 0) {
      assertPayloadBatch(phaseRequests, expected.newBundles, evidence.baseUrl, label);
    } else {
      assertNoPayload(phaseRequests, label);
    }
  }

  const first = plan[0];
  assert.ok(evidence.returned && typeof evidence.returned === 'object',
    'cached return phase is missing');
  assertEngineScenarioEvidence(evidence.returned, first, `return to ${first.id} / ${first.mode}`, {
    firstForMap: false,
  });
  assertSameSet(evidence.returned.newBundles, [], 'cached return downloaded bundles');
  assert.equal(evidence.returned.requests, 0, 'cached return fetched retail payload again');
  assertNoPayload(
    evidence.payloadRequests.filter(({ phase }) => phase === `return:${first.scenario}`),
    `return to ${first.id} / ${first.mode}`
  );
  assertSameSet(evidence.returned.installedMaps, plan.at(-1).installedMapIds,
    'return phase retained maps');
  assertSameSet(evidence.returned.installedBundles, plan.at(-1).installedBundleIds,
    'return phase retained bundles');

  assertSameSet(evidence.retainedMaps, plan.at(-1).installedMapIds, 'final retained maps');
  const retainedIds = plan.at(-1).installedBundleIds;
  assertSameSet(evidence.retainedBundles, retainedIds, 'final retained bundles');
  const bundlesById = new Map(current.catalog.bundles.map((bundle) => [bundle.id, bundle]));
  const retainedBundles = retainedIds.map((id) => bundlesById.get(id));
  assert.ok(retainedBundles.every(Boolean), 'retained bundle is absent from the current manifest');
  assert.equal(evidence.opfsBytes, retainedBundles.reduce((sum, bundle) => sum + bundle.size, 0),
    'OPFS byte count does not match all retained map bundles');
  assert.ok(Array.isArray(evidence.opfsFiles), 'enumerated OPFS file evidence is missing');
  assert.deepEqual(
    [...evidence.opfsFiles].sort((left, right) => left.name.localeCompare(right.name, 'en')),
    retainedBundles
      .map(({ id, name, size, sha256 }) => ({ id, name, kind: 'file', size, sha256 }))
      .sort((left, right) => left.name.localeCompare(right.name, 'en')),
    'enumerated OPFS entries do not exactly match all retained map bundles'
  );
  assert.deepEqual(evidence.fullRetailRequests, [],
    'all-map bundle gate unexpectedly fetched the full retail catalog');

  const knownPhases = new Set([
    ...plan.map(({ scenario }) => scenario),
    `return:${first.scenario}`,
  ]);
  assert.ok(Array.isArray(evidence.payloadRequests), 'payload request evidence is missing');
  for (const request of evidence.payloadRequests) {
    assert.ok(knownPhases.has(request.phase), `payload request has an unknown phase: ${request.phase}`);
  }
  assert.equal(
    evidence.payloadRequests.length,
    evidence.scenarios.reduce((sum, entry) => sum + entry.requests, 0),
    'payload request list does not match per-scenario request counts'
  );
  return current;
}

export async function verifyRetailBundleMapsEvidenceFile({
  evidencePath = new URL('../.selfcheck/retail-bundle-maps-e2e.json', import.meta.url),
  manifestPath,
  currentIdentity,
} = {}) {
  const [rawEvidence, current] = await Promise.all([
    fs.readFile(evidencePath, 'utf8'),
    currentIdentity
      ? Promise.resolve(currentIdentity)
      : readRetailBundleMapsGateIdentity({ manifestPath }),
  ]);
  let evidence;
  try {
    evidence = JSON.parse(rawEvidence);
  } catch (error) {
    throw new Error(`retail bundle map evidence is not valid JSON: ${error.message}`);
  }
  return verifyRetailBundleMapsEvidence(evidence, current);
}

async function main() {
  const identity = await verifyRetailBundleMapsEvidenceFile();
  process.stdout.write(
    `All-map retail bundle evidence verified for ` +
    `${identity.menuMaps.reduce((sum, map) => sum + map.modes.length, 0)} map/mode scenarios ` +
    `in ${identity.catalog.revision}\n`
  );
}

const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (invokedPath === import.meta.url) {
  main().catch((error) => {
    process.stderr.write(`verify-retail-bundle-maps-evidence: ${error.message}\n`);
    process.exitCode = 1;
  });
}
