// Process glue for the per-target globalsetups: spawn the app's own dev
// server, wait until it answers HTTP, and hand vitest a teardown. The apps own
// what runs (their dev stack, their stub flags); this file only owns process
// lifecycle, so it stays target-agnostic.
import { spawn, type ChildProcess } from "node:child_process";
import { openSync } from "node:fs";

export interface BootedProcesses {
  readonly teardown: () => Promise<void>;
  /** Process-group leader pids — what an external `down` must signal. */
  readonly pids: ReadonlyArray<number>;
}

export const bootProcesses = (
  procs: ReadonlyArray<{
    readonly cmd: string;
    readonly args: ReadonlyArray<string>;
    readonly cwd: string;
    readonly env?: Record<string, string | undefined>;
    /** Append stdout+stderr here (long-lived boots need inspectable logs). */
    readonly logFile?: string;
  }>,
  options: { readonly label: string },
): BootedProcesses => {
  const children: ChildProcess[] = [];
  let tearingDown = false;
  for (const proc of procs) {
    const log = proc.logFile ? openSync(proc.logFile, "a") : undefined;
    const child = spawn(proc.cmd, [...proc.args], {
      cwd: proc.cwd,
      env: { ...process.env, ...proc.env },
      stdio:
        log !== undefined ? ["ignore", log, log] : process.env.E2E_VERBOSE ? "inherit" : "ignore",
      // Own process group, so teardown can signal the whole tree — `bunx vite`
      // is a wrapper whose actual server child would otherwise outlive the
      // kill and squat the port into the NEXT invocation's waitForHttp.
      detached: true,
    });
    child.on("exit", (code) => {
      if (code !== 0 && code !== null && !tearingDown) {
        console.error(`[e2e:${options.label}] ${proc.cmd} exited with ${code}`);
      }
    });
    children.push(child);
  }

  // Signal the process GROUP (negative pid); fall back to the direct child
  // when the group is already gone.
  const signalTree = (child: ChildProcess, signal: NodeJS.Signals) => {
    if (child.pid === undefined || child.exitCode !== null) return;
    try {
      process.kill(-child.pid, signal);
    } catch {
      child.kill(signal);
    }
  };

  const exited = (child: ChildProcess): Promise<void> =>
    child.exitCode !== null || child.signalCode !== null
      ? Promise.resolve()
      : new Promise((resolve) => child.once("exit", () => resolve()));

  return {
    teardown: async () => {
      tearingDown = true;
      const allExited = Promise.all(children.map(exited));
      for (const child of children) signalTree(child, "SIGTERM");
      // Wait for a REAL exit (not a fixed sleep) — a lingering server would
      // answer the next invocation's waitForHttp as a half-dead zombie.
      const graceful = await Promise.race([
        allExited.then(() => true),
        new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)),
      ]);
      if (!graceful) {
        for (const child of children) signalTree(child, "SIGKILL");
        await Promise.race([allExited, new Promise((resolve) => setTimeout(resolve, 2_000))]);
      }
    },
    pids: children.flatMap((child) => (child.pid === undefined ? [] : [child.pid])),
  };
};

export const waitForHttp = async (
  url: string,
  options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {},
): Promise<void> => {
  const deadline = Date.now() + (options.timeoutMs ?? 90_000);
  let lastError: unknown;
  while (Date.now() < deadline) {
    try {
      const response = await fetch(url, { redirect: "manual" });
      // During a cold vite compile /api/* falls back to the SPA's 200 HTML —
      // expectRedirect waits for the real handler (302) instead.
      if (options.expectRedirect ? response.status === 302 : response.status < 500) return;
      lastError = new Error(`status ${response.status}`);
    } catch (error) {
      lastError = error;
    }
    await new Promise((resolve) => setTimeout(resolve, 400));
  }
  throw new Error(`timed out waiting for ${url}: ${String(lastError)}`);
};

/**
 * Reboot DOWN-GATE: wait until `url` stops answering (fetch rejects — connection
 * refused/reset). An orderly OS shutdown keeps the daemon serving for several
 * seconds, and a reconnecting tunnel re-establishes the forward, so polling for
 * "up" right after a reboot command can false-pass a reboot that never happened.
 * Gating on the server actually going DOWN first makes restart-persistence prove
 * a real reboot. Throws if it never goes down within the deadline.
 */
export const waitForHttpDown = async (
  url: string,
  options: { readonly timeoutMs?: number } = {},
): Promise<void> => {
  const deadline = Date.now() + (options.timeoutMs ?? 120_000);
  while (Date.now() < deadline) {
    try {
      await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2000) });
    } catch {
      return; // the server (or its tunnel) is gone — the reboot took
    }
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  throw new Error(`${url} never became unreachable — the reboot may not have taken`);
};
