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 { cachePolicy, resolveFile, sendFile, sendJson, sendText } from './server/files.js';
import { handleReplayRequest, listReplays, pruneReplays, resolveReplay } from './server/replays.js';
import { loadRetailCatalog } from './server/retail.js';
import { ReplayManager } from './server/replay-manager.js';
import { DedicatedSupervisor } from './server/supervisor.js';
import { UdpBridge } from './server/udp-bridge.js';

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 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;
  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);
  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 = new DedicatedSupervisor(config.dedicated, logger);
  const replayAllocationsByIp = new Map();
  const now = typeof overrides.now === 'function' ? overrides.now : Date.now;
  const startedAt = Date.now();
  const retailDownloadsByIp = new Map();
  let activeRetailDownloads = 0;
  let lastRetailTransferAt = 0;
  let listening = false;
  let closing = false;

  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 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/replays' || pathname.startsWith('/api/replays/')) {
        const replayWatch = req.method === 'POST'
          && /^\/api\/replays\/[A-Za-z0-9_-]+\/watch$/.test(pathname);
        if (replayWatch && !originAllowed(req, config)) {
          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),
          downloads: activeRetailDownloads,
          retailDownloads: activeRetailDownloads,
          retailBusy,
          uptime: Math.floor((Date.now() - startedAt) / 1000),
        }, config);
      }

      if (pathname === '/api/status') {
        const clients = bridge.publicClients();
        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: supervisor.status(),
          retail: retailCatalog ? {
            ready: true,
            revision: retailCatalog.manifest.revision,
            files: retailCatalog.manifest.files.length,
            totalBytes: retailCatalog.manifest.totalBytes,
            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);
      }
      const retail = retailCatalog?.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');
    }
    if (!config.allowedPorts.has(port) && !replayManager.ownsPort(port)) return rejectUpgrade(socket, 403, 'Forbidden');
    if (!originAllowed(req, config)) return rejectUpgrade(socket, 403, 'Forbidden');
    const capacity = bridge.canAccept(req);
    if (!capacity.ok) return rejectUpgrade(socket, 429, 'Too Many Requests');
    replayManager.touch(port);
    socket.setNoDelay(true);
    wss.handleUpgrade(req, socket, head, (ws) => 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();
  }

  return { config, server, bridge, replayManager, supervisor, 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'));
}
