#!/usr/bin/env node

import { createHash, randomBytes } from 'node:crypto';
import { execFile } from 'node:child_process';
import { createReadStream } from 'node:fs';
import {
  chmod,
  lstat,
  mkdir,
  readFile,
  rename,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';

import {
  RETAIL_FILE_NAMES,
  RETAIL_POLICY,
  RETAIL_SCHEMA,
  retailFingerprint,
  validateRetailManifest,
} from '../server/retail.js';
import {
  parseLanguageTable,
  parsePlayerModels,
  RETAIL_UI_CATALOG_SCHEMA,
} from '../server/retail-ui-catalog.js';

const execFileAsync = promisify(execFile);

const root = path.resolve(import.meta.dirname, '..');
const args = process.argv.slice(2);
if (args.some((arg) => arg !== '--verify') || args.length > 1) {
  throw new Error('usage: node tools/stage-retail-manifest.mjs [--verify]');
}
const forceHash = args[0] === '--verify';
const retailDir = path.resolve(process.env.Q4_RETAIL_DIR || path.join(root, 'assets-src/gog/q4base'));
const output = path.resolve(process.env.Q4_RETAIL_MANIFEST || path.join(root, 'runtime/retail-manifest.json'));

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

async function existingManifest() {
  try {
    return JSON.parse(await readFile(output, 'utf8'));
  } catch (error) {
    if (error?.code === 'ENOENT' || error instanceof SyntaxError) return null;
    throw error;
  }
}

const previous = await existingManifest();
const previousFiles = new Map((previous?.files || []).map((file) => [file.name, file]));
const files = [];
for (const name of RETAIL_FILE_NAMES) {
  const file = path.join(retailDir, name);
  const info = await lstat(file).catch(() => null);
  if (!info?.isFile() || info.isSymbolicLink() || info.size <= 0) {
    throw new Error(`approved retail package is missing, empty, or not a regular file: ${file}`);
  }
  const lastModified = Math.trunc((await stat(file)).mtimeMs);
  const cached = previousFiles.get(name);
  const digest = !forceHash && cached?.size === info.size && cached?.lastModified === lastModified &&
    /^[a-f0-9]{64}$/.test(String(cached.sha256 || ''))
    ? cached.sha256
    : await sha256(file);
  files.push({
    name,
    size: info.size,
    lastModified,
    sha256: digest,
    url: `/retail/q4base/${digest}-${encodeURIComponent(name)}`,
  });
}

const fingerprint = retailFingerprint(files);
const playerSource = await archiveText(path.join(retailDir, 'pak022.pk4'), 'def/player.def');
const languageSource = await archiveText(path.join(retailDir, 'zpak_english.pk4'), 'strings/english_code.lang');
const uiCatalog = {
  schema: RETAIL_UI_CATALOG_SCHEMA,
  models: parsePlayerModels(playerSource, parseLanguageTable(languageSource)),
};
const manifest = {
  schema: RETAIL_SCHEMA,
  policy: RETAIL_POLICY,
  revision: `q4retail-${fingerprint.slice(0, 20)}`,
  fingerprint,
  totalBytes: files.reduce((sum, file) => sum + file.size, 0),
  files,
  ui: uiCatalog,
};
validateRetailManifest(manifest, retailDir);

async function archiveText(archive, entry) {
  const { stdout } = await execFileAsync('unzip', ['-p', archive, entry], {
    encoding: 'utf8',
    maxBuffer: 16 * 1024 * 1024,
  });
  if (!stdout) throw new Error(`approved retail archive has no ${entry}: ${archive}`);
  return stdout;
}

await mkdir(path.dirname(output), { recursive: true });
const temporary = `${output}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
try {
  await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
  await chmod(temporary, 0o644);
  await rename(temporary, output);
} finally {
  await rm(temporary, { force: true });
}

process.stdout.write(`Retail manifest ${manifest.revision}: ${manifest.files.length} files, ${manifest.totalBytes} bytes, ` +
  `${uiCatalog.models.length} player models\n`);
