import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { buildDedicatedMatchCommands, normalizeMatchRequest } from '../public/js/menu-config.js';

const READY_MARKER = 'net_LANServer is enabled. Not sending heartbeats';
const DEFAULT_MATCH_START_TIMEOUT_MS = 120_000;
const MAX_SERVER_INFO_LINES = 512;
const RELOAD_CONFIG_CVARS = Object.freeze([
  's_noSound',
  'net_ip',
  'net_port',
  'net_LANServer',
  'net_serverClientTimeout',
  'si_pure',
  'net_serverAllowDataChecksumMismatch',
  'fs_caseSensitiveOS',
  'net_serverDemo',
  'net_serverDemoPath',
  'si_name',
  'si_maxPlayers',
  'si_warmup',
  'si_fragLimit',
  'si_captureLimit',
  'si_timeLimit',
  'bot_debug',
  'bot_minplayers',
  'si_map',
  'si_gameType',
]);

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

function copyMatch(match) {
  return match ? { ...match } : null;
}

function isoTimestamp(timestamp) {
  return timestamp === null ? null : new Date(timestamp).toISOString();
}

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 matchCommandPayload(match) {
  const commands = buildDedicatedMatchCommands(match);
  const expectedSpawnCommand = `spawnServer ${match.map}`;
  if (!Array.isArray(commands) || commands.length < 2 || commands.at(-1) !== expectedSpawnCommand) {
    throw matchError('MATCH_COMMANDS_INVALID', 'match command builder returned an invalid command sequence', 500);
  }
  for (const command of commands) {
    if (typeof command !== 'string' || !command || command.length > 256 || /[\r\n\0]/.test(command)) {
      throw matchError('MATCH_COMMANDS_INVALID', 'match command builder returned an unsafe command', 500);
    }
  }
  return `${commands.join('\n')}\n`;
}

function dedicatedSetArguments(args) {
  const values = new Map();
  for (let index = 0; index + 2 < args.length; index += 1) {
    if (args[index] === '+set' && RELOAD_CONFIG_CVARS.includes(args[index + 1])) {
      values.set(args[index + 1], String(args[index + 2]));
    }
  }
  return values;
}

function reloadConfigValue(value) {
  const text = String(value);
  if (/["\r\n\0]/.test(text)) {
    throw matchError(
      'MATCH_RELOAD_CONFIG_INVALID',
      'dedicated server configuration contains a value unsafe for autoexec.cfg',
      500,
    );
  }
  return `"${text}"`;
}

function reloadConfigText(args, match) {
  const values = dedicatedSetArguments(args);
  if (match) {
    values.set('bot_minplayers', String(match.botCount));
    values.set('si_fragLimit', String(match.fragLimit));
    values.set('si_captureLimit', String(match.fragLimit));
    values.set('si_timeLimit', String(match.timeLimit));
    values.set('si_gameType', match.mode);
    values.set('si_map', match.map);
  }
  return `${RELOAD_CONFIG_CVARS
    .filter((name) => values.has(name))
    .map((name) => `set ${name} ${reloadConfigValue(values.get(name))}`)
    .join('\n')}\n`;
}

function parseServerInfo(lines) {
  const values = new Map();
  let port = null;
  for (const rawLine of lines) {
    const line = String(rawLine).trim();
    const portMatch = /^server\s+'.*'\s+IP\s*=\s*.*:(\d+)\s*$/i.exec(line);
    if (portMatch) port = Number(portMatch[1]);
    const separator = line.indexOf(' = ');
    if (separator > 0) {
      values.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 3).trim());
    }
  }
  return { port, values };
}

function verifyServerInfo(config, expectedMatch, lines) {
  const { port, values } = parseServerInfo(lines);
  const expected = new Map([
    ['si_map', expectedMatch.map],
    ['si_gametype', expectedMatch.mode],
    ['bot_minplayers', String(expectedMatch.botCount)],
    ['si_fraglimit', String(expectedMatch.fragLimit)],
    ['si_capturelimit', String(expectedMatch.fragLimit)],
    ['si_timelimit', String(expectedMatch.timeLimit)],
  ]);
  const mismatches = [];
  if (port !== config.port) mismatches.push(`net_port=${port ?? 'missing'} expected ${config.port}`);
  for (const [name, expectedValue] of expected) {
    const actual = values.get(name);
    if (actual !== expectedValue) {
      mismatches.push(`${name}=${actual ?? 'missing'} expected ${expectedValue}`);
    }
  }
  if (mismatches.length) {
    throw matchError(
      'MATCH_SERVERINFO_MISMATCH',
      `dedicated server reported an unexpected runtime configuration: ${mismatches.join(', ')}`,
    );
  }
  return {
    port,
    match: copyMatch(expectedMatch),
  };
}

export class DedicatedSupervisor {
  constructor(config, log = console, dependencies = {}) {
    this.config = config;
    this.log = log;
    this.spawnProcess = dependencies.spawnProcess || spawn;
    this.now = dependencies.now || Date.now;
    this.child = null;
    this.timer = null;
    this.stopping = false;
    this.restartAttempt = 0;
    this.startedAt = null;
    this.processReadyAt = null;
    this.matchLoadStartedAt = null;
    this.matchReadyAt = null;
    this.ready = false;
    this.lastExit = null;
    this.initialMatch = copyMatch(dependencies.initialMatch || config.initialMatch);
    this.currentMatch = null;
    this.pendingMatch = null;
    this.pendingReadiness = null;
    this.configureTail = Promise.resolve();
    this.restartingChild = null;
    this.restartKillTimer = null;
    this.initialReadinessWatchdog = null;
    this.expectedReadyMap = null;
    this.observedReadyMap = null;
    this.reloadConfigGeneration = 0;
    this.readinessVerification = null;
    this.readinessVerificationGeneration = 0;
    this.verifiedMatch = null;
    this.verifiedPort = null;
    this.verifiedAt = null;
  }

  start() {
    if (!this.config.enabled || this.stopping || this.child || this.timer) return;
    this.#spawn();
  }

  #clearInitialReadinessWatchdog(child = null) {
    const watchdog = this.initialReadinessWatchdog;
    if (!watchdog || (child && watchdog.child !== child)) return;
    clearTimeout(watchdog.timer);
    this.initialReadinessWatchdog = null;
  }

  #clearReadinessVerification(child = null) {
    if (!this.readinessVerification || (child && this.readinessVerification.child !== child)) return;
    this.readinessVerification = null;
  }

  #watchInitialReadiness(child) {
    if (this.stopping || child !== this.child || this.ready) return;
    const timeoutMs = this.config.matchStartTimeoutMs || DEFAULT_MATCH_START_TIMEOUT_MS;
    const watchdog = { child, timer: null };
    watchdog.timer = setTimeout(() => {
      if (this.initialReadinessWatchdog !== watchdog) return;
      this.initialReadinessWatchdog = null;
      if (this.stopping || child !== this.child || child === this.restartingChild ||
          this.ready || this.processReadyAt !== null) return;
      this.log.warn?.(`OpenQ4 dedicated did not become ready within ${timeoutMs}ms after spawn; restarting`);
      this.#restartAfterReadinessFailure(child, matchError(
        'MATCH_START_TIMEOUT',
        `dedicated server did not become ready within ${timeoutMs}ms`,
        504,
      ));
    }, timeoutMs);
    watchdog.timer.unref?.();
    this.initialReadinessWatchdog = watchdog;
  }

  #spawn() {
    if (this.stopping) return;
    this.#clearInitialReadinessWatchdog();
    this.startedAt = this.now();
    try {
      this.#stageReloadConfig(this.initialMatch);
    } catch (error) {
      this.lastExit = { at: new Date().toISOString(), error: error.message };
      this.log.error?.(`could not stage OpenQ4 reload configuration: ${error.message}`);
      this.#schedule();
      return;
    }
    let child;
    try {
      child = this.spawnProcess(this.config.command, this.config.args, {
        cwd: this.config.cwd,
        env: process.env,
        stdio: ['pipe', 'pipe', 'pipe'],
      });
    } catch (error) {
      this.lastExit = { at: new Date().toISOString(), error: error.message };
      this.#schedule();
      return;
    }
    this.child = child;
    this.ready = false;
    this.processReadyAt = null;
    this.matchLoadStartedAt = this.startedAt;
    this.matchReadyAt = null;
    this.currentMatch = copyMatch(this.initialMatch);
    this.expectedReadyMap = this.initialMatch?.map || null;
    this.observedReadyMap = null;
    this.verifiedMatch = null;
    this.verifiedPort = null;
    this.verifiedAt = null;
    const readinessBuffers = { stdout: '', stderr: '' };
    const inspectReadinessLine = (line) => {
      this.#inspectReadinessVerificationLine(child, line);
      const mapMatch = line.match(/\bMap:\s*([A-Za-z0-9_./-]+)/);
      if (mapMatch) this.observedReadyMap = mapMatch[1];
      if (line.includes(READY_MARKER)) {
        this.#beginReadinessVerification(child, this.observedReadyMap);
      }
    };
    const inspectReadiness = (stream, chunk) => {
      const combined = `${readinessBuffers[stream]}${chunk}`;
      const lines = combined.split(/\r?\n/);
      readinessBuffers[stream] = lines.pop().slice(-1_024);
      for (const line of lines) inspectReadinessLine(line);
    };
    child.stdout?.on('data', (chunk) => {
      inspectReadiness('stdout', chunk);
      process.stdout.write(`[openq4ded] ${chunk}`);
    });
    child.stderr?.on('data', (chunk) => {
      inspectReadiness('stderr', chunk);
      process.stderr.write(`[openq4ded] ${chunk}`);
    });
    child.stdin?.on('error', (error) => {
      this.#restartAfterReadinessFailure(child, matchError(
        'MATCH_COMMAND_WRITE_FAILED',
        `could not write match commands: ${error.message}`,
      ));
    });
    let finalized = false;
    const finalize = (code, signal, error = null) => {
      if (finalized) return;
      finalized = true;
      this.#clearInitialReadinessWatchdog(child);
      this.#clearReadinessVerification(child);
      const runtimeMs = this.now() - this.startedAt;
      this.#rejectPending(child, matchError(
        'MATCH_SERVER_EXITED',
        error ? `dedicated server failed: ${error.message}` : 'dedicated server exited while loading the match',
      ));
      this.child = null;
      if (this.restartingChild === child) {
        this.restartingChild = null;
        if (this.restartKillTimer) clearTimeout(this.restartKillTimer);
        this.restartKillTimer = null;
      }
      this.ready = false;
      this.matchLoadStartedAt = null;
      this.matchReadyAt = null;
      this.currentMatch = null;
      this.expectedReadyMap = null;
      this.observedReadyMap = null;
      this.verifiedMatch = null;
      this.verifiedPort = null;
      this.verifiedAt = null;
      this.lastExit = {
        at: new Date().toISOString(),
        code,
        signal,
        runtimeMs,
        ...(error ? { error: error.message } : {}),
      };
      if (runtimeMs >= this.config.stableMs) this.restartAttempt = 0;
      if (!this.stopping) this.#schedule();
    };
    child.once('error', (error) => finalize(null, null, error));
    child.once('exit', (code, signal) => finalize(code, signal));
    this.#watchInitialReadiness(child);
    this.log.info?.(`OpenQ4 dedicated spawned pid=${child.pid}`);
  }

  #beginReadinessVerification(child, observedMap) {
    if (child !== this.child || child === this.restartingChild) return;
    if (!this.expectedReadyMap || observedMap !== this.expectedReadyMap) {
      if (observedMap) {
        this.log.warn?.(
          `OpenQ4 dedicated reported map ${observedMap} while waiting for ${this.expectedReadyMap}`
        );
      }
      return;
    }
    if (this.readinessVerification?.child === child) return;
    const expectedMatch = copyMatch(this.pendingMatch || this.currentMatch);
    if (!expectedMatch || !child.stdin || child.stdin.destroyed || !child.stdin.writable) {
      this.#restartAfterReadinessFailure(child, matchError(
        'MATCH_SERVERINFO_PIPE_UNAVAILABLE',
        'dedicated server command pipe is unavailable for runtime verification',
      ));
      return;
    }
    const generation = ++this.readinessVerificationGeneration;
    const begin = `Q4_GATEWAY_READY_${generation}_BEGIN`;
    const end = `Q4_GATEWAY_READY_${generation}_END`;
    const verification = {
      child,
      begin,
      end,
      expectedMatch,
      observedMap,
      capturing: false,
      lines: [],
    };
    this.readinessVerification = verification;
    try {
      child.stdin.write(`echo ${begin}\nserverInfo\necho ${end}\n`, (error) => {
        if (!error || this.readinessVerification !== verification) return;
        this.#restartAfterReadinessFailure(child, matchError(
          'MATCH_SERVERINFO_WRITE_FAILED',
          `could not request dedicated server runtime configuration: ${error.message}`,
        ));
      });
    } catch (error) {
      this.#restartAfterReadinessFailure(child, matchError(
        'MATCH_SERVERINFO_WRITE_FAILED',
        `could not request dedicated server runtime configuration: ${error.message}`,
      ));
    }
  }

  #inspectReadinessVerificationLine(child, rawLine) {
    const verification = this.readinessVerification;
    if (!verification || verification.child !== child) return;
    const line = String(rawLine).trim();
    if (line === verification.begin) {
      verification.capturing = true;
      verification.lines = [];
      return;
    }
    if (!verification.capturing) return;
    if (line === verification.end) {
      this.readinessVerification = null;
      let result;
      try {
        result = verifyServerInfo(this.config, verification.expectedMatch, verification.lines);
      } catch (error) {
        this.log.warn?.(error.message);
        this.#restartAfterReadinessFailure(child, error);
        return;
      }
      this.verifiedMatch = result.match;
      this.verifiedPort = result.port;
      this.verifiedAt = this.now();
      this.#markReady(child, verification.observedMap);
      return;
    }
    verification.lines.push(line);
    if (verification.lines.length > MAX_SERVER_INFO_LINES) {
      this.#restartAfterReadinessFailure(child, matchError(
        'MATCH_SERVERINFO_INVALID',
        'dedicated server runtime configuration response exceeded its line limit',
      ));
    }
  }

  #markReady(child, observedMap) {
    if (child !== this.child || child === this.restartingChild) return;
    if (!this.expectedReadyMap || observedMap !== this.expectedReadyMap) {
      if (observedMap) {
        this.log.warn?.(
          `OpenQ4 dedicated reported map ${observedMap} while waiting for ${this.expectedReadyMap}`
        );
      }
      return;
    }
    const wasReady = this.ready;
    const readyAt = this.now();
    this.ready = true;
    this.#clearInitialReadinessWatchdog(child);
    this.#clearReadinessVerification(child);
    if (this.processReadyAt === null) this.processReadyAt = readyAt;
    if (!wasReady || this.matchReadyAt === null) this.matchReadyAt = readyAt;
    const pending = this.pendingReadiness;
    if (pending?.child === child) {
      clearTimeout(pending.timer);
      this.pendingReadiness = null;
      this.pendingMatch = null;
      this.currentMatch = copyMatch(pending.match);
      pending.resolve({ changed: true, match: copyMatch(this.currentMatch) });
    }
    if (!wasReady) this.log.info?.('OpenQ4 dedicated finished loading the authoritative map');
  }

  #rejectPending(child, error) {
    const pending = this.pendingReadiness;
    if (!pending || pending.child !== child) return;
    clearTimeout(pending.timer);
    this.pendingReadiness = null;
    this.pendingMatch = null;
    pending.reject(error);
  }

  #restartAfterReadinessFailure(child, error) {
    if (this.stopping) return;
    if (child !== this.child) return;
    if (this.restartingChild === child) return;
    this.#clearInitialReadinessWatchdog(child);
    this.#clearReadinessVerification(child);
    this.currentMatch = null;
    this.ready = false;
    this.matchLoadStartedAt = null;
    this.matchReadyAt = null;
    this.verifiedMatch = null;
    this.verifiedPort = null;
    this.verifiedAt = null;
    this.restartingChild = child;
    this.#rejectPending(child, error);
    try {
      child.kill('SIGTERM');
    } catch (killError) {
      this.log.error?.(`could not restart OpenQ4 dedicated after readiness failure: ${killError.message}`);
    }
    if (this.restartKillTimer) clearTimeout(this.restartKillTimer);
    this.restartKillTimer = setTimeout(() => {
      this.restartKillTimer = null;
      if (this.child !== child || this.restartingChild !== child) return;
      this.log.warn?.('OpenQ4 dedicated ignored SIGTERM after a readiness failure; sending SIGKILL');
      try {
        child.kill('SIGKILL');
      } catch (killError) {
        this.log.error?.(`could not kill stuck OpenQ4 dedicated: ${killError.message}`);
      }
    }, this.config.stopTimeoutMs);
    this.restartKillTimer.unref?.();
  }

  configureMatch(match) {
    let requested;
    try {
      requested = copyMatch(normalizeMatchRequest(match));
    } catch (error) {
      return Promise.reject(error);
    }
    const operation = this.configureTail.then(() => this.#configureMatch(requested));
    this.configureTail = operation.catch(() => {});
    return operation;
  }

  async #configureMatch(match) {
    if (sameMatch(this.currentMatch, match) && this.ready && this.child) {
      return { changed: false, match: copyMatch(this.currentMatch) };
    }
    if (!this.config.enabled || this.stopping || !this.child || !this.ready) {
      throw matchError('MATCH_SERVER_NOT_READY', 'dedicated server is not ready');
    }
    if (!this.child.stdin || this.child.stdin.destroyed || !this.child.stdin.writable) {
      throw matchError('MATCH_COMMAND_PIPE_UNAVAILABLE', 'dedicated server command pipe is unavailable');
    }

    const payload = matchCommandPayload(match);
    try {
      this.#stageReloadConfig(match);
    } catch (error) {
      throw matchError(
        'MATCH_RELOAD_CONFIG_WRITE_FAILED',
        `could not persist dedicated server configuration: ${error.message}`,
      );
    }
    const child = this.child;
    this.ready = false;
    this.matchLoadStartedAt = this.now();
    this.matchReadyAt = null;
    this.pendingMatch = copyMatch(match);
    this.expectedReadyMap = match.map;
    this.observedReadyMap = null;
    this.verifiedMatch = null;
    this.verifiedPort = null;
    this.verifiedAt = null;

    return new Promise((resolve, reject) => {
      const timeoutMs = this.config.matchStartTimeoutMs || DEFAULT_MATCH_START_TIMEOUT_MS;
      const pending = {
        child,
        match: copyMatch(match),
        resolve,
        reject,
        timer: null,
      };
      pending.timer = setTimeout(() => {
        if (this.pendingReadiness !== pending) return;
        this.#restartAfterReadinessFailure(child, matchError(
          'MATCH_START_TIMEOUT',
          `dedicated server did not become ready within ${timeoutMs}ms`,
          504,
        ));
      }, timeoutMs);
      this.pendingReadiness = pending;

      try {
        child.stdin.write(payload, (error) => {
          if (!error) return;
          this.#restartAfterReadinessFailure(child, matchError(
            'MATCH_COMMAND_WRITE_FAILED',
            `could not write match commands: ${error.message}`,
          ));
        });
      } catch (error) {
        this.#restartAfterReadinessFailure(child, matchError(
          'MATCH_COMMAND_WRITE_FAILED',
          `could not write match commands: ${error.message}`,
        ));
      }
    });
  }

  #stageReloadConfig(match) {
    const destination = this.config.reloadConfigFile;
    if (!destination) return;
    const directory = path.dirname(destination);
    fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
    const temporary = `${destination}.${process.pid}.${Date.now()}.${++this.reloadConfigGeneration}.tmp`;
    try {
      fs.writeFileSync(temporary, reloadConfigText(this.config.args, match), {
        encoding: 'utf8',
        flag: 'wx',
        mode: 0o600,
      });
      fs.renameSync(temporary, destination);
    } finally {
      fs.rmSync(temporary, { force: true });
    }
  }

  #schedule() {
    if (this.stopping || this.timer) return;
    this.restartAttempt += 1;
    const delay = Math.min(this.config.backoffMaxMs,
      this.config.backoffBaseMs * 2 ** Math.min(this.restartAttempt - 1, 10));
    this.log.warn?.(`OpenQ4 dedicated restart in ${delay}ms (attempt ${this.restartAttempt})`);
    this.timer = setTimeout(() => {
      this.timer = null;
      this.#spawn();
    }, delay);
    this.timer.unref?.();
  }

  status() {
    const readyDurationMs = this.matchLoadStartedAt !== null && this.matchReadyAt !== null
      ? Math.max(0, this.matchReadyAt - this.matchLoadStartedAt)
      : null;
    return {
      enabled: this.config.enabled,
      running: !!this.child,
      ready: !!this.child && this.ready,
      pid: this.child?.pid || null,
      command: this.config.enabled ? this.config.command : null,
      startedAt: isoTimestamp(this.startedAt),
      processReadyAt: isoTimestamp(this.processReadyAt),
      matchLoadStartedAt: isoTimestamp(this.matchLoadStartedAt),
      matchReadyAt: isoTimestamp(this.matchReadyAt),
      readyAt: isoTimestamp(this.matchReadyAt),
      readyDurationMs,
      restartAttempt: this.restartAttempt,
      restartPending: !!this.timer,
      lastExit: this.lastExit,
      currentMatch: copyMatch(this.currentMatch),
      pendingMatch: copyMatch(this.pendingMatch),
      verifiedMatch: copyMatch(this.verifiedMatch),
      verifiedPort: this.verifiedPort,
      verifiedAt: isoTimestamp(this.verifiedAt),
    };
  }

  async stop() {
    this.stopping = true;
    this.#clearInitialReadinessWatchdog();
    this.#clearReadinessVerification();
    if (this.timer) clearTimeout(this.timer);
    this.timer = null;
    if (this.restartKillTimer) clearTimeout(this.restartKillTimer);
    this.restartKillTimer = null;
    const child = this.child;
    if (child) {
      this.#rejectPending(child, matchError('MATCH_SERVER_STOPPING', 'dedicated server is stopping'));
    }
    if (!child) return;
    await new Promise((resolve) => {
      let finished = false;
      let killer = null;
      const done = () => {
        if (finished) return;
        finished = true;
        if (killer) clearTimeout(killer);
        resolve();
      };
      child.once('exit', done);
      try {
        if (this.restartingChild !== child) child.kill('SIGTERM');
      } catch {
        done();
        return;
      }
      killer = setTimeout(() => {
        try { child.kill('SIGKILL'); } catch {}
        done();
      }, this.config.stopTimeoutMs);
      killer.unref?.();
    });
  }
}
