/**
 * Supervised Oxigraph server — REAL process supervision, NO mocks.
 *
 * The retired version drove `startOxigraphServer` with an injected spawn
 * factory returning a `FakeChild` EventEmitter and a fetch stub for the
 * readiness probe — process lifecycle (signals, exits, bind failures) was
 * hand-emulated, so a divergence between the emulation and real OS process
 * behaviour could never surface.
 *
 * This version spawns a REAL executable child. The stand-in is a tiny Node
 * program with oxigraph's CLI surface (`serve --location <dir> --bind
 * <host:port>`) that binds a REAL port, answers the REAL readiness probe over
 * real HTTP, exits non-zero on a REAL bind failure (EADDRINUSE), and honours
 * REAL SIGTERM — so every supervision contract (ready-probe resolution,
 * failed-start rejection + cleanup, stop() semantics, crash → restart with
 * backoff, no restart after stop) is proven against genuine OS processes,
 * sockets, and signals. The unit under test — the supervisor — is exactly the
 * production code path; only the supervised binary's identity differs, and
 * the binary's own behaviour is real, not emitted by the test.
 */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, rm, writeFile, chmod } from 'node:fs/promises';
import { createServer, type Server } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startOxigraphServer } from '../src/daemon/oxigraph-server.js';

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

let dir: string;
let standin: string;

// A real port that is free at allocation time. The OS hands us an ephemeral
// port; we close the probe listener and reuse the number immediately.
async function freePort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const srv = createServer();
    srv.once('error', reject);
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address();
      if (!addr || typeof addr === 'string') return reject(new Error('no port'));
      const port = addr.port;
      srv.close(() => resolve(port));
    });
  });
}

async function fetchPid(port: number): Promise<number> {
  const res = await fetch(`http://127.0.0.1:${port}/pid`);
  return Number(await res.text());
}

async function portAnswers(port: number): Promise<boolean> {
  try {
    const res = await fetch(`http://127.0.0.1:${port}/query`, { signal: AbortSignal.timeout(500) });
    return res.ok;
  } catch {
    return false;
  }
}

beforeAll(async () => {
  dir = await mkdtemp(join(tmpdir(), 'oxi-server-real-'));
  standin = join(dir, 'oxigraph-standin.cjs');
  await writeFile(
    standin,
    `#!/usr/bin/env node
// Real stand-in server with oxigraph's CLI surface. Binds a REAL port,
// answers the readiness probe, exposes its pid, exits 1 on a REAL bind
// failure, exits 0 on SIGTERM.
const http = require('node:http');
const bindIdx = process.argv.indexOf('--bind');
const [host, port] = process.argv[bindIdx + 1].split(':');
const srv = http.createServer((req, res) => {
  if (req.url === '/pid') { res.statusCode = 200; res.end(String(process.pid)); return; }
  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/sparql-results+json');
  res.end(JSON.stringify({ head: {}, boolean: true }));
});
srv.on('error', (e) => { console.error('bind failed: ' + e.message); process.exit(1); });
srv.listen(Number(port), host);
process.on('SIGTERM', () => { srv.close(() => process.exit(0)); setTimeout(() => process.exit(0), 100).unref(); });
`,
    'utf8',
  );
  await chmod(standin, 0o755);
});

afterAll(async () => {
  await rm(dir, { recursive: true, force: true }).catch(() => {});
});

function startOpts(port: number, extra: Record<string, unknown> = {}) {
  return {
    binaryPath: standin,
    location: dir,
    port,
    readyTimeoutMs: 5_000,
    readyIntervalMs: 50,
    stopGraceMs: 1_000,
    restartBackoffBaseMs: 100,
    restartBackoffMaxMs: 200,
    log: () => {},
    ...extra,
  };
}

describe('startOxigraphServer (real child processes)', () => {
  it('spawns the binary and resolves once the real readiness probe answers', async () => {
    const port = await freePort();
    const handle = await startOxigraphServer(startOpts(port));
    try {
      expect(handle.port).toBe(port);
      expect(handle.queryEndpoint).toContain(`:${port}`);
      // The child really owns a really-bound socket.
      expect(await portAnswers(port)).toBe(true);
    } finally {
      await handle.stop();
    }
  });

  it('rejects (and does not leak the child) when the bind REALLY fails — port already taken', async () => {
    const port = await freePort();
    // Genuinely occupy the port so the child hits a real EADDRINUSE. Track
    // the blocker's accepted sockets: the supervisor's (client-aborted)
    // readiness probes leave half-open server-side connections behind, and
    // `server.close()` waits for them — destroy them so teardown completes.
    const blocker: Server = createServer();
    const blockerSockets = new Set<import('node:net').Socket>();
    blocker.on('connection', (s) => {
      blockerSockets.add(s);
      s.on('close', () => blockerSockets.delete(s));
    });
    await new Promise<void>((resolve) => blocker.listen(port, '127.0.0.1', resolve));
    try {
      await expect(
        startOxigraphServer(startOpts(port, { readyTimeoutMs: 1_500 })),
      ).rejects.toThrow(/ready|bind|exit/i);
    } finally {
      for (const s of blockerSockets) s.destroy();
      await new Promise<void>((resolve) => blocker.close(() => resolve()));
    }
    // The failed child must not linger holding anything; the blocker is
    // closed, and nothing answers the probe (the supervisor killed it).
    await sleep(150);
    expect(await portAnswers(port)).toBe(false);
  });

  it('stop() terminates the real child (SIGTERM) and frees the port', async () => {
    const port = await freePort();
    const handle = await startOxigraphServer(startOpts(port));
    expect(await portAnswers(port)).toBe(true);
    await handle.stop();
    // The real process exited and the real socket is released.
    await sleep(100);
    expect(await portAnswers(port)).toBe(false);
  });

  it('restarts the child after an unexpected crash (real SIGKILL), with a new pid', async () => {
    const port = await freePort();
    const handle = await startOxigraphServer(startOpts(port));
    try {
      const pid1 = await fetchPid(port);
      expect(pid1).toBeGreaterThan(0);

      // Genuinely crash the supervised child from outside.
      process.kill(pid1, 'SIGKILL');

      // The supervisor must respawn it (backoff base 100ms). Poll until the
      // probe answers again with a DIFFERENT real pid.
      let pid2 = 0;
      for (let i = 0; i < 100; i++) {
        await sleep(100);
        try {
          pid2 = await fetchPid(port);
          if (pid2 && pid2 !== pid1) break;
        } catch {
          /* not back yet */
        }
      }
      expect(pid2, 'supervisor never respawned the crashed child').toBeGreaterThan(0);
      expect(pid2).not.toBe(pid1);
    } finally {
      await handle.stop();
    }
  });

  it('does NOT restart after stop() — the port stays free past the backoff window', async () => {
    const port = await freePort();
    const handle = await startOxigraphServer(startOpts(port));
    expect(await portAnswers(port)).toBe(true);
    await handle.stop();
    // Wait well past restartBackoffMaxMs: a buggy supervisor would have
    // respawned by now and the probe would answer.
    await sleep(600);
    expect(await portAnswers(port)).toBe(false);
  });
});
