import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';

import { ReplayManager, replayArguments } from '../server/replay-manager.js';

class FakeChild extends EventEmitter {
  constructor(pid = 1234) {
    super();
    this.pid = pid;
    this.stdout = new PassThrough();
    this.stderr = new PassThrough();
    this.exitCode = null;
    this.signalCode = null;
    this.kills = [];
  }

  kill(signal) {
    this.kills.push(signal);
    if (this.exitCode !== null || this.signalCode !== null) return false;
    this.signalCode = signal;
    queueMicrotask(() => this.emit('exit', null, signal));
    return true;
  }
}

class LingeringChild extends FakeChild {
  kill(signal) {
    this.kills.push(signal);
    return true;
  }
}

function config(t, overrides = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'q4-replay-manager-'));
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
  return {
    enabled: true,
    command: '/test/openQ4-ded_x64',
    cwd: root,
    homeRoot: path.join(root, 'instances'),
    ports: new Set([28040, 28041]),
    maxViewers: 24,
    startTimeoutMs: 1_000,
    endGraceMs: 5,
    idleMs: 60_000,
    hardCapMs: 120_000,
    pollMs: 60_000,
    stopTimeoutMs: 50,
    ...overrides,
  };
}

function replay(root, id = 'demo-id') {
  return {
    id,
    name: `${id}.q4sd`,
    path: path.join(root, `${id}.q4sd`),
    metadata: { map: 'mp/q4dm1' },
  };
}

test('replay process arguments isolate save data and start the Q4TV command', (t) => {
  const value = config(t);
  const args = replayArguments(value, 28040, '/replays/match.q4sd');
  assert.deepEqual(args.slice(-5), ['+set', 'ri_waitForViewer', '1', '+playServerDemoServer', '/replays/match.q4sd']);
  assert.deepEqual(args.slice(-2), ['+playServerDemoServer', '/replays/match.q4sd']);
  assert.deepEqual(args.slice(args.indexOf('si_gameType') - 1, args.indexOf('si_gameType') + 2), ['+set', 'si_gameType', 'DM']);
  assert.deepEqual(
    args.slice(args.indexOf('net_serverAllowDataChecksumMismatch') - 1, args.indexOf('net_serverAllowDataChecksumMismatch') + 2),
    ['+set', 'net_serverAllowDataChecksumMismatch', '1'],
  );
  assert.deepEqual(
    args.slice(args.indexOf('sys_allowMultipleInstances') - 1, args.indexOf('sys_allowMultipleInstances') + 2),
    ['+set', 'sys_allowMultipleInstances', '1'],
  );
  assert.ok(args.includes(path.join(value.homeRoot, '28040')));
  assert.ok(args.includes('28040'));
});

test('watch waits for readiness, reuses a live replay, and frees its dynamic port at demo end', async (t) => {
  const value = config(t);
  const children = [];
  const calls = [];
  const manager = new ReplayManager(value, { info() {}, warn() {} }, {
    spawnProcess(command, args, options) {
      calls.push({ command, args, options });
      const child = new FakeChild(2000 + children.length);
      children.push(child);
      return child;
    },
  });
  t.after(() => manager.close());

  const descriptor = replay(value.cwd);
  const firstPromise = manager.watch(descriptor);
  assert.equal(manager.ownsPort(28040), true);
  children[0].stdout.write(`serving full-state server demo ${descriptor.path} on port 28040\n`);
  const first = await firstPromise;
  assert.equal(first.reused, false);
  assert.equal(first.instance.port, 28040);
  assert.equal(calls.length, 1);

  const second = await manager.watch(descriptor);
  assert.equal(second.reused, true);
  assert.equal(second.instance, first.instance);
  assert.equal(calls.length, 1);
  assert.deepEqual(manager.status().map(({ port, ready, ending, map }) => ({ port, ready, ending, map })), [
    { port: 28040, ready: true, ending: false, map: 'mp/q4dm1' },
  ]);

  children[0].stdout.write(`finished network server demo ${descriptor.path}\n`);
  await new Promise((resolve) => setTimeout(resolve, 20));
  assert.equal(children[0].kills[0], 'SIGTERM');
  assert.equal(manager.ownsPort(28040), false);
});

test('reused watch does not extend idle lifetime but a viewer touch does', async (t) => {
  const value = config(t);
  const child = new FakeChild(2500);
  let now = 1_000;
  const manager = new ReplayManager(value, { info() {}, warn() {} }, {
    now: () => now,
    spawnProcess() {
      return child;
    },
  });
  t.after(() => manager.close());

  const descriptor = replay(value.cwd);
  const firstPromise = manager.watch(descriptor);
  child.stdout.write(`serving full-state server demo ${descriptor.path} on port 28040\n`);
  const first = await firstPromise;
  assert.equal(first.instance.lastSeen, 1_000);

  now = 2_000;
  const reused = await manager.watch(descriptor);
  assert.equal(reused.reused, true);
  assert.equal(reused.instance.lastSeen, 1_000);

  manager.touch(first.instance.port);
  assert.equal(first.instance.lastSeen, 2_000);
});

test('concurrent viewers share one startup and a full pool returns 503', async (t) => {
  const value = config(t, { ports: new Set([28040]) });
  const children = [];
  const manager = new ReplayManager(value, { info() {}, warn() {} }, {
    spawnProcess() {
      const child = new FakeChild(3000 + children.length);
      children.push(child);
      return child;
    },
  });
  t.after(() => manager.close());

  const descriptor = replay(value.cwd);
  const a = manager.watch(descriptor);
  const b = manager.watch(descriptor);
  assert.equal(children.length, 1);
  children[0].stdout.write('serving full-state server demo ready\n');
  const [first, second] = await Promise.all([a, b]);
  assert.equal(first.instance, second.instance);
  assert.equal(second.reused, true);

  await assert.rejects(manager.watch(replay(value.cwd, 'other')), (error) =>
    error.code === 'REPLAY_POOL_BUSY' && error.statusCode === 503);
});

test('a readiness marker flushed after startup timeout cannot revive the stopping instance', async (t) => {
  const value = config(t, { startTimeoutMs: 10, stopTimeoutMs: 1_000 });
  const child = new LingeringChild(4000);
  const messages = [];
  const manager = new ReplayManager(value, {
    info(message) { messages.push(message); },
    warn() {},
  }, {
    spawnProcess() { return child; },
  });
  t.after(() => {
    child.emit('exit', null, 'SIGTERM');
    return manager.close();
  });

  await assert.rejects(manager.watch(replay(value.cwd)), (error) =>
    error.code === 'REPLAY_START_TIMEOUT');
  assert.deepEqual(child.kills, ['SIGTERM']);

  child.stdout.write('serving full-state server demo too late\n');
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(manager.status().map(({ ready, ending }) => ({ ready, ending })), [
    { ready: false, ending: true },
  ]);
  assert.equal(messages.some((message) => message.includes('is ready')), false);
});
