#!/usr/bin/env node

import { createHash } from 'node:crypto';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

const ROOT = path.resolve(import.meta.dirname, '..');
const DIST = path.resolve(process.env.Q4_MODS_DIST || path.join(ROOT, 'mods/dist'));
const INDEX = path.resolve(process.env.Q4_MODS_INDEX || path.join(ROOT, 'mods/index.json'));
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
const MAX_ENTRIES = 4096;
const MAX_RATIO = 200;
const MAP_COMPILE_TIMEOUT_MS = 90 * 60_000;
const SAFE_RESTART_TIMEOUT_MS = 15 * 60_000;
const FREE_LICENSES = new Set([
  'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'GPL-2.0-or-later',
  'GPL-3.0-or-later', 'MIT', 'BSD-2-Clause', 'BSD-3-Clause'
]);
const ADDON_PATTERN = /^q4web_[a-z0-9_-]{1,80}-[a-f0-9]{12}\.pk4$/;

function die(message) {
  const error = new Error(message);
  error.userFacing = true;
  throw error;
}

function parseArgs(argv) {
  const options = { force: false, stage: true, restart: true, author: '' };
  const positional = [];
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === '--force') options.force = true;
    else if (arg === '--no-stage') options.stage = false;
    else if (arg === '--no-restart') options.restart = false;
    else if (['--license', '--source', '--author', '--client'].includes(arg)) {
      const value = argv[++index];
      if (!value || value.startsWith('--')) die(`Brak wartości dla ${arg}.`);
      options[arg.slice(2)] = value;
    } else if (arg.startsWith('--')) die(`Nieznana opcja: ${arg}`);
    else positional.push(arg);
  }
  if (positional.length !== 1) {
    die('Użycie: node tools/ingest.mjs mapa.pk4 --license SPDX --source URL [--author nazwa] [--force]');
  }
  options.input = path.resolve(positional[0]);
  if (!FREE_LICENSES.has(options.license)) {
    die(`--license musi być wolną licencją: ${[...FREE_LICENSES].join(', ')}`);
  }
  if (options.source !== 'self-authored') {
    let source;
    try { source = new URL(options.source); } catch { die('--source musi być adresem HTTPS albo wartością self-authored.'); }
    if (source.protocol !== 'https:') die('--source musi używać HTTPS.');
  }
  return options;
}

function run(command, args, options = {}) {
  const result = spawnSync(command, args, {
    cwd: options.cwd || ROOT,
    encoding: 'utf8',
    timeout: options.timeout || 120_000,
    maxBuffer: options.maxBuffer || 64 * 1024 * 1024,
    env: options.env || process.env
  });
  if (result.error || result.status !== 0) {
    const details = (result.stderr || result.stdout || result.error?.message || '').trim();
    die(`${options.label || command} nie powiodło się${details ? `:\n${details.slice(-6000)}` : '.'}`);
  }
  return result.stdout;
}

function archiveEntries(file) {
  run('unzip', ['-tqq', file], { label: 'Walidacja ZIP' });
  const output = run('zipinfo', ['-l', file], { label: 'Odczyt katalogu ZIP' });
  const entries = [];
  for (const line of output.split('\n')) {
    const match = /^([dl-][rwxstST-]{9})\s+\S+\s+\S+\s+(\d+)\s+\S+\s+(\d+)\s+\S+\s+\S+\s+\S+\s+(.+)$/.exec(line);
    if (!match) continue;
    entries.push({ mode: match[1], size: Number(match[2]), compressed: Number(match[3]), name: match[4] });
  }
  if (!entries.length) die('PK4 nie zawiera plików.');
  if (entries.length > MAX_ENTRIES) die(`PK4 przekracza limit ${MAX_ENTRIES} wpisów.`);

  const names = new Set();
  let expanded = 0;
  let compressed = 0;
  for (const entry of entries) {
    const archivePath = entry.mode.startsWith('d') && entry.name.endsWith('/') ? entry.name.slice(0, -1) : entry.name;
    const parts = archivePath.split('/');
    if (entry.mode.startsWith('l')) die(`Dowiązania symboliczne nie są dozwolone: ${entry.name}`);
    if (!archivePath || archivePath.length > 240 || archivePath.includes('\\') || archivePath.startsWith('/') ||
        !/^[A-Za-z0-9_.\/-]+$/.test(archivePath) || parts.some((part) => !part || part === '.' || part === '..')) {
      die(`Niebezpieczna albo nieobsługiwana ścieżka w PK4: ${entry.name}`);
    }
    const key = archivePath.toLowerCase();
    if (names.has(key)) die(`Powtórzona ścieżka (bez rozróżniania wielkości liter): ${entry.name}`);
    names.add(key);
    if (!entry.mode.startsWith('d')) {
      expanded += entry.size;
      compressed += entry.compressed;
      if (/\.(dll|so|dylib|exe|com|bat|cmd|js|mjs|wasm)$/i.test(entry.name)) {
        die(`Pakiety danych nie mogą zawierać kodu wykonywalnego: ${entry.name}`);
      }
    }
  }
  if (expanded > MAX_EXPANDED_BYTES) die('Rozpakowana zawartość PK4 przekracza 1 GiB.');
  if (compressed > 0 && expanded / compressed > MAX_RATIO) die(`Podejrzany współczynnik kompresji PK4 (${(expanded / compressed).toFixed(1)}:1).`);
  return entries.filter((entry) => !entry.mode.startsWith('d'));
}

function mapSources(entries) {
  return entries.filter((entry) => /^maps\/(?:mp\/)?[a-z0-9_-]+\.map$/i.test(entry.name));
}

function mapProducts(entries, extension) {
  return entries.filter((entry) => new RegExp(`^maps/(?:mp/)?[a-z0-9_-]+\\.${extension}$`, 'i').test(entry.name));
}

function mapIdentity(file) {
  const normalized = file.replace(/\\/g, '/').replace(/^maps\//i, '').replace(/\.[^.]+$/, '');
  if (!/^(?:mp\/)?[a-z0-9_-]+$/i.test(normalized)) die(`Nieprawidłowa nazwa mapy: ${file}`);
  return normalized.toLowerCase();
}

function prepareCompilerRoot(engineRoot, temporary) {
  const compilerRoot = path.join(temporary, 'compiler');
  const compilerGame = path.join(compilerRoot, 'baseoq4');
  const runtimeGame = path.join(engineRoot, 'baseoq4');
  const runtimeRetail = path.join(engineRoot, 'q4base');
  fs.mkdirSync(compilerGame, { recursive: true });

  if (!fs.existsSync(runtimeRetail)) die(`Brak danych bazowych kompilatora: ${runtimeRetail}`);
  fs.symlinkSync(fs.realpathSync(runtimeRetail), path.join(compilerRoot, 'q4base'), 'dir');
  for (const name of fs.readdirSync(runtimeGame).sort()) {
    if (ADDON_PATTERN.test(name)) continue;
    const source = path.join(runtimeGame, name);
    if (!fs.statSync(source).isFile()) continue;
    fs.symlinkSync(source, path.join(compilerGame, name), 'file');
  }
  return compilerRoot;
}

function compileMap(input, source, options, temporary) {
  const client = path.resolve(options.client || process.env.Q4_CLIENT_BIN || path.join(ROOT, 'runtime/native/openQ4-client_x64'));
  if (!fs.existsSync(client)) die(`Brak natywnego kompilatora ${client}. Najpierw uruchom tools/build-native.sh.`);
  const gameRoot = path.join(temporary, 'dev/baseoq4');
  const saveRoot = path.join(temporary, 'save');
  const home = path.join(temporary, 'home');
  fs.mkdirSync(gameRoot, { recursive: true });
  fs.mkdirSync(saveRoot, { recursive: true });
  fs.mkdirSync(home, { recursive: true });
  run('unzip', ['-qq', input, '-d', gameRoot], { label: 'Rozpakowanie mapy' });

  const map = mapIdentity(source.name);
  const stagedSource = path.join(saveRoot, 'baseoq4', `maps/${map}.map`);
  fs.mkdirSync(path.dirname(stagedSource), { recursive: true });
  fs.copyFileSync(path.join(gameRoot, source.name), stagedSource);
  const engineRoot = path.dirname(client);
  const compilerRoot = prepareCompilerRoot(engineRoot, temporary);
  const compilerClient = path.join(compilerRoot, path.basename(client));
  fs.copyFileSync(client, compilerClient);
  fs.chmodSync(compilerClient, fs.statSync(client).mode & 0o777);
  const result = spawnSync('xvfb-run', ['-a', compilerClient,
    '+set', 'fs_basepath', compilerRoot,
    '+set', 'fs_cdpath', compilerRoot,
    '+set', 'fs_devpath', path.join(temporary, 'dev'),
    '+set', 'fs_savepath', saveRoot,
    '+set', 'fs_game', 'baseoq4',
    '+set', 'sys_allowMultipleInstances', '1',
    '+set', 'fs_caseSensitiveOS', '0',
    '+set', 'r_fullscreen', '0',
    '+set', 's_noSound', '1',
    '+dmap', map,
    '+runAAS', map,
    '+quit'
  ], {
    cwd: compilerRoot,
    encoding: 'utf8',
    timeout: MAP_COMPILE_TIMEOUT_MS,
    maxBuffer: 128 * 1024 * 1024,
    env: { ...process.env, HOME: home }
  });
  const log = `${result.stdout || ''}\n${result.stderr || ''}`;
  fs.writeFileSync(path.join(temporary, 'compile.log'), log);
  const engineFatal = /(?:^|\n)(?:\^[0-9])*(?:ERROR|FATAL):/i.test(log);
  if (result.error || result.status !== 0 || engineFatal) {
    const reason = result.error?.message || `kod wyjścia ${result.status}`;
    die(`Kompilacja mapy nie powiodła się (${reason}). Ostatnie wpisy:\n${log.slice(-6000)}`);
  }

  for (const extension of ['proc', 'cm', 'aas32']) {
    const product = path.join(gameRoot, `maps/${map}.${extension}`);
    const savedProduct = path.join(saveRoot, 'baseoq4', `maps/${map}.${extension}`);
    if (!fs.existsSync(product) && fs.existsSync(savedProduct)) {
      fs.mkdirSync(path.dirname(product), { recursive: true });
      fs.copyFileSync(savedProduct, product);
    }
    if (!fs.existsSync(product) || fs.statSync(product).size < 16) {
      die(`Kompilator nie utworzył kompletnego maps/${map}.${extension}.`);
    }
  }

  const work = path.join(temporary, 'compiled.pk4');
  run('zip', ['-rqX', work, '.'], { cwd: gameRoot, timeout: 120_000, label: 'Pakowanie skompilowanej mapy' });
  return work;
}

function firstBytes(archive, entry, temporary) {
  const target = path.join(temporary, 'inspect');
  fs.mkdirSync(target, { recursive: true });
  run('unzip', ['-oqq', archive, entry, '-d', target], { label: `Odczyt ${entry}` });
  const data = fs.readFileSync(path.join(target, entry));
  if (data.length < 16) die(`Plik ${entry} jest pusty albo uszkodzony.`);
  return data.subarray(0, 32).toString('ascii');
}

function addText(archive, relative, value, temporary) {
  const stage = path.join(temporary, 'metadata');
  const file = path.join(stage, relative);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, value);
  fs.utimesSync(file, new Date('2000-01-01T00:00:00Z'), new Date('2000-01-01T00:00:00Z'));
  run('zip', ['-qX', archive, relative], { cwd: stage, label: `Dodanie ${relative}` });
}

function corePaths() {
  const result = new Set();
  const directories = process.env.Q4_CORE_PATHS_DIRS
    ? process.env.Q4_CORE_PATHS_DIRS.split(path.delimiter).filter(Boolean).map((entry) => path.resolve(entry))
    : [path.join(ROOT, 'assets-src/gog/q4base'), path.join(ROOT, 'runtime/native/baseoq4')];
  for (const directory of directories) {
    if (!fs.existsSync(directory)) continue;
    for (const name of fs.readdirSync(directory).filter((entry) => /\.pk4$/i.test(entry) && !ADDON_PATTERN.test(entry)).sort()) {
      const file = path.join(directory, name);
      let listing;
      try { listing = execFileSync('unzip', ['-Z1', file], { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 }); } catch { continue; }
      for (const entry of listing.split('\n')) if (entry) result.add(entry.toLowerCase());
    }
  }
  return result;
}

function sha256(file) {
  const hash = createHash('sha256');
  const descriptor = fs.openSync(file, 'r');
  const buffer = Buffer.allocUnsafe(1024 * 1024);
  try {
    while (true) {
      const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, null);
      if (!bytes) break;
      hash.update(buffer.subarray(0, bytes));
    }
  } finally { fs.closeSync(descriptor); }
  return hash.digest('hex');
}

function updateIndex(record) {
  fs.mkdirSync(path.dirname(INDEX), { recursive: true });
  let index = { schema: 1, maps: [] };
  try { index = JSON.parse(fs.readFileSync(INDEX, 'utf8')); } catch {}
  index.schema = 1;
  index.maps = (Array.isArray(index.maps) ? index.maps : []).filter((entry) => entry.map !== record.map);
  index.maps.push(record);
  index.maps.sort((left, right) => left.map.localeCompare(right.map, 'en'));
  fs.writeFileSync(`${INDEX}.tmp-${process.pid}`, `${JSON.stringify(index, null, 2)}\n`);
  fs.renameSync(`${INDEX}.tmp-${process.pid}`, INDEX);
}

function main() {
  const options = parseArgs(process.argv.slice(2));
  if (!fs.existsSync(options.input) || !fs.statSync(options.input).isFile()) die(`Brak pliku: ${options.input}`);
  if (fs.statSync(options.input).size > MAX_ARCHIVE_BYTES) die('PK4 przekracza limit 512 MiB.');
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-ingest-'));
  try {
    let work = path.join(temporary, 'work.pk4');
    fs.copyFileSync(options.input, work);
    let entries = archiveEntries(work);
    let products = mapProducts(entries, 'proc');
    const sources = mapSources(entries);
    if (products.length === 0) {
      if (sources.length !== 1) die('PK4 musi zawierać dokładnie jedną mapę źródłową albo jeden maps/.../*.proc.');
      work = compileMap(work, sources[0], options, temporary);
      entries = archiveEntries(work);
      products = mapProducts(entries, 'proc');
    }
    if (products.length !== 1) die('Pipeline obsługuje dokładnie jedną mapę w każdym PK4.');

    const map = mapIdentity(products[0].name);
    const prefix = `maps/${map}`;
    const byName = new Map(entries.map((entry) => [entry.name.toLowerCase(), entry]));
    for (const extension of ['map', 'proc', 'cm', 'aas32']) {
      if (!byName.has(`${prefix}.${extension}`)) die(`Brak ${prefix}.${extension}; mapa i dane botów muszą być kompletne.`);
    }
    if (!firstBytes(work, `${prefix}.proc`, temporary).startsWith('PROC "4"')) die(`${prefix}.proc ma nieprawidłowy nagłówek Quake 4.`);
    if (!firstBytes(work, `${prefix}.cm`, temporary).startsWith('CM "3"')) die(`${prefix}.cm ma nieprawidłowy nagłówek Quake 4.`);
    if (!firstBytes(work, `${prefix}.aas32`, temporary).startsWith('DewmAAS "1.08"')) die(`${prefix}.aas32 ma nieprawidłowy nagłówek Quake 4.`);

    const collisions = corePaths();
    for (const entry of entries) {
      if (collisions.has(entry.name.toLowerCase())) die(`Pakiet próbuje nadpisać plik rdzenia: ${entry.name}`);
    }

    const slug = map.replace('/', '_');
    const defName = `def/q4web_${slug}.def`;
    if (!byName.has(defName)) {
      const title = slug.replace(/^mp_/, '').split('_').map((part) => part ? part[0].toUpperCase() + part.slice(1) : '').join(' ');
      addText(work, defName, `mapDef ${map} {\n  "name" "${title}"\n  "DM" "1"\n  "Team DM" "1"\n  "Tourney" "1"\n  "Gametype_Deathmatch" "1"\n  "Gametype_TeamDeathmatch" "1"\n  "Gametype_Tournament" "1"\n}\n`, temporary);
    }
    const metadata = {
      schema: 1,
      map,
      license: options.license,
      source: options.source,
      author: options.author || null
    };
    addText(work, `q4web/${slug}.json`, `${JSON.stringify(metadata, null, 2)}\n`, temporary);

    const hash = sha256(work);
    const filename = `q4web_${slug}-${hash.slice(0, 12)}.pk4`;
    fs.mkdirSync(DIST, { recursive: true });
    const previous = fs.readdirSync(DIST).filter((name) => name.startsWith(`q4web_${slug}-`) && ADDON_PATTERN.test(name));
    if (previous.some((name) => name !== filename) && !options.force) {
      die(`Mapa ${map} jest już opublikowana. Użyj --force, aby zastąpić jej wersję.`);
    }
    const destination = path.join(DIST, filename);
    fs.copyFileSync(work, `${destination}.tmp-${process.pid}`);
    fs.renameSync(`${destination}.tmp-${process.pid}`, destination);
    for (const name of previous) if (name !== filename) fs.rmSync(path.join(DIST, name), { force: true });
    updateIndex({ ...metadata, file: filename, sha256: hash, bytes: fs.statSync(destination).size });

    if (options.stage) run(process.execPath, [path.join(ROOT, 'tools/stage-web-content.mjs')], { timeout: 5 * 60_000, label: 'Publikacja contentu' });
    if (options.restart && spawnSync('systemctl', ['is-active', '--quiet', 'q4']).status === 0) {
      run(path.join(ROOT, 'tools/safe_restart.sh'), [], { timeout: SAFE_RESTART_TIMEOUT_MS, label: 'Bezpieczny restart q4' });
    }
    process.stdout.write(`${JSON.stringify({ ok: true, map, file: filename, sha256: hash, bots: `${prefix}.aas32` }, null, 2)}\n`);
  } finally {
    if (process.env.Q4_INGEST_KEEP_TEMP === '1') process.stderr.write(`Katalog roboczy ingest: ${temporary}\n`);
    else fs.rmSync(temporary, { recursive: true, force: true });
  }
}

try { main(); } catch (error) {
  process.stderr.write(`${error.userFacing ? '' : 'Błąd: '}${error.message || error}\n`);
  process.exitCode = 1;
}
