import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import { loadConfig } from './server/config.js';
import { botNavigationMapIds, loadBotNavigation } from './server/bot-navigation.js';
import { cachePolicy, resolveFile, sendFile, sendJson, sendText } from './server/files.js';
import {
  handleReplayRequest,
  listReplays,
  pruneReplays,
  REPLAY_CLAIM_HEADER,
  REPLAY_CLAIM_PATTERN,
  resolveReplay,
} from './server/replays.js';
import { normalizeNativeIdentity, readNativeIdentitySync } from './server/native-identity.js';
import { loadRetailCatalog } from './server/retail.js';
import { loadRetailBundleCatalog } from './server/retail-bundles.js';
import { ReplayManager } from './server/replay-manager.js';
import { DedicatedSupervisor } from './server/supervisor.js';
import { UdpBridge } from './server/udp-bridge.js';
import { DEFAULT_MATCH, MULTIPLAYER_MAPS, normalizeMatchRequest } from './public/js/menu-config.js';

const MATCH_BODY_LIMIT = 8 * 1024;
const DEFAULT_MATCH_RATE_LIMIT_MS = 10_000;
const DEFAULT_MATCH_RESERVATION_MS = 5 * 60_000;
const MATCH_RESERVATION_HEADER = 'x-q4-match-reservation';
const MATCH_RESERVATION_PATTERN = /^[a-f0-9]{64}$/;

function logLine(level, message) {
  const line = `${new Date().toISOString()} [q4] ${message}`;
  (level === 'error' ? console.error : level === 'warn' ? console.warn : console.log)(line);
}

const logger = {
  info: (message) => logLine('info', message),
  warn: (message) => logLine('warn', message),
  error: (message) => logLine('error', message),
};

function rejectUpgrade(socket, status, reason) {
  if (!socket.writable) return socket.destroy();
  const body = `${reason}\n`;
  socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
}

function originAllowed(req, config) {
  const origin = req.headers.origin;
  if (!origin) return true;
  try {
    const parsed = new URL(origin);
    if (parsed.host === req.headers.host) return true;
    return config.allowedOrigins.has(parsed.origin);
  } catch {
    return false;
  }
}

function sameOrigin(req) {
  const origin = req.headers.origin;
  const host = req.headers.host;
  if (typeof origin !== 'string' || typeof host !== 'string') return false;
  try {
    const parsed = new URL(origin);
    const forwarded = String(req.headers['x-forwarded-proto'] || '').split(',', 1)[0].trim().toLowerCase();
    const requestProtocol = forwarded === 'http' || forwarded === 'https'
      ? `${forwarded}:`
      : req.socket.encrypted ? 'https:' : 'http:';
    return parsed.protocol === requestProtocol && parsed.host === host;
  } catch {
    return false;
  }
}

function requestError(statusCode, code, message) {
  const error = new Error(message);
  error.statusCode = statusCode;
  error.code = code;
  return error;
}

function matchReservationToken(req) {
  const token = req.headers[MATCH_RESERVATION_HEADER];
  if (typeof token !== 'string' || !MATCH_RESERVATION_PATTERN.test(token)) {
    throw requestError(400, 'MATCH_RESERVATION_TOKEN_INVALID', 'a valid match reservation token is required');
  }
  return token;
}

function readJsonBody(req, maxBytes = MATCH_BODY_LIMIT) {
  const contentType = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
  if (contentType !== 'application/json') {
    req.resume();
    return Promise.reject(requestError(415, 'MATCH_CONTENT_TYPE', 'content type must be application/json'));
  }
  const contentEncoding = String(req.headers['content-encoding'] || 'identity').trim().toLowerCase();
  if (contentEncoding !== 'identity') {
    req.resume();
    return Promise.reject(requestError(415, 'MATCH_CONTENT_ENCODING', 'encoded request bodies are not supported'));
  }
  const declaredLength = req.headers['content-length'];
  if (declaredLength !== undefined) {
    if (!/^\d+$/.test(String(declaredLength))) {
      req.resume();
      return Promise.reject(requestError(400, 'MATCH_CONTENT_LENGTH', 'invalid content length'));
    }
    if (Number(declaredLength) > maxBytes) {
      req.resume();
      return Promise.reject(requestError(413, 'MATCH_BODY_TOO_LARGE', `request body exceeds ${maxBytes} bytes`));
    }
  }

  return new Promise((resolve, reject) => {
    const chunks = [];
    let bytes = 0;
    let settled = false;
    const fail = (error) => {
      if (settled) return;
      settled = true;
      chunks.length = 0;
      req.resume();
      reject(error);
    };
    req.on('data', (chunk) => {
      if (settled) return;
      bytes += chunk.length;
      if (bytes > maxBytes) {
        fail(requestError(413, 'MATCH_BODY_TOO_LARGE', `request body exceeds ${maxBytes} bytes`));
        return;
      }
      chunks.push(chunk);
    });
    req.once('aborted', () => fail(requestError(400, 'MATCH_BODY_ABORTED', 'request body was aborted')));
    req.once('error', () => fail(requestError(400, 'MATCH_BODY_READ_FAILED', 'could not read request body')));
    req.once('end', () => {
      if (settled) return;
      settled = true;
      if (!bytes) return reject(requestError(400, 'MATCH_JSON_INVALID', 'request body must contain JSON'));
      let text;
      try {
        text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
      } catch {
        return reject(requestError(400, 'MATCH_JSON_INVALID', 'request body must be valid UTF-8 JSON'));
      }
      try {
        resolve(JSON.parse(text));
      } catch {
        reject(requestError(400, 'MATCH_JSON_INVALID', 'request body must be valid JSON'));
      }
    });
  });
}

function sameMatch(left, right) {
  return Boolean(left && right
    && left.map === right.map
    && left.mode === right.mode
    && left.botCount === right.botCount
    && left.fragLimit === right.fragLimit
    && left.timeLimit === right.timeLimit);
}

function staticMount(pathname, prefix, root) {
  if (!pathname.startsWith(prefix)) return null;
  const relative = pathname.slice(prefix.length);
  return relative ? resolveFile(root, relative) : null;
}

function replayWatchId(req, pathname, replayDir) {
  if (req.method !== 'POST') return null;
  if (!REPLAY_CLAIM_PATTERN.test(String(req.headers[REPLAY_CLAIM_HEADER] || ''))) return null;
  const match = /^\/api\/replays\/([A-Za-z0-9_-]+)\/watch$/.exec(pathname);
  return match && resolveReplay(replayDir, match[1]) ? match[1] : null;
}

export function createGateway(overrides = {}) {
  const config = loadConfig(overrides);
  const retailCatalog = loadRetailCatalog(config.retail);
  const retailBundleCatalog = loadRetailBundleCatalog({
    enabled: config.retail.enabled,
    directory: config.retail.bundleDirectory,
    manifestFile: config.retail.bundleManifestFile,
  }, retailCatalog);
  const retailUiCatalog = retailCatalog?.uiCatalog || null;
  let botNavigation = null;
  try {
    botNavigation = loadBotNavigation({
      ...config.botNavigation,
      retailCatalog,
    });
  } catch (error) {
    logger.warn(`retail bot navigation disabled: ${error.message}`);
  }
  const botNavigationMaps = botNavigationMapIds(botNavigation);
  const multiplayerMaps = MULTIPLAYER_MAPS.map((map) => {
    const botNavigationReady = botNavigationMaps.has(map.id);
    const botModes = botNavigationReady ? [...map.botModes] : [];
    return {
      ...map,
      botNavigationReady,
      botModes,
      botReady: botModes.length > 0,
    };
  });
  const botModesByMap = new Map(multiplayerMaps.map((map) => [map.id, new Set(map.botModes)]));
  const nativeIdentity = Object.hasOwn(overrides, 'nativeIdentity')
    ? (overrides.nativeIdentity === null ? null : normalizeNativeIdentity(overrides.nativeIdentity))
    : (config.dedicated.enabled || overrides.nativeIdentityPaths)
      ? readNativeIdentitySync({
        rootDir: config.dedicated.cwd,
        dedicatedPath: config.dedicated.command,
        gameLibPath: path.join(config.dedicated.cwd, 'baseoq4', 'game-mp_x64.so'),
        buildInfoPath: path.join(config.dedicated.cwd, 'build-info', 'build.txt'),
        ...(overrides.nativeIdentityPaths || {}),
      })
      : null;
  fs.mkdirSync(config.replayDir, { recursive: true });
  pruneReplays(config.replayDir, config.replayRetention);
  const replayPruner = setInterval(() => pruneReplays(config.replayDir, config.replayRetention), 60_000);
  replayPruner.unref();
  const bridge = new UdpBridge(config, logger);
  const replayManager = overrides.replayManager || new ReplayManager(config.replayPlayback, logger, {
    viewerCount: (port) => [...bridge.clients.values()].filter((client) => client.port === port).length,
  });
  const supervisor = overrides.supervisor || new DedicatedSupervisor(config.dedicated, logger, {
    initialMatch: DEFAULT_MATCH,
  });
  const replayAllocationsByIp = new Map();
  const now = typeof overrides.now === 'function' ? overrides.now : Date.now;
  const matchChangesByIp = new Map();
  const matchRateLimitMs = Math.max(0, Number(overrides.matchRateLimitMs ?? DEFAULT_MATCH_RATE_LIMIT_MS));
  const matchReservationMs = Math.max(0, Number(overrides.matchReservationMs ?? DEFAULT_MATCH_RESERVATION_MS));
  let matchMutation = null;
  let matchReservation = null;
  const startedAt = Date.now();
  const retailDownloadsByIp = new Map();
  let activeRetailDownloads = 0;
  let lastRetailTransferAt = 0;
  let listening = false;
  let closing = false;

  const currentMatchReservation = (stamp = now()) => {
    if (matchReservation && stamp >= matchReservation.until) matchReservation = null;
    return matchReservation;
  };

  const reserveMatch = (token, stamp = now()) => {
    matchReservation = matchReservationMs > 0
      ? { token, until: stamp + matchReservationMs }
      : null;
  };

  const sendMatchReserved = (req, res, reservation, stamp) => sendJson(req, res, 409, {
    error: 'the configured match is reserved for the joining player',
    code: 'MATCH_JOIN_RESERVED',
  }, config, { 'Retry-After': String(Math.max(1, Math.ceil((reservation.until - stamp) / 1000))) });

  const guardReplayAllocation = (req, pathname) => {
    const replayId = replayWatchId(req, pathname, config.replayDir);
    if (!replayId) return null;

    const stamp = now();
    const cutoff = stamp - config.replayAllocation.windowMs;
    for (const [ip, history] of replayAllocationsByIp) {
      const recent = history.filter((entry) => entry > cutoff);
      if (recent.length) replayAllocationsByIp.set(ip, recent);
      else replayAllocationsByIp.delete(ip);
    }

    const active = replayManager.status().some((instance) =>
      instance.replayId === replayId && !instance.ending);
    if (active) return null;

    const ip = bridge.ipFor(req);
    const history = replayAllocationsByIp.get(ip) || [];
    if (history.length >= config.replayAllocation.maxNewPerIp) {
      return Math.max(1, history[0] + config.replayAllocation.windowMs - stamp);
    }
    history.push(stamp);
    replayAllocationsByIp.set(ip, history);
    return null;
  };

  const handleMatchRequest = async (req, res) => {
    if (!sameOrigin(req)) {
      req.resume();
      return sendJson(req, res, 403, { error: 'same-origin request required', code: 'MATCH_ORIGIN_FORBIDDEN' }, config);
    }

    let match;
    let reservationToken;
    try {
      match = normalizeMatchRequest(await readJsonBody(req));
    } catch (error) {
      const knownValidationError = typeof error?.code === 'string' && error.code.startsWith('MATCH_');
      return sendJson(req, res, error.statusCode || (knownValidationError ? 400 : 500), {
        error: error.message || 'invalid match request',
        code: error.code || 'MATCH_INVALID',
      }, config);
    }
    try {
      reservationToken = matchReservationToken(req);
    } catch (error) {
      return sendJson(req, res, error.statusCode || 400, {
        error: error.message,
        code: error.code || 'MATCH_RESERVATION_TOKEN_INVALID',
      }, config);
    }
    if (match.botCount > 0 && !botModesByMap.get(match.map)?.has(match.mode)) {
      return sendJson(req, res, 409, {
        error: 'verified bot navigation is not staged for this map',
        code: 'MATCH_BOT_NAVIGATION_UNAVAILABLE',
      }, config);
    }

    if (matchMutation) {
      return sendJson(req, res, 409, {
        error: 'another match configuration is in progress',
        code: 'MATCH_CHANGE_IN_PROGRESS',
      }, config, { 'Retry-After': '2' });
    }

    const state = supervisor.status();
    const activePlayers = [...bridge.clients.values()].filter((client) =>
      !client.closed && client.port === config.gamePort).length;
    const stamp = now();
    const reservation = currentMatchReservation(stamp);
    const ownsReservation = reservation?.token === reservationToken;
    if (state.ready && !state.pendingMatch && sameMatch(state.currentMatch, match)) {
      if (activePlayers === 0) {
        if (reservation && !ownsReservation) return sendMatchReserved(req, res, reservation, stamp);
        reserveMatch(reservationToken, stamp);
      }
      return sendJson(req, res, 200, {
        ok: true,
        changed: false,
        port: config.gamePort,
        match,
      }, config);
    }

    if (activePlayers > 0) {
      return sendJson(req, res, 409, {
        error: 'cannot change match while players are connected',
        code: 'MATCH_PLAYERS_ACTIVE',
        players: activePlayers,
      }, config);
    }

    if (reservation && !ownsReservation) return sendMatchReserved(req, res, reservation, stamp);
    const ip = bridge.ipFor(req);
    const previous = matchChangesByIp.get(ip);
    if (!ownsReservation && matchRateLimitMs > 0 && previous !== undefined && stamp - previous < matchRateLimitMs) {
      const retryAfterMs = Math.max(1, previous + matchRateLimitMs - stamp);
      return sendJson(req, res, 429, {
        error: 'match configuration rate limit exceeded',
        code: 'MATCH_RATE_LIMIT',
      }, config, { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) });
    }
    matchChangesByIp.set(ip, stamp);

    const mutation = {
      match,
      reservationToken,
      cancelReservation: false,
      promise: supervisor.configureMatch(match),
    };
    matchMutation = mutation;
    try {
      const result = await mutation.promise;
      if (mutation.cancelReservation) matchReservation = null;
      else reserveMatch(reservationToken);
      return sendJson(req, res, 200, {
        ok: true,
        changed: result?.changed !== false,
        port: config.gamePort,
        match: result?.match || match,
      }, config);
    } catch (error) {
      return sendJson(req, res, error.statusCode || 503, {
        error: error.message || 'could not configure match',
        code: error.code || 'MATCH_CONFIGURE_FAILED',
      }, config);
    } finally {
      if (matchMutation === mutation) matchMutation = null;
    }
  };

  const handleMatchReservationCancel = (req, res) => {
    if (!sameOrigin(req)) {
      req.resume();
      return sendJson(req, res, 403, { error: 'same-origin request required', code: 'MATCH_ORIGIN_FORBIDDEN' }, config);
    }
    let token;
    try {
      token = matchReservationToken(req);
    } catch (error) {
      req.resume();
      return sendJson(req, res, error.statusCode || 400, {
        error: error.message,
        code: error.code || 'MATCH_RESERVATION_TOKEN_INVALID',
      }, config);
    }
    req.resume();
    if (matchMutation?.reservationToken === token) matchMutation.cancelReservation = true;
    const reservation = currentMatchReservation();
    const released = reservation?.token === token;
    if (released) matchReservation = null;
    return sendJson(req, res, 200, { ok: true, released }, config);
  };

  const server = http.createServer(async (req, res) => {
    let url;
    try {
      url = new URL(req.url, 'http://q4.local');
    } catch {
      return sendText(req, res, 400, 'bad request', config);
    }
    const pathname = url.pathname;

    try {
      if (closing && pathname.startsWith('/retail/')) {
        return sendText(req, res, 503, 'service restart in progress', config, { 'Retry-After': '5' });
      }
      if (pathname === '/api/match') {
        if (req.method !== 'POST') {
          req.resume();
          return sendText(req, res, 405, 'method not allowed', config, { Allow: 'POST' });
        }
        return await handleMatchRequest(req, res);
      }
      if (pathname === '/api/match/reservation') {
        if (req.method !== 'DELETE') {
          req.resume();
          return sendText(req, res, 405, 'method not allowed', config, { Allow: 'DELETE' });
        }
        return handleMatchReservationCancel(req, res);
      }
      if (pathname === '/api/replays' || pathname.startsWith('/api/replays/')) {
        const replayClaimMutation = (req.method === 'POST' || req.method === 'DELETE')
          && /^\/api\/replays\/[A-Za-z0-9_-]+\/watch$/.test(pathname);
        if (replayClaimMutation && !sameOrigin(req)) {
          return sendText(req, res, 403, 'forbidden', config);
        }
        const retryAfterMs = guardReplayAllocation(req, pathname);
        if (retryAfterMs !== null) {
          return sendJson(req, res, 429, {
            error: 'too many new replay allocations',
            code: 'REPLAY_ALLOCATION_RATE_LIMIT',
          }, config, { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) });
        }
        if (await handleReplayRequest(req, res, pathname, config, replayManager)) return;
      }
      if (req.method !== 'GET' && req.method !== 'HEAD') {
        return sendText(req, res, 405, 'method not allowed', config, { Allow: 'GET, HEAD' });
      }

      if (pathname === '/healthz') {
        const clients = bridge.clients.size;
        const dedicated = supervisor.status();
        const healthy = !config.dedicated.enabled || dedicated.ready;
        const retailBusy = activeRetailDownloads > 0 || (lastRetailTransferAt > 0 &&
          Date.now() - lastRetailTransferAt < config.retail.drainGraceMs);
        return sendJson(req, res, healthy ? 200 : 503, {
          status: healthy ? 'ok' : 'degraded',
          service: 'q4',
          port: server.address()?.port ?? config.port,
          clients,
          players: clients,
          gamePort: config.gamePort,
          dedicated: dedicated.ready,
          retail: Boolean(retailCatalog),
          retailBundles: Boolean(retailBundleCatalog),
          downloads: activeRetailDownloads,
          retailDownloads: activeRetailDownloads,
          retailBusy,
          uptime: Math.floor((Date.now() - startedAt) / 1000),
        }, config);
      }

      if (pathname === '/api/status') {
        const clients = bridge.publicClients();
        const dedicated = supervisor.status();
        return sendJson(req, res, 200, {
          status: 'ok',
          service: 'q4',
          uptime: Math.floor((Date.now() - startedAt) / 1000),
          clients: clients.length,
          players: clients.length,
          connections: clients,
          udp: {
            host: config.udpHost,
            defaultPort: config.gamePort,
            allowedPorts: [...config.allowedPorts].sort((a, b) => a - b),
          },
          dedicated,
          match: {
            current: dedicated.currentMatch || null,
            pending: dedicated.pendingMatch || null,
          },
          catalog: {
            revision: retailUiCatalog?.revision || null,
            models: retailUiCatalog?.models || [],
            maps: multiplayerMaps,
          },
          native: nativeIdentity,
          retail: retailCatalog ? {
            ready: true,
            revision: retailCatalog.manifest.revision,
            files: retailCatalog.manifest.files.length,
            totalBytes: retailCatalog.manifest.totalBytes,
            bundles: retailBundleCatalog ? {
              ready: true,
              revision: retailBundleCatalog.manifest.revision,
              files: retailBundleCatalog.manifest.bundles.length,
              maps: retailBundleCatalog.manifest.maps.length,
              totalBytes: retailBundleCatalog.manifest.totalBytes,
            } : { ready: false },
            downloads: activeRetailDownloads,
            maxConcurrent: config.retail.maxConcurrent,
          } : { ready: false },
          replays: listReplays(config.replayDir, config.replayRetention).length,
          replayInstances: replayManager.status(),
        }, config);
      }

      const engine = staticMount(pathname, '/engine/', config.engineDir);
      if (engine) return sendFile(req, res, engine.path, config, {
        stat: engine.stat, mount: 'engine', cacheControl: cachePolicy(engine.path, 'engine'),
      });
      const content = staticMount(pathname, '/content/', config.contentDir);
      if (content) return sendFile(req, res, content.path, config, {
        stat: content.stat, mount: 'content', cacheControl: cachePolicy(content.path, 'content'),
      });
      if (retailCatalog && pathname === '/retail/manifest.json') {
        return sendJson(req, res, 200, retailCatalog.manifest, config);
      }
      if (retailBundleCatalog && pathname === '/retail/bundles/manifest.json') {
        return sendJson(req, res, 200, retailBundleCatalog.manifest, config);
      }
      const retail = retailCatalog?.byUrl.get(pathname) || retailBundleCatalog?.byUrl.get(pathname);
      if (retail) {
        const ip = bridge.ipFor(req);
        const activeForIp = retailDownloadsByIp.get(ip) || 0;
        if (activeRetailDownloads >= config.retail.maxConcurrent || activeForIp >= config.retail.maxPerIp) {
          return sendText(req, res, 429, 'too many retail downloads', config, { 'Retry-After': '2' });
        }
        activeRetailDownloads += 1;
        retailDownloadsByIp.set(ip, activeForIp + 1);
        let settled = false;
        const settle = () => {
          if (settled) return;
          settled = true;
          activeRetailDownloads = Math.max(0, activeRetailDownloads - 1);
          const remainingForIp = (retailDownloadsByIp.get(ip) || 1) - 1;
          if (remainingForIp > 0) retailDownloadsByIp.set(ip, remainingForIp);
          else retailDownloadsByIp.delete(ip);
          lastRetailTransferAt = Date.now();
        };
        res.once('finish', settle);
        res.once('close', settle);
        try {
          return sendFile(req, res, retail.path, config, {
            stat: retail.stat,
            fd: retail.fd,
            mount: 'retail',
            etag: `"${retail.sha256}"`,
            cacheControl: 'private, no-store',
          });
        } catch (error) {
          settle();
          throw error;
        }
      }
      if (pathname === '/js/voicechat.js') {
        const stat = fs.statSync(config.voicechatFile);
        if (!stat.isFile()) return sendText(req, res, 404, 'not found', config);
        return sendFile(req, res, config.voicechatFile, config, {
          stat, mount: 'public', cacheControl: 'no-cache',
        });
      }

      const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
      const file = resolveFile(config.publicDir, relative);
      if (file) return sendFile(req, res, file.path, config, {
        stat: file.stat, mount: 'public', cacheControl: cachePolicy(file.path, 'public'),
      });
      return sendText(req, res, 404, 'not found', config);
    } catch (error) {
      logger.error(`HTTP ${req.method} ${pathname}: ${error.stack || error}`);
      if (!res.headersSent) return sendText(req, res, 500, 'internal error', config);
      res.destroy();
    }
  });

  server.keepAliveTimeout = 65_000;
  server.headersTimeout = 70_000;
  server.requestTimeout = 30_000;
  server.on('clientError', (_error, socket) => rejectUpgrade(socket, 400, 'Bad Request'));

  const wss = new WebSocketServer({
    noServer: true,
    maxPayload: Math.max(config.maxFrameBytes, config.voiceMaxMessageBytes),
  });
  wss.on('error', (error) => logger.error(`WebSocket server: ${error.stack || error}`));
  server.on('upgrade', (req, socket, head) => {
    if (closing) return rejectUpgrade(socket, 503, 'Service Unavailable');
    let url;
    try { url = new URL(req.url, 'http://q4.local'); } catch { return rejectUpgrade(socket, 400, 'Bad Request'); }
    const pathname = url.pathname;
    const match = /^\/game\/(\d{1,5})$/.exec(pathname);
    if (!match) return rejectUpgrade(socket, 404, 'Not Found');
    const port = Number.parseInt(match[1], 10);
    const room = String(port);
    if (url.searchParams.getAll('room').some((candidate) => candidate !== room)) {
      return rejectUpgrade(socket, 400, 'Bad Request');
    }
    const reservationTokens = url.searchParams.getAll('reservation');
    if (reservationTokens.length > 1 || reservationTokens.some((token) => !MATCH_RESERVATION_PATTERN.test(token))) {
      return rejectUpgrade(socket, 400, 'Bad Request');
    }
    const reservationToken = reservationTokens[0] || null;
    const replayClaimTokens = url.searchParams.getAll('replayClaim');
    if (replayClaimTokens.length > 1 || replayClaimTokens.some((token) => !REPLAY_CLAIM_PATTERN.test(token))) {
      return rejectUpgrade(socket, 400, 'Bad Request');
    }
    const replayClaimToken = replayClaimTokens[0] || null;
    const replayPort = replayManager.ownsPort(port);
    if (!config.allowedPorts.has(port) && !replayPort) return rejectUpgrade(socket, 403, 'Forbidden');
    if (!originAllowed(req, config)) return rejectUpgrade(socket, 403, 'Forbidden');
    if (replayPort && (!sameOrigin(req) || !replayClaimToken ||
        !replayManager.canClaim(port, replayClaimToken))) {
      return rejectUpgrade(socket, 403, 'Forbidden');
    }
    const dedicated = supervisor.status();
    if (port === config.gamePort && dedicated.enabled &&
        (matchMutation || dedicated.pendingMatch || !dedicated.ready)) {
      return rejectUpgrade(socket, 503, 'Service Unavailable');
    }
    const capacity = bridge.canAccept(req);
    if (!capacity.ok) return rejectUpgrade(socket, 429, 'Too Many Requests');
    socket.setNoDelay(true);
    wss.handleUpgrade(req, socket, head, (ws) => {
      if (replayPort && !replayManager.claim(port, replayClaimToken)) {
        ws.close(1008, 'Replay claim expired');
        return;
      }
      const reservation = currentMatchReservation();
      if (port === config.gamePort && sameOrigin(req) && reservationToken &&
          reservation?.token === reservationToken) {
        matchReservation = null;
      }
      bridge.attach(ws, req, port, room);
    });
  });

  async function listen() {
    if (listening) return server.address();
    await new Promise((resolve, reject) => {
      const onError = (error) => { server.off('listening', onListen); reject(error); };
      const onListen = () => { server.off('error', onError); resolve(); };
      server.once('error', onError);
      server.once('listening', onListen);
      server.listen(config.port, config.host);
    });
    listening = true;
    supervisor.start();
    const address = server.address();
    logger.info(`gateway listening on ${typeof address === 'string' ? address : `${address.address}:${address.port}`}`);
    return address;
  }

  async function close() {
    if (closing) return;
    closing = true;
    clearInterval(replayPruner);
    bridge.closeAll();
    // Both managers own child processes with independent stop deadlines. Stop
    // them together so shutdown remains inside systemd's TimeoutStopSec budget.
    await Promise.all([
      replayManager.close(),
      supervisor.stop(),
    ]);
    await new Promise((resolve) => {
      wss.close(() => resolve());
      if (!listening) resolve();
    });
    if (listening) {
      await new Promise((resolve) => server.close(() => resolve()));
      listening = false;
    }
    retailCatalog?.close();
    retailBundleCatalog?.close();
  }

  return {
    config,
    server,
    bridge,
    replayManager,
    supervisor,
    nativeIdentity,
    retailUiCatalog,
    retailBundleCatalog,
    botNavigation,
    listen,
    close,
    address: () => server.address(),
  };
}

const main = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (main) {
  const gateway = createGateway();
  gateway.listen().catch((error) => {
    logger.error(error.stack || error);
    process.exitCode = 1;
  });
  let stopping = false;
  const shutdown = async (signal) => {
    if (stopping) return;
    stopping = true;
    logger.info(`received ${signal}, shutting down`);
    const force = setTimeout(() => process.exit(1), 10_000);
    force.unref();
    try {
      await gateway.close();
      clearTimeout(force);
      process.exit(0);
    } catch (error) {
      logger.error(error.stack || error);
      process.exit(1);
    }
  };
  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
}
