#!/usr/bin/env node
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { chromium } from 'playwright-core';
import {
  selectServerRetailBundles,
  verifyServerRetailBundleManifest,
} from '../public/js/importer.js';

const baseUrl = (process.env.Q4_E2E_URL || process.argv[2] || 'http://127.0.0.1:3280').replace(/\/$/, '');
const chrome = process.env.Q4_CHROME || '/usr/bin/google-chrome';
const timeoutMs = Number(process.env.Q4_BUNDLE_CACHE_E2E_TIMEOUT_MS || 30 * 60_000);
const keepProfile = process.env.Q4_E2E_KEEP_PROFILE === '1';
const evidencePath = path.resolve('.selfcheck/retail-bundle-cache-e2e.json');
const bundleManifestPath = path.resolve(
  process.env.Q4_RETAIL_BUNDLE_MANIFEST || 'runtime/retail-bundle-manifest.json'
);
const firstMap = 'mp/q4web_arena';
const secondMap = 'mp/q4dm3';

assert.ok(Number.isInteger(timeoutMs) && timeoutMs >= 60_000,
  'Q4_BUNDLE_CACHE_E2E_TIMEOUT_MS must be an integer of at least 60000');

function absoluteUrl(relativeUrl) {
  return new URL(relativeUrl, `${baseUrl}/`).href;
}

function bundleKinds(selection) {
  return new Set(selection.bundles.map((bundle) => bundle.kind));
}

function selectedWithout(selection, installedIds) {
  return selection.bundles.filter((bundle) => !installedIds.has(bundle.id));
}

function requestSummary(request) {
  return {
    url: request.url,
    method: request.method,
    range: request.range,
    ifRange: request.ifRange,
  };
}

function assertPayloadBatch(requests, expectedBundles, label) {
  const expectedByUrl = new Map(expectedBundles.map((bundle) => [absoluteUrl(bundle.url), bundle]));
  const actualUrls = [...new Set(requests.map((request) => request.url))].sort();
  assert.deepEqual(actualUrls, [...expectedByUrl.keys()].sort(),
    `${label}: unexpected retail payload set: ${JSON.stringify(requests.map(requestSummary))}`);

  for (const [url, bundle] of expectedByUrl) {
    const ranges = requests.filter((request) => request.url === url);
    assert.ok(ranges.length > 0, `${label}: ${bundle.id} was not downloaded`);
    let cursor = 0;
    for (const request of ranges) {
      assert.equal(request.method, 'GET', `${label}: payload must use GET`);
      assert.equal(request.ifRange, `\"${bundle.sha256}\"`,
        `${label}: ${bundle.id} was not bound to its SHA-256 ETag`);
      const match = /^bytes=(\d+)-(\d+)$/.exec(request.range || '');
      assert.ok(match, `${label}: ${bundle.id} request has no bounded Range: ${JSON.stringify(request)}`);
      const start = Number(match[1]);
      const end = Number(match[2]);
      assert.equal(start, cursor, `${label}: ${bundle.id} ranges contain a gap or retry`);
      assert.ok(end >= start && end < bundle.size, `${label}: invalid range for ${bundle.id}`);
      cursor = end + 1;
    }
    assert.equal(cursor, bundle.size, `${label}: ${bundle.id} did not download exactly once`);
  }
}

function assertNoPayload(requests, label) {
  assert.deepEqual(requests.map(requestSummary), [], `${label}: cached map fetched retail payload again`);
}

function assertCacheState(snapshot, { activeMap, installedMaps, expectedBundles, revision, hashed = false }, label) {
  assert.equal(snapshot.supported, true, `${label}: OPFS is unavailable`);
  assert.equal(snapshot.result?.state, 'ready', `${label}: install is not ready`);
  assert.equal(snapshot.active?.state, 'ready', `${label}: active OPFS revision is not ready`);
  assert.equal(snapshot.active?.source, 'operator-server-bundles', `${label}: full retail fallback was used`);
  assert.equal(snapshot.active?.revision, revision, `${label}: wrong bundle revision is active`);
  assert.equal(snapshot.active?.activeMap, activeMap, `${label}: wrong map is active`);
  assert.deepEqual(snapshot.active?.installedMaps, [...installedMaps].sort(),
    `${label}: installed map history was not retained`);
  assert.equal(snapshot.descriptor?.bundled, true, `${label}: launch descriptor is not bundle-aware`);
  assert.equal(snapshot.descriptor?.map, activeMap, `${label}: launch descriptor points at the wrong map`);
  assert.equal(snapshot.descriptor?.performancePreset, 'balanced', `${label}: wrong rendering preset`);
  assert.equal(snapshot.descriptor?.maxMachineSpec, 2, `${label}: unsupported machine spec`);

  const expectedFiles = [...expectedBundles]
    .sort((left, right) => left.id.localeCompare(right.id, 'en'))
    .map((bundle) => ({
      id: bundle.id,
      name: bundle.name,
      size: bundle.size,
      sha256: hashed ? bundle.sha256 : null,
    }));
  assert.deepEqual(snapshot.opfsFiles, expectedFiles, `${label}: OPFS payload set does not match the manifest`);
}

async function withTimeout(promise, label) {
  let timer;
  try {
    return await Promise.race([
      promise,
      new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}

async function ensureMap(page, map, label, { hashFiles = false } = {}) {
  return withTimeout(page.evaluate(async ({ requestedMap, calculateHashes }) => {
    const { RetailImporter } = await import('/js/importer.js');
    const { sha256Blob } = calculateHashes ? await import('/js/sha256.js') : { sha256Blob: null };
    const importer = new RetailImporter();
    let progressEvents = 0;
    let lastProgress = null;
    const result = await importer.ensureServerRetail({
      map: requestedMap,
      onProgress(event) {
        progressEvents += 1;
        lastProgress = {
          state: event.state || null,
          map: event.map || null,
          file: event.file || null,
          completedBytes: Number(event.completedBytes) || 0,
          totalBytes: Number(event.totalBytes) || 0,
          cached: event.cached === true,
        };
      },
    });
    const active = await importer.getActiveImport();
    const rawDescriptor = await importer.launchDescriptor();
    const descriptor = rawDescriptor && {
      revision: rawDescriptor.revision,
      basePath: rawDescriptor.basePath,
      q4basePath: rawDescriptor.q4basePath,
      bundled: rawDescriptor.bundled,
      map: rawDescriptor.map || null,
      performancePreset: rawDescriptor.performancePreset || null,
      maxMachineSpec: rawDescriptor.maxMachineSpec || null,
    };

    const opfsFiles = [];
    if (active) {
      const storageRoot = await navigator.storage.getDirectory();
      const dataRoot = await storageRoot.getDirectoryHandle('q4-retail');
      const revisions = await dataRoot.getDirectoryHandle('revisions');
      const revision = await revisions.getDirectoryHandle(active.revision);
      const runtime = await revision.getDirectoryHandle('runtime');
      const q4base = await runtime.getDirectoryHandle('q4base');
      for (const record of active.files || []) {
        const file = await (await q4base.getFileHandle(record.name)).getFile();
        opfsFiles.push({
          id: record.id,
          name: record.name,
          size: file.size,
          sha256: calculateHashes ? await sha256Blob(file) : null,
        });
      }
      opfsFiles.sort((left, right) => left.id.localeCompare(right.id, 'en'));
    }

    return {
      supported: importer.supported,
      result,
      active,
      descriptor,
      opfsFiles,
      progressEvents,
      lastProgress,
      estimate: await navigator.storage.estimate(),
    };
  }, { requestedMap: map, calculateHashes: hashFiles }), label);
}

async function loadCatalog() {
  const url = absoluteUrl('/retail/bundles/manifest.json');
  const response = await fetch(url, { headers: { Accept: 'application/json' } });
  if (!response.ok) {
    throw new Error(
      `Retail bundle endpoint is unavailable at ${url} (HTTP ${response.status}). ` +
      'Stage the private bundle manifest and start a gateway configured for bundle delivery.'
    );
  }
  return verifyServerRetailBundleManifest(await response.json());
}

async function loadLocalManifestIdentity() {
  const bytes = await fs.readFile(bundleManifestPath);
  let manifest;
  try {
    manifest = JSON.parse(bytes.toString('utf8'));
  } catch (error) {
    throw new Error(`Local retail bundle manifest is not valid JSON: ${error.message}`);
  }
  const catalog = await verifyServerRetailBundleManifest(manifest);
  return {
    catalog,
    bytes: bytes.byteLength,
    sha256: createHash('sha256').update(bytes).digest('hex'),
    ...(await (async () => {
      const identity = await import('./verify-retail-bundle-cache-evidence.mjs')
        .then(({ readRetailBundleManifestIdentity }) => readRetailBundleManifestIdentity({
          manifestPath: bundleManifestPath,
        }));
      return {
        identitySha256: identity.identitySha256,
        codeSha256: identity.codeSha256,
      };
    })()),
  };
}

const [catalog, localManifest] = await Promise.all([loadCatalog(), loadLocalManifestIdentity()]);
for (const field of ['revision', 'sourceRevision', 'fingerprint', 'sourceFingerprint']) {
  assert.equal(catalog[field], localManifest.catalog[field],
    `served retail bundle catalog ${field} does not match ${bundleManifestPath}`);
}
const firstSelection = selectServerRetailBundles(catalog, firstMap);
const secondSelection = selectServerRetailBundles(catalog, secondMap);
for (const [label, selection] of [[firstMap, firstSelection], [secondMap, secondSelection]]) {
  const kinds = bundleKinds(selection);
  assert.ok(kinds.has('common') && kinds.has('map'), `${label} must select common and map bundles`);
}
const firstIds = new Set(firstSelection.bundleIds);
const sharedCommon = firstSelection.bundles.filter((bundle) =>
  bundle.kind === 'common' && secondSelection.bundleIds.includes(bundle.id));
assert.ok(sharedCommon.length > 0, `${firstMap} and ${secondMap} do not share a common bundle`);
const secondNewBundles = selectedWithout(secondSelection, firstIds);
assert.ok(secondNewBundles.some((bundle) => bundle.kind === 'map'), `${secondMap} has no new map bundle`);
assert.ok(secondNewBundles.every((bundle) => bundle.kind !== 'common'),
  `${secondMap} would download another common bundle instead of reusing the cache`);

const knownPayloadUrls = new Set(catalog.bundles.map((bundle) => absoluteUrl(bundle.url)));
const payloadRequests = [];
const fullRetailRequests = [];
const profile = await fs.mkdtemp(path.join(os.tmpdir(), 'q4-retail-bundle-cache-'));
let context = null;
const startedAt = new Date().toISOString();

try {
  context = await chromium.launchPersistentContext(profile, {
    executablePath: chrome,
    headless: true,
    args: ['--disable-dev-shm-usage', '--no-sandbox'],
    viewport: { width: 1024, height: 720 },
    locale: 'pl-PL',
  });
  context.on('request', (request) => {
    const url = request.url();
    const headers = request.headers();
    if (knownPayloadUrls.has(url)) {
      payloadRequests.push({
        url,
        method: request.method(),
        range: headers.range || null,
        ifRange: headers['if-range'] || null,
      });
    }
    if (new URL(url).pathname.startsWith('/retail/q4base/')) {
      fullRetailRequests.push({ url, method: request.method(), range: headers.range || null });
    }
  });

  const pages = context.pages();
  const page = pages[0] || await context.newPage();
  for (const extra of pages.slice(1)) await extra.close();
  await page.goto(`${baseUrl}/legal/NOTICE.txt?retail-bundle-cache-e2e=1`, { waitUntil: 'domcontentloaded' });
  const capabilities = await page.evaluate(() => ({
    secure: isSecureContext,
    storage: Boolean(navigator.storage),
    opfs: typeof navigator.storage?.getDirectory === 'function',
  }));
  assert.equal(capabilities.secure, true, `test origin is not secure: ${JSON.stringify(capabilities)}`);
  assert.equal(capabilities.opfs, true, `Chromium does not expose OPFS: ${JSON.stringify(capabilities)}`);

  let offset = payloadRequests.length;
  const first = await ensureMap(page, firstMap, `first ${firstMap} install`, { hashFiles: true });
  const firstRequests = payloadRequests.slice(offset);
  assertPayloadBatch(firstRequests, firstSelection.bundles, `first ${firstMap} install`);
  assertCacheState(first, {
    activeMap: firstMap,
    installedMaps: [firstMap],
    expectedBundles: firstSelection.bundles,
    revision: catalog.revision,
    hashed: true,
  }, `first ${firstMap} install`);

  await page.reload({ waitUntil: 'domcontentloaded' });
  offset = payloadRequests.length;
  const repeated = await ensureMap(page, firstMap, `reloaded ${firstMap}`);
  const repeatedRequests = payloadRequests.slice(offset);
  assertNoPayload(repeatedRequests, `reloaded ${firstMap}`);
  assert.equal(repeated.lastProgress?.cached, true, `reloaded ${firstMap} was not reported as cached`);
  assertCacheState(repeated, {
    activeMap: firstMap,
    installedMaps: [firstMap],
    expectedBundles: firstSelection.bundles,
    revision: catalog.revision,
  }, `reloaded ${firstMap}`);

  offset = payloadRequests.length;
  const switched = await ensureMap(page, secondMap, `switch to ${secondMap}`);
  const switchedRequests = payloadRequests.slice(offset);
  assertPayloadBatch(switchedRequests, secondNewBundles, `switch to ${secondMap}`);
  const allInstalledById = new Map([...firstSelection.bundles, ...secondSelection.bundles]
    .map((bundle) => [bundle.id, bundle]));
  assertCacheState(switched, {
    activeMap: secondMap,
    installedMaps: [firstMap, secondMap],
    expectedBundles: [...allInstalledById.values()],
    revision: catalog.revision,
  }, `switch to ${secondMap}`);

  offset = payloadRequests.length;
  const returned = await ensureMap(page, firstMap, `return to ${firstMap}`, { hashFiles: true });
  const returnedRequests = payloadRequests.slice(offset);
  assertNoPayload(returnedRequests, `return to ${firstMap}`);
  assert.equal(returned.lastProgress?.cached, true, `return to ${firstMap} was not reported as cached`);
  assertCacheState(returned, {
    activeMap: firstMap,
    installedMaps: [firstMap, secondMap],
    expectedBundles: [...allInstalledById.values()],
    revision: catalog.revision,
    hashed: true,
  }, `return to ${firstMap}`);
  assert.deepEqual(fullRetailRequests, [], 'map-aware cache unexpectedly fetched the full 23-file retail catalog');

  const finalLocalManifest = await loadLocalManifestIdentity();
  assert.equal(finalLocalManifest.sha256, localManifest.sha256,
    'local retail bundle manifest changed while the cache E2E was running');
  assert.equal(finalLocalManifest.bytes, localManifest.bytes,
    'local retail bundle manifest size changed while the cache E2E was running');
  assert.equal(finalLocalManifest.identitySha256, localManifest.identitySha256,
    'retail importer/gateway code changed while the cache E2E was running');

  const evidence = {
    schema: 3,
    status: 'passed',
    startedAt,
    checkedAt: new Date().toISOString(),
    baseUrl,
    revision: catalog.revision,
    sourceRevision: catalog.sourceRevision,
    manifestSha256: localManifest.sha256,
    manifestBytes: localManifest.bytes,
    runtimeIdentitySha256: localManifest.identitySha256,
    codeSha256: localManifest.codeSha256,
    phases: {
      first: { map: firstMap, bundles: firstSelection.bundleIds, requests: firstRequests.length },
      repeated: { map: firstMap, requests: repeatedRequests.length },
      switched: { map: secondMap, bundles: secondNewBundles.map((bundle) => bundle.id), requests: switchedRequests.length },
      returned: { map: firstMap, requests: returnedRequests.length },
    },
    retainedMaps: returned.active.installedMaps,
    retainedBundles: returned.active.installedBundleIds,
    opfsBytes: returned.opfsFiles.reduce((sum, file) => sum + file.size, 0),
    opfsFiles: returned.opfsFiles,
    payloadRequests: payloadRequests.map(requestSummary),
  };
  await fs.mkdir(path.dirname(evidencePath), { recursive: true });
  const temporaryEvidence = `${evidencePath}.${process.pid}.tmp`;
  await fs.writeFile(temporaryEvidence, `${JSON.stringify(evidence, null, 2)}\n`);
  await fs.rename(temporaryEvidence, evidencePath);
  process.stdout.write(
    `Q4 retail bundle cache E2E passed at ${baseUrl}: ` +
    `${firstMap} cached, reload 0 payloads, ${secondMap} added ${secondNewBundles.length} bundle(s), return 0 payloads.\n`
  );
} finally {
  await context?.close().catch(() => {});
  if (keepProfile) process.stderr.write(`Retained Chromium profile: ${profile}\n`);
  else await fs.rm(profile, { recursive: true, force: true });
}
