#!/usr/bin/env node
// Spawns the built API server, waits for /api/healthz, runs a wrapped
// command, and tears the server down on exit. If TEST_API_BASE is set in
// the environment, the spawn is skipped and the wrapped command runs
// against that externally-managed server. If a healthy API server is
// already running on the target port (e.g. the dev API Server workflow),
// the helper reuses it instead of trying to bind a second process.
//
// Usage:
//   node ./scripts/with-server.mjs -- <command> [args...]
//
// Env knobs:
//   TEST_API_BASE             If set, skip spawning and use this URL.
//   PORT                      Port to bind the spawned server to (default 8080).
//   TEST_API_WAIT_TIMEOUT_MS  Health-wait timeout (default 60000 here).
//   TEST_API_WAIT_INTERVAL_MS Poll interval (default 500).
//   API_SERVER_LOG_TAIL       Number of stderr/stdout lines to dump on
//                             startup failure (default 60).
//   WITH_SERVER_FORCE_BUILD=1 Force a rebuild even if dist/index.mjs exists.

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const apiServerDir = path.resolve(scriptDir, "..");

function parseArgs(argv) {
  const sep = argv.indexOf("--");
  const rest = sep === -1 ? argv : argv.slice(sep + 1);
  if (rest.length === 0) {
    console.error(
      "with-server.mjs: missing wrapped command. Usage: with-server.mjs -- <cmd> [args...]",
    );
    process.exit(2);
  }
  return { cmd: rest[0], cmdArgs: rest.slice(1) };
}

function tail(buffer, n) {
  const lines = buffer.split(/\r?\n/);
  return lines.slice(-n).join("\n");
}

async function isAlreadyHealthy(url) {
  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 1_500);
    const res = await fetch(url, { signal: controller.signal });
    clearTimeout(timer);
    if (!res.ok) return false;
    const body = await res.json().catch(() => ({}));
    return body && body.status === "ok";
  } catch {
    return false;
  }
}

async function waitForHealth(url, timeoutMs, intervalMs, isChildAlive) {
  const started = Date.now();
  let attempt = 0;
  let lastError = null;
  while (Date.now() - started < timeoutMs) {
    if (!isChildAlive()) {
      return {
        ok: false,
        reason: "API server child exited before becoming healthy",
        lastError,
        attempts: attempt,
      };
    }
    attempt += 1;
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), 2_000);
      const res = await fetch(url, { signal: controller.signal });
      clearTimeout(timer);
      if (res.ok) {
        const body = await res.json().catch(() => ({}));
        if (body && body.status === "ok") {
          return { ok: true, attempts: attempt, elapsedMs: Date.now() - started };
        }
        lastError = new Error(`Unexpected health payload: ${JSON.stringify(body)}`);
      } else {
        lastError = new Error(`HTTP ${res.status} from ${url}`);
      }
    } catch (err) {
      lastError = err;
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  return {
    ok: false,
    reason: `API server did not become healthy within ${timeoutMs}ms`,
    lastError,
    attempts: attempt,
  };
}

// Centralized lifecycle so signal handlers, top-level errors, and the
// happy-path exit all funnel through the same shutdown sequence. This
// guarantees the spawned API server is killed exactly once and that
// repeated signals never short-circuit an in-flight teardown.
function createLifecycle() {
  let server = null;
  let serverExited = false;
  let serverExitCode = null;
  let serverExitSignal = null;
  let shutdownPromise = null;
  let activeChild = null;

  function setServer(proc) {
    server = proc;
    server.on("exit", (code, signal) => {
      serverExited = true;
      serverExitCode = code;
      serverExitSignal = signal;
    });
  }

  function setActiveChild(proc) {
    activeChild = proc;
  }

  function clearActiveChild(proc) {
    if (activeChild === proc) activeChild = null;
  }

  function shutdown(signal = "SIGTERM") {
    if (shutdownPromise) return shutdownPromise;
    shutdownPromise = (async () => {
      // Forward signal to wrapped command first so it can flush.
      if (activeChild && !activeChild.killed) {
        try {
          activeChild.kill(signal);
        } catch {
          /* noop */
        }
      }
      if (!server || serverExited) return;
      await new Promise((resolve) => {
        const onExit = () => resolve();
        server.once("exit", onExit);
        try {
          server.kill(signal);
        } catch {
          server.removeListener("exit", onExit);
          resolve();
          return;
        }
        const force = setTimeout(() => {
          try {
            if (!serverExited) server.kill("SIGKILL");
          } catch {
            /* noop */
          }
        }, 5_000);
        server.once("exit", () => clearTimeout(force));
      });
    })();
    return shutdownPromise;
  }

  return {
    setServer,
    setActiveChild,
    clearActiveChild,
    shutdown,
    get serverExited() {
      return serverExited;
    },
    get serverExitCode() {
      return serverExitCode;
    },
    get serverExitSignal() {
      return serverExitSignal;
    },
  };
}

function runWrappedCommand(cmd, cmdArgs, env, lifecycle) {
  return new Promise((resolve) => {
    const child = spawn(cmd, cmdArgs, {
      stdio: "inherit",
      env,
      shell: false,
    });
    if (lifecycle) lifecycle.setActiveChild(child);
    child.on("exit", (code, signal) => {
      if (lifecycle) lifecycle.clearActiveChild(child);
      resolve({ code: code ?? (signal ? 1 : 0), signal });
    });
    child.on("error", (err) => {
      if (lifecycle) lifecycle.clearActiveChild(child);
      console.error(`with-server.mjs: failed to spawn wrapped command: ${err.message}`);
      resolve({ code: 1, signal: null });
    });
  });
}

function buildIfNeeded() {
  const distEntry = path.join(apiServerDir, "dist", "index.mjs");
  if (existsSync(distEntry) && process.env.WITH_SERVER_FORCE_BUILD !== "1") {
    return Promise.resolve();
  }
  return new Promise((resolve, reject) => {
    const child = spawn("node", ["./build.mjs"], {
      cwd: apiServerDir,
      stdio: "inherit",
      env: process.env,
    });
    child.on("exit", (code) => {
      if (code === 0) resolve();
      else reject(new Error(`API server build failed with exit code ${code}`));
    });
    child.on("error", (err) => reject(err));
  });
}

async function main() {
  const { cmd, cmdArgs } = parseArgs(process.argv.slice(2));

  // Externally-managed server: skip spawn entirely.
  if (process.env.TEST_API_BASE) {
    console.log(
      `with-server.mjs: TEST_API_BASE=${process.env.TEST_API_BASE} set; using existing server.`,
    );
    const { code } = await runWrappedCommand(cmd, cmdArgs, process.env, null);
    process.exit(code);
  }

  const port = process.env.PORT ?? "8080";
  const apiBase = `http://localhost:${port}`;
  const healthUrl = `${apiBase}/api/healthz`;

  if (await isAlreadyHealthy(healthUrl)) {
    console.log(
      `with-server.mjs: detected healthy API server at ${healthUrl}; reusing it (skipping spawn).`,
    );
    const reuseEnv = { ...process.env, TEST_API_BASE: apiBase };
    const { code } = await runWrappedCommand(cmd, cmdArgs, reuseEnv, null);
    process.exit(code);
  }

  await buildIfNeeded();

  const tailLines = Number(process.env.API_SERVER_LOG_TAIL ?? 60);
  const timeoutMs = Number(process.env.TEST_API_WAIT_TIMEOUT_MS ?? 60_000);
  const intervalMs = Number(process.env.TEST_API_WAIT_INTERVAL_MS ?? 500);

  const serverEnv = {
    ...process.env,
    PORT: port,
    NODE_ENV: process.env.NODE_ENV ?? "development",
  };

  const lifecycle = createLifecycle();

  // Install centralized signal/error handlers ONCE so repeated signals
  // share the same in-flight shutdown promise instead of racing.
  const onSignal = (sig) => {
    lifecycle.shutdown(sig).finally(() => {
      const exitCode = sig === "SIGINT" ? 130 : 143;
      process.exit(exitCode);
    });
  };
  process.on("SIGINT", () => onSignal("SIGINT"));
  process.on("SIGTERM", () => onSignal("SIGTERM"));
  process.on("uncaughtException", async (err) => {
    console.error("with-server.mjs: uncaught exception", err);
    try {
      await lifecycle.shutdown("SIGTERM");
    } finally {
      process.exit(1);
    }
  });
  process.on("unhandledRejection", async (err) => {
    console.error("with-server.mjs: unhandled rejection", err);
    try {
      await lifecycle.shutdown("SIGTERM");
    } finally {
      process.exit(1);
    }
  });

  console.log(`with-server.mjs: starting API server on ${apiBase} ...`);
  const server = spawn("node", ["--enable-source-maps", "./dist/index.mjs"], {
    cwd: apiServerDir,
    env: serverEnv,
    stdio: ["ignore", "pipe", "pipe"],
  });
  lifecycle.setServer(server);

  let stdoutBuf = "";
  let stderrBuf = "";
  const MAX_BUF = 256 * 1024;
  server.stdout.on("data", (chunk) => {
    stdoutBuf += chunk.toString();
    if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
    process.stdout.write(chunk);
  });
  server.stderr.on("data", (chunk) => {
    stderrBuf += chunk.toString();
    if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
    process.stderr.write(chunk);
  });

  const health = await waitForHealth(
    healthUrl,
    timeoutMs,
    intervalMs,
    () => !lifecycle.serverExited,
  );

  if (!health.ok) {
    console.error(`\nwith-server.mjs: ${health.reason}`);
    if (health.lastError) {
      console.error(
        `Last health-check error: ${health.lastError.message ?? health.lastError}`,
      );
    }
    if (lifecycle.serverExited) {
      console.error(
        `API server exited prematurely (code=${lifecycle.serverExitCode}, signal=${lifecycle.serverExitSignal}).`,
      );
    }
    if (stderrBuf.trim()) {
      console.error(`\n----- API server stderr (last ${tailLines} lines) -----`);
      console.error(tail(stderrBuf, tailLines));
      console.error(`----- end stderr -----`);
    }
    if (!stderrBuf.trim() && stdoutBuf.trim()) {
      console.error(`\n----- API server stdout (last ${tailLines} lines) -----`);
      console.error(tail(stdoutBuf, tailLines));
      console.error(`----- end stdout -----`);
    }
    console.error(
      "\nHint: API server failed to start. Check the logs above, or set TEST_API_BASE to point at a running instance.",
    );
    await lifecycle.shutdown("SIGTERM");
    process.exit(1);
  }

  console.log(
    `with-server.mjs: API server ready at ${healthUrl} after ${health.elapsedMs}ms (attempt ${health.attempts}).`,
  );

  const wrappedEnv = { ...process.env, TEST_API_BASE: apiBase };
  const { code, signal } = await runWrappedCommand(cmd, cmdArgs, wrappedEnv, lifecycle);

  await lifecycle.shutdown("SIGTERM");

  if (signal) {
    process.exit(128 + (signal === "SIGINT" ? 2 : 15));
  }
  process.exit(code ?? 0);
}

main().catch(async (err) => {
  console.error("with-server.mjs: unexpected error", err);
  process.exit(1);
});
