import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';

import { resolveFile } from './files.js';

export const RETAIL_BUNDLE_SCHEMA = 1;
export const RETAIL_BUNDLE_POLICY = 'operator-authorized-retail-bundles-v1';

const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SOURCE_REVISION_PATTERN = /^q4retail-[a-f0-9]{20}$/;
const BUNDLE_ID_PATTERN = /^[a-z0-9][a-z0-9._/-]{0,119}$/;
const BUNDLE_FILE_PATTERN = /^[a-z0-9][a-z0-9._-]{0,154}\.pk4$/;
const BUNDLE_KINDS = new Set(['common', 'map', 'botnav']);

function fail(message) {
  throw new Error(`retail bundle catalog: ${message}`);
}

function sha256File(file) {
  const hash = createHash('sha256');
  const fd = fs.openSync(file, 'r');
  const buffer = Buffer.allocUnsafe(1024 * 1024);
  try {
    let offset = 0;
    while (true) {
      const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, offset);
      if (bytesRead === 0) break;
      hash.update(buffer.subarray(0, bytesRead));
      offset += bytesRead;
    }
  } finally {
    fs.closeSync(fd);
  }
  return hash.digest('hex');
}

export function retailBundleFingerprint({ sourceRevision, sourceFingerprint, bundles, maps }) {
  const hash = createHash('sha256');
  hash.update(`${sourceRevision}\0${sourceFingerprint}\n`);
  for (const bundle of [...bundles].sort((left, right) => left.id.localeCompare(right.id, 'en'))) {
    hash.update(
      `${bundle.id}\0${bundle.kind}\0${bundle.name}\0${bundle.size}\0${bundle.sha256}\0${bundle.url}\n`,
    );
  }
  for (const map of [...maps].sort((left, right) => left.id.localeCompare(right.id, 'en'))) {
    hash.update(`${map.id}\0${map.bundleIds.join(',')}\n`);
  }
  return hash.digest('hex');
}

export function validateRetailBundleManifest(
  manifest,
  bundleDir,
  sourceRetailManifest,
  { verifyFiles = true } = {},
) {
  if (manifest?.schema !== RETAIL_BUNDLE_SCHEMA || manifest?.policy !== RETAIL_BUNDLE_POLICY ||
      manifest?.performancePreset !== 'balanced' || manifest?.maxMachineSpec !== 2 ||
      !SOURCE_REVISION_PATTERN.test(String(manifest.sourceRevision || '')) ||
      !SHA256_PATTERN.test(String(manifest.sourceFingerprint || '')) ||
      !SHA256_PATTERN.test(String(manifest.fingerprint || '')) ||
      !Array.isArray(manifest.bundles) || !Array.isArray(manifest.maps)) {
    fail('invalid schema, policy, source, profile, bundles, maps, or fingerprint');
  }
  if (!sourceRetailManifest || manifest.sourceRevision !== sourceRetailManifest.revision ||
      manifest.sourceFingerprint !== sourceRetailManifest.fingerprint) {
    fail('source retail identity does not match the active approved catalog');
  }
  if (manifest.bundles.length < 2 || manifest.bundles.length > 512) {
    fail('bundle count is outside the approved bounds');
  }

  const bundleIds = new Set();
  const bundleNames = new Set();
  const bundles = [];
  let commonBundleCount = 0;
  let commonBundleId = null;
  let commonBundleNameIsCanonical = true;
  let totalBytes = 0;
  for (const raw of manifest.bundles) {
    const rawName = String(raw?.name || '');
    const id = String(raw?.id || '').toLowerCase();
    const name = rawName.toLowerCase();
    const kind = String(raw?.kind || '').toLowerCase();
    const size = Number(raw?.size);
    const sha256 = String(raw?.sha256 || '').toLowerCase();
    const url = String(raw?.url || '');
    if (!BUNDLE_ID_PATTERN.test(id) || bundleIds.has(id) ||
        !BUNDLE_FILE_PATTERN.test(name) || bundleNames.has(name) ||
        !BUNDLE_KINDS.has(kind) || !Number.isSafeInteger(size) || size <= 0 ||
        !SHA256_PATTERN.test(sha256) ||
        url !== `/retail/bundles/${sha256}-${encodeURIComponent(name)}`) {
      fail(`invalid or duplicate bundle entry: ${id || name || '<unnamed>'}`);
    }
    const resolved = resolveFile(bundleDir, name);
    if (verifyFiles && (!resolved || resolved.stat.size !== size || sha256File(resolved.path) !== sha256)) {
      fail(`bundle file differs from the staged manifest: ${name}`);
    }
    bundleIds.add(id);
    bundleNames.add(name);
    totalBytes += size;
    bundles.push({ id, name, kind, size, sha256, url, path: resolved?.path, stat: resolved?.stat });
    if (kind === 'common') {
      commonBundleCount += 1;
      commonBundleId = id;
      commonBundleNameIsCanonical &&= rawName === 'pak001.pk4';
    }
  }
  if (commonBundleCount !== 1 || !commonBundleNameIsCanonical) {
    fail('exactly one common bundle named pak001.pk4 is required');
  }

  const kindsById = new Map(bundles.map((bundle) => [bundle.id, bundle.kind]));
  const mapIds = new Set();
  const maps = [];
  for (const raw of manifest.maps) {
    const id = String(raw?.id || '').toLowerCase();
    const bundleIdsForMap = Array.isArray(raw?.bundleIds)
      ? raw.bundleIds.map((entry) => String(entry || '').toLowerCase())
      : [];
    const unique = new Set(bundleIdsForMap);
    if (!BUNDLE_ID_PATTERN.test(id) || mapIds.has(id) ||
        bundleIdsForMap.length < 2 || bundleIdsForMap.length > 8 ||
        unique.size !== bundleIdsForMap.length ||
        bundleIdsForMap.some((bundleId) => !bundleIds.has(bundleId)) ||
        !bundleIdsForMap.includes(commonBundleId) ||
        !bundleIdsForMap.some((bundleId) => kindsById.get(bundleId) === 'map')) {
      fail(`invalid or duplicate map bundle graph: ${id || '<unnamed>'}`);
    }
    mapIds.add(id);
    maps.push({ id, bundleIds: bundleIdsForMap });
  }
  if (maps.length < 1) fail('at least one map bundle graph is required');
  if (!Number.isSafeInteger(manifest.totalBytes) || manifest.totalBytes !== totalBytes) {
    fail('totalBytes does not match the unique bundle files');
  }

  const fingerprint = retailBundleFingerprint({
    sourceRevision: manifest.sourceRevision,
    sourceFingerprint: manifest.sourceFingerprint,
    bundles,
    maps,
  });
  if (fingerprint !== manifest.fingerprint ||
      manifest.revision !== `q4bundles-${fingerprint.slice(0, 20)}`) {
    fail('fingerprint or revision does not match the bundle graph');
  }

  return {
    manifest: {
      schema: RETAIL_BUNDLE_SCHEMA,
      policy: RETAIL_BUNDLE_POLICY,
      revision: manifest.revision,
      fingerprint,
      sourceRevision: manifest.sourceRevision,
      sourceFingerprint: manifest.sourceFingerprint,
      performancePreset: 'balanced',
      maxMachineSpec: 2,
      totalBytes,
      bundles: bundles.map(({ path: _path, stat: _stat, ...bundle }) => bundle),
      maps,
    },
    byUrl: new Map(bundles.map((bundle) => [bundle.url, bundle])),
  };
}

export function loadRetailBundleCatalog({ enabled, directory, manifestFile }, sourceRetailCatalog) {
  if (!enabled || !sourceRetailCatalog) return null;
  let parsed;
  try {
    parsed = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
  } catch (error) {
    if (error?.code === 'ENOENT') return null;
    throw error;
  }
  const catalog = validateRetailBundleManifest(parsed, directory, sourceRetailCatalog.manifest);
  const descriptors = [];
  try {
    for (const file of catalog.byUrl.values()) {
      const fd = fs.openSync(file.path, 'r');
      descriptors.push(fd);
      const pinned = fs.fstatSync(fd);
      if (!pinned.isFile() || pinned.dev !== file.stat.dev || pinned.ino !== file.stat.ino ||
          pinned.size !== file.stat.size) {
        fail(`bundle changed while the catalog was being pinned: ${file.name}`);
      }
      file.fd = fd;
      file.stat = pinned;
    }
    let closed = false;
    return {
      ...catalog,
      directory: path.resolve(directory),
      manifestFile: path.resolve(manifestFile),
      close() {
        if (closed) return;
        closed = true;
        for (const fd of descriptors) fs.closeSync(fd);
      },
    };
  } catch (error) {
    for (const fd of descriptors) {
      try { fs.closeSync(fd); } catch {}
    }
    throw error;
  }
}
