import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

import { MULTIPLAYER_MAPS } from '../public/js/menu-config.js';
import { createGateway } from '../server.js';
import {
  RETAIL_FILE_NAMES,
  RETAIL_POLICY,
  RETAIL_SCHEMA,
  retailFingerprint,
  validateRetailManifest,
} from '../server/retail.js';
import {
  RETAIL_BUNDLE_POLICY,
  RETAIL_BUNDLE_SCHEMA,
  retailBundleFingerprint,
  validateRetailBundleManifest,
} from '../server/retail-bundles.js';

function sha256(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

function request(port, pathname, options = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      host: '127.0.0.1',
      port,
      path: pathname,
      method: options.method || 'GET',
      headers: options.headers,
    }, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
    });
    req.on('error', reject);
    req.end();
  });
}

function stagedRetail(root, { largeFirst = false } = {}) {
  const directory = path.join(root, 'retail');
  fs.mkdirSync(directory);
  const files = [];
  for (const [index, name] of RETAIL_FILE_NAMES.entries()) {
    const bytes = largeFirst && index === 0
      ? Buffer.alloc(8 * 1024 * 1024, 0x51)
      : Buffer.from(`approved:${name}`);
    const file = path.join(directory, name);
    fs.writeFileSync(file, bytes);
    const stat = fs.statSync(file);
    const digest = sha256(bytes);
    files.push({
      name,
      size: stat.size,
      lastModified: Math.trunc(stat.mtimeMs),
      sha256: digest,
      url: `/retail/q4base/${digest}-${encodeURIComponent(name)}`,
    });
  }
  const fingerprint = retailFingerprint(files);
  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: {
      schema: 1,
      models: [{ id: 'model_player_marine', label: 'Kane', team: 'Marine' }],
    },
  };
  const manifestFile = path.join(root, 'retail-manifest.json');
  fs.writeFileSync(manifestFile, `${JSON.stringify(manifest)}\n`);
  return { directory, manifestFile, manifest };
}

function stagedRetailBundles(root, retail) {
  const directory = path.join(root, 'retail-bundles');
  fs.mkdirSync(directory);
  const definitions = [
    { id: 'common', name: 'pak001.pk4', kind: 'common', bytes: Buffer.from('shared assets') },
    { id: 'map/mp/q4web_arena', name: 'pak910_q4retail_q4web_arena.pk4', kind: 'map', bytes: Buffer.from('q4web assets') },
    { id: 'map/mp/q4dm3', name: 'pak910_q4retail_q4dm3.pk4', kind: 'map', bytes: Buffer.from('q4dm3 assets') },
  ];
  const bundles = definitions.map(({ bytes, ...definition }) => {
    const file = path.join(directory, definition.name);
    fs.writeFileSync(file, bytes);
    const digest = sha256(bytes);
    return {
      ...definition,
      size: bytes.length,
      sha256: digest,
      url: `/retail/bundles/${digest}-${encodeURIComponent(definition.name)}`,
    };
  });
  const maps = [
    { id: 'mp/q4web_arena', bundleIds: ['common', 'map/mp/q4web_arena'] },
    { id: 'mp/q4dm3', bundleIds: ['common', 'map/mp/q4dm3'] },
  ];
  const identity = {
    sourceRevision: retail.manifest.revision,
    sourceFingerprint: retail.manifest.fingerprint,
    bundles,
    maps,
  };
  const fingerprint = retailBundleFingerprint(identity);
  const manifest = {
    schema: RETAIL_BUNDLE_SCHEMA,
    policy: RETAIL_BUNDLE_POLICY,
    revision: `q4bundles-${fingerprint.slice(0, 20)}`,
    fingerprint,
    sourceRevision: identity.sourceRevision,
    sourceFingerprint: identity.sourceFingerprint,
    performancePreset: 'balanced',
    maxMachineSpec: 2,
    totalBytes: bundles.reduce((sum, bundle) => sum + bundle.size, 0),
    bundles,
    maps,
  };
  const manifestFile = path.join(root, 'retail-bundle-manifest.json');
  fs.writeFileSync(manifestFile, `${JSON.stringify(manifest)}\n`);
  return { directory, manifestFile, manifest };
}

test('bundle catalog requires one canonical pak001.pk4 common bundle', () => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-retail-bundle-contract-'));
  try {
    const retail = stagedRetail(root);
    const retailBundles = stagedRetailBundles(root, retail);
    const validate = (manifest) => validateRetailBundleManifest(
      manifest,
      retailBundles.directory,
      retail.manifest,
      { verifyFiles: false },
    );

    const missing = structuredClone(retailBundles.manifest);
    missing.bundles[0].kind = 'botnav';
    assert.throws(() => validate(missing), /exactly one common bundle named pak001\.pk4/);

    const renamed = structuredClone(retailBundles.manifest);
    renamed.bundles[0].name = 'pak900_q4retail_common.pk4';
    renamed.bundles[0].url = `/retail/bundles/${renamed.bundles[0].sha256}-${renamed.bundles[0].name}`;
    assert.throws(() => validate(renamed), /exactly one common bundle named pak001\.pk4/);

    const wrongCase = structuredClone(retailBundles.manifest);
    wrongCase.bundles[0].name = 'PAK001.PK4';
    assert.throws(() => validate(wrongCase), /exactly one common bundle named pak001\.pk4/);

    const duplicate = structuredClone(retailBundles.manifest);
    const secondCommon = {
      ...duplicate.bundles[1],
      id: 'common-secondary',
      name: 'pak002_common.pk4',
      kind: 'common',
    };
    secondCommon.url = `/retail/bundles/${secondCommon.sha256}-${secondCommon.name}`;
    duplicate.bundles.push(secondCommon);
    duplicate.totalBytes += secondCommon.size;
    assert.throws(() => validate(duplicate), /exactly one common bundle named pak001\.pk4/);
  } finally {
    fs.rmSync(root, { recursive: true, force: true });
  }
});

async function fixture(t, options = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-retail-server-'));
  const dirs = {};
  for (const name of ['public', 'engine', 'content', 'replays']) {
    dirs[name] = path.join(root, name);
    fs.mkdirSync(dirs[name]);
  }
  fs.writeFileSync(path.join(dirs.public, 'index.html'), '<!doctype html><title>q4</title>');
  const retail = stagedRetail(root, options);
  const retailBundles = options.bundles ? stagedRetailBundles(root, retail) : null;
  fs.writeFileSync(path.join(retail.directory, 'quake4key'), 'never exposed');
  fs.writeFileSync(path.join(retail.directory, 'gamex86.dll'), 'never exposed');
  const gateway = createGateway({
    rootDir: root,
    publicDir: dirs.public,
    engineDir: dirs.engine,
    contentDir: dirs.content,
    replayDir: dirs.replays,
    voicechatFile: path.join(dirs.public, 'missing-voicechat.js'),
    host: '127.0.0.1',
    port: 0,
    spawnDedicated: false,
    serveRetail: true,
    retailDir: retail.directory,
    retailManifestFile: retail.manifestFile,
    ...(retailBundles ? {
      retailBundleDir: retailBundles.directory,
      retailBundleManifestFile: retailBundles.manifestFile,
    } : {}),
    retailMaxConcurrent: options.retailMaxConcurrent || 16,
    retailMaxPerIp: options.retailMaxPerIp || 2,
  });
  await gateway.listen();
  t.after(async () => {
    await gateway.close();
    fs.rmSync(root, { recursive: true, force: true });
  });
  return { root, retail, retailBundles, gateway, port: gateway.address().port };
}

test('map bundles expose only their hash-bound graph and share retail range limits', async (t) => {
  const { port, retail, retailBundles } = await fixture(t, { bundles: true });
  const health = JSON.parse((await request(port, '/healthz')).body);
  assert.equal(health.retail, true);
  assert.equal(health.retailBundles, true);

  const manifestResponse = await request(port, '/retail/bundles/manifest.json');
  assert.equal(manifestResponse.status, 200);
  assert.equal(manifestResponse.headers['cache-control'], 'no-store');
  assert.deepEqual(JSON.parse(manifestResponse.body), retailBundles.manifest);

  const status = JSON.parse((await request(port, '/api/status')).body);
  assert.deepEqual(status.retail.bundles, {
    ready: true,
    revision: retailBundles.manifest.revision,
    files: retailBundles.manifest.bundles.length,
    maps: retailBundles.manifest.maps.length,
    totalBytes: retailBundles.manifest.totalBytes,
  });

  const approved = retailBundles.manifest.bundles[0];
  const expected = fs.readFileSync(path.join(retailBundles.directory, approved.name));
  const partial = await request(port, approved.url, {
    headers: { Range: 'bytes=1-5', 'If-Range': `"${approved.sha256}"` },
  });
  assert.equal(partial.status, 206);
  assert.equal(partial.headers.etag, `"${approved.sha256}"`);
  assert.equal(partial.headers['cache-control'], 'private, no-store');
  assert.equal(partial.headers['content-range'], `bytes 1-5/${approved.size}`);
  assert.deepEqual(partial.body, expected.subarray(1, 6));

  for (const pathname of [
    '/retail/bundles/not-listed.pk4',
    '/retail/bundles/%2e%2e%2fretail-manifest.json',
    `/retail/q4base/${approved.sha256}-${approved.name}`,
  ]) {
    assert.equal((await request(port, pathname)).status, 404, pathname);
  }

  const stale = structuredClone(retailBundles.manifest);
  stale.sourceRevision = `q4retail-${'0'.repeat(20)}`;
  assert.throws(
    () => validateRetailBundleManifest(stale, retailBundles.directory, retail.manifest),
    /source retail identity/,
  );

  const firstBundle = retailBundles.manifest.bundles[0];
  const original = fs.readFileSync(path.join(retailBundles.directory, firstBundle.name));
  fs.writeFileSync(path.join(retailBundles.directory, firstBundle.name), Buffer.alloc(original.length, 0x71));
  assert.throws(
    () => validateRetailBundleManifest(retailBundles.manifest, retailBundles.directory, retail.manifest),
    /bundle file differs/,
  );
});

test('retail catalog exposes only exact hashed allowlist routes with strong range validators', async (t) => {
  const { port, retail } = await fixture(t);
  const health = JSON.parse((await request(port, '/healthz')).body);
  assert.equal(health.retail, true);
  assert.equal(health.retailDownloads, 0);

  const manifestResponse = await request(port, '/retail/manifest.json');
  assert.equal(manifestResponse.status, 200);
  assert.equal(manifestResponse.headers['cache-control'], 'no-store');
  assert.deepEqual(JSON.parse(manifestResponse.body), retail.manifest);
  const status = JSON.parse((await request(port, '/api/status')).body);
  assert.deepEqual(status.catalog, {
    revision: retail.manifest.revision,
    models: retail.manifest.ui.models,
    maps: MULTIPLAYER_MAPS.map((map) => {
      const botNavigationReady = map.id === 'mp/q4web_arena';
      const botModes = botNavigationReady ? [...map.botModes] : [];
      return {
        ...map,
        botNavigationReady,
        botModes,
        botReady: botModes.length > 0,
      };
    }),
  });

  const approved = retail.manifest.files[0];
  const expected = fs.readFileSync(path.join(retail.directory, approved.name));
  const head = await request(port, approved.url, { method: 'HEAD' });
  assert.equal(head.status, 200);
  assert.equal(head.body.length, 0);
  assert.equal(head.headers.etag, `"${approved.sha256}"`);
  assert.equal(head.headers['cache-control'], 'private, no-store');
  assert.equal(head.headers['cross-origin-resource-policy'], 'same-origin');

  const partial = await request(port, approved.url, {
    headers: { Range: 'bytes=2-7', 'If-Range': `"${approved.sha256}"` },
  });
  assert.equal(partial.status, 206);
  assert.equal(partial.headers['content-range'], `bytes 2-7/${approved.size}`);
  assert.deepEqual(partial.body, expected.subarray(2, 8));

  const staleRange = await request(port, approved.url, {
    headers: { Range: 'bytes=2-7', 'If-Range': '"wrong"' },
  });
  assert.equal(staleRange.status, 200);
  assert.deepEqual(staleRange.body, expected);

  for (const pathname of [
    '/retail/q4base/quake4key',
    '/retail/q4base/gamex86.dll',
    '/retail/q4base/not-listed.pk4',
    '/retail/q4base/%2e%2e%2fquake4key',
  ]) {
    assert.equal((await request(port, pathname)).status, 404, pathname);
  }
});

test('catalog pins approved inodes so an atomic path replacement cannot change immutable URL bytes', async (t) => {
  const { port, retail } = await fixture(t);
  const approved = retail.manifest.files[0];
  const original = fs.readFileSync(path.join(retail.directory, approved.name));
  const replacement = path.join(retail.directory, '.replacement.pk4');
  fs.writeFileSync(replacement, Buffer.alloc(original.length, 0x58));
  fs.renameSync(replacement, path.join(retail.directory, approved.name));

  const response = await request(port, approved.url);
  assert.equal(response.status, 200);
  assert.deepEqual(response.body, original);
  assert.equal(sha256(response.body), approved.sha256);
});

test('retail stream leases enforce limits and appear in health', async (t) => {
  const { port, retail } = await fixture(t, {
    largeFirst: true,
    retailMaxConcurrent: 1,
    retailMaxPerIp: 1,
  });
  const approved = retail.manifest.files[0];
  const first = http.request({ host: '127.0.0.1', port, path: approved.url });
  const response = await new Promise((resolve, reject) => {
    first.on('response', (value) => { value.pause(); resolve(value); });
    first.on('error', reject);
    first.end();
  });

  const health = JSON.parse((await request(port, '/healthz')).body);
  assert.equal(health.retailDownloads, 1);
  assert.equal(health.retailBusy, true);
  const limited = await request(port, retail.manifest.files[1].url);
  assert.equal(limited.status, 429);
  assert.equal(limited.headers['retry-after'], '2');
  response.destroy();
});

test('retail manifest validator rejects incomplete and unpinned catalogs', () => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-retail-invalid-'));
  try {
    const retail = stagedRetail(root);
    const missing = structuredClone(retail.manifest);
    missing.files.pop();
    assert.throws(() => validateRetailManifest(missing, retail.directory), /exactly the approved English/);

    const changed = structuredClone(retail.manifest);
    changed.files[0].url = '/retail/q4base/quake4key';
    assert.throws(() => validateRetailManifest(changed, retail.directory), /invalid or unapproved/);

    const missingUi = structuredClone(retail.manifest);
    delete missingUi.ui;
    assert.throws(() => validateRetailManifest(missingUi, retail.directory), /invalid UI metadata/);
  } finally {
    fs.rmSync(root, { recursive: true, force: true });
  }
});
