import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

import {
  IMPORT_CHUNK_SIZE,
  REQUIRED_RETAIL_FILES,
  RetailImporter,
  RetailImportError,
  makeRevisionId,
  normalizeRetailSelection,
  validateServerRetailManifest,
  verifyServerRetailManifest,
  validateRetailFiles
} from '../public/js/importer.js';
import {
  EngineLoader,
  EngineLaunchError,
  guardPointerLockPromise,
  launchArguments,
  websocketEndpoint
} from '../public/js/engine-loader.js';
import { createEngineAdapter } from '../public/js/engine-adapter.js';
import { InputRouter, installKeyboardInput } from '../public/js/input.js';
import {
  subyBase,
  subyDirectRedirect,
  subyPortal,
  subyValidHost
} from '../public/js/identity.js';

const root = new URL('../', import.meta.url);

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

function fixtureFile(name, content = name, options = {}) {
  const blob = new Blob([content]);
  return {
    name,
    size: blob.size,
    lastModified: options.lastModified ?? 1234,
    webkitRelativePath: options.path ?? `Quake 4/q4base/${name}`,
    slice: blob.slice.bind(blob)
  };
}

function retailFixture(overrides = {}) {
  return [
    ...REQUIRED_RETAIL_FILES.map((name) => fixtureFile(name, overrides[name] ?? `data:${name}`)),
    fixtureFile('zpak_english.pk4', overrides['zpak_english.pk4'] ?? 'language')
  ];
}

function notFound(message) {
  return new DOMException(message, 'NotFoundError');
}

class MemoryWritable {
  constructor(handle, { keepExistingData = false } = {}) {
    this.handle = handle;
    this.buffer = keepExistingData ? handle.bytes.slice() : new Uint8Array(0);
    this.position = 0;
    this.closed = false;
  }

  async write(value) {
    let bytes;
    if (typeof value === 'string') bytes = new TextEncoder().encode(value);
    else if (value instanceof Uint8Array) bytes = value;
    else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
    else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
    else throw new TypeError('unsupported write');
    const required = this.position + bytes.byteLength;
    if (this.buffer.byteLength < required) {
      const grown = new Uint8Array(required);
      grown.set(this.buffer);
      this.buffer = grown;
    }
    this.buffer.set(bytes, this.position);
    this.position += bytes.byteLength;
  }

  async seek(position) {
    this.position = position;
  }

  async truncate(size) {
    if (this.buffer.byteLength === size) return;
    const resized = new Uint8Array(size);
    resized.set(this.buffer.subarray(0, size));
    this.buffer = resized;
    if (this.position > size) this.position = size;
  }

  async close() {
    this.closed = true;
    this.handle.bytes = this.buffer.slice();
  }

  async abort() {
    this.closed = true;
  }
}

class MemoryFileHandle {
  constructor(name) {
    this.kind = 'file';
    this.name = name;
    this.bytes = new Uint8Array(0);
  }

  async createWritable(options) {
    return new MemoryWritable(this, options);
  }

  async getFile() {
    return new Blob([this.bytes]);
  }
}

class MemoryDirectoryHandle {
  constructor(name = '') {
    this.kind = 'directory';
    this.name = name;
    this.directories = new Map();
    this.files = new Map();
  }

  async getDirectoryHandle(name, { create = false } = {}) {
    if (this.directories.has(name)) return this.directories.get(name);
    if (!create) throw notFound(`missing directory ${name}`);
    const directory = new MemoryDirectoryHandle(name);
    this.directories.set(name, directory);
    return directory;
  }

  async getFileHandle(name, { create = false } = {}) {
    if (this.files.has(name)) return this.files.get(name);
    if (!create) throw notFound(`missing file ${name}`);
    const file = new MemoryFileHandle(name);
    this.files.set(name, file);
    return file;
  }

  async removeEntry(name, { recursive = false } = {}) {
    if (this.files.delete(name)) return;
    const directory = this.directories.get(name);
    if (!directory) throw notFound(`missing entry ${name}`);
    if (!recursive && (directory.files.size || directory.directories.size)) throw new DOMException('not empty', 'InvalidModificationError');
    this.directories.delete(name);
  }
}

function memoryStorage({ quota = 1024 * 1024 * 1024, usage = 0 } = {}) {
  const directory = new MemoryDirectoryHandle();
  return {
    directory,
    persisted: false,
    async getDirectory() { return directory; },
    async estimate() { return { quota, usage }; },
    async persist() { this.persisted = true; return true; }
  };
}

function runtimeFetchFixture(requests) {
  const files = new Map([
    ['/content/baseoq4/mod.json', new TextEncoder().encode('{"game":"q4web-free"}')],
    ['/content/baseoq4/pak0.pk4', new TextEncoder().encode('free-runtime-pak0')],
    ['/content/baseoq4/pak1.pk4', new TextEncoder().encode('free-runtime-pak1')],
    ['/content/baseoq4/q4web_mp_test-aaaaaaaaaaaa.pk4', new TextEncoder().encode('free-addon')]
  ]);
  const manifest = {
    schema: 2,
    policy: 'free-only-v1',
    build: 'q4web-free-test-build',
    revokedBuilds: ['openq4-975e296b80b6'],
    files: [...files].map(([url, bytes]) => ({
      name: url.split('/').at(-1),
      url,
      size: bytes.byteLength,
      sha256: sha256(bytes),
      kind: url.endsWith('/mod.json')
        ? 'metadata'
        : /\/pak[01]\.pk4$/.test(url) ? 'runtime-pack' : 'addon',
      license: 'CC0-1.0',
      source: 'self-authored'
    }))
  };
  return async (url, options = {}) => {
    requests.push([url, options.headers?.Range || null]);
    if (url.endsWith('/manifest.json')) {
      return new Response(JSON.stringify(manifest), { headers: { 'content-type': 'application/json' } });
    }
    const bytes = files.get(url);
    if (!bytes) return new Response('missing', { status: 404 });
    const match = /^bytes=(\d+)-$/.exec(options.headers?.Range || '');
    const offset = match ? Number(match[1]) : 0;
    return new Response(bytes.slice(offset), { status: offset ? 206 : 200 });
  };
}

function serverRetailPayload(name, large) {
  if (name !== 'pak001.pk4' || !large) {
    return new TextEncoder().encode(`operator-retail:${name}:verified-payload`);
  }
  const bytes = new Uint8Array(IMPORT_CHUNK_SIZE + 37);
  for (let index = 0; index < bytes.length; index += 1) bytes[index] = (index * 31 + 17) & 0xff;
  return bytes;
}

function serverRetailFixture({ large = false } = {}) {
  const names = [...REQUIRED_RETAIL_FILES, 'zpak_english.pk4'];
  const payloads = new Map(names.map((name) => [
    name,
    serverRetailPayload(name, large)
  ]));
  const files = names.map((name, index) => {
    const bytes = payloads.get(name);
    const digest = sha256(bytes);
    return {
      name,
      size: bytes.byteLength,
      lastModified: 1_750_000_000_000 + index,
      sha256: digest,
      url: `/retail/q4base/${digest}-${encodeURIComponent(name)}`
    };
  });
  const fingerprint = sha256(files
    .map((file) => `${file.name}\0${file.size}\0${file.sha256}\n`)
    .join(''));
  return {
    payloads,
    manifest: {
      schema: 1,
      policy: 'operator-authorized-retail-v1',
      revision: `q4retail-${fingerprint.slice(0, 20)}`,
      fingerprint,
      totalBytes: files.reduce((sum, file) => sum + file.size, 0),
      files
    }
  };
}

function serverRetailFetchFixture({
  corrupt = null,
  failOnce = null,
  failAt = IMPORT_CHUNK_SIZE,
  large = Boolean(failOnce)
} = {}) {
  const fixture = serverRetailFixture({ large });
  const requests = [];
  let pendingFailure = Boolean(failOnce);
  const fetchImpl = async (url, options = {}) => {
    const headers = new Headers(options.headers || {});
    const range = headers.get('range');
    const ifRange = headers.get('if-range');
    requests.push({ url, range, ifRange });
    if (url === '/retail/manifest.json') {
      return new Response(JSON.stringify(fixture.manifest), {
        headers: { 'content-type': 'application/json' }
      });
    }
    const entry = fixture.manifest.files.find((file) => file.url === url);
    if (!entry) return new Response('missing', { status: 404 });
    const match = /^bytes=(\d+)-(\d+)$/.exec(range || '');
    assert.ok(match, `retail request must use a bounded byte range: ${range}`);
    const start = Number(match[1]);
    const end = Number(match[2]);
    assert.equal(ifRange, `"${entry.sha256}"`);
    assert.ok(start >= 0 && end >= start && end < entry.size);

    if (entry.name === failOnce && pendingFailure && start === failAt) {
      pendingFailure = false;
      throw new Error('simulated interrupted retail range');
    }
    let bytes = fixture.payloads.get(entry.name).slice(start, end + 1);
    if (entry.name === corrupt) bytes = Uint8Array.from(bytes, (byte) => byte ^ 0x01);
    return new Response(bytes, {
      status: 206,
      headers: {
        'accept-ranges': 'bytes',
        'content-length': String(bytes.byteLength),
        'content-range': `bytes ${start}-${end}/${entry.size}`,
        etag: `"${entry.sha256}"`
      }
    });
  };
  return { ...fixture, requests, fetchImpl };
}

test('retail validation accepts only a complete flat q4base package set', () => {
  const complete = validateRetailFiles(retailFixture());
  assert.equal(complete.ok, true);
  assert.equal(complete.missing.length, 0);
  assert.equal(complete.files.length, REQUIRED_RETAIL_FILES.length + 1);

  const missing = validateRetailFiles(retailFixture().filter((file) => file.name !== 'pak007.pk4'));
  assert.equal(missing.ok, false);
  assert.deepEqual(missing.missing, ['pak007.pk4']);

  const duplicate = normalizeRetailSelection([...retailFixture(), fixtureFile('PAK001.PK4')]);
  assert.equal(duplicate.duplicates.length, 1);
  assert.equal(validateRetailFiles(duplicate).ok, false);

  const nested = fixtureFile('ignored.pk4', 'bad', { path: 'Quake 4/q4base/maps/ignored.pk4' });
  assert.equal(normalizeRetailSelection([nested]).files.length, 0);

  const empty = retailFixture().map((file) => file.name === 'pak004.pk4' ? fixtureFile(file.name, '') : file);
  assert.equal(validateRetailFiles(empty).ok, false);
  assert.deepEqual(validateRetailFiles(empty).invalid, ['pak004.pk4']);
});

test('revision ids are order-independent and change with selected file metadata', () => {
  const files = retailFixture();
  const forward = makeRevisionId(normalizeRetailSelection(files));
  const reverse = makeRevisionId(normalizeRetailSelection([...files].reverse()));
  assert.equal(forward, reverse);

  const changed = files.map((file) => file.name === 'pak003.pk4'
    ? fixtureFile(file.name, 'changed payload', { lastModified: 9999 })
    : file);
  assert.notEqual(makeRevisionId(normalizeRetailSelection(changed)), forward);
});

test('server retail manifest accepts only the exact approved English package set', async () => {
  const { manifest } = serverRetailFixture();
  const validated = validateServerRetailManifest(manifest);
  assert.equal((await verifyServerRetailManifest(manifest)).fingerprint, manifest.fingerprint);
  assert.equal(validated.files.length, REQUIRED_RETAIL_FILES.length + 1);
  assert.deepEqual(validated.files.map((file) => file.name), [
    ...REQUIRED_RETAIL_FILES,
    'zpak_english.pk4'
  ]);

  const invalid = [];
  const missing = structuredClone(manifest);
  missing.files.pop();
  missing.totalBytes = missing.files.reduce((sum, file) => sum + file.size, 0);
  invalid.push(missing);

  const duplicate = structuredClone(manifest);
  duplicate.files.at(-1).name = 'pak001.pk4';
  invalid.push(duplicate);

  const unapprovedLanguage = structuredClone(manifest);
  unapprovedLanguage.files.at(-1).name = 'zpak_french.pk4';
  invalid.push(unapprovedLanguage);

  const unhashedUrl = structuredClone(manifest);
  unhashedUrl.files[0].url = '/retail/q4base/pak001.pk4';
  invalid.push(unhashedUrl);

  const wrongTotal = structuredClone(manifest);
  wrongTotal.totalBytes += 1;
  invalid.push(wrongTotal);

  const wrongRevision = structuredClone(manifest);
  wrongRevision.revision = 'q4retail-00000000000000000000';
  invalid.push(wrongRevision);

  for (const candidate of invalid) {
    assert.throws(
      () => validateServerRetailManifest(candidate),
      (error) => error instanceof RetailImportError && error.code === 'INVALID_SERVER_RETAIL_MANIFEST'
    );
  }

  const wrongFingerprint = structuredClone(manifest);
  wrongFingerprint.fingerprint = '0'.repeat(64);
  wrongFingerprint.revision = `q4retail-${wrongFingerprint.fingerprint.slice(0, 20)}`;
  await assert.rejects(
    verifyServerRetailManifest(wrongFingerprint),
    (error) => error instanceof RetailImportError && error.code === 'INVALID_SERVER_RETAIL_MANIFEST'
  );
});

test('server retail downloads into OPFS, activates the revision and reuses verified cache', async () => {
  const storage = memoryStorage();
  const fixture = serverRetailFetchFixture();
  const progress = [];
  const importer = new RetailImporter({ storage, fetchImpl: fixture.fetchImpl });
  const installed = await importer.ensureServerRetail({
    onProgress: (state) => progress.push(state)
  });

  assert.equal(installed.state, 'ready');
  assert.equal(installed.source, 'operator-server');
  assert.equal(installed.serverFingerprint, fixture.manifest.fingerprint);
  assert.equal(installed.completedBytes, fixture.manifest.totalBytes);
  assert.equal(storage.persisted, true);
  assert.equal((await importer.getActiveImport()).revision, fixture.manifest.revision);
  assert.equal((await importer.launchDescriptor()).basePath,
    `/opfs/q4-retail/revisions/${fixture.manifest.revision}/runtime`);
  assert.equal(progress.at(-1).state, 'ready');

  const revisions = storage.directory.directories.get('q4-retail').directories.get('revisions');
  const q4base = revisions.directories.get(fixture.manifest.revision)
    .directories.get('runtime').directories.get('q4base');
  for (const file of fixture.manifest.files) {
    assert.deepEqual(q4base.files.get(file.name).bytes, fixture.payloads.get(file.name));
  }
  assert.equal(
    fixture.requests.filter(({ url }) => url !== '/retail/manifest.json').length,
    fixture.manifest.files.reduce((sum, file) => sum + Math.ceil(file.size / IMPORT_CHUNK_SIZE), 0)
  );
  for (const request of fixture.requests.filter(({ url }) => url !== '/retail/manifest.json')) {
    const file = fixture.manifest.files.find((entry) => entry.url === request.url);
    assert.equal(request.ifRange, `"${file.sha256}"`);
    assert.match(request.range, /^bytes=\d+-\d+$/);
  }

  const requestsBeforeReuse = fixture.requests.length;
  const reused = await importer.ensureServerRetail();
  assert.equal(reused.state, 'ready');
  assert.deepEqual(fixture.requests.slice(requestsBeforeReuse), [{
    url: '/retail/manifest.json',
    range: null,
    ifRange: null
  }]);
});

test('interrupted server retail download resumes the durable OPFS file with Range', async () => {
  const storage = memoryStorage();
  const fixture = serverRetailFetchFixture({ failOnce: 'pak001.pk4' });
  const importer = new RetailImporter({ storage, fetchImpl: fixture.fetchImpl });

  await assert.rejects(importer.ensureServerRetail(), /simulated interrupted retail range/);
  const failed = await importer.getRevisionManifest(fixture.manifest.revision);
  assert.equal(failed.state, 'error');
  assert.equal(failed.completedBytes, IMPORT_CHUNK_SIZE);

  const installed = await importer.ensureServerRetail();
  assert.equal(installed.state, 'ready');
  assert.deepEqual(
    fixture.requests
      .filter(({ url }) => url === fixture.manifest.files[0].url)
      .map(({ range, ifRange }) => ({ range, ifRange })),
    [
      { range: `bytes=0-${IMPORT_CHUNK_SIZE - 1}`, ifRange: `"${fixture.manifest.files[0].sha256}"` },
      { range: `bytes=${IMPORT_CHUNK_SIZE}-${fixture.manifest.files[0].size - 1}`, ifRange: `"${fixture.manifest.files[0].sha256}"` },
      { range: `bytes=${IMPORT_CHUNK_SIZE}-${fixture.manifest.files[0].size - 1}`, ifRange: `"${fixture.manifest.files[0].sha256}"` }
    ]
  );

  const revisions = storage.directory.directories.get('q4-retail').directories.get('revisions');
  const q4base = revisions.directories.get(fixture.manifest.revision)
    .directories.get('runtime').directories.get('q4base');
  assert.deepEqual(q4base.files.get('pak001.pk4').bytes, fixture.payloads.get('pak001.pk4'));
});

test('server retail rejects repeated SHA-256 mismatches without activating the revision', async () => {
  const storage = memoryStorage();
  const fixture = serverRetailFetchFixture({ corrupt: 'pak001.pk4' });
  const importer = new RetailImporter({ storage, fetchImpl: fixture.fetchImpl });

  await assert.rejects(
    importer.ensureServerRetail(),
    (error) => error instanceof RetailImportError && error.code === 'SERVER_RETAIL_HASH_MISMATCH'
  );
  assert.equal(await importer.getActiveImport(), null);
  const failed = await importer.getRevisionManifest(fixture.manifest.revision);
  assert.equal(failed.state, 'error');
  assert.equal(failed.completedBytes, 0);
  assert.equal(
    fixture.requests.filter(({ url }) => url === fixture.manifest.files[0].url).length,
    2 * Math.ceil(fixture.manifest.files[0].size / IMPORT_CHUNK_SIZE)
  );
});

test('OPFS import persists mountable q4base files, activates revisions and detects duplicates', async () => {
  const storage = memoryStorage();
  const importer = new RetailImporter({ storage, now: () => '2026-07-14T12:00:00.000Z' });
  const files = retailFixture();
  const first = await importer.import(files);
  assert.equal(first.duplicate, false);
  assert.equal(first.manifest.state, 'ready');
  assert.equal(first.manifest.completedBytes, first.manifest.totalBytes);
  assert.equal(storage.persisted, true);

  const active = await importer.getActiveImport();
  assert.equal(active.revision, first.revision);
  const descriptor = await importer.launchDescriptor();
  assert.equal(descriptor.basePath, `/opfs/q4-retail/revisions/${first.revision}/runtime`);
  const revisions = storage.directory.directories.get('q4-retail').directories.get('revisions');
  const q4base = revisions.directories.get(first.revision).directories.get('runtime').directories.get('q4base');
  assert.equal(new TextDecoder().decode(q4base.files.get('pak001.pk4').bytes), 'data:pak001.pk4');
  const chunks = [];
  for await (const chunk of importer.openFileChunks(first.revision, 'pak001.pk4')) chunks.push(chunk);
  assert.equal(new TextDecoder().decode(Buffer.concat(chunks)), 'data:pak001.pk4');

  const second = await importer.import(files);
  assert.equal(second.duplicate, true);
  assert.equal(second.revision, first.revision);
});

test('cancelled OPFS imports resume from durable chunk manifests', async () => {
  const storage = memoryStorage();
  let clock = 0;
  const importer = new RetailImporter({ storage, now: () => `2026-07-14T12:00:${String(clock++).padStart(2, '0')}.000Z` });
  const files = retailFixture();
  const controller = new AbortController();

  await assert.rejects(
    importer.import(files, {
      signal: controller.signal,
      onProgress(state) {
        if (state.completedBytes > 0) controller.abort();
      }
    }),
    (error) => error.name === 'AbortError'
  );

  const revision = makeRevisionId(validateRetailFiles(files));
  const cancelled = await importer.getRevisionManifest(revision);
  assert.equal(cancelled.state, 'cancelled');
  assert.ok(cancelled.completedBytes > 0);
  assert.ok(cancelled.completedBytes < cancelled.totalBytes);

  const resumed = await importer.import(files);
  assert.equal(resumed.manifest.state, 'ready');
  assert.equal(resumed.manifest.completedBytes, resumed.manifest.totalBytes);
});

test('openQ4 runtime assets install once in a shared mountable OPFS root', async () => {
  const storage = memoryStorage();
  const requests = [];
  const importer = new RetailImporter({ storage, fetchImpl: runtimeFetchFixture(requests) });
  await storage.directory.getDirectoryHandle('q4-runtime', { create: true })
    .then((directory) => directory.getDirectoryHandle('openq4-975e296b80b6', { create: true }));
  const runtime = await importer.ensureRuntimeAssets();
  assert.equal(runtime.basePath, '/opfs/q4-runtime/q4web-free-test-build');
  assert.equal(
    storage.directory.directories.get('q4-runtime').directories.has('openq4-975e296b80b6'),
    false
  );
  const installed = storage.directory.directories.get('q4-runtime')
    .directories.get('q4web-free-test-build').directories.get('baseoq4');
  assert.equal(new TextDecoder().decode(installed.files.get('mod.json').bytes), '{"game":"q4web-free"}');
  assert.equal(
    new TextDecoder().decode(installed.files.get('q4web_mp_test-aaaaaaaaaaaa.pk4').bytes),
    'free-addon'
  );

  await importer.ensureRuntimeAssets();
  assert.deepEqual(requests.map(([url]) => url), [
    '/content/baseoq4/manifest.json',
    '/content/baseoq4/mod.json',
    '/content/baseoq4/pak0.pk4',
    '/content/baseoq4/pak1.pk4',
    '/content/baseoq4/q4web_mp_test-aaaaaaaaaaaa.pk4',
    '/content/baseoq4/manifest.json'
  ]);

  installed.files.get('mod.json').bytes[0] ^= 0xff;
  await importer.ensureRuntimeAssets();
  assert.deepEqual(requests.map(([url]) => url).slice(-2), [
    '/content/baseoq4/manifest.json',
    '/content/baseoq4/mod.json'
  ]);
  assert.equal(new TextDecoder().decode(installed.files.get('mod.json').bytes), '{"game":"q4web-free"}');
});

test('runtime manifest installs immutable map addons and removes superseded ones', async () => {
  const storage = memoryStorage();
  const core = new Map([
    ['mod.json', new TextEncoder().encode('{}')],
    ['pak0.pk4', new TextEncoder().encode('free-runtime-pak0')],
    ['pak1.pk4', new TextEncoder().encode('free-runtime-pak1')]
  ]);
  const versions = [
    ['q4web_mp_alpha-aaaaaaaaaaaa.pk4', new TextEncoder().encode('alpha')],
    ['q4web_mp_beta-bbbbbbbbbbbb.pk4', new TextEncoder().encode('beta')]
  ];
  let active = 0;
  const fetchImpl = async (url) => {
    if (url.endsWith('/manifest.json')) {
      const all = new Map([...core, versions[active]]);
      return new Response(JSON.stringify({
        schema: 2,
        policy: 'free-only-v1',
        build: 'q4web-free-addons',
        revokedBuilds: [],
        files: [...all].map(([name, bytes]) => ({
          name,
          size: bytes.byteLength,
          sha256: sha256(bytes),
          url: `/content/baseoq4/${name}`,
          kind: name === 'mod.json'
            ? 'metadata'
            : /^pak[01]\.pk4$/.test(name) ? 'runtime-pack' : 'addon',
          license: 'CC0-1.0',
          source: 'self-authored'
        }))
      }));
    }
    const name = url.split('/').at(-1);
    const bytes = core.get(name) || (versions[active][0] === name ? versions[active][1] : null);
    return bytes ? new Response(bytes) : new Response('missing', { status: 404 });
  };
  const importer = new RetailImporter({ storage, fetchImpl });
  await importer.ensureRuntimeAssets();
  const baseoq4 = storage.directory.directories.get('q4-runtime')
    .directories.get('q4web-free-addons').directories.get('baseoq4');
  assert.equal(baseoq4.files.has(versions[0][0]), true);

  active = 1;
  await importer.ensureRuntimeAssets();
  assert.equal(baseoq4.files.has(versions[0][0]), false);
  assert.equal(new TextDecoder().decode(baseoq4.files.get(versions[1][0]).bytes), 'beta');
});

test('quota protection runs before writing retail chunks', async () => {
  const importer = new RetailImporter({ storage: memoryStorage({ quota: 32 * 1024 * 1024 }) });
  await assert.rejects(
    importer.import(retailFixture()),
    (error) => error instanceof RetailImportError && error.code === 'QUOTA_EXCEEDED'
  );
});

test('input sources cannot release each other and releaseAll clears axes', () => {
  const events = [];
  const router = new InputRouter({
    action: (action, down, source) => events.push(['action', action, down, source]),
    move: (x, y) => events.push(['move', x, y]),
    weapon: (step) => events.push(['weapon', step])
  });
  router.press('fire', 'touch:1');
  router.press('fire', 'touch:2');
  router.release('fire', 'touch:1');
  assert.deepEqual(events.filter((event) => event[0] === 'action'), [['action', 'fire', true, 'touch:1']]);
  router.release('fire', 'touch:2');
  assert.deepEqual(events.filter((event) => event[0] === 'action').at(-1), ['action', 'fire', false, 'touch:2']);

  router.setMove('touch:3', 0.8, -0.5);
  router.press('ptt', 'touch:4');
  router.pulse('weapon-next', 'touch:5');
  router.releaseAll();
  assert.deepEqual(events.at(-1), ['move', 0, 0]);
  assert.ok(events.some((event) => event[0] === 'weapon' && event[1] === 1));
  assert.ok(events.some((event) => event[1] === 'ptt' && event[2] === false));
});

test('PTT captures T before the engine keyboard handler', () => {
  const listeners = new Map();
  const options = new Map();
  const windowObject = {
    addEventListener(type, listener, value) {
      listeners.set(type, listener);
      options.set(type, value);
    }
  };
  const canvas = { addEventListener() {}, focus() {} };
  const events = [];
  const router = new InputRouter({ action: (...parts) => events.push(parts), move() {} });
  const destroy = installKeyboardInput({ canvas, router, windowObject });
  const event = {
    code: 'KeyT',
    repeat: false,
    prevented: false,
    stopped: false,
    preventDefault() { this.prevented = true; },
    stopImmediatePropagation() { this.stopped = true; }
  };
  listeners.get('keydown')(event);
  assert.equal(options.get('keydown').capture, true);
  assert.equal(event.prevented, true);
  assert.equal(event.stopped, true);
  assert.deepEqual(events[0], ['ptt', true, 'keyboard:KeyT']);
  destroy();
});

test('keyboard input shields focused UI controls from the SDL engine listener', () => {
  const listeners = new Map();
  const windowObject = {
    addEventListener(type, listener) {
      listeners.set(type, listener);
    }
  };
  const canvas = { addEventListener() {}, focus() {} };
  const actions = [];
  const router = new InputRouter({ action: (...parts) => actions.push(parts), move() {} });
  const destroy = installKeyboardInput({ canvas, router, windowObject });
  const event = {
    code: 'Enter',
    target: { closest: () => ({ tagName: 'BUTTON' }) },
    prevented: false,
    stopped: false,
    preventDefault() { this.prevented = true; },
    stopImmediatePropagation() { this.stopped = true; }
  };

  listeners.get('keydown')(event);
  assert.equal(event.stopped, true);
  assert.equal(event.prevented, false, 'browser-default button activation must remain enabled');
  assert.deepEqual(actions, []);
  destroy();
});

test('engine discovery honors a local manifest and websocket gateway contract', async () => {
  const requests = [];
  const loader = new EngineLoader({
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' },
    fetchImpl: async (url, options = {}) => {
      requests.push([url, options.method || 'GET']);
      return {
        ok: true,
        async json() {
          return { script: '/engine/openq4.mjs', module: true, factory: 'createOpenQ4', build: 'test-build' };
        }
      };
    }
  });
  const manifest = await loader.discover();
  assert.equal(manifest.script, '/engine/openq4.mjs');
  assert.equal(manifest.module, true);
  assert.equal(manifest.factory, 'createOpenQ4');
  assert.deepEqual(requests, [['/engine/engine-manifest.json', 'GET']]);
  assert.equal(websocketEndpoint(28004, { protocol: 'https:', host: 'q4.suby.pl' }), 'wss://q4.suby.pl/game/28004');
  assert.throws(() => websocketEndpoint(80, { protocol: 'https:', host: 'q4.suby.pl' }), EngineLaunchError);
  assert.deepEqual(launchArguments(
    { type: 'multiplayer', port: 28004 },
    { basePath: '/opfs/q4-retail/revisions/r2/runtime' },
    { basePath: '/opfs/q4-runtime/build' }
  ), [
    '+set', 'fs_basepath', '/opfs/q4-retail/revisions/r2/runtime',
    '+set', 'fs_cdpath', '/opfs/q4-runtime/build',
    '+set', 'fs_savepath', '/opfs/q4-user',
    '+set', 'fs_caseSensitiveOS', '0',
    '+set', 'r_fullscreen', '0',
    '+set', 'r_swapInterval', '1',
    '+set', 'si_gameType', 'DM',
    '+set', 'ui_autoJoin', '1',
    '+connect', '127.0.0.1:28004'
  ]);
  const replayArguments = launchArguments(
    { type: 'replay', port: 28021 },
    { basePath: '/opfs/q4-retail/revisions/r2/runtime' },
    { basePath: '/opfs/q4-runtime/build' }
  );
  assert.deepEqual(replayArguments.slice(-5), ['+set', 'si_gameType', 'DM', '+connect', '127.0.0.1:28021']);
  assert.equal(replayArguments.includes('ui_autoJoin'), false, 'replay viewers must remain spectators');
  assert.deepEqual(launchArguments(
    { type: 'singleplayer' },
    { basePath: '/opfs/q4-retail/revisions/r2/runtime' },
    { basePath: '/opfs/q4-runtime/build' }
  ).slice(-12), [
    '+set', 'r_fullscreen', '0',
    '+set', 'r_swapInterval', '1',
    '+set', 'si_gameType', 'singleplayer',
    '+set', 'com_skipLoadingContinue', '1'
  ]);
  assert.deepEqual(launchArguments(
    { type: 'multiplayer', port: 28004, nick: 'Ada' },
    { basePath: '/opfs/q4-retail/revisions/r2/runtime' },
    { basePath: '/opfs/q4-runtime/build' }
  ).slice(-8), ['+set', 'ui_name', 'Ada', '+set', 'ui_autoJoin', '1', '+connect', '127.0.0.1:28004']);
});

test('pointer-lock compatibility guard observes rejected browser promises without hiding them from callers', async () => {
  let calls = 0;
  let observed = 0;
  const rejection = new DOMException('document is not active', 'WrongDocumentError');
  const result = {
    catch(handler) {
      observed += 1;
      handler(rejection);
      return Promise.resolve();
    }
  };
  const canvas = {
    requestPointerLock(options) {
      calls += 1;
      assert.deepEqual(options, { unadjustedMovement: true });
      return result;
    }
  };

  assert.equal(guardPointerLockPromise(canvas), canvas);
  assert.equal(guardPointerLockPromise(canvas), canvas, 'the canvas must not be wrapped twice');
  assert.equal(canvas.requestPointerLock({ unadjustedMovement: true }), result);
  assert.equal(calls, 1);
  assert.equal(observed, 1);

  const synchronousCanvas = {
    requestPointerLock() {
      throw rejection;
    }
  };
  guardPointerLockPromise(synchronousCanvas);
  await assert.rejects(synchronousCanvas.requestPointerLock(), (error) => error === rejection);
});

test('engine discovery selects isolated campaign, multiplayer and replay artifacts', async () => {
  const manifest = {
    schema: 1,
    build: 'dual-test',
    variants: {
      singleplayer: { script: '/engine/openq4-sp.mjs', wasm: '/engine/openq4-sp.wasm', factory: 'createOpenQ4SP' },
      multiplayer: { script: '/engine/openq4-mp.mjs', wasm: '/engine/openq4-mp.wasm', factory: 'createOpenQ4MP' }
    }
  };
  const loader = new EngineLoader({
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' },
    fetchImpl: async () => ({ ok: true, async json() { return manifest; } })
  });
  const singleplayer = await loader.discover('singleplayer');
  const multiplayer = await loader.discover('multiplayer');
  const replay = await loader.discover('replay');
  assert.equal(singleplayer.script, '/engine/openq4-sp.mjs');
  assert.equal(singleplayer.wasm, '/engine/openq4-sp.wasm');
  assert.equal(multiplayer.factory, 'createOpenQ4MP');
  assert.equal(replay.script, '/engine/openq4-mp.mjs');
  await assert.rejects(new EngineLoader({
    locationObject: { origin: 'https://q4.suby.pl' },
    fetchImpl: async () => ({ ok: true, async json() { return { ...manifest, variants: { singleplayer: manifest.variants.singleplayer } }; } })
  }).discover('multiplayer'), (error) => error.code === 'ENGINE_VARIANT_MISSING');
});

test('ES module engine artifacts use the Emscripten default factory export', async () => {
  const loader = new EngineLoader({
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' }
  });
  const moduleConfig = { arguments: ['+set', 'com_allowConsole', '1'] };
  const script = 'data:text/javascript,export default async function(config){return {config,kind:"default"}}';
  const loaded = await loader.loadArtifact({ script, module: true, factory: null }, moduleConfig);
  assert.equal(loaded.kind, 'default');
  assert.equal(loaded.config, moduleConfig);
});

test('engine discovery reports missing artifacts instead of claiming readiness', async () => {
  const loader = new EngineLoader({
    locationObject: { origin: 'http://localhost:3280', protocol: 'http:', host: 'localhost:3280' },
    fetchImpl: async () => ({ ok: false })
  });
  await assert.rejects(loader.discover(), (error) => error.code === 'ENGINE_MISSING');
});

function fakeEngineDocument(globalObject, { engineReady = false } = {}) {
  return {
    createElement() {
      const listeners = new Map();
      return {
        addEventListener(name, callback) { listeners.set(name, callback); },
        get listeners() { return listeners; }
      };
    },
    head: {
      append(script) {
        queueMicrotask(async () => {
          for (const hook of globalObject.Module.preRun || []) await hook.call(globalObject.Module);
          await globalObject.Module.onRuntimeInitialized.call(globalObject.Module);
          if (engineReady) await globalObject.Q4Shell.markReady(globalObject.Module);
          script.listeners.get('load')?.();
        });
      }
    }
  };
}

test('engine readiness follows runtime initialization and invokes retail/network adapters', async () => {
  const calls = [];
  const globalObject = {
    Q4EngineAdapter: {
      mountRetail({ retail }) { calls.push(['mount', retail.revision]); },
      runtimeReady({ launch }) { calls.push(['ready', launch.type]); },
      connect(network) { calls.push(['connect', network.websocketUrl]); }
    }
  };
  const loader = new EngineLoader({
    globalObject,
    documentObject: fakeEngineDocument(globalObject),
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' },
    timeoutMs: 1000,
    fetchImpl: async () => ({ ok: true, async json() { return { script: '/engine/test.js' }; } })
  });
  const retail = { revision: 'r2-test', basePath: '/opfs/q4-retail/revisions/r2-test/runtime', manifest: { state: 'ready' }, openFileChunks() {} };
  let readyCallback = false;
  const result = await loader.launch({
    canvas: {},
    launch: { type: 'multiplayer', port: 28004 },
    importer: { async launchDescriptor() { return retail; } },
    onReady() { readyCallback = true; }
  });
  assert.equal(loader.ready, true);
  assert.equal(readyCallback, true);
  assert.equal(result.retail.revision, 'r2-test');
  assert.deepEqual(calls, [
    ['mount', 'r2-test'],
    ['ready', 'multiplayer'],
    ['connect', 'wss://q4.suby.pl/game/28004']
  ]);
});

test('production engine readiness waits for the framework signal after wasm initialization', async () => {
  const globalObject = {};
  const loader = new EngineLoader({
    globalObject,
    documentObject: fakeEngineDocument(globalObject, { engineReady: true }),
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' },
    timeoutMs: 1000,
    fetchImpl: async () => ({
      ok: true,
      async json() { return { script: '/engine/openq4.js', readySignal: 'engine' }; }
    })
  });
  const result = await loader.launch({
    canvas: {},
    launch: { type: 'singleplayer' },
    importer: { async launchDescriptor() { return { revision: 'r2-test', basePath: '/opfs/retail' }; } }
  });
  assert.equal(result.manifest.readySignal, 'engine');
  assert.equal(loader.ready, true);
});

test('replay launch connects the MP artifact to an isolated spectator port', async () => {
  const calls = [];
  const globalObject = {
    Q4EngineAdapter: {
      runtimeReady({ network }) { calls.push(['ready', network]); },
      connect(network) { calls.push(['connect', network]); }
    }
  };
  const loader = new EngineLoader({
    globalObject,
    documentObject: fakeEngineDocument(globalObject),
    locationObject: { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' },
    timeoutMs: 1000,
    fetchImpl: async () => ({ ok: true, async json() { return { script: '/engine/test.js' }; } })
  });
  const result = await loader.launch({
    canvas: {},
    launch: { type: 'replay', port: 28021, replay: { id: 'demo' } },
    importer: { async launchDescriptor() { return { revision: 'r1-test', basePath: '/opfs/retail' }; } }
  });
  assert.equal(loader.ready, true);
  assert.equal(result.network.port, 28021);
  assert.equal(result.network.spectator, true);
  assert.equal(result.network.websocketUrl, 'wss://q4.suby.pl/game/28021');
  assert.deepEqual(calls.map(([name]) => name), ['ready', 'connect']);
});

test('engine adapter stores a server demo in OPFS and invokes the native replay command', async () => {
  const storage = memoryStorage();
  const calls = [];
  const adapter = createEngineAdapter({
    storage,
    fetchImpl: async () => new Response('real-q4-server-demo')
  });
  adapter.runtimeReady({
    module: {
      ccall(name, result, types, values) {
        calls.push([name, result, types, values]);
        return 1;
      }
    }
  });
  await adapter.playReplay({ id: 'demo_123', downloadUrl: '/api/replays/demo_123' });
  const demos = storage.directory.directories.get('q4-user').directories.get('baseoq4').directories.get('demos');
  assert.equal(new TextDecoder().decode(demos.files.get('server-demo_123.q4sd').bytes), 'real-q4-server-demo');
  assert.deepEqual(calls, [[
    'Q4_WebExecuteCommand', 'number', ['string'], ['playServerDemo "demos/server-demo_123.q4sd"']
  ]]);
});

test('canonical identity routes both domain families and preserves deep links', async (context) => {
  const previous = {
    location: globalThis.location,
    document: globalThis.document,
    localStorage: globalThis.localStorage,
    fetch: globalThis.fetch
  };
  context.after(() => {
    globalThis.location = previous.location;
    globalThis.document = previous.document;
    globalThis.localStorage = previous.localStorage;
    globalThis.fetch = previous.fetch;
  });

  let replacement = null;
  let fetched = null;
  globalThis.location = {
    hostname: 'q4.suby.pl',
    search: '?connect=28004',
    href: 'https://q4.suby.pl/arena?connect=28004#score',
    replace(value) { replacement = value; }
  };
  globalThis.document = { cookie: 'suby_nick=Ada' };
  globalThis.localStorage = { getItem() { return ''; }, setItem() {} };
  globalThis.fetch = async (url) => { fetched = url; return {}; };

  assert.equal(subyBase(), '.suby.pl');
  assert.equal(subyPortal(), 'https://gry.suby.pl');
  assert.equal(subyValidHost(), true);
  assert.equal(await subyDirectRedirect(), true);
  assert.equal(fetched, 'https://q4.z6.mm.mk:11443/healthz');
  assert.equal(replacement, 'https://q4.z6.mm.mk:11443/arena?connect=28004&nick=Ada#score');

  globalThis.location.hostname = 'q4.z6.mm.mk';
  assert.equal(subyBase(), '.z6.mm.mk');
  assert.equal(subyPortal(), 'https://gry.z6.mm.mk:11443');
  assert.equal(await subyDirectRedirect(), false);
});

test('offline shell excludes engine, content, APIs and game sockets', async () => {
  const [html, css, app, input, worker, manifestText] = await Promise.all([
    readFile(new URL('public/index.html', root), 'utf8'),
    readFile(new URL('public/css/app.css', root), 'utf8'),
    readFile(new URL('public/js/app.js', root), 'utf8'),
    readFile(new URL('public/js/input.js', root), 'utf8'),
    readFile(new URL('public/sw.js', root), 'utf8'),
    readFile(new URL('public/manifest.webmanifest', root), 'utf8')
  ]);
  const manifest = JSON.parse(manifestText);
  assert.match(worker, /const CACHE_VERSION = 'q4-shell-v3';/);
  assert.match(worker, /new Request\(file, \{ cache: 'reload' \}\)/);
  assert.match(worker, /\.then\(\(\) => self\.skipWaiting\(\)\)/);
  assert.ok((worker.match(/fetch\(event\.request, \{ cache: 'no-cache' \}\)/g) || []).length >= 2);
  assert.equal(manifest.display, 'fullscreen');
  assert.match(html, /id="gameCanvas"/);
  assert.match(html, /id="canvasShell"/);
  assert.match(html, /data-action="fire"/);
  assert.match(html, /data-action="ptt"/);
  assert.match(css, /@media \(orientation: portrait\)/);
  assert.match(input, /pointercancel/);
  assert.match(input, /lostpointercapture/);
  assert.match(app, /launch\.type !== 'replay'/);
  assert.match(app, /subyPlayBeacon\('q4'/);
  assert.doesNotMatch(app, /getContext\(['"]2d['"]\)/);
  assert.match(worker, /api\|game\|engine\|content/);
  assert.doesNotMatch(worker.match(/const SHELL_FILES = \[[\s\S]*?\];/)?.[0] || '', /\/engine\//);
  assert.ok(IMPORT_CHUNK_SIZE >= 4 * 1024 * 1024);
});
