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

import { EngineLoader, engineLogMilestone } from '../public/js/engine-loader.js';

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

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

function locationObject() {
  return { origin: 'https://q4.suby.pl', protocol: 'https:', host: 'q4.suby.pl' };
}

function assertVersionQuery(url, sha256) {
  const parsed = new URL(url, 'https://q4.suby.pl');
  assert.deepEqual([...parsed.searchParams], [['v', sha256]]);
  assert.equal(parsed.hash, '');
  return parsed.pathname;
}

test('cold engine startup allows the full release-E2E initialization window', () => {
  const loader = new EngineLoader({
    fetchImpl: async () => new Response(),
    locationObject: locationObject()
  });
  assert.equal(loader.timeoutMs, 30 * 60_000);
});

test('runtime and GameLib progress phases run sequentially', async () => {
  const source = await readFile(new URL('public/js/engine-loader.js', root), 'utf8');
  assert.match(source, /const runtimeAssets = await runtimeAssetsPromise;[\s\S]*await this\.loadGameLib/);
  assert.doesNotMatch(source, /Promise\.all\(\[\s*runtimeAssetsPromise,\s*this\.loadGameLib/);
});

test('a factory that outlives the startup timeout taints the document for reload', async () => {
  const loader = new EngineLoader({
    fetchImpl: async () => new Response(),
    locationObject: locationObject(),
    timeoutMs: 10
  });
  loader.discover = async () => ({
    script: '/engine/openq4-sp.mjs',
    module: true,
    factory: null,
    wasm: '/engine/openq4-sp.wasm',
    arguments: [],
    build: 'late-factory-test',
    readySignal: 'engine',
    gameLib: null
  });
  loader.loadArtifact = async () => {
    await new Promise((resolve) => setTimeout(resolve, 50));
    return {};
  };

  await assert.rejects(loader.launch({
    canvas: {},
    launch: { type: 'singleplayer' },
    importer: { async launchDescriptor() { return { basePath: '/opfs/test' }; } }
  }), (error) => error.code === 'ENGINE_TIMEOUT');
  assert.equal(loader.tainted, true);
  assert.equal(loader.launching, false);
  assert.equal(loader.ready, false);
});

test('staged engine manifest cache versions match every declared artifact hash', async () => {
  const manifest = JSON.parse(await readFile(new URL('engine/engine-manifest.json', root), 'utf8'));
  const expected = {
    singleplayer: ['openq4-sp.mjs', 'openq4-sp.wasm', 'game-sp_wasm.wasm'],
    multiplayer: ['openq4-mp.mjs', 'openq4-mp.wasm', 'game-mp_wasm.wasm'],
    replay: ['openq4-mp.mjs', 'openq4-mp.wasm', 'game-mp_wasm.wasm']
  };
  assert.equal(manifest.schema, 2);
  for (const [variant, filenames] of Object.entries(expected)) {
    const entry = manifest.variants[variant];
    assert.equal(assertVersionQuery(entry.script, entry.sha256.script), `/engine/${filenames[0]}`);
    assert.equal(assertVersionQuery(entry.wasm, entry.sha256.wasm), `/engine/${filenames[1]}`);
    assert.equal(assertVersionQuery(entry.gameLib.url, entry.gameLib.sha256), `/engine/${filenames[2]}`);
    assert.equal(entry.gameLib.filename, filenames[2]);
  }
});

test('staged SDL3 runtimes expose writable fullscreen and callback hooks', async () => {
  const runtimes = await Promise.all([
    readFile(new URL('engine/openq4-sp.mjs', root), 'utf8'),
    readFile(new URL('engine/openq4-mp.mjs', root), 'utf8')
  ]);
  for (const runtime of runtimes) {
    assert.match(runtime, /Module\[['"]requestFullscreen['"]\]\s*=\s*requestFullscreen;/);
    assert.doesNotMatch(runtime, /unexportedRuntimeSymbol\(['"]requestFullscreen['"]/);
    assert.match(runtime, /dynCalls\[['"]iiii['"]\]/);
    assert.match(runtime, /wasmExports\[['"]dynCall_iiii['"]\]/);
    assert.doesNotMatch(runtime, /Attempted to invoke wasm function pointer with signature "iiii"/);
  }
});

test('schema 2 engine manifests select isolated hashed GameLib side modules', async () => {
  const manifest = {
    schema: 2,
    build: 'dynamic-test',
    variants: {
      singleplayer: {
        script: `/engine/openq4-sp.mjs?v=${'c'.repeat(64)}`,
        wasm: `/engine/openq4-sp.wasm?v=${'d'.repeat(64)}`,
        gameLib: {
          source: 'src/game',
          url: `/engine/game-sp_wasm.wasm?v=${'a'.repeat(64)}`,
          filename: 'game-sp_wasm.wasm',
          sha256: 'a'.repeat(64)
        }
      },
      multiplayer: {
        script: `/engine/openq4-mp.mjs?v=${'e'.repeat(64)}`,
        wasm: `/engine/openq4-mp.wasm?v=${'f'.repeat(64)}`,
        gameLib: {
          source: 'src/mpgame',
          url: `/engine/game-mp_wasm.wasm?v=${'b'.repeat(64)}`,
          filename: 'game-mp_wasm.wasm',
          sha256: 'b'.repeat(64)
        }
      }
    }
  };
  const loader = new EngineLoader({
    locationObject: locationObject(),
    fetchImpl: async () => new Response(JSON.stringify(manifest), {
      headers: { 'content-type': 'application/json' }
    })
  });

  const singleplayer = await loader.discover('singleplayer');
  const multiplayer = await loader.discover('multiplayer');
  const replay = await loader.discover('replay');
  assert.equal(singleplayer.gameLib.filename, 'game-sp_wasm.wasm');
  assert.equal(multiplayer.gameLib.filename, 'game-mp_wasm.wasm');
  assert.equal(replay.gameLib.filename, 'game-mp_wasm.wasm');
  assert.equal(singleplayer.script, `/engine/openq4-sp.mjs?v=${'c'.repeat(64)}`);
  assert.equal(singleplayer.wasm, `/engine/openq4-sp.wasm?v=${'d'.repeat(64)}`);
  assert.equal(singleplayer.gameLib.url, `/engine/game-sp_wasm.wasm?v=${'a'.repeat(64)}`);
  assert.equal(
    loader.locateFile('openQ4-client_wasm.wasm', singleplayer),
    `https://q4.suby.pl/engine/openq4-sp.wasm?v=${'d'.repeat(64)}`
  );

  manifest.variants.singleplayer.gameLib = manifest.variants.multiplayer.gameLib;
  await assert.rejects(loader.discover('singleplayer'), (error) => error.code === 'INVALID_ENGINE_MANIFEST');
});

test('GameLib download is SHA-256 gated before it reaches WasmFS', async () => {
  const bytes = new TextEncoder().encode('side-module-fixture');
  const sha256 = digest(bytes);
  const gameLib = {
    source: 'src/game',
    url: `/engine/game-sp_wasm.wasm?v=${sha256}`,
    filename: 'game-sp_wasm.wasm',
    sha256
  };
  const requests = [];
  const progress = [];
  const loader = new EngineLoader({
    fetchImpl: async (url) => {
      requests.push(url);
      return new Response(bytes, { headers: { 'content-length': String(bytes.byteLength) } });
    },
    locationObject: locationObject()
  });
  const loaded = await loader.loadGameLib(gameLib, { onProgress: (state) => progress.push(state) });
  assert.deepEqual(loaded.bytes, bytes);
  assert.equal(requests[0], gameLib.url);
  assert.equal(progress[0].state, 'download');
  assert.equal(progress[0].completedBytes, 0);
  assert.ok(progress.some((state) => state.state === 'verify' && state.indeterminate === true));
  assert.deepEqual(progress.at(-1), {
    state: 'ready',
    file: gameLib.filename,
    completedBytes: bytes.byteLength,
    totalBytes: bytes.byteLength
  });

  await assert.rejects(
    loader.loadGameLib({ ...gameLib, sha256: '0'.repeat(64) }),
    (error) => error.code === 'ENGINE_GAMELIB_INTEGRITY'
  );
});

test('OpenQ4 stdout exposes stable, user-facing initialization milestones', () => {
  assert.deepEqual(engineLogMilestone('file system initialized.'), {
    stage: 'engine-filesystem',
    message: 'Dane gry zostały zindeksowane.'
  });
  assert.deepEqual(engineLogMilestone('renderer startup phase: vertexCache.Init'), {
    stage: 'engine-renderer',
    message: 'Przygotowanie pamięci geometrii…'
  });
  assert.deepEqual(engineLogMilestone('renderer startup phase: renderer startup complete'), {
    stage: 'engine-renderer',
    message: 'Renderer WebGL 2 jest gotowy.'
  });
  assert.equal(engineLogMilestone('warning: unsupported syscall: __syscall_mlock'), null);
});

test('engine launch stages the verified side module before main runs', async () => {
  const bytes = new TextEncoder().encode('dynamic-game-code');
  const calls = [];
  const globalObject = {
    Q4EngineAdapter: {
      runtimeReady() {
        calls.push(['ready']);
      }
    }
  };
  const loader = new EngineLoader({
    globalObject,
    locationObject: locationObject(),
    timeoutMs: 1000,
    fetchImpl: async (url) => {
      assert.equal(url, '/engine/game-sp_wasm.wasm');
      return new Response(bytes);
    }
  });
  loader.discover = async () => ({
    script: '/engine/openq4-sp.mjs',
    module: true,
    factory: null,
    wasm: '/engine/openq4-sp.wasm',
    arguments: [],
    build: 'dynamic-test',
    readySignal: 'runtime',
    gameLib: {
      source: 'src/game',
      url: '/engine/game-sp_wasm.wasm',
      filename: 'game-sp_wasm.wasm',
      sha256: digest(bytes)
    }
  });
  loader.loadArtifact = async (_manifest, moduleConfig) => {
    moduleConfig.FS_createPath = (...args) => calls.push(['path', ...args]);
    moduleConfig.FS_createDataFile = (...args) => calls.push(['file', ...args]);
    for (const hook of moduleConfig.preRun) await hook.call(moduleConfig);
    await moduleConfig.onRuntimeInitialized.call(moduleConfig);
    return moduleConfig;
  };

  await loader.launch({
    canvas: {},
    launch: { type: 'singleplayer' },
    importer: {
      async launchDescriptor() {
        return { revision: 'test', basePath: '/opfs/q4-retail/test' };
      }
    }
  });

  assert.deepEqual(calls[0], ['path', '/', 'baseoq4', true, true]);
  assert.deepEqual(calls[1].slice(0, 3), ['file', '/baseoq4', 'game-sp_wasm.wasm']);
  assert.deepEqual(calls[1][3], bytes);
  assert.deepEqual(calls[1].slice(4), [true, false, true]);
  assert.deepEqual(calls[2], ['ready']);
});

test('build and E2E contracts require dynamic linkage, web thread fallback and causal mouse state', async () => {
  const [
    build,
    runtimePatch,
    browserVideoPatch,
    browserVideoInitPatch,
    damageBlobPatch,
    gameLibPatch,
    botGuardPatch,
    threadPatch,
    blockingLoadPatch,
    e2e
  ] = await Promise.all([
    readFile(new URL('tools/build-wasm.sh', root), 'utf8'),
    readFile(new URL('port/native/patches/0007-static-gamelibs-and-web-runtime.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0036-preserve-browser-video-settings-after-reload.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0037-normalize-browser-video-before-renderer-init.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0038-resolve-bare-damage-blob-materials.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0009-port-bots-to-mpgame.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0017-guard-bot-entity-slots.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0010-web-background-download-fallback.patch', root), 'utf8'),
    readFile(new URL('port/native/patches/0011-throttle-blocking-load-pacifier.patch', root), 'utf8'),
    readFile(new URL('tools/e2e-full.mjs', root), 'utf8')
  ]);
  assert.match(build, /-sMAIN_MODULE=1/);
  assert.match(build, /content\/baseoq4\/game-sp_wasm\.wasm/);
  assert.match(build, /content\/baseoq4\/game-mp_wasm\.wasm/);
  assert.match(build, /dylink\.0/);
  assert.match(build, /GameLib side module does not export GetGameAPI/);
  assert.match(build, /generated runtime does not export \{method\}/);
  assert.match(build, /0010-web-background-download-fallback\.patch/);
  assert.match(build, /Background downloads use the synchronous browser fallback/);
  assert.match(build, /return f"\{path\}\?v=\{sha256\}"/);
  assert.match(build, /manifest cache version mismatch/);
  assert.match(runtimePatch, /-sSIDE_MODULE=1/);
  assert.match(runtimePatch, /-sMAIN_MODULE=1['"], ['"]-sEXPORT_ALL=1/);
  assert.match(runtimePatch, /-sASYNCIFY_STACK_SIZE=8388608/);
  assert.match(runtimePatch, /game DLL '%s' returned null interfaces/);
  assert.equal(
    (gameLibPatch.match(/#if defined\( OPENQ4_STATIC_GAME \) \|\| defined\( __EMSCRIPTEN__ \)/g) || []).length,
    2
  );
  assert.match(gameLibPatch, /while \( botClients < targetBots && activeClients < maxPlayers \)/);
  assert.match(gameLibPatch, /minimum bot population maintained while slots are available/);
  assert.doesNotMatch(gameLibPatch, /while \( activeClients < target \)/);
  assert.match(build, /0017-guard-bot-entity-slots\.patch/);
  assert.match(botGuardPatch, /entinfo = BotPlayerForIndex\( i \)/);
  assert.match(botGuardPatch, /entityNum < 0 \|\| entityNum >= MAX_GENTITIES/);
  assert.doesNotMatch(botGuardPatch, /^\+\s*.*gameLocal\.entities\[i\]->Cast<idPlayer>/m);
  assert.match(runtimePatch, /OPENQ4_WEB_GAME_SP/);
  assert.match(runtimePatch, /OPENQ4_WEB_GAME_MP/);
  assert.match(runtimePatch, /Emscripten builds must select exactly one Web GameLib variant/);
  assert.match(runtimePatch, /return "game_sp";/);
  assert.match(runtimePatch, /return "game_mp";/);
  assert.match(runtimePatch, /FS_createPath,FS_createDataFile/);
  assert.match(runtimePatch, /removeRunDependency,requestFullscreen/);
  assert.match(runtimePatch, /Browser GameLib side modules are staged explicitly at \/baseoq4/);
  assert.match(runtimePatch, /exeDir = "\/"/);
  assert.match(runtimePatch, /Q4_WebInputState/);
  assert.match(runtimePatch, /browserVideoSettingsNormalized/);
  assert.match(runtimePatch, /SetCVarBool\( "r_fullscreen", false \)/);
  assert.match(runtimePatch, /SetCVarInteger\( "r_screenFraction", 100 \)/);
  assert.match(runtimePatch, /SetCVarInteger\( "r_resolutionScaleMode", 0 \)/);
  assert.match(runtimePatch, /SetCVarInteger\( "r_swapInterval", 1 \)/);
  assert.match(build, /0036-preserve-browser-video-settings-after-reload\.patch/);
  assert.match(browserVideoPatch, /full ReloadEngine reconstructs archived cvars/);
  assert.match(browserVideoPatch, /GetCVarInteger\( "r_screenFraction" \) != 100/);
  assert.match(browserVideoPatch, /GetCVarInteger\( "r_resolutionScaleMode" \) != 0/);
  assert.match(browserVideoPatch, /GetCVarInteger\( "r_swapInterval" \) != 1/);
  assert.doesNotMatch(browserVideoPatch, /^\+\s*browserVideoSettingsNormalized/m);
  assert.match(build, /0037-normalize-browser-video-before-renderer-init\.patch/);
  assert.match(browserVideoInitPatch, /InitGame runs before the first Emscripten frame/);
  assert.match(browserVideoInitPatch, /#ifdef __EMSCRIPTEN__/);
  assert.match(browserVideoInitPatch, /SetCVarBool\( "r_fullscreen", false \)/);
  assert.match(browserVideoInitPatch, /SetCVarInteger\( "r_screenFraction", 100 \)/);
  assert.match(browserVideoInitPatch, /SetCVarInteger\( "r_resolutionScaleMode", 0 \)/);
  assert.match(browserVideoInitPatch, /SetCVarInteger\( "r_swapInterval", 1 \)/);
  assert.match(browserVideoInitPatch, /InitRenderSystem\(\)/);
  assert.match(build, /0038-resolve-bare-damage-blob-materials\.patch/);
  assert.equal((damageBlobPatch.match(/materialName\.Length\(\) > 0/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/kv->GetKey\(\)\.Icmp\( "mtr_blob" \)/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/materialName\.Icmp\( "genericdamage" \)/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/materialName = va\( "textures\/decals\/%s"/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/idStr::Icmp\( materialName, "genericdamage" \)/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/blobMaterial->IsImplicit\(\)/g) || []).length, 2);
  assert.equal((damageBlobPatch.match(/TestMaterialFlag\( MF_DEFAULTED \)/g) || []).length, 2);
  assert.doesNotMatch(runtimePatch, /OPENQ4_STATIC_/);
  assert.match(threadPatch, /#ifdef __EMSCRIPTEN__[\s\S]*synchronous browser fallback/);
  assert.match(threadPatch, /background URL downloads are unavailable in the browser/);
  assert.match(threadPatch, /#else[\s\S]*Sys_CreateThread/);
  assert.match(blockingLoadPatch, /Browser builds have no independent async thread/);
  assert.match(blockingLoadPatch, /common->Async\(\)/);
  assert.match(blockingLoadPatch, /minimumBlockingLoadFrameIntervalMsec = 100\.0f/);
  assert.match(blockingLoadPatch, /session->PacifierUpdate\(\)/);
  assert.match(blockingLoadPatch, /requestAnimationFrame owns browser presentation pacing/);
  assert.match(blockingLoadPatch, /return configuredInterval;/);
  assert.doesNotMatch(blockingLoadPatch, /Post-map frame|Map load epilogue/);
  assert.match(e2e, /Q4_WebInputState/);
  assert.match(e2e, /Q4_E2E_COMMAND_READY/);
  assert.match(e2e, /Q4_E2E_MAP_READY/);
  assert.match(e2e, /assertVariantResourceSet/);
  assert.match(e2e, /fetched the isolated/);
  assert.doesNotMatch(e2e, /SDL3\?\.mouse_buttons/);
});
