import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';

const READY_LINE = 'serving full-state server demo ';
const FINISHED_LINE = 'finished network server demo ';
const LOAD_FAILURE = /could not open server demo|invalid server demo|unsupported server demo|fatal error/i;

export class ReplayInstanceError extends Error {
  constructor(code, message, statusCode = 500) {
    super(message);
    this.name = 'ReplayInstanceError';
    this.code = code;
    this.statusCode = statusCode;
  }
}

export function replayArguments(config, port, replayPath) {
  const home = path.join(config.homeRoot, String(port));
  return [
    '+set', 'fs_basepath', config.cwd,
    '+set', 'fs_savepath', home,
    '+set', 'net_ip', '127.0.0.1',
    '+set', 'net_port', String(port),
    '+set', 'net_LANServer', '1',
    '+set', 'net_serverDemo', '0',
    '+set', 'sys_allowMultipleInstances', '1',
    '+set', 'si_gameType', 'DM',
    '+set', 'si_pure', '0',
    '+set', 'net_serverAllowDataChecksumMismatch', '1',
    '+set', 'ri_maxViewers', String(config.maxViewers),
    '+set', 'ri_waitForViewer', '1',
    '+playServerDemoServer', replayPath,
  ];
}

function lineReader(onLine) {
  let pending = '';
  return (chunk) => {
    pending += String(chunk).replaceAll('\r', '');
    let newline;
    while ((newline = pending.indexOf('\n')) !== -1) {
      onLine(pending.slice(0, newline));
      pending = pending.slice(newline + 1);
    }
    if (pending.length > 16 * 1024) pending = pending.slice(-8 * 1024);
  };
}

export class ReplayManager {
  constructor(config, log = console, dependencies = {}) {
    this.config = config;
    this.log = log;
    this.spawnProcess = dependencies.spawnProcess || spawn;
    this.viewerCount = dependencies.viewerCount || (() => 0);
    this.now = dependencies.now || Date.now;
    this.instances = new Map();
    this.pendingByReplay = new Map();
    this.closed = false;
    this.sweepTimer = setInterval(() => this.sweep(), config.pollMs);
    this.sweepTimer.unref?.();
  }

  ownsPort(port) {
    return this.instances.has(Number(port));
  }

  touch(port) {
    const instance = this.instances.get(Number(port));
    if (instance) instance.lastSeen = this.now();
  }

  status() {
    return [...this.instances.values()].map((instance) => ({
      port: instance.port,
      replayId: instance.replay.id,
      replay: instance.replay.name,
      map: instance.replay.metadata?.map || null,
      pid: instance.child?.pid || null,
      ready: instance.ready,
      ending: instance.ending,
      startedAt: new Date(instance.startedAt).toISOString(),
      lastSeenAt: new Date(instance.lastSeen).toISOString(),
    }));
  }

  async watch(replay) {
    if (this.closed || !this.config.enabled) {
      throw new ReplayInstanceError('REPLAY_DISABLED', 'server replay playback is disabled', 503);
    }
    const existing = [...this.instances.values()].find((instance) =>
      instance.replay.id === replay.id && instance.ready && !instance.ending);
    if (existing) {
      return { instance: existing, reused: true };
    }
    const pending = this.pendingByReplay.get(replay.id);
    if (pending) {
      const instance = await pending;
      return { instance, reused: true };
    }

    const start = this.start(replay);
    this.pendingByReplay.set(replay.id, start);
    try {
      return { instance: await start, reused: false };
    } finally {
      if (this.pendingByReplay.get(replay.id) === start) this.pendingByReplay.delete(replay.id);
    }
  }

  async start(replay) {
    const port = [...this.config.ports].find((candidate) => !this.instances.has(candidate));
    if (!port) throw new ReplayInstanceError('REPLAY_POOL_BUSY', 'all replay playback ports are busy', 503);

    const startedAt = this.now();
    const instance = {
      port,
      replay,
      child: null,
      ready: false,
      ending: false,
      startedAt,
      lastSeen: startedAt,
      endTimer: null,
      killTimer: null,
    };
    this.instances.set(port, instance);
    fs.mkdirSync(path.join(this.config.homeRoot, String(port)), { recursive: true });

    let child;
    try {
      child = this.spawnProcess(this.config.command, replayArguments(this.config, port, replay.path), {
        cwd: this.config.cwd,
        env: process.env,
        stdio: ['ignore', 'pipe', 'pipe'],
      });
    } catch (error) {
      this.instances.delete(port);
      throw new ReplayInstanceError('REPLAY_SPAWN_FAILED', error.message, 500);
    }
    instance.child = child;
    this.log.info?.(`replay instance :${port} spawned pid=${child.pid || 'unknown'} for ${replay.name}`);

    await new Promise((resolve, reject) => {
      let settled = false;
      const finishStart = (error = null) => {
        if (settled) return;
        settled = true;
        clearTimeout(timeout);
        if (error) reject(error);
        else resolve();
      };
      const failStart = (message, code = 'REPLAY_START_FAILED') => {
        this.stopPort(port, 'start failure');
        finishStart(new ReplayInstanceError(code, message, 500));
      };
      const onLine = (line) => {
        if (line.includes(READY_LINE)) {
          if (settled || instance.ending) return;
          instance.ready = true;
          this.log.info?.(`replay instance :${port} is ready`);
          finishStart();
          return;
        }
        if (line.includes(FINISHED_LINE)) {
          this.markFinished(instance);
          return;
        }
        if (!settled && !instance.ready && LOAD_FAILURE.test(line)) {
          failStart(line.trim() || 'replay failed to load');
        }
      };
      child.stdout?.on('data', lineReader(onLine));
      child.stderr?.on('data', lineReader((line) => {
        onLine(line);
        if (line.trim()) this.log.warn?.(`replay :${port}: ${line.slice(0, 500)}`);
      }));
      child.once('error', (error) => {
        if (!instance.ready) finishStart(new ReplayInstanceError('REPLAY_SPAWN_FAILED', error.message, 500));
        this.remove(instance);
      });
      child.once('exit', (code, signal) => {
        if (!instance.ready) {
          finishStart(new ReplayInstanceError(
            'REPLAY_EXITED', `replay process exited before ready (code=${code}, signal=${signal})`, 500));
        }
        this.log.info?.(`replay instance :${port} exited code=${code} signal=${signal}`);
        this.remove(instance);
      });
      const timeout = setTimeout(() => failStart(
        `replay process did not become ready within ${this.config.startTimeoutMs}ms`, 'REPLAY_START_TIMEOUT'),
      this.config.startTimeoutMs);
      timeout.unref?.();
    });
    return instance;
  }

  markFinished(instance) {
    if (instance.ending) return;
    instance.ending = true;
    this.log.info?.(`replay instance :${instance.port} finished; stopping after final-score grace`);
    instance.endTimer = setTimeout(() => this.stopPort(instance.port, 'replay finished'), this.config.endGraceMs);
    instance.endTimer.unref?.();
  }

  sweep() {
    const now = this.now();
    for (const instance of this.instances.values()) {
      if (instance.ending) continue;
      if (this.viewerCount(instance.port) > 0) instance.lastSeen = now;
      if (now - instance.startedAt >= this.config.hardCapMs) {
        this.stopPort(instance.port, 'hard cap');
      } else if (now - instance.lastSeen >= this.config.idleMs) {
        this.stopPort(instance.port, 'idle');
      }
    }
  }

  stopPort(port, reason = 'stopped') {
    const instance = this.instances.get(Number(port));
    if (!instance || !instance.child) return;
    instance.ending = true;
    clearTimeout(instance.endTimer);
    this.log.info?.(`stopping replay instance :${instance.port} (${reason})`);
    try { instance.child.kill('SIGTERM'); } catch { this.remove(instance); return; }
    if (instance.child.exitCode !== null || instance.child.signalCode !== null) {
      this.remove(instance);
      return;
    }
    clearTimeout(instance.killTimer);
    instance.killTimer = setTimeout(() => {
      try { instance.child.kill('SIGKILL'); } catch {}
    }, this.config.stopTimeoutMs);
    instance.killTimer.unref?.();
  }

  remove(instance) {
    clearTimeout(instance.endTimer);
    clearTimeout(instance.killTimer);
    if (this.instances.get(instance.port) === instance) this.instances.delete(instance.port);
  }

  async close() {
    if (this.closed) return;
    this.closed = true;
    clearInterval(this.sweepTimer);
    const children = [...this.instances.values()].filter((instance) => instance.child);
    for (const instance of children) this.stopPort(instance.port, 'gateway shutdown');
    await Promise.all(children.map((instance) => new Promise((resolve) => {
      if (!this.instances.has(instance.port)) return resolve();
      const timeout = setTimeout(resolve, this.config.stopTimeoutMs + 250);
      timeout.unref?.();
      instance.child.once('exit', () => { clearTimeout(timeout); resolve(); });
    })));
  }
}
