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 { createGateway } from '../server.js';
import {
  RETAIL_FILE_NAMES,
  RETAIL_POLICY,
  RETAIL_SCHEMA,
  retailFingerprint,
  validateRetailManifest,
} from '../server/retail.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,
  };
  const manifestFile = path.join(root, 'retail-manifest.json');
  fs.writeFileSync(manifestFile, `${JSON.stringify(manifest)}\n`);
  return { directory, manifestFile, manifest };
}

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);
  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,
    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, gateway, port: gateway.address().port };
}

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 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/);
  } finally {
    fs.rmSync(root, { recursive: true, force: true });
  }
});
