#!/usr/bin/env node

import { createHash, randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import {
  copyFile,
  lstat,
  mkdir,
  readFile,
  readdir,
  rename,
  rm,
  stat,
  writeFile
} from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

const root = path.resolve(import.meta.dirname, '..');
const policyPath = path.resolve(process.env.Q4_PUBLIC_CONTENT_POLICY || path.join(root, 'content-src/public-runtime.json'));
const output = path.resolve(process.env.Q4_CONTENT_OUTPUT || process.argv[2] || path.join(root, 'content/assets/baseoq4'));
const nativeOutput = path.resolve(process.env.Q4_NATIVE_CONTENT_OUTPUT || path.join(root, 'runtime/native/baseoq4'));
const engineBuildInfo = path.resolve(process.env.Q4_ENGINE_BUILD_INFO || path.join(root, 'engine/build-info.txt'));
const auditTool = path.join(root, 'tools/audit-public-content.py');
const entityDefChecker = path.join(root, 'tools/check-entitydef-parity.py');
const runtimeBuilder = path.join(root, 'tools/build-free-runtime.py');
const auditReport = path.resolve(process.env.Q4_CONTENT_AUDIT_REPORT || path.join(root, '.selfcheck/content-license-audit.json'));
const addonPattern = /^q4web_[a-z0-9_-]{1,80}-[a-f0-9]{12}\.pk4$/;
const shaPattern = /^[a-f0-9]{64}$/;
const safeRelativePattern = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/;
const openQ4BaseVersionPattern = /^\d+\.\d+\.\d+$/;
const approvedLicenses = new Set(['CC0-1.0']);
const runtimePackNames = new Set(['pak0.pk4', 'pak1.pk4']);
const requiredModManifestFields = [
  'name',
  'version',
  'releaseDate',
  'website',
  'author',
  'requiredopenQ4Version'
];

function fail(message) {
  throw new Error(message);
}

function resolveInsideRoot(relative, label) {
  if (typeof relative !== 'string' || !safeRelativePattern.test(relative) || path.isAbsolute(relative)) {
    fail(`${label} is not a safe repository-relative path`);
  }
  const resolved = path.resolve(root, relative);
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
    fail(`${label} escapes the repository root`);
  }
  return resolved;
}

async function regularFile(file, label) {
  const info = await lstat(file).catch(() => null);
  if (!info?.isFile() || info.isSymbolicLink() || info.size <= 0) {
    fail(`${label} is missing, empty, or not a regular file: ${file}`);
  }
  return info;
}

async function regularDirectory(directory, label) {
  const info = await lstat(directory).catch(() => null);
  if (!info?.isDirectory() || info.isSymbolicLink()) {
    fail(`${label} is missing or not a regular directory: ${directory}`);
  }
}

async function sha256(file) {
  const hash = createHash('sha256');
  for await (const chunk of createReadStream(file)) hash.update(chunk);
  return hash.digest('hex');
}

function parseJson(text, label) {
  try {
    return JSON.parse(text);
  } catch (error) {
    fail(`${label} is not valid JSON: ${error.message}`);
  }
}

function run(command, args, label) {
  const result = spawnSync(command, args, {
    cwd: root,
    encoding: 'utf8',
    maxBuffer: 8 * 1024 * 1024
  });
  if (result.status !== 0) fail(`${label} failed:\n${result.stderr || result.stdout}`);
  return result.stdout;
}

function retailDirectories() {
  const configured = process.env.Q4_RETAIL_AUDIT_DIRS;
  const values = configured
    ? configured.split(path.delimiter).filter(Boolean)
    : [path.join(root, 'assets-src/gog/q4base')];
  if (!values.length) fail('Q4_RETAIL_AUDIT_DIRS does not name any private retail directory');
  return values.map((entry) => path.resolve(entry));
}

function buildFreeRuntime(stage) {
  return parseJson(run('python3', [
    runtimeBuilder,
    '--source', path.join(root, 'content-src/baseoq4'),
    '--output', stage
  ], 'free runtime build'), 'free runtime build output');
}

async function validateEngineBuild(runtimeSourceSha256) {
  await regularFile(engineBuildInfo, 'frozen engine build-info');
  const values = new Map();
  for (const line of (await readFile(engineBuildInfo, 'utf8')).split(/\r?\n/).filter(Boolean)) {
    const separator = line.indexOf('=');
    if (separator <= 0 || values.has(line.slice(0, separator))) {
      fail(`invalid frozen engine build-info line: ${line}`);
    }
    values.set(line.slice(0, separator), line.slice(separator + 1));
  }
  if (values.get('runtime_source_sha256') !== runtimeSourceSha256) {
    fail(
      'frozen engine was not built against the approved free runtime source ' +
      `(expected ${runtimeSourceSha256}, got ${values.get('runtime_source_sha256') || 'missing'})`
    );
  }
}

async function validatePolicy(stage, freeRuntime) {
  await regularFile(policyPath, 'public content policy');
  const policy = parseJson(await readFile(policyPath, 'utf8'), 'public content policy');
  if (policy?.schema !== 1 || policy.policy !== 'free-only-v1' || !Array.isArray(policy.files) ||
      !shaPattern.test(String(policy.runtimeSourceSha256 || '')) ||
      !openQ4BaseVersionPattern.test(String(policy.requiredopenQ4Version || ''))) {
    fail(
      'public content policy must use schema 1, policy free-only-v1, a runtime source fingerprint, ' +
      'and a numeric required openQ4 version'
    );
  }
  if (freeRuntime?.schema !== 1 || freeRuntime.policy !== policy.policy ||
      freeRuntime.sourceSha256 !== policy.runtimeSourceSha256 || !Array.isArray(freeRuntime.packs)) {
    fail('deterministic free runtime build disagrees with the public content policy');
  }
  if (!Array.isArray(policy.revokedBuilds) ||
      policy.revokedBuilds.some((entry) => typeof entry !== 'string' || !/^[a-z0-9._-]{1,120}$/.test(entry))) {
    fail('public content policy has invalid revokedBuilds');
  }
  const revokedBuilds = [...new Set(policy.revokedBuilds)].sort();
  if (revokedBuilds.length !== policy.revokedBuilds.length) fail('public content policy repeats a revoked build');

  const builtPacks = new Map(freeRuntime.packs.map((entry) => [entry?.name, entry]));
  const names = new Set();
  const approvedPacks = new Set();
  const records = [];
  let metadataCount = 0;
  let addonCount = 0;
  for (const raw of policy.files) {
    if (!raw || typeof raw !== 'object') fail('public content policy contains a non-object file entry');
    const name = String(raw.name || '');
    const kind = String(raw.kind || '');
    if (names.has(name)) fail(`public content policy repeats ${name}`);
    names.add(name);

    let sourceFile;
    if (kind === 'metadata') {
      metadataCount += 1;
      if (name !== 'mod.json') fail('the only permitted public metadata file is mod.json');
      sourceFile = resolveInsideRoot(raw.path, `${name} source`);
    } else if (kind === 'runtime-pack') {
      if (!runtimePackNames.has(name)) fail(`unapproved runtime pack name: ${name}`);
      approvedPacks.add(name);
      const sourceDirectory = resolveInsideRoot(raw.sourceDir, `${name} sourceDir`);
      await regularDirectory(sourceDirectory, `${name} sourceDir`);
      if (sourceDirectory !== path.join(root, 'content-src/baseoq4', name.slice(0, -4))) {
        fail(`${name} sourceDir is not the canonical self-authored pack source`);
      }
      sourceFile = path.join(stage, name);
      const built = builtPacks.get(name);
      if (!built || built.sha256 !== raw.sha256 || built.bytes !== raw.bytes) {
        fail(`${name} deterministic build differs from its pinned policy`);
      }
    } else if (kind === 'addon') {
      addonCount += 1;
      if (!addonPattern.test(name)) fail(`invalid approved addon name: ${name}`);
      sourceFile = resolveInsideRoot(raw.path, `${name} source`);
    } else {
      fail(`unsupported public content kind for ${name}`);
    }

    if (!approvedLicenses.has(raw.license) || raw.source !== 'self-authored') {
      fail(`${name} does not have approved self-authored free provenance`);
    }
    if (!shaPattern.test(String(raw.sha256 || '')) ||
        !Number.isSafeInteger(raw.bytes) || raw.bytes <= 0) {
      fail(`${name} has an invalid pinned size or SHA-256`);
    }
    const licenseEvidenceFile = resolveInsideRoot(raw.licenseFile, `${name} licenseFile`);
    const info = await regularFile(sourceFile, `${name} source`);
    await regularFile(licenseEvidenceFile, `${name} license evidence`);
    if (info.size !== raw.bytes || await sha256(sourceFile) !== raw.sha256) {
      fail(`${name} differs from its pinned public content policy`);
    }
    if (!/CC0 1\.0 Universal/i.test(await readFile(licenseEvidenceFile, 'utf8'))) {
      fail(`${name} license evidence does not contain CC0 1.0 Universal`);
    }
    if (kind === 'metadata') {
      const modManifest = parseJson(await readFile(sourceFile, 'utf8'), 'public mod.json');
      if (!modManifest || typeof modManifest !== 'object' || Array.isArray(modManifest)) {
        fail('public mod.json must be a JSON object');
      }
      for (const field of requiredModManifestFields) {
        if (typeof modManifest[field] !== 'string' || modManifest[field].length === 0) {
          fail(`public mod.json field ${field} must be a non-empty string`);
        }
      }
      if (modManifest.requiredopenQ4Version !== policy.requiredopenQ4Version) {
        fail(
          'public mod.json requiredopenQ4Version must match the version pinned by the public content policy'
        );
      }
    } else if (kind === 'addon') {
      const indexFile = resolveInsideRoot(raw.index, `${name} index`);
      await regularFile(indexFile, `${name} index`);
      const index = parseJson(await readFile(indexFile, 'utf8'), `${name} index`);
      const matches = Array.isArray(index.maps)
        ? index.maps.filter((entry) => entry?.map === raw.map)
        : [];
      if (index.schema !== 1 || matches.length !== 1) {
        fail(`${name} is not represented exactly once in its map index`);
      }
      const record = matches[0];
      for (const field of ['file', 'sha256', 'bytes', 'license', 'source', 'author']) {
        if (record[field] !== raw[field === 'file' ? 'name' : field]) {
          fail(`${name} provenance disagrees with mods/index.json field ${field}`);
        }
      }
    }
    records.push({ ...raw, sourceFile, licenseEvidenceFile });
  }
  if (metadataCount !== 1 || addonCount < 1 ||
      approvedPacks.size !== runtimePackNames.size ||
      [...runtimePackNames].some((name) => !approvedPacks.has(name))) {
    fail('public policy requires mod.json, both minimal runtime packs, and at least one approved addon');
  }
  return {
    policy,
    revokedBuilds,
    records: records.sort((left, right) => left.name.localeCompare(right.name, 'en'))
  };
}

function runAudit(stage, allowlist, retail) {
  const args = [
    auditTool,
    '--public-dir', stage,
    '--allowlist', allowlist,
    '--report', auditReport
  ];
  for (const directory of retail) args.push('--retail-dir', directory);
  process.stdout.write(run('python3', args, 'public content audit'));
}

function verifyEntityDefParity(stage, records) {
  const args = [
    entityDefChecker,
    '--native-pack', path.join(nativeOutput, 'pak0.pk4'),
    '--native-pack', path.join(nativeOutput, 'pak1.pk4'),
    '--browser-pack', path.join(stage, 'pak0.pk4'),
    '--browser-pack', path.join(stage, 'pak1.pk4')
  ];
  for (const record of records.filter((entry) => entry.kind === 'addon')) {
    args.push('--shared-pack', path.join(stage, record.name));
  }
  process.stdout.write(run('python3', args, 'native/browser entityDef parity check'));
}

async function publishDirectory(stage) {
  const backup = `${output}.old-${process.pid}-${randomBytes(4).toString('hex')}`;
  let hadOutput = false;
  try {
    await rename(output, backup);
    hadOutput = true;
  } catch (error) {
    if (error.code !== 'ENOENT') throw error;
  }
  try {
    await rename(stage, output);
  } catch (error) {
    if (hadOutput) await rename(backup, output).catch(() => {});
    throw error;
  }
  if (hadOutput) await rm(backup, { recursive: true, force: true });
}

async function syncNativeAddons(records) {
  const info = await stat(nativeOutput).catch(() => null);
  if (!info?.isDirectory()) return;
  const approved = records.filter((record) => record.kind === 'addon');
  const keep = new Set(approved.map((record) => record.name));
  for (const name of await readdir(nativeOutput)) {
    if (addonPattern.test(name) && !keep.has(name)) {
      await rm(path.join(nativeOutput, name), { force: true });
    }
  }
  for (const record of approved) {
    const temporary = path.join(nativeOutput, `.${record.name}.tmp-${process.pid}`);
    await copyFile(record.sourceFile, temporary);
    await rename(temporary, path.join(nativeOutput, record.name));
  }
}

const allowlist = resolveInsideRoot(
  process.env.Q4_RETAIL_DUPLICATE_ALLOWLIST || 'content-src/retail-duplicate-allowlist.json',
  'retail duplicate allowlist'
);
await regularFile(allowlist, 'retail duplicate allowlist');
await regularFile(auditTool, 'public content audit tool');
await regularFile(entityDefChecker, 'entityDef parity checker');
await regularFile(runtimeBuilder, 'free runtime builder');

const stage = path.join(
  path.dirname(output),
  `.${path.basename(output)}.stage-${process.pid}-${randomBytes(4).toString('hex')}`
);
await mkdir(path.dirname(output), { recursive: true });
await rm(stage, { recursive: true, force: true });
await mkdir(stage, { recursive: true });

try {
  const freeRuntime = buildFreeRuntime(stage);
  const { policy, revokedBuilds, records } = await validatePolicy(stage, freeRuntime);
  await validateEngineBuild(policy.runtimeSourceSha256);

  const files = [];
  for (const record of records) {
    const destination = path.join(stage, record.name);
    if (record.kind !== 'runtime-pack') await copyFile(record.sourceFile, destination);
    files.push({
      name: record.name,
      size: record.bytes,
      sha256: record.sha256,
      url: `/content/baseoq4/${record.name}`,
      kind: record.kind,
      license: record.license,
      source: record.source
    });
  }
  verifyEntityDefParity(stage, records);
  const fingerprint = createHash('sha256')
    .update(records.map((record) => [
      record.name,
      record.sha256,
      record.bytes,
      record.kind,
      record.license,
      record.source
    ].join('\0')).join('\n'))
    .digest('hex');
  const manifest = {
    schema: 2,
    policy: policy.policy,
    build: `q4web-free-${fingerprint.slice(0, 20)}`,
    runtimeSourceSha256: policy.runtimeSourceSha256,
    revokedBuilds,
    files
  };
  await writeFile(path.join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
  runAudit(stage, allowlist, retailDirectories());
  await syncNativeAddons(records);
  await publishDirectory(stage);
  process.stdout.write(`${path.join(output, 'manifest.json')}\n`);
} catch (error) {
  await rm(stage, { recursive: true, force: true });
  throw error;
}
