import { mkdirSync, writeFileSync, readFileSync, openSync, existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { contextModeAdapter } from "../../adapters/contextMode.ts";
import { paths } from "../../lifecycle/paths.ts";
import { writePid, isAlive, removePid } from "../../lifecycle/pid.ts";
import { findFreePort, isPortInUse } from "../../lifecycle/port.ts";
import { loadGlobalConfig, setGlobalConfigPort } from "../../lifecycle/config.ts";
import { startProxy, type StartProxyHandle } from "../../proxy/server.ts";

export interface StartFlags {
  port?: string;
  background?: boolean;
  foreground?: boolean;
  supervise?: boolean;
}
interface StartOpts { returnHandle?: boolean; projectDir?: string }

async function resolvePinnedPort(): Promise<number> {
  const cfg = loadGlobalConfig();
  if (typeof cfg.port === "number" && cfg.port > 0 && !(await isPortInUse(cfg.port))) {
    return cfg.port;
  }
  const fresh = await findFreePort();
  setGlobalConfigPort(fresh);
  return fresh;
}

export async function cmdStart(flags: StartFlags, opts: StartOpts = {}, verbose = false): Promise<number | StartProxyHandle> {
  // Internal role: long-lived supervisor that keeps the proxy alive across crashes.
  if (flags.supervise) return runSupervisor(flags, verbose);

  // Internal/dev role: the actual proxy process (spawned by the supervisor, or run
  // standalone for tests/dev). Falls through to the proxy boot below.
  if (!flags.foreground) {
    // User-facing background launch: refuse if already running, then spawn a
    // detached supervisor (NOT the proxy directly). pidFile holds the SUPERVISOR
    // pid so stop/cld/isAlive key off the thing that survives a proxy crash.
    if (isAlive(paths.pidFile())) {
      if (!verbose) { console.log("err=already-running"); return 1; }
      console.log(`fewtok already running (pid ${readFileSync(paths.pidFile(), "utf8").trim()})`);
      return 1;
    }
    mkdirSync(paths.state(), { recursive: true });
    const port = flags.port && flags.port !== "0" ? Number(flags.port) : await resolvePinnedPort();
    mkdirSync(paths.logs(), { recursive: true });
    const logPath = join(paths.logs(), `proxy-${new Date().toISOString().slice(0, 10)}.log`);
    const logFd = openSync(logPath, "a");
    const child = Bun.spawn(
      [process.execPath, process.argv[1]!, "start", "--supervise", "--port", String(port)],
      { stdio: ["ignore", logFd, logFd], env: process.env },
    );
    child.unref();
    const ready = await waitForReady(port, 5000);
    if (!ready) {
      if (!verbose) { console.log("err=start-failed"); } else { console.error(`fewtok failed to start on port ${port} within 5s — see ${logPath}`); }
      return 1;
    }
    if (!verbose) { console.log("ok"); return 0; }
    console.log(`fewtok started on port ${port} (background, supervisor pid ${child.pid}) → log ${logPath}`);
    return 0;
  }

  mkdirSync(paths.state(), { recursive: true });
  const port = flags.port === "0" || flags.port === undefined
    ? await resolvePinnedPort()
    : Number(flags.port);
  const upstream = process.env.FEWTOK_UPSTREAM ?? "https://api.anthropic.com";
  const projectDir = opts.projectDir ?? paths.root();
  // Primary = the supervisor's designated child (FT_PRIMARY=1) or any standalone
  // (unsupervised) proxy. Only the primary repairs the shared ctx-mode fence.
  const isPrimary = process.env.FT_PRIMARY === "1" || process.env.FT_SUPERVISED !== "1";
  const repairCtxMode = (): void => {
    if (contextModeAdapter.isInstalled() && contextModeAdapter.repair) {
      const r = contextModeAdapter.repair();
      if (verbose && r.symlinked.length > 0) {
        console.log(`ctx-mode: symlinked ${r.symlinked.length} version(s) → ${r.current}`);
      }
    }
  };
  // onBecomePrimary: run on SIGUSR2 promotion (old primary retired/died) so the new
  // primary takes over the single-writer fence repair, not just at boot.
  const handle = await startProxy({ port, upstream, projectDir, primary: isPrimary, onBecomePrimary: repairCtxMode });
  // Under the supervisor, pidFile belongs to the supervisor — the proxy must not
  // clobber it. Standalone/dev foreground still claims pidFile itself.
  const supervised = process.env.FT_SUPERVISED === "1";
  if (!supervised) writePid(paths.pidFile(), process.pid);
  writeFileSync(paths.portFile(), String(handle.port));
  // Readiness handshake for the supervisor's graceful restart: the supervisor polls
  // ready-<pid> before retiring the old child. Written only after Bun.serve has bound.
  try { writeFileSync(paths.readyFile(process.pid), String(handle.port)); } catch { /* best-effort */ }
  if (!verbose) { console.log("ok"); } else { console.log(`fewtok started on port ${handle.port} → ${upstream}`); }

  // Primary-only boot fence repair; the poller + SIGTERM/SIGINT shutdown + SIGUSR2
  // promotion now live inside startProxy (one owner, every child).
  if (isPrimary) repairCtxMode();

  if (opts.returnHandle) return handle;
  return await new Promise<number>(() => { /* park: startProxy owns signal lifecycle */ });
}

// Long-lived supervisor: owns pidFile, designates the primary child, (re)spawns it
// on crash with backoff, gives up on a tight crash-loop, fans reload signals out to
// every live child, and drives the SIGUSR2 graceful rolling restart (spawn an incoming
// child on the shared reusePort socket, wait for its readiness handshake, retire the
// old, promote the incoming). Event-driven: it reacts to child-exit events instead of
// blocking in a respawn loop, so the graceful restart's transient two-child window is a
// first-class state rather than a special case.
async function runSupervisor(flags: StartFlags, _verbose: boolean): Promise<number> {
  const port = flags.port && flags.port !== "0" ? Number(flags.port) : await resolvePinnedPort();
  mkdirSync(paths.state(), { recursive: true });
  writePid(paths.pidFile(), process.pid);

  type Child = ReturnType<typeof Bun.spawn>;
  // Child SIGTERM is ambiguous (stop vs graceful-retire), so the SIGKILL escalation
  // lives HERE: short grace on a stop, long grace on a graceful-restart retire (the
  // child may be draining a live SSE stream).
  const STOP_GRACE_MS = Number(process.env.FT_STOP_GRACE_MS ?? 8000);
  const RESTART_GRACE_MS = Number(process.env.FT_RESTART_GRACE_MS ?? 300_000);
  const RESTART_READY_MS = Number(process.env.FT_RESTART_READY_MS ?? 10_000);
  // Bound the wait for the primary's in-process reload to land new bytes on disk
  // (rename .next→live). A bad/identical .next never renames, so this just times
  // out → no secondary roll (correct: nothing to propagate).
  const RELOAD_PROMOTE_MS = Number(process.env.FT_RELOAD_PROMOTE_MS ?? 8000);
  // Bound the wait before SIGUSR2-promoting a NOT-yet-ready survivor: defer the signal
  // until its readyFile proves startProxy resolved (= SIGUSR2 handler registered). A
  // child still in Bun runtime init has SIGUSR2 at DEFAULT disposition = terminate (exit
  // 140); .lkg-fallback boot is the slowest path, so keep this generous.
  const PROMOTE_READY_MS = Number(process.env.FT_PROMOTE_READY_MS ?? 15_000);
  const WINDOW_MS = 60_000;
  const MAX_RESTARTS = 5;

  // Live bundle path + content hash. onReload watches this to detect the moment the
  // PRIMARY has promoted .next→live (the sole renamer), the signal to roll the
  // secondary onto the new bytes.
  const distLive = join(paths.distDir(), "proxy-handler.js");
  const liveHash = (): string | null => {
    try { return createHash("sha256").update(readFileSync(distLive)).digest("hex").slice(0, 12); }
    catch { return null; }
  };

  let stopRequested = false;
  // Steady N=2: two children always bound on the shared reusePort socket. One is the
  // primary (FT_PRIMARY=1: runs WAL checkpoint, ctx-mode fence, owns statusline.json,
  // does .lkg/rename on reload); the other is the secondary (FT_PRIMARY=0: serves +
  // compresses + append-only writeRequest). On a primary crash the supervisor promotes
  // the proven-alive secondary, so primacy is handed to a known-good process, never a
  // fresh boot.
  let primary: Child | null = null;
  let secondary: Child | null = null;
  // Graceful-restart transients (step 2, primary roll). `pendingIncoming` is spawned-
  // but-not-yet-ready; `incoming` is ready and awaiting promotion. Both MUST be tracked
  // from spawn so stop()/the done-check reach them; only their own onExit clears them.
  let incoming: Child | null = null;
  let pendingIncoming: Child | null = null;
  // Graceful-restart step 1 (secondary roll): the OLD secondary being retired after its
  // replacement is already spawned into `secondary`. Its onExit is a no-op (replacement
  // already serving) — this marker distinguishes a planned retire from a crash.
  let retiringSecondary: Child | null = null;
  let restartInProgress = false;
  let restartTarget: "secondary" | "primary" | null = null;
  // GLOBAL crash budget (not per-role): a post-selfCheck runtime-crashing dist cascades
  // by shuffling crashes BETWEEN roles via promotion (primary dies → promote secondary →
  // it dies → promote next …). A per-role counter never sees the cascade because each
  // promotion spawns a fresh role-child; only a single global window converges it to the
  // LKG latch, then to give-up if even LKG crashloops.
  const crashes: number[] = [];
  let globalUseLkg = false;

  let resolveDone: () => void = () => {};
  const done = new Promise<void>((r) => { resolveDone = r; });

  const alive = (c: Child | null): c is Child => c !== null && c.exitCode === null;
  const noneAlive = (): boolean =>
    !alive(primary) && !alive(secondary) && !alive(incoming) && !alive(pendingIncoming) && !alive(retiringSecondary);
  const cleanupReady = (pid: number): void => {
    try { if (existsSync(paths.readyFile(pid))) unlinkSync(paths.readyFile(pid)); } catch { /* ignore */ }
  };

  const spawnChild = (isPrimary: boolean): Child => {
    const childEnv: Record<string, string | undefined> = { ...process.env, FT_SUPERVISED: "1" };
    // Set, never delete: a steady secondary must report primary=false to /healthz and
    // the statusline gate (both read process.env.FT_PRIMARY live), which `delete` —
    // leaving it `undefined !== "0"` → truthy — got wrong.
    childEnv.FT_PRIMARY = isPrimary ? "1" : "0";
    // C1: once the global budget trips, new children boot from proven .lkg bytes.
    if (globalUseLkg) childEnv.FT_USE_LKG = "1"; else delete childEnv.FT_USE_LKG;
    return Bun.spawn(
      [process.execPath, process.argv[1]!, "start", "--foreground", "--port", String(port)],
      { stdio: ["ignore", "inherit", "inherit"], env: childEnv },
    );
  };
  const spawnRole = (isPrimary: boolean): Child => { const c = spawnChild(isPrimary); supervise(c); return c; };

  const waitForChildReady = async (pid: number, timeoutMs: number): Promise<boolean> => {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      if (existsSync(paths.readyFile(pid))) return true;
      await new Promise((r) => setTimeout(r, 50));
    }
    return false;
  };

  const killEscalate = (c: Child, graceMs: number): void => {
    try { c.kill(); } catch { /* already gone */ }
    const cc = c;
    setTimeout(() => { try { if (alive(cc)) cc.kill("SIGKILL"); } catch { /* gone */ } }, graceMs);
  };

  // Charge one crash against the global window. Trip MAX_RESTARTS in WINDOW_MS → latch
  // FT_USE_LKG for new spawns (reset the window, give LKG a fair shot). Crashloop even
  // on LKG → "giveup" (genuinely broken, not a bad dist).
  const chargeCrash = (): "continue" | "giveup" => {
    const now = Date.now();
    crashes.push(now);
    while (crashes.length > 0 && now - crashes[0]! > WINDOW_MS) crashes.shift();
    if (crashes.length >= MAX_RESTARTS) {
      if (!globalUseLkg) {
        globalUseLkg = true;
        crashes.length = 0;
        console.error(`[fewtok supervisor] ${MAX_RESTARTS} child crashes within 60s — switching new children to .lkg (FT_USE_LKG=1)`);
        return "continue";
      }
      return "giveup";
    }
    return "continue";
  };

  const giveUp = (code: number): void => {
    console.error(
      `[fewtok supervisor] children crashlooping even on .lkg (last code=${code}) — giving up. ` +
        "Fix the build, then run `fewtok start`.",
    );
    stopRequested = true;
    for (const c of [primary, secondary, incoming, pendingIncoming, retiringSecondary]) {
      if (alive(c)) killEscalate(c, STOP_GRACE_MS);
    }
    if (noneAlive()) resolveDone();
  };

  // Hand background-job ownership to a child via SIGUSR2. CRITICAL: never SIGUSR2 a child
  // that has not registered its handler yet. The double-death cascade respawns a fresh
  // child into `secondary` (line below), then promotes it on the NEXT death while it is
  // still in Bun runtime init — SIGUSR2 there is DEFAULT disposition = terminate (exit
  // 140). That self-inflicted death charges the crash budget and storms into a false
  // giveup despite a good .lkg. Gate on the child's readyFile (written only after
  // startProxy resolves, i.e. after the SIGUSR2 handler is installed), mirroring the
  // graceful-restart path's wait on `incoming`. A proven survivor (steady secondary)
  // already has its readyFile → signal immediately, zero added latency (single-staggered
  // crash + graceful-restart behaviour unchanged).
  const promote = (c: Child): void => {
    primary = c;
    const sendPromotion = (): void => {
      // Fire-time re-check: a deferred promotion must no-op if c died or was replaced as
      // primary during the wait (its own onExit drives recovery). Also covers the
      // co-killed-sibling race — a sibling SIGKILLed at the same instant may still have a
      // stale readyFile on disk before its onExit runs cleanupReady; alive(c) rejects it.
      if (primary !== c || !alive(c)) return;
      try { process.kill(c.pid, "SIGUSR2"); } catch { /* race */ }
      console.log(`[fewtok supervisor] promoted survivor pid ${c.pid} → primary`);
    };
    if (existsSync(paths.readyFile(c.pid))) { sendPromotion(); return; }
    // Promotee not yet ready (fresh respawn under near-simultaneous double-death): defer
    // the signal until its readyFile appears. Redundancy is already restored by the
    // secondary respawn; this child keeps serving (reusePort) un-signalled until proven.
    console.error(`[fewtok supervisor] promotee pid ${c.pid} not yet ready — deferring SIGUSR2 until its readyFile`);
    void waitForChildReady(c.pid, PROMOTE_READY_MS).then((ready) => {
      if (ready) { sendPromotion(); return; }
      // Alive but never ready within the window = wedged boot. Leaving it as `primary`
      // means NO child runs the maintenance jobs (silent stats rot — the exact failure
      // the supervisor election exists to prevent). Kill it so onExit re-drives election
      // onto a fresh child. A genuinely broken build then trips the crash budget → .lkg
      // → giveup, which is the correct terminal state.
      if (primary === c && alive(c)) {
        console.error(`[fewtok supervisor] promotee pid ${c.pid} not ready within ${PROMOTE_READY_MS}ms — killing to re-elect`);
        killEscalate(c, STOP_GRACE_MS);
      }
    });
  };

  const scheduleRespawn = (role: "primary" | "secondary", code: number): void => {
    const backoff = Math.min(250 * 2 ** Math.max(0, crashes.length - 1), 2000);
    console.error(
      `[fewtok supervisor] ${role} exited (code=${code}); respawning in ${backoff}ms ` +
        `(${crashes.length}/${MAX_RESTARTS} in window${globalUseLkg ? ", lkg" : ""})`,
    );
    setTimeout(() => {
      if (stopRequested) return;
      const c = spawnRole(role === "primary");
      if (role === "primary") primary = c; else secondary = c;
    }, backoff);
  };

  // Single exit handler; role resolved by IDENTITY at exit time (live, not at attach):
  // a child promoted secondary→primary keeps the handler it had as secondary.
  const onExit = (c: Child, code: number): void => {
    cleanupReady(c.pid);
    if (stopRequested) {
      if (c === primary) primary = null;
      else if (c === secondary) secondary = null;
      else if (c === incoming) incoming = null;
      else if (c === pendingIncoming) pendingIncoming = null;
      else if (c === retiringSecondary) retiringSecondary = null;
      if (noneAlive()) resolveDone();
      return;
    }
    // Planned secondary-roll retire (step 1): replacement already spawned into `secondary`.
    if (c === retiringSecondary) { retiringSecondary = null; return; }
    // Primary-roll transients dying before promotion → abort the roll; steady pair serves.
    if (c === pendingIncoming) {
      pendingIncoming = null;
      restartInProgress = false; restartTarget = null;
      console.error(`[fewtok supervisor] graceful restart aborted: incoming exited during startup (code=${code})`);
      return;
    }
    if (c === incoming) {
      incoming = null;
      restartInProgress = false; restartTarget = null;
      console.error(`[fewtok supervisor] graceful restart aborted: incoming exited before promotion (code=${code})`);
      return;
    }
    if (c === primary) {
      // Planned primary-roll retire (step 2): promote the ready incoming.
      if (restartInProgress && restartTarget === "primary" && alive(incoming)) {
        primary = incoming; incoming = null;
        restartInProgress = false; restartTarget = null;
        try { process.kill(primary.pid, "SIGUSR2"); } catch { /* race */ }
        console.log(`[fewtok supervisor] graceful restart complete; new primary pid ${primary.pid}`);
        return;
      }
      // CRASH.
      primary = null;
      if (restartInProgress) { restartInProgress = false; restartTarget = null; } // cancel any in-flight roll
      if (chargeCrash() === "giveup") return giveUp(code);
      if (alive(secondary)) {
        // Promote the proven-alive survivor (no refused window), restore redundancy.
        const surv = secondary; secondary = null;
        promote(surv);
        secondary = spawnRole(false);
        return;
      }
      scheduleRespawn("primary", code);
      return;
    }
    if (c === secondary) {
      // CRASH — primary still serving, so no refused window.
      secondary = null;
      if (restartInProgress) { restartInProgress = false; restartTarget = null; } // cancel any in-flight roll
      if (chargeCrash() === "giveup") return giveUp(code);
      scheduleRespawn("secondary", code);
      return;
    }
    // Stale child (already replaced) — nothing to do.
  };

  const supervise = (c: Child): void => { void c.exited.then((code) => onExit(c, code)); };

  const stop = (): void => {
    stopRequested = true;
    for (const c of [primary, secondary, incoming, pendingIncoming, retiringSecondary]) {
      if (alive(c)) killEscalate(c, STOP_GRACE_MS);
    }
    if (noneAlive()) resolveDone();
  };
  process.on("SIGTERM", stop);
  process.on("SIGINT", stop);

  // reload (cmdReload) signals pidFile = this supervisor. Under N=2 the OLD design
  // fanned the signal to every child, so BOTH ran the full reload transaction and
  // RACED renameSync(.next→live) (loser throws ENOENT → never swaps → serves stale)
  // and copyFileSync→.lkg (non-atomic → corrupt last-known-good). Instead: signal ONLY
  // the primary to verify+promote+swap in-process (sole renamer, sole .lkg seeder — no
  // race), then roll the secondary via the verified spawn-new-then-retire machinery so
  // the fresh secondary boots the new `live` bytes. No concurrent rename, no .lkg
  // corruption, no new in-process secondary-reload codepath; the only cost is the
  // secondary briefly absent during respawn, which the still-serving primary covers
  // (reusePort → zero refused).
  const onReload = async (): Promise<void> => {
    if (stopRequested || restartInProgress || !alive(primary)) return;
    restartInProgress = true;
    restartTarget = "secondary"; // a primary crash mid-reload must take the crash path, not planned-promote

    // 1. Primary verifies + promotes (.next→live) + swaps its bundle in-process.
    const before = liveHash();
    try { process.kill(primary.pid, "SIGUSR1"); } catch { /* race: primary exiting */ }

    // 2. Wait until live bytes change on disk == primary renamed .next→live. A rejected
    //    (selfCheck fail / no .next) or identical-byte reload never changes live → times
    //    out → no secondary roll (correct: there is nothing new to propagate).
    let promoted = false;
    const deadline = Date.now() + RELOAD_PROMOTE_MS;
    while (Date.now() < deadline) {
      if (stopRequested || !restartInProgress) { restartInProgress = false; restartTarget = null; return; }
      const now = liveHash();
      if (now !== null && now !== before) { promoted = true; break; }
      await new Promise((r) => setTimeout(r, 100));
    }
    if (!promoted) {
      restartInProgress = false; restartTarget = null;
      console.log("[fewtok supervisor] reload: live bytes unchanged (rejected or no-op) — secondary not rolled");
      return;
    }

    // PRECONDITION (stated, not assumed): between the primary's in-process swap above and
    //    the secondary roll below there is a transient version split — primary on new bytes,
    //    secondary still on old. Option (b) BOUNDS this window to one respawn (seconds), but
    //    it is not atomic. It is benign ONLY because compression is byte-deterministic across
    //    versions (cache-safety invariant 2): identical compressed output → identical cache
    //    keys → identical user-facing behaviour regardless of which child serves. A future
    //    version that changes compression output breaks this assumption and would need a
    //    supervisor-coordinated swap-ack before any child promotes. Do not claim atomicity.
    //
    // 3. Roll the secondary onto the new bytes (planned retire — NOT charged to the crash
    //    budget; retiringSecondary makes the old one's onExit a no-op). Reuses the exact
    //    step-1 machinery from onRestart.
    if (!alive(secondary)) { restartInProgress = false; restartTarget = null; return; }
    const oldSec = secondary;
    const newSec = spawnRole(false); // boots loadHandlerModule("live") = the just-promoted bytes
    cleanupReady(newSec.pid);
    secondary = newSec;
    retiringSecondary = oldSec;
    if (oldSec) killEscalate(oldSec, RESTART_GRACE_MS);
    const ok = await waitForChildReady(newSec.pid, RESTART_READY_MS);
    if (stopRequested || !restartInProgress) return; // stop()/crash cancelled the roll
    restartInProgress = false; restartTarget = null;
    if (!ok || !alive(newSec)) {
      console.error("[fewtok supervisor] reload: new secondary not ready after roll — killing (crash path restores it)");
      killEscalate(newSec, STOP_GRACE_MS); // its onExit (c===secondary) crash-respawns a healthy one
      return;
    }
    console.log(`[fewtok supervisor] reload complete: primary pid ${primary?.pid} swapped in-process, secondary rolled to new bytes (pid ${newSec.pid})`);
  };
  process.on("SIGUSR1", () => { void onReload(); });
  process.on("SIGHUP", () => { void onReload(); });

  // SIGUSR2 → graceful rolling restart (cmdRestart --graceful targets this supervisor).
  // Rolls BOTH children so no stale-code child lingers. Step 1 (secondary) is a plain
  // spawn-new-then-retire — the primary serves throughout so a sub-second reduced-
  // redundancy blip drops zero connections, no precise handshake needed. Step 2 (primary)
  // uses the verified handshake: spawn incoming as secondary, wait ready, retire old
  // primary, onExit promotes incoming. A crash during either step cancels the roll
  // (restartInProgress flipped false by the crash branch) and the crash path takes over.
  const onRestart = async (): Promise<void> => {
    if (stopRequested || restartInProgress || !alive(primary) || !alive(secondary)) return;
    restartInProgress = true;

    // STEP 1 — roll SECONDARY (plain).
    restartTarget = "secondary";
    const oldSec = secondary;
    const newSec = spawnRole(false);
    cleanupReady(newSec.pid);
    secondary = newSec;          // track replacement immediately
    retiringSecondary = oldSec;  // old one's onExit is now a no-op
    if (oldSec) killEscalate(oldSec, RESTART_GRACE_MS);
    const ok1 = await waitForChildReady(newSec.pid, RESTART_READY_MS);
    if (stopRequested || !restartInProgress) return; // stop()/crash cancelled the roll
    if (!ok1 || !alive(newSec)) {
      console.error("[fewtok supervisor] graceful restart aborted: new secondary not ready");
      // Don't leave a not-ready child serving — kill it; its onExit (c===secondary) runs
      // the normal crash→respawn machinery to restore a healthy secondary.
      killEscalate(newSec, STOP_GRACE_MS);
      restartInProgress = false; restartTarget = null;
      return;
    }

    // STEP 2 — roll PRIMARY (precise handshake).
    restartTarget = "primary";
    const inc = spawnRole(false); // spawn as secondary; promoted only after old retires
    cleanupReady(inc.pid);
    pendingIncoming = inc;        // track BEFORE the await
    const ok2 = await waitForChildReady(inc.pid, RESTART_READY_MS);
    if (stopRequested) { try { if (alive(inc)) inc.kill(); } catch { /* ignore */ } return; }
    if (!restartInProgress) { // crash cancelled mid-wait; inc's onExit clears pendingIncoming
      if (alive(inc)) { try { inc.kill(); } catch { /* ignore */ } }
      return;
    }
    if (!ok2 || !alive(inc)) {
      console.error("[fewtok supervisor] graceful restart aborted: incoming primary not ready");
      if (alive(inc)) { try { inc.kill(); } catch { /* ignore */ } }
      pendingIncoming = null; incoming = null;
      restartInProgress = false; restartTarget = null;
      return;
    }
    incoming = inc;
    pendingIncoming = null;
    const oldPri = primary;
    if (oldPri) killEscalate(oldPri, RESTART_GRACE_MS);
    // oldPri's onExit (c===primary, restartTarget==="primary", incoming alive) promotes.
  };
  process.on("SIGUSR2", () => { void onRestart(); });

  primary = spawnRole(true);
  secondary = spawnRole(false);
  await done;

  try { removePid(paths.pidFile()); } catch { /* ignore */ }
  try { if (existsSync(paths.portFile())) unlinkSync(paths.portFile()); } catch { /* ignore */ }
  return 0;
}

async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (await isPortInUse(port) && existsSync(paths.portFile())) {
      try {
        const written = Number(readFileSync(paths.portFile(), "utf8").trim());
        if (written === port) return true;
      } catch {}
    }
    await new Promise((r) => setTimeout(r, 50));
  }
  return false;
}
