import path from 'node:path';
import { isIP } from 'node:net';

const DEFAULT_TRUSTED_PROXIES = ['127.0.0.1', '::1', '10.6.0.105'];
const MATCH_OWNED_DEDICATED_CVARS = new Set([
  'bot_minplayers',
  'si_fragLimit',
  'si_captureLimit',
  'si_timeLimit',
  'si_map',
  'si_gameType',
]);

export function normalizeIp(value) {
  const candidate = String(value ?? '').trim();
  const version = isIP(candidate);
  if (version === 4) return candidate;
  if (version !== 6 || candidate.includes('%')) return null;

  let canonical;
  try { canonical = new URL(`http://[${candidate}]/`).hostname.slice(1, -1).toLowerCase(); } catch { return null; }
  const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(canonical);
  if (!mapped) return canonical;
  const high = Number.parseInt(mapped[1], 16);
  const low = Number.parseInt(mapped[2], 16);
  return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
}

function bool(value, fallback = false) {
  if (value === undefined || value === null || value === '') return fallback;
  return /^(1|true|yes|on)$/i.test(String(value));
}

function retailDelivery(value) {
  const mode = String(value || 'disabled').trim().toLowerCase();
  if (mode === 'disabled') return false;
  if (mode === 'operator-authorized') return true;
  throw new TypeError('Q4_RETAIL_DELIVERY must be disabled or operator-authorized');
}

function integer(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
  const parsed = Number.parseInt(String(value ?? ''), 10);
  if (!Number.isFinite(parsed)) return fallback;
  return Math.min(max, Math.max(min, parsed));
}

function jsonArray(value, fallback) {
  if (!value) return fallback;
  try {
    const parsed = JSON.parse(value);
    return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : fallback;
  } catch {
    return fallback;
  }
}

function ports(value, fallback) {
  const parsed = String(value || '')
    .split(',')
    .map((part) => Number.parseInt(part.trim(), 10))
    .filter((port) => Number.isInteger(port) && port >= 1 && port <= 65535);
  return new Set(parsed.length ? parsed : fallback);
}

function trustedProxySet(value) {
  const entries = value instanceof Set || Array.isArray(value)
    ? [...value]
    : String(value ?? '').split(',').map((entry) => entry.trim()).filter(Boolean);
  const result = new Set();
  for (const entry of entries) {
    const normalized = normalizeIp(entry);
    if (!normalized) throw new TypeError(`invalid trusted proxy IP: ${entry}`);
    result.add(normalized);
  }
  return result;
}

function absolute(root, value, fallback) {
  return path.resolve(root, value || fallback);
}

function setArgumentValue(args, name) {
  let value = null;
  for (let index = 0; index + 2 < args.length; index += 1) {
    if (args[index] === '+set' && args[index + 1] === name) value = args[index + 2];
  }
  return value;
}

function withSetArgument(args, name, value) {
  if (setArgumentValue(args, name) !== null) return [...args];
  const result = [...args];
  const spawnIndex = result.findIndex((argument) => argument === '+spawnServer');
  result.splice(spawnIndex < 0 ? result.length : spawnIndex, 0, '+set', name, value);
  return result;
}

function withoutSetArguments(args, names) {
  const result = [];
  for (let index = 0; index < args.length; index += 1) {
    if (args[index] === '+set' && index + 2 < args.length && names.has(args[index + 1])) {
      index += 2;
      continue;
    }
    result.push(args[index]);
  }
  return result;
}

function defaultDedicatedSavePath(env, dedicatedCwd) {
  if (path.isAbsolute(String(env.XDG_DATA_HOME || ''))) {
    return path.resolve(env.XDG_DATA_HOME);
  }
  if (path.isAbsolute(String(env.HOME || ''))) {
    return path.resolve(env.HOME, '.local/share/openq4');
  }
  return path.join(dedicatedCwd, 'home');
}

export function loadConfig(overrides = {}) {
  const env = overrides.env || process.env;
  const rootDir = path.resolve(overrides.rootDir || path.join(import.meta.dirname, '..'));
  const replayDir = path.resolve(overrides.replayDir || absolute(rootDir, env.Q4_REPLAY_DIR, 'replays'));
  const trustedProxyInput = overrides.trustedProxies !== undefined
    ? overrides.trustedProxies
    : env.Q4_TRUSTED_PROXIES !== undefined
      ? env.Q4_TRUSTED_PROXIES
      : DEFAULT_TRUSTED_PROXIES;
  const trustedProxies = trustedProxySet(trustedProxyInput);
  const replayAllocationLimit = integer(
    overrides.replayAllocationLimit ?? env.Q4_REPLAY_NEW_ALLOCATIONS_PER_IP,
    2,
    { min: 1, max: 100 },
  );
  const replayAllocationWindowMs = integer(
    overrides.replayAllocationWindowMs ?? env.Q4_REPLAY_ALLOCATION_WINDOW_MS,
    10 * 60_000,
    { min: 10_000, max: 24 * 60 * 60_000 },
  );
  const gamePort = overrides.gamePort ?? integer(env.Q4_GAME_PORT, 28004, { min: 1, max: 65535 });
  const retailMaxConcurrent = overrides.retailMaxConcurrent ?? integer(env.Q4_RETAIL_MAX_CONCURRENT, 16, {
    min: 1,
    max: 128,
  });
  const retailMaxPerIp = overrides.retailMaxPerIp ?? integer(env.Q4_RETAIL_MAX_PER_IP, 2, { min: 1, max: 16 });
  if (retailMaxPerIp > retailMaxConcurrent) {
    throw new TypeError('Q4_RETAIL_MAX_PER_IP must not exceed Q4_RETAIL_MAX_CONCURRENT');
  }
  const allowedPorts = overrides.allowedPorts instanceof Set
    ? new Set(overrides.allowedPorts)
    : Array.isArray(overrides.allowedPorts)
      ? new Set(overrides.allowedPorts)
      : ports(env.Q4_GAME_PORTS, [gamePort]);
  allowedPorts.add(gamePort);

  for (const port of allowedPorts) {
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
      throw new TypeError(`invalid UDP port in allowlist: ${port}`);
    }
  }

  const udpHost = overrides.udpHost || env.Q4_UDP_HOST || '127.0.0.1';
  if (!['127.0.0.1', '::1'].includes(udpHost)) {
    throw new Error('Q4_UDP_HOST must be a loopback address');
  }

  const dedicatedCwd = path.resolve(overrides.dedicatedCwd || env.Q4_DEDICATED_CWD
    || path.join(rootDir, 'runtime', 'native'));
  const requestedDedicatedArgs = overrides.dedicatedArgs || jsonArray(env.Q4_DEDICATED_ARGS_JSON, [
    '+set', 's_noSound', '1',
    '+set', 'net_ip', udpHost,
    '+set', 'net_port', String(gamePort),
    '+set', 'net_LANServer', '1',
    // Browser map precache can block game packets for longer than Q4's 40s
    // native default even though the WebSocket transport remains healthy.
    '+set', 'net_serverClientTimeout', '180',
    '+set', 'si_pure', '0',
    '+set', 'net_serverAllowDataChecksumMismatch', '1',
    '+set', 'fs_caseSensitiveOS', '0',
    '+set', 'net_serverDemo', '1',
    '+set', 'net_serverDemoPath', replayDir,
    '+set', 'si_name', 'Quake 4 Web',
    // Reserve admission headroom while the server maintains a literal eight-bot floor.
    '+set', 'si_maxPlayers', '12',
    '+set', 'si_warmup', '0',
    '+set', 'bot_debug', '0',
    '+spawnServer',
  ]);
  const configuredSavePath = setArgumentValue(requestedDedicatedArgs, 'fs_savepath')
    || overrides.dedicatedSavePath
    || defaultDedicatedSavePath(env, dedicatedCwd);
  const dedicatedSavePath = path.resolve(dedicatedCwd, configuredSavePath);
  const configuredDedicatedArgs = withoutSetArguments(
    requestedDedicatedArgs,
    MATCH_OWNED_DEDICATED_CVARS,
  );
  const dedicatedArgsWithPort = withSetArgument(
    configuredDedicatedArgs,
    'net_port',
    String(gamePort),
  );
  if (setArgumentValue(dedicatedArgsWithPort, 'net_port') !== String(gamePort)) {
    throw new TypeError('dedicated net_port must match Q4_GAME_PORT');
  }
  const dedicatedArgs = withSetArgument(
    dedicatedArgsWithPort,
    'fs_savepath',
    dedicatedSavePath,
  );
  const dedicatedCommand = overrides.dedicatedCommand || env.Q4_DEDICATED_BIN
    || path.join(rootDir, 'runtime', 'native', 'openQ4-ded_x64');
  const replayPorts = overrides.replayPorts instanceof Set
    ? new Set(overrides.replayPorts)
    : Array.isArray(overrides.replayPorts)
      ? new Set(overrides.replayPorts)
      : ports(env.Q4_REPLAY_PORTS, [28040, 28041, 28042, 28043, 28044, 28045, 28046, 28047]);
  for (const port of replayPorts) {
    if (!Number.isInteger(port) || port < 1 || port > 65535 || allowedPorts.has(port)) {
      throw new TypeError(`invalid or conflicting replay UDP port: ${port}`);
    }
  }

  return {
    rootDir,
    host: overrides.host || env.HOST || '0.0.0.0',
    port: overrides.port ?? integer(env.PORT, 3280, { min: 0, max: 65535 }),
    publicDir: path.resolve(overrides.publicDir || absolute(rootDir, env.Q4_PUBLIC_DIR, 'public')),
    engineDir: path.resolve(overrides.engineDir || absolute(rootDir, env.Q4_ENGINE_DIR, 'engine')),
    contentDir: path.resolve(overrides.contentDir || absolute(rootDir, env.Q4_CONTENT_DIR, 'content/assets')),
    retail: {
      enabled: overrides.serveRetail ?? retailDelivery(env.Q4_RETAIL_DELIVERY),
      directory: path.resolve(overrides.retailDir || absolute(rootDir, env.Q4_RETAIL_DIR, 'assets-src/gog/q4base')),
      manifestFile: path.resolve(overrides.retailManifestFile ||
        absolute(rootDir, env.Q4_RETAIL_MANIFEST, 'runtime/retail-manifest.json')),
      bundleDirectory: path.resolve(overrides.retailBundleDir ||
        absolute(rootDir, env.Q4_RETAIL_BUNDLE_DIR, 'runtime/retail-bundles')),
      bundleManifestFile: path.resolve(overrides.retailBundleManifestFile ||
        absolute(rootDir, env.Q4_RETAIL_BUNDLE_MANIFEST, 'runtime/retail-bundle-manifest.json')),
      maxConcurrent: retailMaxConcurrent,
      maxPerIp: retailMaxPerIp,
      drainGraceMs: overrides.retailDrainGraceMs ?? integer(env.Q4_RETAIL_DRAIN_GRACE_MS, 15_000, {
        min: 1_000,
        max: 120_000,
      }),
    },
    replayDir,
    replayRetention: overrides.replayRetention ?? integer(env.Q4_REPLAY_RETENTION, 300, { min: 0, max: 10_000 }),
    replayAllocation: {
      maxNewPerIp: replayAllocationLimit,
      windowMs: replayAllocationWindowMs,
    },
    voicechatFile: path.resolve(overrides.voicechatFile || env.Q4_VOICECHAT_FILE || '/srv/_shared/voice/voicechat.js'),
    udpHost,
    gamePort,
    allowedPorts,
    trustedProxies,
    crossOriginIsolation: overrides.crossOriginIsolation ?? bool(env.Q4_CROSS_ORIGIN_ISOLATION, true),
    maxFrameBytes: overrides.maxFrameBytes ?? integer(env.Q4_WS_MAX_FRAME_BYTES, 65_507, { min: 512, max: 65_507 }),
    maxFramesPerSecond: overrides.maxFramesPerSecond ?? integer(env.Q4_WS_MAX_FRAMES_PER_SECOND, 300, { min: 1, max: 10_000 }),
    maxBytesPerSecond: overrides.maxBytesPerSecond ?? integer(env.Q4_WS_MAX_BYTES_PER_SECOND, 2 * 1024 * 1024, { min: 1024 }),
    voiceMaxMessageBytes: overrides.voiceMaxMessageBytes ?? integer(env.Q4_VOICE_MAX_MESSAGE_BYTES, 16 * 1024, { min: 512, max: 64 * 1024 }),
    voiceMaxMessagesPerSecond: overrides.voiceMaxMessagesPerSecond ?? integer(env.Q4_VOICE_MAX_MESSAGES_PER_SECOND, 30, { min: 1, max: 300 }),
    voiceMaxBytesPerSecond: overrides.voiceMaxBytesPerSecond ?? integer(env.Q4_VOICE_MAX_BYTES_PER_SECOND, 128 * 1024, { min: 1024, max: 2 * 1024 * 1024 }),
    maxClients: overrides.maxClients ?? integer(env.Q4_WS_MAX_CLIENTS, 64, { min: 1, max: 1024 }),
    maxClientsPerIp: overrides.maxClientsPerIp ?? integer(env.Q4_WS_MAX_CLIENTS_PER_IP, 16, { min: 1, max: 256 }),
    pendingDatagrams: overrides.pendingDatagrams ?? integer(env.Q4_WS_PENDING_DATAGRAMS, 32, { min: 1, max: 512 }),
    idleMs: overrides.idleMs ?? integer(env.Q4_WS_IDLE_MS, 120_000, { min: 1_000 }),
    pingMs: overrides.pingMs ?? integer(env.Q4_WS_PING_MS, 25_000, { min: 1_000 }),
    allowedOrigins: new Set(overrides.allowedOrigins || String(env.Q4_ALLOWED_ORIGINS || '')
      .split(',').map((origin) => origin.trim()).filter(Boolean)),
    dedicated: {
      enabled: overrides.spawnDedicated ?? bool(env.Q4_SPAWN_DEDICATED, false),
      command: dedicatedCommand,
      args: dedicatedArgs,
      cwd: dedicatedCwd,
      port: gamePort,
      savePath: dedicatedSavePath,
      reloadConfigFile: path.join(dedicatedSavePath, 'baseoq4', 'autoexec.cfg'),
      backoffBaseMs: overrides.backoffBaseMs ?? integer(env.Q4_DEDICATED_BACKOFF_BASE_MS, 1_000, { min: 100 }),
      backoffMaxMs: overrides.backoffMaxMs ?? integer(env.Q4_DEDICATED_BACKOFF_MAX_MS, 30_000, { min: 100 }),
      stableMs: overrides.stableMs ?? integer(env.Q4_DEDICATED_STABLE_MS, 60_000, { min: 1_000 }),
      stopTimeoutMs: overrides.stopTimeoutMs ?? integer(env.Q4_DEDICATED_STOP_TIMEOUT_MS, 5_000, { min: 100 }),
      matchStartTimeoutMs: overrides.matchStartTimeoutMs
        ?? integer(env.Q4_MATCH_START_TIMEOUT_MS, 120_000, { min: 5_000, max: 300_000 }),
    },
    botNavigation: {
      manifestFile: path.resolve(overrides.botNavigationManifestFile || env.Q4_BOT_NAVIGATION_MANIFEST
        || path.join(rootDir, 'runtime', 'retail-botnav-manifest.json')),
      packDirectory: path.resolve(overrides.botNavigationPackDirectory || env.Q4_BOT_NAVIGATION_PACK_DIR
        || path.join(dedicatedCwd, 'baseoq4')),
      clientFile: path.resolve(overrides.botNavigationClientFile || env.Q4_CLIENT_BIN
        || path.join(dedicatedCwd, 'openQ4-client_x64')),
      gameLibFile: path.resolve(overrides.botNavigationGameLibFile || env.Q4_BOT_NAVIGATION_GAME_LIB
        || path.join(dedicatedCwd, 'baseoq4', 'game-mp_x64.so')),
    },
    replayPlayback: {
      enabled: overrides.replayPlaybackEnabled ?? bool(env.Q4_REPLAY_PLAYBACK, true),
      command: overrides.replayCommand || env.Q4_REPLAY_BIN || dedicatedCommand,
      cwd: path.resolve(overrides.replayCwd || env.Q4_REPLAY_CWD || dedicatedCwd),
      homeRoot: path.resolve(overrides.replayHomeRoot || env.Q4_REPLAY_HOME_ROOT
        || path.join(rootDir, 'runtime', 'replay-instances')),
      ports: replayPorts,
      maxViewers: overrides.replayMaxViewers ?? integer(env.Q4_REPLAY_MAX_VIEWERS, 24, { min: 1, max: 32 }),
      startTimeoutMs: overrides.replayStartTimeoutMs
        ?? integer(env.Q4_REPLAY_START_TIMEOUT_MS, 120_000, { min: 1_000, max: 120_000 }),
      endGraceMs: overrides.replayEndGraceMs
        ?? integer(env.Q4_REPLAY_END_GRACE_MS, 15_000, { min: 0, max: 120_000 }),
      idleMs: overrides.replayIdleMs
        ?? integer(env.Q4_REPLAY_IDLE_MS, 10 * 60_000, { min: 10_000 }),
      hardCapMs: overrides.replayHardCapMs
        ?? integer(env.Q4_REPLAY_HARD_CAP_MS, 60 * 60_000, { min: 60_000 }),
      pollMs: overrides.replayPollMs
        ?? integer(env.Q4_REPLAY_POLL_MS, 30_000, { min: 1_000, max: 60_000 }),
      stopTimeoutMs: overrides.replayStopTimeoutMs
        ?? integer(env.Q4_REPLAY_STOP_TIMEOUT_MS, 5_000, { min: 100, max: 30_000 }),
    },
  };
}
