#!/usr/bin/env node

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

import {
  selectServerRetailBundles,
  verifyServerRetailBundleManifest,
} from '../public/js/importer.js';

export const REQUIRED_BUNDLE_CACHE_PHASES = Object.freeze([
  'first',
  'repeated',
  'switched',
  'returned',
]);

const DEFAULT_RUNTIME_CODE_PATHS = Object.freeze({
  importer: new URL('../public/js/importer.js', import.meta.url),
  app: new URL('../public/js/app.js', import.meta.url),
  retailGateway: new URL('../server/retail-bundles.js', import.meta.url),
  serverConfig: new URL('../server/config.js', import.meta.url),
  dedicatedSupervisor: new URL('../server/supervisor.js', import.meta.url),
  server: new URL('../server.js', import.meta.url),
});

function sha256(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

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

function assertSameSet(actual, expected, label) {
  exactStringArray(actual, label);
  assert.deepEqual([...actual].sort(), [...expected].sort(), `${label} does not match the current manifest`);
}

function positiveRequestCount(value, label) {
  assert.ok(Number.isSafeInteger(value) && value > 0, `${label} must be a positive integer`);
}

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;
}

export async function readRetailBundleManifestIdentity({
  manifestPath = new URL('../runtime/retail-bundle-manifest.json', import.meta.url),
  codePaths = DEFAULT_RUNTIME_CODE_PATHS,
} = {}) {
  const codeEntries = Object.entries(codePaths);
  const [bytes, ...codeBytes] = await Promise.all([
    fs.readFile(manifestPath),
    ...codeEntries.map(([, file]) => fs.readFile(file)),
  ]);
  let manifest;
  try {
    manifest = JSON.parse(bytes.toString('utf8'));
  } catch (error) {
    throw new Error(`retail bundle manifest is not valid JSON: ${error.message}`);
  }
  const catalog = await verifyServerRetailBundleManifest(manifest);
  const codeSha256 = Object.fromEntries(codeEntries.map(([name], index) => [name, sha256(codeBytes[index])]));
  const identity = {
    catalog,
    manifestSha256: sha256(bytes),
    manifestBytes: bytes.byteLength,
    codeSha256,
  };
  return {
    ...identity,
    identitySha256: sha256(Buffer.from(JSON.stringify({
      revision: catalog.revision,
      sourceRevision: catalog.sourceRevision,
      manifestSha256: identity.manifestSha256,
      manifestBytes: identity.manifestBytes,
      codeSha256,
    }))),
  };
}

export function verifyRetailBundleCacheEvidence(evidence, current) {
  assert.ok(evidence && typeof evidence === 'object' && !Array.isArray(evidence),
    'retail bundle cache evidence must be a JSON object');
  assert.equal(evidence.schema, 3, 'retail bundle cache evidence must use schema 3');
  assert.equal(evidence.status, 'passed', 'retail bundle cache E2E did not pass');
  assert.equal(evidence.revision, current.catalog.revision,
    'retail bundle cache evidence revision does not match the current manifest');
  assert.equal(evidence.sourceRevision, current.catalog.sourceRevision,
    'retail bundle cache evidence sourceRevision does not match the current manifest');
  assert.equal(evidence.manifestSha256, current.manifestSha256,
    'retail bundle cache evidence SHA-256 does not match the current manifest bytes');
  assert.equal(evidence.manifestBytes, current.manifestBytes,
    'retail bundle cache evidence byte count does not match the current manifest');
  assert.equal(evidence.runtimeIdentitySha256, current.identitySha256,
    'retail bundle cache evidence does not match the current importer/gateway identity');
  assert.deepEqual(evidence.codeSha256, current.codeSha256,
    'retail bundle cache evidence code hashes do not match the current importer/gateway');

  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');

  assert.ok(evidence.phases && typeof evidence.phases === 'object' && !Array.isArray(evidence.phases),
    'retail bundle cache phase evidence is missing');
  assert.deepEqual(Object.keys(evidence.phases).sort(), [...REQUIRED_BUNDLE_CACHE_PHASES].sort(),
    'retail bundle cache evidence must contain exactly the required phases');
  const { first, repeated, switched, returned } = evidence.phases;
  for (const [name, phase] of Object.entries(evidence.phases)) {
    assert.ok(phase && typeof phase === 'object' && !Array.isArray(phase), `${name} phase is invalid`);
    assert.equal(typeof phase.map, 'string', `${name} phase map is invalid`);
  }
  assert.notEqual(first.map, switched.map, 'switched phase did not select another map');
  assert.equal(repeated.map, first.map, 'repeated phase did not reload the first map');
  assert.equal(returned.map, first.map, 'returned phase did not return to the first map');
  assert.equal(repeated.requests, 0, 'repeated phase fetched retail payload again');
  assert.equal(returned.requests, 0, 'returned phase fetched retail payload again');
  positiveRequestCount(first.requests, 'first phase request count');
  positiveRequestCount(switched.requests, 'switched phase request count');

  const firstSelection = selectServerRetailBundles(current.catalog, first.map);
  const secondSelection = selectServerRetailBundles(current.catalog, switched.map);
  assertSameSet(first.bundles, firstSelection.bundleIds, 'first phase bundles');
  const firstIds = new Set(firstSelection.bundleIds);
  const switchedBundleIds = secondSelection.bundleIds.filter((id) => !firstIds.has(id));
  assert.ok(switchedBundleIds.length > 0, 'switched map adds no bundle to the persistent cache');
  assertSameSet(switched.bundles, switchedBundleIds, 'switched phase bundles');

  const retainedMapIds = [firstSelection.map, secondSelection.map];
  assertSameSet(evidence.retainedMaps, retainedMapIds, 'retained maps');
  const retainedBundleIds = [...new Set([
    ...firstSelection.bundleIds,
    ...secondSelection.bundleIds,
  ])];
  assertSameSet(evidence.retainedBundles, retainedBundleIds, 'retained bundles');

  const bundlesById = new Map(current.catalog.bundles.map((bundle) => [bundle.id, bundle]));
  const retainedBundles = retainedBundleIds.map((id) => bundlesById.get(id));
  const expectedOpfsBytes = retainedBundles.reduce((sum, bundle) => sum + bundle.size, 0);
  assert.equal(evidence.opfsBytes, expectedOpfsBytes,
    'OPFS byte count does not match the retained bundles');
  assert.ok(Array.isArray(evidence.opfsFiles), 'hashed OPFS file evidence is missing');
  assert.deepEqual(
    [...evidence.opfsFiles].sort((left, right) => String(left.id).localeCompare(String(right.id), 'en')),
    retainedBundles
      .map(({ id, name, size, sha256: digest }) => ({ id, name, size, sha256: digest }))
      .sort((left, right) => left.id.localeCompare(right.id, 'en')),
    'actual OPFS file hashes do not match the retained bundles'
  );

  assert.ok(Array.isArray(evidence.payloadRequests), 'payload request evidence is missing');
  const expectedRequestCount = first.requests + switched.requests;
  assert.equal(evidence.payloadRequests.length, expectedRequestCount,
    'payload request list does not match phase request counts');
  const downloadedIds = new Set([...firstSelection.bundleIds, ...switchedBundleIds]);
  const bundlesByPath = new Map([...downloadedIds].map((id) => {
    const bundle = bundlesById.get(id);
    return [bundle.url, bundle];
  }));
  const requestsByBundle = new Map([...downloadedIds].map((id) => [id, []]));
  for (const request of evidence.payloadRequests) {
    assert.ok(request && typeof request === 'object' && !Array.isArray(request),
      'payload request entry is invalid');
    assert.equal(request.method, 'GET', 'retail bundle payload was not fetched with GET');
    let requestUrl;
    assert.doesNotThrow(() => { requestUrl = new URL(request.url); }, 'payload request URL is invalid');
    const bundle = bundlesByPath.get(requestUrl.pathname);
    assert.ok(bundle, `payload request is not required by the tested maps: ${requestUrl.pathname}`);
    assert.equal(request.ifRange, `\"${bundle.sha256}\"`,
      `payload request is not bound to ${bundle.id} SHA-256`);
    const match = /^bytes=(\d+)-(\d+)$/.exec(request.range || '');
    assert.ok(match, `payload request for ${bundle.id} has no bounded Range`);
    requestsByBundle.get(bundle.id).push({ start: Number(match[1]), end: Number(match[2]) });
  }
  for (const bundle of retainedBundles) {
    const ranges = requestsByBundle.get(bundle.id);
    assert.ok(ranges?.length > 0, `payload request evidence is missing bundle ${bundle.id}`);
    let cursor = 0;
    for (const range of ranges) {
      assert.equal(range.start, cursor, `payload ranges for ${bundle.id} contain a gap or duplicate`);
      assert.ok(Number.isSafeInteger(range.end) && range.end >= range.start && range.end < bundle.size,
        `payload range for ${bundle.id} is outside its manifest size`);
      cursor = range.end + 1;
    }
    assert.equal(cursor, bundle.size, `payload ranges for ${bundle.id} do not cover it exactly once`);
  }

  return current;
}

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

async function main() {
  const identity = await verifyRetailBundleCacheEvidenceFile();
  process.stdout.write(
    `Retail bundle cache evidence verified for ${identity.catalog.revision} (${identity.manifestSha256})\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-cache-evidence: ${error.message}\n`);
    process.exitCode = 1;
  });
}
