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

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

export const RETAIL_SCHEMA = 1;
export const RETAIL_POLICY = 'operator-authorized-retail-v1';
export const RETAIL_FILE_NAMES = Object.freeze([
  ...Array.from({ length: 22 }, (_, index) => `pak${String(index + 1).padStart(3, '0')}.pk4`),
  'zpak_english.pk4',
]);

const SHA256_PATTERN = /^[a-f0-9]{64}$/;

export function retailFingerprint(files) {
  const hash = createHash('sha256');
  for (const file of [...files].sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
    hash.update(`${file.name}\0${file.size}\0${file.sha256}\n`);
  }
  return hash.digest('hex');
}

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

export function validateRetailManifest(manifest, retailDir, { verifyFiles = true } = {}) {
  if (manifest?.schema !== RETAIL_SCHEMA || manifest?.policy !== RETAIL_POLICY ||
      !Array.isArray(manifest.files) || !SHA256_PATTERN.test(String(manifest.fingerprint || ''))) {
    fail('invalid schema, policy, files, or fingerprint');
  }

  const expected = new Set(RETAIL_FILE_NAMES);
  const names = new Set();
  let totalBytes = 0;
  const files = [];
  for (const raw of manifest.files) {
    const name = String(raw?.name || '').toLowerCase();
    const size = Number(raw?.size);
    const lastModified = Number(raw?.lastModified);
    const sha256 = String(raw?.sha256 || '').toLowerCase();
    const url = String(raw?.url || '');
    if (!expected.has(name) || names.has(name) || !Number.isSafeInteger(size) || size <= 0 ||
        !Number.isSafeInteger(lastModified) || lastModified < 0 || !SHA256_PATTERN.test(sha256) ||
        url !== `/retail/q4base/${sha256}-${encodeURIComponent(name)}`) {
      fail(`invalid or unapproved file entry: ${name || '<unnamed>'}`);
    }
    const resolved = resolveFile(retailDir, name);
    if (verifyFiles && (!resolved || resolved.stat.size !== size || Math.trunc(resolved.stat.mtimeMs) !== lastModified)) {
      fail(`file metadata differs from the staged manifest: ${name}`);
    }
    names.add(name);
    totalBytes += size;
    files.push({ name, size, lastModified, sha256, url, path: resolved?.path, stat: resolved?.stat });
  }

  if (names.size !== expected.size || [...expected].some((name) => !names.has(name))) {
    fail('manifest must contain exactly the approved English retail package set');
  }
  if (!Number.isSafeInteger(manifest.totalBytes) || manifest.totalBytes !== totalBytes) {
    fail('totalBytes does not match the approved files');
  }
  const fingerprint = retailFingerprint(files);
  if (fingerprint !== manifest.fingerprint || manifest.revision !== `q4retail-${fingerprint.slice(0, 20)}`) {
    fail('fingerprint or revision does not match the file records');
  }

  return {
    manifest: {
      schema: RETAIL_SCHEMA,
      policy: RETAIL_POLICY,
      revision: manifest.revision,
      fingerprint,
      totalBytes,
      files: files.map(({ path: _path, stat: _stat, ...file }) => file),
    },
    byUrl: new Map(files.map((file) => [file.url, file])),
  };
}

export function loadRetailCatalog({ enabled, directory, manifestFile }) {
  if (!enabled) return null;
  const parsed = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
  const catalog = validateRetailManifest(parsed, directory);
  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(`file 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;
  }
}
