import {
  subyDirectRedirect,
  subyNick,
  subyNickSet,
  subyPlayBeacon
} from './identity.js';
import {
  RetailImporter,
  RetailImportError,
  formatBytes,
  makeRevisionId,
  selectServerRetailBundles,
  validateRetailFiles
} from './importer.js';
import { EngineLoader, EngineLaunchError } from './engine-loader.js';
import { createLauncher, missingBundleBytes } from './menu.js';
import {
  BrowserInputSink,
  GamepadController,
  InputRouter,
  TouchController,
  installKeyboardInput
} from './input.js';
const directRoute = subyDirectRedirect();

const byId = (id) => document.getElementById(id);
const canvas = byId('gameCanvas');
const runtimePanel = byId('runtimePanel');
const runtimeBadge = byId('runtimeBadge');
const runtimeKicker = byId('runtimeKicker');
const runtimeTitle = byId('runtimeTitle');
const runtimeMessage = byId('runtimeMessage');
const runtimeProgressArea = byId('runtimeProgressArea');
const runtimeProgress = byId('runtimeProgress');
const runtimeProgressStage = byId('runtimeProgressStage');
const runtimeProgressText = byId('runtimeProgressText');
const runtimeDetails = byId('runtimeDetails');
const primaryAction = byId('primaryAction');
const retryButton = byId('retryButton');
const touchOverlay = byId('touchOverlay');
const importer = new RetailImporter();
const engineLoader = new EngineLoader();
const inputRouter = new InputRouter(new BrowserInputSink({
  target: canvas,
  onMenu: () => leaveGameToLauncher()
}));
let voiceHud = null;
const voiceHudReady = import('./voicehud.js')
  .then(({ installQ4VoiceHud }) => {
    voiceHud = installQ4VoiceHud({ notify: toast });
    return voiceHud;
  })
  .catch((error) => {
    console.warn('Voice chat module is unavailable', error);
    return null;
  });

let primaryHandler = () => startEngine({ type: 'singleplayer' });
let retryHandler = null;
let selectedFiles = [];
let selectedValidation = null;
let importController = null;
let launching = false;
let currentLaunch = null;
let initialLinkHandled = false;
let launcher = null;
let launcherStatus = null;
let playerModels = [];
let launcherRefreshTimer = null;
let preparingLaunch = false;
let leavingForLauncher = false;
let pendingReplayClaim = null;
let runtimeProgressFloor = 0;
const releasedReplayClaims = new Set();
const MATCH_RESERVATION_HEARTBEAT_MS = 2 * 60_000;
const MATCH_RESERVATION_HEADER = 'X-Q4-Match-Reservation';
const MATCH_RESERVATION_KEY = 'q4_match_reservation_v1';
const MATCH_RESERVATION_PATTERN = /^[a-f0-9]{64}$/;
const REPLAY_CLAIM_HEADER = 'X-Q4-Replay-Claim';
const REPLAY_CLAIM_PATTERN = /^[a-f0-9]{64}$/;
const QUEUED_LAUNCH_KEY = 'q4_queued_launch_v1';
const ENGINE_FAILURE_KEY = 'q4_engine_failure_v1';

const ENGINE_STAGE_LABELS = Object.freeze({
  retail: 'Dane Quake 4',
  discover: 'Manifest silnika',
  runtime: 'Dane OpenQ4',
  gamelib: 'Moduł zasad gry',
  load: 'Kod silnika',
  dependencies: 'Moduły WebAssembly',
  engine: 'Framework OpenQ4',
  'engine-filesystem': 'System plików',
  'engine-renderer': 'Renderer WebGL 2',
  'engine-audio': 'Dźwięk',
  'engine-game': 'Zasady gry',
  'engine-session': 'Sesja gry',
  adapter: 'Uruchamianie gry',
  ready: 'Gotowe'
});

const ENGINE_PROGRESS_RANGES = Object.freeze({
  retail: [0.55, 0.56],
  discover: [0.56, 0.58],
  runtime: [0.58, 0.70],
  gamelib: [0.70, 0.82],
  load: [0.82, 0.84],
  dependencies: [0.84, 0.90],
  engine: [0.90, 0.93],
  'engine-filesystem': [0.93, 0.94],
  'engine-renderer': [0.94, 0.97],
  'engine-audio': [0.97, 0.98],
  'engine-game': [0.98, 0.99],
  'engine-session': [0.99, 0.995],
  adapter: [0.995, 0.999],
  ready: [1, 1]
});

function drawShellCanvas() {
  if (document.body.classList.contains('engine-active')) return;
  const ratio = Math.min(window.devicePixelRatio || 1, 2);
  const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
  const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
  if (canvas.width !== width) canvas.width = width;
  if (canvas.height !== height) canvas.height = height;
}

function setBadge(label, level = 'idle') {
  runtimeBadge.textContent = label;
  runtimeBadge.dataset.level = level;
}

function setRuntimeProgress({
  label = 'Uruchamianie OpenQ4',
  completedBytes,
  totalBytes,
  completedUnits,
  totalUnits,
  rangeStart = 0,
  rangeEnd = 1,
  indeterminate = false
} = {}) {
  runtimeProgressArea.hidden = false;
  runtimeProgressStage.textContent = label;
  const byteTotal = Number(totalBytes);
  const byteCompleted = Number(completedBytes);
  const unitTotal = Number(totalUnits);
  const unitCompleted = Number(completedUnits);
  const hasBytes = !indeterminate && Number.isFinite(byteTotal) && byteTotal > 0 && Number.isFinite(byteCompleted);
  const hasUnits = !indeterminate && Number.isFinite(unitTotal) && unitTotal > 0 && Number.isFinite(unitCompleted);
  if (!indeterminate) {
    const completed = hasBytes ? byteCompleted : unitCompleted;
    const total = hasBytes ? byteTotal : unitTotal;
    const start = Math.max(0, Math.min(1, Number(rangeStart) || 0));
    const end = Math.max(start, Math.min(1, Number(rangeEnd) || 0));
    const localRatio = hasBytes || hasUnits
      ? Math.max(0, Math.min(1, completed / total))
      : 0;
    runtimeProgressFloor = Math.max(runtimeProgressFloor, start + localRatio * (end - start));
    const ratio = runtimeProgressFloor;
    const percent = Math.floor(ratio * 100);
    runtimeProgressArea.dataset.mode = 'determinate';
    runtimeProgress.value = ratio;
    runtimeProgressText.textContent = hasBytes
      ? `${percent}% · ${formatBytes(Math.max(0, completed))} / ${formatBytes(total)}`
      : hasUnits
        ? `${percent}% · ${Math.max(0, completed)} / ${total}`
        : `${percent}% · Trwa…`;
    runtimeProgress.setAttribute('aria-valuetext', `${label}: ${runtimeProgressText.textContent}`);
    return;
  }
  runtimeProgressArea.dataset.mode = 'indeterminate';
  runtimeProgress.removeAttribute('value');
  runtimeProgressText.textContent = 'Trwa…';
  runtimeProgress.setAttribute('aria-valuetext', `${label}: trwa`);
}

function hideRuntimeProgress() {
  runtimeProgressArea.hidden = true;
  runtimeProgress.removeAttribute('value');
  runtimeProgress.removeAttribute('aria-valuetext');
}

function setRuntime({
  kicker = 'OPENQ4 / WEBASSEMBLY',
  title,
  message,
  level = 'idle',
  badge = 'GOTOWY',
  primaryLabel = null,
  onPrimary = null,
  retryLabel = null,
  onRetry = null,
  details = null,
  progress = null
}) {
  document.body.classList.remove('engine-active');
  runtimePanel.hidden = false;
  runtimeKicker.textContent = kicker;
  runtimeTitle.textContent = title;
  runtimeMessage.textContent = message;
  setBadge(badge, level);

  primaryAction.hidden = !primaryLabel;
  if (primaryLabel) primaryAction.textContent = primaryLabel;
  primaryHandler = onPrimary || (() => {});

  retryButton.hidden = !retryLabel;
  if (retryLabel) retryButton.textContent = retryLabel;
  retryHandler = onRetry;

  runtimeDetails.hidden = !details;
  runtimeDetails.textContent = details || '';
  runtimeProgressFloor = 0;
  if (progress) setRuntimeProgress(progress);
  else hideRuntimeProgress();
  drawShellCanvas();
}

function errorDetails(error) {
  const lines = [];
  if (error?.code) lines.push(error.code);
  if (error?.details && Object.keys(error.details).length) {
    try {
      lines.push(JSON.stringify(error.details, null, 2));
    } catch (_ignored) {
      // Error details are diagnostic only.
    }
  }
  return lines.join('\n');
}

function toast(message, type = 'info') {
  const element = document.createElement('div');
  element.className = `toast ${type}`;
  element.textContent = message;
  byId('toastRegion').append(element);
  window.setTimeout(() => element.remove(), 4800);
}

function launchView(launch) {
  if (launch?.type === 'multiplayer') return 'multiplayer';
  if (launch?.type === 'replay') return 'replays';
  return 'campaign';
}

function retailMapForLaunch(launch) {
  if (launch?.type === 'multiplayer') return String(launch.match?.map || launch.map || '').trim();
  if (launch?.type === 'replay') {
    return String(launch.replay?.metadata?.map || launch.replay?.map || launch.watch?.map || '').trim();
  }
  return '';
}

function showLauncher(view = 'campaign') {
  if (!launcher || launching || preparingLaunch) return;
  if (document.body.classList.contains('engine-active')) {
    leaveGameToLauncher(view);
    return;
  }
  runtimePanel.hidden = true;
  launcher.show(view);
}

async function jsonResponse(response, fallbackMessage) {
  const payload = await response.json().catch(() => null);
  if (response.ok) return payload;
  throw new EngineLaunchError(
    payload?.code || 'REQUEST_FAILED',
    payload?.error || fallbackMessage || `Żądanie nie powiodło się (HTTP ${response.status}).`,
    { status: response.status, ...(payload || {}) }
  );
}

async function refreshLauncherStatus() {
  const response = await fetch('/api/status', { cache: 'no-store' });
  const status = await jsonResponse(response, 'Nie udało się pobrać statusu serwera.');
  const firstStatus = launcherStatus === null;
  launcherStatus = status;
  playerModels = Array.isArray(status.catalog?.models) ? status.catalog.models : [];
  const gamePort = Number(status.udp?.defaultPort);
  const players = Array.isArray(status.connections)
    ? status.connections.filter((client) => Number(client.port) === gamePort).length
    : Number(status.players) || 0;
  launcher?.setMatchCatalog({
    models: playerModels,
    ...(Array.isArray(status.catalog?.maps) ? { maps: status.catalog.maps } : {}),
    ...(firstStatus && status.match?.current ? { match: status.match.current } : {})
  });
  launcher?.setStatus({
    online: Boolean(status.dedicated?.ready),
    players,
    message: status.match?.pending
      ? 'Serwer ładuje mapę…'
      : status.dedicated?.ready
        ? 'Serwer gotowy'
        : status.dedicated?.running
          ? 'Serwer uruchamia mapę…'
          : 'Serwer jest zatrzymany'
  });
  return status;
}

async function fetchReplays() {
  const response = await fetch('/api/replays', { cache: 'no-store' });
  const payload = await jsonResponse(response, 'Nie udało się pobrać listy powtórek.');
  const replays = Array.isArray(payload?.replays) ? payload.replays : [];
  launcher?.setReplays(replays);
  return replays;
}

function getMatchReservationToken() {
  try {
    const stored = sessionStorage.getItem(MATCH_RESERVATION_KEY);
    if (MATCH_RESERVATION_PATTERN.test(stored || '')) return stored;
    const bytes = new Uint8Array(32);
    globalThis.crypto.getRandomValues(bytes);
    const token = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
    sessionStorage.setItem(MATCH_RESERVATION_KEY, token);
    return token;
  } catch (error) {
    throw new EngineLaunchError(
      'MATCH_RESERVATION_UNAVAILABLE',
      'Przeglądarka nie może bezpiecznie zarezerwować wybranego meczu.',
      { cause: error?.message || String(error) }
    );
  }
}

function releaseMatchReservation() {
  let token;
  try {
    token = sessionStorage.getItem(MATCH_RESERVATION_KEY);
  } catch {
    return;
  }
  if (!MATCH_RESERVATION_PATTERN.test(token || '')) return;
  fetch('/api/match/reservation', {
    method: 'DELETE',
    headers: { Accept: 'application/json', [MATCH_RESERVATION_HEADER]: token },
    keepalive: true
  }).catch(() => {});
}

function createReplayClaimToken() {
  try {
    const bytes = new Uint8Array(32);
    globalThis.crypto.getRandomValues(bytes);
    return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
  } catch (error) {
    throw new EngineLaunchError(
      'REPLAY_CLAIM_UNAVAILABLE',
      'Przeglądarka nie może bezpiecznie uruchomić wybranej powtórki.',
      { cause: error?.message || String(error) }
    );
  }
}

function releaseReplayClaim(claim = pendingReplayClaim) {
  if (!claim && currentLaunch?.type === 'replay') {
    claim = {
      replayId: currentLaunch.replay?.id,
      token: currentLaunch.replayClaimToken
    };
  }
  const replayId = String(claim?.replayId || '');
  const token = String(claim?.token || '');
  if (!/^[A-Za-z0-9_-]{1,512}$/.test(replayId) || !REPLAY_CLAIM_PATTERN.test(token)) return;
  const key = `${replayId}:${token}`;
  if (releasedReplayClaims.has(key)) return;
  releasedReplayClaims.add(key);
  if (pendingReplayClaim?.replayId === replayId && pendingReplayClaim?.token === token) {
    pendingReplayClaim = null;
  }
  fetch(`/api/replays/${encodeURIComponent(replayId)}/watch`, {
    method: 'DELETE',
    headers: { Accept: 'application/json', [REPLAY_CLAIM_HEADER]: token },
    keepalive: true
  }).catch(() => {});
}

function keepMatchReserved(match, reservationToken) {
  let stopped = false;
  let pending = false;
  const refresh = async () => {
    if (stopped || pending) return;
    pending = true;
    try {
      const response = await fetch('/api/match', {
        method: 'POST',
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
          [MATCH_RESERVATION_HEADER]: reservationToken
        },
        body: JSON.stringify(match)
      });
      await jsonResponse(response, 'Nie udało się odnowić rezerwacji meczu.');
    } catch (error) {
      console.warn('Match reservation heartbeat failed', error);
    } finally {
      pending = false;
    }
  };
  const timer = window.setInterval(refresh, MATCH_RESERVATION_HEARTBEAT_MS);
  return () => {
    stopped = true;
    window.clearInterval(timer);
  };
}

async function configureMultiplayer({ match, profile, settings }) {
  if (preparingLaunch || launching || document.body.classList.contains('engine-active')) {
    throw new EngineLaunchError('ENGINE_ALREADY_STARTED', 'Uruchamianie gry już trwa.');
  }
  preparingLaunch = true;
  let stopMatchReservationHeartbeat = null;
  try {
    const reservationToken = getMatchReservationToken();
    await importer.ensureServerRetail({
      map: match.map,
      onProgress: (state) => {
        const percent = state.totalBytes ? Math.floor((state.completedBytes / state.totalBytes) * 100) : 0;
        launcher?.setBusy(true, `Przygotowanie danych gry: ${percent}%`);
      }
    });
    launcher?.setBusy(true, 'Ładowanie wybranej mapy na serwerze');
    const response = await fetch('/api/match', {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        [MATCH_RESERVATION_HEADER]: reservationToken
      },
      body: JSON.stringify(match)
    });
    let configured;
    try {
      configured = await jsonResponse(response, 'Nie udało się przygotować meczu.');
    } catch (error) {
      if (error?.code === 'MATCH_PLAYERS_ACTIVE') {
        throw new EngineLaunchError(error.code, 'Nie można teraz zmienić mapy: na serwerze trwa rozgrywka.');
      }
      if (error?.code === 'MATCH_CHANGE_IN_PROGRESS') {
        throw new EngineLaunchError(error.code, 'Serwer przygotowuje już inny mecz. Spróbuj ponownie za moment.');
      }
      if (error?.code === 'MATCH_JOIN_RESERVED') {
        throw new EngineLaunchError(error.code, 'Nowy mecz czeka na gracza, który go uruchomił. Spróbuj ponownie za chwilę.');
      }
      if (error?.code === 'MATCH_RATE_LIMIT') {
        throw new EngineLaunchError(error.code, 'Zmiana meczu była przed chwilą wykonywana. Spróbuj ponownie za moment.');
      }
      if (error?.code === 'MATCH_SERVER_NOT_READY' || error?.code === 'MATCH_START_TIMEOUT') {
        throw new EngineLaunchError(error.code, 'Serwer nie zakończył ładowania mapy. Spróbuj ponownie za moment.');
      }
      throw error;
    }
    const configuredMatch = configured.match || match;
    stopMatchReservationHeartbeat = keepMatchReserved(configuredMatch, reservationToken);
    const model = playerModels.find((entry) => entry.id === profile?.model);
    await startEngine({
      type: 'multiplayer',
      port: Number(configured.port),
      match: configuredMatch,
      reservationToken,
      profile: { ...profile, modelTeam: model?.team || 'auto' },
      settings
    });
  } finally {
    stopMatchReservationHeartbeat?.();
    preparingLaunch = false;
  }
}

function renderRetailProgress(state) {
  const verifying = state.state === 'verify-retail';
  const verb = verifying ? 'Sprawdzanie' : 'Pobieranie';
  const file = state.file ? ` · ${state.file}` : '';
  runtimeTitle.textContent = state.state === 'verify-retail'
    ? 'Sprawdzanie danych gry'
    : state.state === 'ready'
      ? 'Dane gry są gotowe'
      : 'Pobieranie danych gry';
  runtimeMessage.textContent = verifying
    ? `${verb} integralności danych gry${file}`
    : `${verb} danych gry ` +
      `(${formatBytes(state.completedBytes)} / ${formatBytes(state.totalBytes)})${file}`;
  setRuntimeProgress({
    label: verifying ? 'Weryfikacja danych Quake 4' : 'Dane Quake 4',
    completedBytes: state.completedBytes,
    totalBytes: state.totalBytes,
    rangeStart: 0,
    rangeEnd: 0.55,
    indeterminate: verifying
  });
  setBadge('DANE', 'loading');
}

function renderEngineProgress(state = {}) {
  if (state.message) runtimeMessage.textContent = state.message;
  const [rangeStart, rangeEnd] = ENGINE_PROGRESS_RANGES[state.stage]
    || [runtimeProgressFloor, runtimeProgressFloor];
  setRuntimeProgress({
    label: ENGINE_STAGE_LABELS[state.stage] || 'Uruchamianie OpenQ4',
    completedBytes: state.completedBytes,
    totalBytes: state.totalBytes,
    completedUnits: state.completedUnits,
    totalUnits: state.totalUnits,
    rangeStart,
    rangeEnd,
    indeterminate: Boolean(state.indeterminate || /^verify/.test(String(state.state || '')))
  });
  setBadge('START', 'loading');
}

function openDialog(dialog) {
  if (!dialog) return;
  for (const open of document.querySelectorAll('dialog[open]')) {
    if (open !== dialog) open.close();
  }
  if (!dialog.open) dialog.showModal();
}

function updateUrlForLaunch(launch) {
  const url = new URL(location.href);
  url.searchParams.delete('connect');
  url.searchParams.delete('replay');
  if (launch.type === 'multiplayer') url.searchParams.set('connect', String(launch.port));
  if (launch.type === 'replay') url.searchParams.set('replay', launch.replay.id);
  history.replaceState({ q4: launch.type }, '', url);
}

function clearLaunchUrl() {
  const url = new URL(location.href);
  url.searchParams.delete('connect');
  url.searchParams.delete('replay');
  history.replaceState({ q4: 'launcher' }, '', url);
}

function queueLaunchAfterReload(launch) {
  try {
    sessionStorage.setItem(QUEUED_LAUNCH_KEY, JSON.stringify(launch));
  } catch (error) {
    throw new EngineLaunchError(
      'ENGINE_RESTART_UNAVAILABLE',
      'Przeglądarka nie może przygotować ponownego uruchomienia silnika.',
      { cause: error?.message || String(error) }
    );
  }
  location.reload();
}

function takeQueuedLaunch() {
  let value = null;
  try {
    value = sessionStorage.getItem(QUEUED_LAUNCH_KEY);
    sessionStorage.removeItem(QUEUED_LAUNCH_KEY);
  } catch {
    return null;
  }
  if (!value || value.length > 64 * 1024) return null;
  try {
    const launch = JSON.parse(value);
    return launch && ['singleplayer', 'multiplayer', 'replay'].includes(launch.type) ? launch : null;
  } catch {
    return null;
  }
}

function takeEngineFailure() {
  let value = null;
  try {
    value = sessionStorage.getItem(ENGINE_FAILURE_KEY);
    sessionStorage.removeItem(ENGINE_FAILURE_KEY);
  } catch {
    return null;
  }
  if (!value || value.length > 16 * 1024) return null;
  try {
    const failure = JSON.parse(value);
    return typeof failure?.message === 'string' ? failure : null;
  } catch {
    return null;
  }
}

function reloadToLauncher(view, error = null) {
  launcher?.select(view);
  releaseMatchReservation();
  releaseReplayClaim();
  try {
    sessionStorage.removeItem(QUEUED_LAUNCH_KEY);
    if (error) {
      sessionStorage.setItem(ENGINE_FAILURE_KEY, JSON.stringify({
        code: String(error.code || 'ENGINE_START_FAILED').slice(0, 128),
        message: String(error.message || 'Nie udało się uruchomić gry.').slice(0, 4000)
      }));
    }
  } catch {}
  clearLaunchUrl();
  location.reload();
}

function leaveGameToLauncher(view = launchView(currentLaunch)) {
  const engineStarted = document.body.classList.contains('engine-active')
    || launching
    || preparingLaunch
    || engineLoader.launching
    || engineLoader.ready;
  if (!engineStarted || leavingForLauncher) return false;
  leavingForLauncher = true;
  for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
  document.exitPointerLock?.();
  inputRouter.releaseAll();
  voiceHud?.session?.disable?.();
  reloadToLauncher(view);
  return true;
}

function launchFromUrl() {
  const params = new URLSearchParams(location.search);
  const replay = params.get('replay');
  if (replay && /^[A-Za-z0-9_-]{1,512}$/.test(replay)) return { type: 'replay', replayId: replay };
  const connect = params.get('connect');
  if (connect) {
    const match = /(?:^|:)(\d{4,5})$/.exec(connect);
    const port = Number(match?.[1] || connect);
    if (Number.isInteger(port) && port >= 1024 && port <= 65535) return { type: 'multiplayer', port };
  }
  return null;
}

async function replayMetadata(id) {
  const response = await fetch(`/api/replays/${encodeURIComponent(id)}/meta`, { cache: 'no-store' });
  if (!response.ok) throw new EngineLaunchError('REPLAY_NOT_FOUND', 'Wybrana powtórka nie istnieje na serwerze.');
  return response.json();
}

async function allocateReplay(replay) {
  const id = String(replay?.id || '');
  if (!/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
    throw new EngineLaunchError('REPLAY_INVALID', 'Powtórka ma nieprawidłowy identyfikator.');
  }
  const replayClaimToken = createReplayClaimToken();
  const claim = { replayId: id, token: replayClaimToken };
  pendingReplayClaim = claim;
  let response;
  let watch;
  try {
    response = await fetch(`/api/replays/${encodeURIComponent(id)}/watch`, {
      method: 'POST',
      headers: { Accept: 'application/json', [REPLAY_CLAIM_HEADER]: replayClaimToken }
    });
    watch = await jsonResponse(response, 'Nie udało się uruchomić serwera powtórki.');
  } catch (error) {
    releaseReplayClaim(claim);
    const message = {
      REPLAY_START_TIMEOUT: 'Serwer powtórki nie zakończył ładowania w wyznaczonym czasie.',
      REPLAY_POOL_BUSY: 'Wszystkie miejsca do odtwarzania powtórek są teraz zajęte.',
      REPLAY_DISABLED: 'Odtwarzanie powtórek jest chwilowo wyłączone.',
      REPLAY_UNAVAILABLE: 'Odtwarzanie powtórek jest chwilowo niedostępne.'
    }[error?.code] || 'Nie udało się przygotować wybranej powtórki.';
    throw new EngineLaunchError(error?.code || 'REPLAY_START_FAILED', message, error?.details);
  }
  const port = Number(watch?.port);
  if (!Number.isInteger(port) || port < 1024 || port > 65535) {
    releaseReplayClaim(claim);
    throw new EngineLaunchError('REPLAY_START_FAILED', 'Serwer powtórki zwrócił nieprawidłowy port.');
  }
  return { type: 'replay', port, replay, watch, replayClaimToken };
}

async function startReplay(replay, settings = launcher?.getState().settings) {
  if (preparingLaunch || launching || document.body.classList.contains('engine-active')) {
    throw new EngineLaunchError('ENGINE_ALREADY_STARTED', 'Uruchamianie gry już trwa.');
  }
  preparingLaunch = true;
  try {
    await importer.ensureServerRetail({
      map: String(replay?.metadata?.map || replay?.map || '').trim(),
      onProgress: (state) => {
        const percent = state.totalBytes ? Math.floor((state.completedBytes / state.totalBytes) * 100) : 0;
        launcher?.setBusy(true, `Przygotowanie danych gry: ${percent}%`);
      }
    });
    launcher?.setBusy(true, 'Uruchamianie serwera powtórki');
    const launch = await allocateReplay(replay);
    return await startEngine({ ...launch, settings });
  } finally {
    preparingLaunch = false;
  }
}

async function handleInitialLink() {
  if (initialLinkHandled || launching || preparingLaunch) return;
  const link = launchFromUrl();
  if (!link) return;
  initialLinkHandled = true;
  if (link.type === 'replay') {
    try {
      const replay = await replayMetadata(link.replayId);
      await startReplay(replay);
    } catch (error) {
      initialLinkHandled = false;
      renderEngineError(error, null, false, () => handleInitialLink());
    }
    return;
  }
  const current = launcherStatus?.match?.current;
  const defaultPort = Number(launcherStatus?.udp?.defaultPort);
  if (current?.map && (!defaultPort || defaultPort === link.port)) link.match = current;
  await startEngine(link);
}

async function readiness() {
  if (await directRoute) return;
  if (launching || preparingLaunch || document.body.classList.contains('engine-active')) return;
  if (!importer.supported) {
    setRuntime({
      kicker: 'PAMIĘĆ PRZEGLĄDARKI',
      title: 'OPFS jest niedostępny',
      message: 'Uruchom grę w aktualnym Chrome, Edge lub innej przeglądarce obsługującej Origin Private File System.',
      badge: 'BŁĄD',
      level: 'error',
      details: 'OPFS_UNSUPPORTED'
    });
    return;
  }

  setRuntime({
    kicker: 'QUAKE 4 / DANE GRY',
    title: 'Sprawdzanie instalacji',
    message: 'Sprawdzanie silnika i manifestu danych gry…',
    badge: 'DANE',
    level: 'loading',
    progress: { label: 'Sprawdzanie instalacji' }
  });

  try {
    const [catalog, bundleCatalog, manifest] = await Promise.all([
      importer.serverRetailCatalog(),
      importer.serverRetailBundleCatalog().catch(() => null),
      engineLoader.discover()
    ]);
    const cached = await importer.getRevisionManifest(catalog.revision);
    const fullReady = cached?.state === 'ready' && cached?.source === 'operator-server' &&
      cached?.serverFingerprint === catalog.fingerprint;
    const selectedMap = launcher?.getState()?.match?.map || '';
    const bundleSelection = bundleCatalog
      ? (() => {
          try { return selectServerRetailBundles(bundleCatalog, selectedMap); } catch { return null; }
        })()
      : null;
    const bundled = bundleCatalog ? await importer.getRevisionManifest(bundleCatalog.revision) : null;
    const reusableBundleCache = bundled?.source === 'operator-server-bundles' &&
      bundled?.serverFingerprint === bundleCatalog?.fingerprint;
    const bundleReady = reusableBundleCache && bundled?.state === 'ready' &&
      bundled?.installedMaps?.includes(bundleSelection?.map);
    const missingBytes = bundleSelection
      ? missingBundleBytes(bundleSelection, reusableBundleCache ? bundled?.installedBundleIds : [])
      : 0;
    const ready = fullReady || bundleReady;
    if (launching || preparingLaunch || document.body.classList.contains('engine-active')) return;
    setRuntime({
      kicker: 'OPENQ4 / GOTOWY',
      title: ready ? 'Silnik i dane są gotowe' : 'Silnik jest gotowy',
      message: fullReady
        ? `Dane gry są zapisane w tej przeglądarce. Kompilacja ${manifest.build || manifest.script}.`
        : bundleReady
          ? `Dane wybranej mapy są zapisane w tej przeglądarce. Kompilacja ${manifest.build || manifest.script}.`
          : bundleSelection
            ? `Do pobrania dla wybranej mapy: ${formatBytes(missingBytes)}. Pobrane paczki pozostaną w tej przeglądarce.`
            : `Przy pierwszym uruchomieniu gra automatycznie pobierze i sprawdzi ${formatBytes(catalog.totalBytes)} danych.`,
      badge: 'GOTOWY',
      level: 'ready'
    });
    runtimePanel.hidden = true;
    launcher?.setBusy(false).show();
    await Promise.all([
      refreshLauncherStatus().catch(() => launcher?.setStatus({
        online: false,
        players: 0,
        message: 'Status serwera jest niedostępny'
      })),
      fetchReplays().catch(() => [])
    ]);
    const engineFailure = takeEngineFailure();
    if (engineFailure) {
      launcher?.announceError(engineFailure.message);
      return;
    }
    const queuedLaunch = takeQueuedLaunch();
    if (queuedLaunch) {
      const stopHeartbeat = queuedLaunch.type === 'multiplayer' && queuedLaunch.match
        ? keepMatchReserved(queuedLaunch.match, queuedLaunch.reservationToken || getMatchReservationToken())
        : null;
      try {
        await startEngine(queuedLaunch);
      } finally {
        stopHeartbeat?.();
      }
    } else {
      await handleInitialLink();
    }
  } catch (error) {
    if (launching || preparingLaunch || document.body.classList.contains('engine-active')) return;
    renderEngineError(error, null, false, () => readiness());
  }
}

function renderEngineError(error, launch = currentLaunch, retryLaunch = true, customRetry = null) {
  launcher?.setBusy(false).announceError(error?.message || 'Nie udało się uruchomić gry.');
  const missingEngine = error?.code === 'ENGINE_MISSING';
  const missingRetail = error?.code === 'RETAIL_MISSING';
  const title = missingEngine
    ? 'Brak kompilacji silnika'
    : missingRetail
      ? 'Brak danych gry'
      : 'Nie udało się uruchomić gry';
  const retryAction = customRetry
    || (retryLaunch && launch ? () => startEngine(launch) : null)
    || (missingEngine ? () => readiness() : null);
  setRuntime({
    kicker: 'START ZATRZYMANY',
    title,
    message: error?.message || 'Nieznany błąd uruchamiania.',
    badge: 'BŁĄD',
    level: 'error',
    primaryLabel: missingRetail ? 'Wybierz pliki gry' : null,
    onPrimary: missingRetail ? () => openImportDialog() : null,
    retryLabel: retryAction ? (missingEngine ? 'Sprawdź ponownie' : 'Spróbuj ponownie') : null,
    onRetry: retryAction,
    details: errorDetails(error)
  });
}

async function startEngine(launch) {
  if (await directRoute) return;
  if (launching || document.body.classList.contains('engine-active')) return;
  if (engineLoader.ready) {
    queueLaunchAfterReload(launch);
    return;
  }
  if (launch?.type === 'multiplayer' && !retailMapForLaunch(launch)) {
    const current = launcherStatus?.match?.current;
    const defaultPort = Number(launcherStatus?.udp?.defaultPort);
    if (current?.map && (!defaultPort || defaultPort === Number(launch.port))) {
      launch = { ...launch, match: current };
    }
  }
  const nick = subyNick() || 'Gracz';
  launch = { ...launch, nick, profile: { ...(launch.profile || {}), nick } };
  launching = true;
  currentLaunch = launch;
  if (launch.type === 'multiplayer') await voiceHudReady;
  for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
  launcher?.setBusy(true, 'Uruchamianie gry').hide();
  setRuntime({
    kicker: 'OPENQ4 / START',
    title: 'Uruchamianie silnika',
    message: 'Przygotowanie lokalnych danych gry…',
    badge: 'START',
    level: 'loading',
    progress: { label: 'Dane Quake 4' }
  });

  try {
    await importer.ensureServerRetail({
      map: retailMapForLaunch(launch),
      onProgress: renderRetailProgress
    });
    await engineLoader.launch({
      canvas,
      launch,
      importer,
      onStatus: renderEngineProgress,
      onReady: () => {
        if (launch.type === 'replay' && pendingReplayClaim?.token === launch.replayClaimToken) {
          pendingReplayClaim = null;
        }
        document.body.classList.add('engine-active');
        canvas.removeAttribute('aria-hidden');
        runtimePanel.hidden = true;
        launcher?.setBusy(false).hide();
        touchOverlay.setAttribute('aria-hidden', 'false');
        setBadge('ONLINE', 'ready');
        updateUrlForLaunch(launch);
        canvas.focus({ preventScroll: true });
        if (launch.type !== 'replay') subyPlayBeacon('q4', subyNick());
      }
    });
  } catch (error) {
    if (engineLoader.tainted) reloadToLauncher(launchView(launch), error);
    else if (launch.type === 'replay') {
      releaseReplayClaim({ replayId: launch.replay?.id, token: launch.replayClaimToken });
      renderEngineError(error, null, false, () => startReplay(launch.replay, launch.settings));
    } else renderEngineError(error, launch);
  } finally {
    launching = false;
  }
}

async function refreshImports() {
  const activeRevision = byId('activeRevision');
  const list = byId('revisionList');
  list.replaceChildren();
  try {
    const state = await importer.listImports();
    activeRevision.textContent = state.activeRevision || 'Brak';
    if (!state.manifests.length) {
      const empty = document.createElement('p');
      empty.className = 'empty-state';
      empty.textContent = 'Brak lokalnych rewizji.';
      list.append(empty);
      return;
    }

    for (const manifest of state.manifests) {
      const row = document.createElement('div');
      row.className = 'revision-item';
      const copy = document.createElement('div');
      const name = document.createElement('strong');
      name.textContent = manifest.revision;
      const meta = document.createElement('span');
      const stateLabel = {
        ready: 'gotowa', importing: 'przerwana', cancelled: 'anulowana', error: 'błąd'
      }[manifest.state] || manifest.state;
      meta.textContent = `${stateLabel} · ${formatBytes(manifest.completedBytes)} / ${formatBytes(manifest.totalBytes)}`;
      copy.append(name, meta);

      const action = document.createElement('button');
      if (manifest.state === 'ready' && state.activeRevision !== manifest.revision) {
        action.textContent = 'Użyj';
        action.addEventListener('click', async () => {
          await importer.activate(manifest.revision);
          await refreshImports();
          await readiness();
        });
      } else {
        action.textContent = 'Usuń';
        action.addEventListener('click', async () => {
          if (!window.confirm(`Usunąć lokalną rewizję ${manifest.revision}?`)) return;
          await importer.remove(manifest.revision);
          await refreshImports();
          await readiness();
        });
      }
      row.append(copy, action);
      list.append(row);
    }
  } catch (error) {
    activeRevision.textContent = 'Błąd';
    const empty = document.createElement('p');
    empty.className = 'empty-state';
    empty.textContent = error.message || String(error);
    list.append(empty);
  }
}

async function updateSelection(files) {
  selectedFiles = Array.from(files || []);
  selectedValidation = validateRetailFiles(selectedFiles);
  const summary = byId('selectionSummary');
  const message = byId('validationMessage');
  const start = byId('startImportButton');
  summary.hidden = false;
  byId('selectedFileCount').textContent = String(selectedValidation.files.length);
  byId('selectedBytes').textContent = formatBytes(selectedValidation.totalBytes);
  message.className = 'validation-message';
  start.disabled = true;

  let previous = null;
  try {
    previous = await importer.getRevisionManifest(makeRevisionId(selectedValidation));
    const quota = await importer.quota(selectedValidation, previous);
    byId('availableBytes').textContent = formatBytes(quota.available);
    if (!selectedValidation.ok) {
      message.classList.add('error');
      if (selectedValidation.duplicates.length) {
        message.textContent = `Powtórzone nazwy: ${selectedValidation.duplicates.map((entry) => entry.name).join(', ')}`;
      } else if (selectedValidation.invalid.length) {
        message.textContent = `Puste pakiety: ${selectedValidation.invalid.join(', ')}`;
      } else {
        message.textContent = `Brakuje: ${selectedValidation.missing.join(', ')}`;
      }
      return;
    }
    if (!quota.ok) {
      message.classList.add('error');
      message.textContent = `Za mało miejsca: potrzeba ${formatBytes(quota.required)}, dostępne ${formatBytes(quota.available)}.`;
      return;
    }
    message.classList.add('ok');
    message.textContent = previous?.state === 'ready'
      ? 'Ta rewizja jest już zapisana. Import sprawdzi i aktywuje istniejące dane.'
      : previous
        ? 'Znaleziono niedokończoną rewizję. Import zostanie wznowiony.'
        : 'Zestaw plików jest kompletny.';
    start.disabled = false;
  } catch (error) {
    byId('availableBytes').textContent = '—';
    message.classList.add('error');
    message.textContent = error.message || String(error);
  }
}

async function beginImport() {
  if (!selectedValidation?.ok || importController) return;
  importController = new AbortController();
  const progressArea = byId('importProgressArea');
  const progress = byId('importProgress');
  const progressText = byId('importProgressText');
  const progressLabel = byId('importProgressLabel');
  const cancel = byId('cancelImportButton');
  const start = byId('startImportButton');
  progressArea.hidden = false;
  cancel.hidden = false;
  start.disabled = true;

  try {
    const result = await importer.import(selectedFiles, {
      signal: importController.signal,
      onProgress: (state) => {
        const ratio = state.totalBytes ? state.completedBytes / state.totalBytes : 0;
        progress.value = Math.max(0, Math.min(1, ratio));
        progressText.textContent = `${Math.floor(ratio * 100)}%`;
        progressLabel.textContent = state.file || (state.state === 'ready' ? 'Gotowe' : 'Importowanie');
      }
    });
    progress.value = 1;
    progressText.textContent = '100%';
    toast(result.duplicate ? 'Istniejąca rewizja została aktywowana.' : 'Pliki gry zostały zapisane.', 'info');
    await refreshImports();
    await readiness();
  } catch (error) {
    if (error?.name === 'AbortError') toast('Import został anulowany.', 'info');
    else {
      toast(error.message || String(error), 'error');
      byId('validationMessage').className = 'validation-message error';
      byId('validationMessage').textContent = error.message || String(error);
    }
    await refreshImports();
  } finally {
    importController = null;
    cancel.hidden = true;
    start.disabled = !selectedValidation?.ok;
  }
}

async function openImportDialog() {
  openDialog(byId('importDialog'));
  await refreshImports();
}

async function openMultiplayer() {
  openDialog(byId('multiplayerDialog'));
  const row = byId('serverStatus');
  row.dataset.online = 'false';
  byId('serverStatusText').textContent = 'Sprawdzanie serwera…';
  byId('serverPlayers').textContent = '—';
  try {
    const response = await fetch('/api/status', { cache: 'no-store' });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const status = await response.json();
    row.dataset.online = String(Boolean(status.dedicated?.running));
    byId('serverStatusText').textContent = status.dedicated?.running ? 'Serwer działa' : 'Serwer gry jest zatrzymany';
    byId('serverPlayers').textContent = `${Number(status.players) || 0} gr.`;
    if (status.udp?.defaultPort) byId('serverPort').value = String(status.udp.defaultPort);
  } catch (_error) {
    byId('serverStatusText').textContent = 'Status jest niedostępny';
  }
}

async function openReplays() {
  openDialog(byId('replayDialog'));
  const list = byId('replayList');
  list.replaceChildren();
  const loading = document.createElement('p');
  loading.className = 'empty-state';
  loading.textContent = 'Ładowanie…';
  list.append(loading);
  try {
    const response = await fetch('/api/replays', { cache: 'no-store' });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const payload = await response.json();
    list.replaceChildren();
    if (!payload.replays?.length) {
      const empty = document.createElement('p');
      empty.className = 'empty-state';
      empty.textContent = 'Brak zapisanych powtórek.';
      list.append(empty);
      return;
    }
    for (const replay of payload.replays) {
      const row = document.createElement('div');
      row.className = 'replay-item';
      const copy = document.createElement('div');
      const name = document.createElement('strong');
      name.textContent = replay.name;
      const meta = document.createElement('span');
      meta.textContent = `${formatBytes(replay.size)} · ${new Date(replay.modifiedAt).toLocaleString('pl-PL')}`;
      copy.append(name, meta);
      const play = document.createElement('button');
      play.textContent = 'Odtwórz';
      play.addEventListener('click', async () => {
        try {
          await startReplay(replay);
        } catch (error) {
          renderEngineError(error, null, false, () => startReplay(replay));
        }
      });
      row.append(copy, play);
      list.append(row);
    }
  } catch (error) {
    list.replaceChildren();
    const empty = document.createElement('p');
    empty.className = 'empty-state';
    empty.textContent = `Nie udało się pobrać listy: ${error.message || error}`;
    list.append(empty);
  }
}

function installUi() {
  launcher = createLauncher({
    onCampaign: ({ difficulty, settings }) => startEngine({
      type: 'singleplayer',
      difficulty,
      settings
    }),
    onMultiplayer: (selection) => configureMultiplayer(selection),
    onReplay: ({ replay, settings }) => startReplay(replay, settings),
    onRefreshReplays: () => fetchReplays(),
    onSelect: (view) => {
      if (view === 'multiplayer') refreshLauncherStatus().catch(() => {});
      if (view === 'replays') fetchReplays().catch((error) => launcher?.announceError(error));
    }
  });
  primaryAction.addEventListener('click', () => primaryHandler());
  retryButton.addEventListener('click', () => retryHandler?.());
  byId('campaignButton').addEventListener('click', () => showLauncher('campaign'));
  byId('multiplayerButton').addEventListener('click', () => showLauncher('multiplayer'));
  byId('replayButton').addEventListener('click', () => showLauncher('replays'));
  byId('importButton').addEventListener('click', () => openImportDialog());
  byId('menuButton').addEventListener('click', () => openDialog(byId('menuDialog')));
  byId('refreshImportsButton').addEventListener('click', () => refreshImports());

  const nickInput = byId('nickInput');
  nickInput.value = subyNick() || subyNickSet('Gracz');
  nickInput.addEventListener('change', () => {
    const nick = subyNickSet(nickInput.value) || 'Gracz';
    nickInput.value = nick;
  });

  const directoryInput = byId('retailDirectory');
  const fileInput = byId('retailFiles');
  byId('chooseDirectoryButton').addEventListener('click', () => {
    directoryInput.value = '';
    directoryInput.click();
  });
  byId('chooseFilesButton').addEventListener('click', () => {
    fileInput.value = '';
    fileInput.click();
  });
  directoryInput.addEventListener('change', () => updateSelection(directoryInput.files));
  fileInput.addEventListener('change', () => updateSelection(fileInput.files));

  const dropTarget = byId('dropTarget');
  dropTarget.addEventListener('dragover', (event) => {
    event.preventDefault();
    dropTarget.dataset.drag = 'true';
  });
  dropTarget.addEventListener('dragleave', () => { dropTarget.dataset.drag = 'false'; });
  dropTarget.addEventListener('drop', (event) => {
    event.preventDefault();
    dropTarget.dataset.drag = 'false';
    updateSelection(event.dataTransfer.files);
  });
  byId('startImportButton').addEventListener('click', () => beginImport());
  byId('cancelImportButton').addEventListener('click', () => importController?.abort());

  byId('joinServerButton').addEventListener('click', () => {
    const port = Number(byId('serverPort').value);
    if (!Number.isInteger(port) || port < 1024 || port > 65535) {
      toast('Nieprawidłowy port serwera.', 'error');
      return;
    }
    startEngine({ type: 'multiplayer', port });
  });

  byId('fullscreenButton').addEventListener('click', async () => {
    byId('menuDialog').close();
    try {
      if (document.fullscreenElement) await document.exitFullscreen();
      else await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
    } catch (error) {
      toast(error.message || String(error), 'error');
    }
  });
  byId('pointerLockButton').addEventListener('click', async () => {
    byId('menuDialog').close();
    try {
      await canvas.requestPointerLock?.({ unadjustedMovement: true });
    } catch (_error) {
      canvas.requestPointerLock?.();
    }
  });
  byId('exitToLauncherButton').addEventListener('click', () => leaveGameToLauncher());
  byId('aboutButton').addEventListener('click', () => {
    byId('menuDialog').close();
    openDialog(byId('aboutDialog'));
  });
}

function installInput() {
  new TouchController({
    overlay: touchOverlay,
    moveZone: byId('moveZone'),
    moveKnob: byId('moveKnob'),
    lookZone: byId('lookZone'),
    router: inputRouter
  }).install();
  installKeyboardInput({
    canvas,
    router: inputRouter,
    active: () => document.body.classList.contains('engine-active') || launching || preparingLaunch,
    onMenu: () => leaveGameToLauncher()
  });
  new GamepadController({
    router: inputRouter,
    active: () => document.body.classList.contains('engine-active')
  }).start();
}

async function registerServiceWorker() {
  if (!('serviceWorker' in navigator) || !window.isSecureContext) return;
  try {
    await navigator.serviceWorker.register('/sw.js', { scope: '/' });
  } catch (error) {
    console.warn('PWA service worker registration failed', error);
  }
}

installUi();
installInput();
window.addEventListener('resize', drawShellCanvas, { passive: true });
window.addEventListener('orientationchange', () => window.setTimeout(drawShellCanvas, 120), { passive: true });
drawShellCanvas();
registerServiceWorker();
launcherRefreshTimer = window.setInterval(() => {
  if (document.hidden || launching || preparingLaunch || document.body.classList.contains('engine-active') || byId('launcher')?.hidden) return;
  refreshLauncherStatus().catch(() => {});
}, 10_000);
readiness().catch((error) => {
  const normalized = error instanceof RetailImportError ? error : new RetailImportError('READINESS_FAILED', error.message || String(error));
  renderEngineError(normalized, null, false, () => readiness());
});

window.Q4App = Object.freeze({
  importer,
  engineLoader,
  startEngine,
  startReplay,
  launcher,
  refreshLauncherStatus,
  input: inputRouter,
  leaveGameToLauncher,
  get voice() { return voiceHud?.session || null; },
  readiness
});
