import { spawnSync } from 'node:child_process';
import type { BrainEngine } from '../core/engine.ts';
import { startMcpServer } from '../mcp/server.ts';

// Maximum time the stdio path will wait for engine.disconnect() (PGLite
// close + advisory lock release) before forcing exit. Keeps a wedged
// disconnect from trapping the process forever; the abandoned lock dir is
// already covered by the in-process stale-lock check (acquireLock walks
// the dir, sees a dead PID, and removes it).
const CLEANUP_DEADLINE_MS = 5_000;

// Boot-readiness deadline (#3273). A serve process that wedges mid-boot
// (e.g. an MCP boot step that never completes because a configured
// upstream is unreachable) holds the PGLite write lock indefinitely: the
// post-#2348 lock discipline never steals from a live holder, so every
// CLI consumer times out until someone hunts down and kills the PID. If
// startMcpServer hasn't finished connecting the transport within this
// window, we release the engine (dropping the lock) and exit non-zero so
// a supervisor can restart with backoff. Env-tunable via
// GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS; 0 disables.
const DEFAULT_BOOT_TIMEOUT_SECONDS = 60;

// How often the parent-process watchdog polls the live kernel parent PID
// (via `readLiveParentPid`, NOT the cached `process.ppid` — see that
// helper's comment). We don't receive a signal when our parent dies (the
// kernel just re-parents us to init / launchd / a subreaper-PID), so
// polling is the only reliable way to detect "parent went away without
// closing stdin". 5s matches the cadence in the concurrent #591 PR;
// faster polling has no benefit, slower would extend the lock-leak window.
const PARENT_WATCHDOG_INTERVAL_MS = 5_000;

export interface ServeOptions {
  // Test seam — defaults to the live process. The lifecycle plumbing reads
  // these for stdin EOF detection, signal handlers, and exit, so unit
  // tests can drive end-to-end shutdown via mocked streams without
  // spawning a real Bun process. `exit` is typed as `void` (not `never`)
  // so test stubs that record + return are accepted without casts;
  // `process.exit`'s `never` return is assignable to `void`.
  stdin?: NodeJS.ReadableStream & { isTTY?: boolean };
  signals?: Pick<NodeJS.Process, 'on'>;
  exit?: (code?: number) => void;
  log?: (msg: string) => void;
  // Test seam: replace startMcpServer to avoid booting the real MCP SDK
  // (which unconditionally attaches a 'data' listener to real
  // process.stdin and would pollute the test runner's stdin handle).
  // Defaults to the real implementation when omitted.
  startMcpServer?: (engine: BrainEngine) => Promise<void>;
  // Test seam for the parent-process watchdog. The default
  // (`readLiveParentPid`) reads the live kernel PPID via `ps` on POSIX
  // because `process.ppid` is captured at process creation and does not
  // refresh on re-parent (Node/Bun parity). On Windows — where the
  // kernel never re-parents, so the cached ppid stays correct — it
  // probes the original parent's liveness with signal-0 instead and
  // reports 0 once the parent is gone. Tests inject a stub so they can
  // simulate the parent dying without spawning ps or re-parenting any
  // real process.
  getParentPid?: () => number;
  // Test seam: replace setInterval/clearInterval so the watchdog can
  // fire deterministically in tests instead of waiting 5s. Defaults to
  // the global timer functions.
  setInterval?: (fn: () => void, ms: number) => unknown;
  clearInterval?: (handle: unknown) => void;
  // Test seam for the one-shot watchdog readiness probe. The default
  // runs `spawnSync('ps', ['-o','ppid=','-p',PID])` on POSIX (signal-0
  // against our own PID on Windows) and returns true on success. Tests
  // inject a stub to simulate ps unavailability (e.g.
  // stripped containers, busybox without procps) without modifying PATH.
  // When the probe returns false, `installStdioLifecycle` skips the
  // watchdog interval entirely and emits a loud stderr line. Without
  // the probe, the original PR's behavior was a silent no-op: every
  // tick fell through to the cached `process.ppid` and the watchdog
  // never fired, while still claiming to be installed.
  probeWatchdog?: () => boolean;
  // v0.34.1 (#870): test seam for the MCP_STDIO=1 piped-stdin guard.
  // When true, runServe skips the stdin 'end'/'close' shutdown hooks
  // because the wrapping gateway (OpenClaw bundle-mcp, others) pipes the
  // JSON-RPC handshake and closes stdin immediately. Signal handlers and
  // transport.onclose still cover legitimate shutdown.
  // Defaults to `process.env.MCP_STDIO === '1'` when omitted.
  mcpStdio?: boolean;
  // Test seam for the boot-readiness deadline (#3273). Milliseconds.
  // Defaults to GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS (seconds; 60 when
  // unset, 0 disables) when omitted.
  bootTimeoutMs?: number;
}

/**
 * Teardown for the HTTP serve path, reached once the server lifecycle resolves.
 *
 * `serve` deliberately skips both `finishCliTeardown` and the force-exit seam,
 * so simply returning here leaves the never-disconnected engine's handles
 * keeping an orphaned process alive — port released, but the PID still owning
 * the PGLite write lock, which blocks every later CLI write. Disconnect first
 * (checkpoint / pool drain) so the store is not left needing recovery, raced
 * against the same deadline the stdio path uses in case a wedged WASM close
 * would otherwise trap us.
 *
 * Extracted and seam-injected because this — not the socket severing in
 * serve-http.ts — is the half that actually closes the orphan, and it was
 * previously unreachable from a test.
 *
 * ponytail: on SIGTERM this races process-cleanup's own exit(143) and loses,
 * because that path does not await a disconnect. That is the outcome we want.
 * Plumb a settle-reason through `runServeHttp` if it ever needs to be
 * guaranteed rather than merely reliable.
 */
export async function finishHttpServe(
  engine: Pick<BrainEngine, 'disconnect'>,
  opts: Pick<ServeOptions, 'exit' | 'log'> & { deadlineMs?: number } = {},
): Promise<void> {
  const exit = opts.exit ?? ((code?: number) => process.exit(code));
  const log = opts.log ?? ((msg: string) => console.error(msg));
  const deadlineMs = opts.deadlineMs ?? CLEANUP_DEADLINE_MS;

  let exited = false;
  const exitOnce = (code: number) => {
    if (exited) return;
    exited = true;
    exit(code);
  };

  const deadline = setTimeout(() => {
    log(`GBrain MCP server: cleanup deadline (${deadlineMs}ms) exceeded — forcing exit`);
    exitOnce(0);
  }, deadlineMs);
  deadline.unref?.();

  try {
    await engine.disconnect();
  } catch (err: unknown) {
    log(`GBrain MCP server: cleanup error: ${err instanceof Error ? err.message : String(err)}`);
  }
  clearTimeout(deadline);
  // `process.exit` never returns, so the guard is inert in production. It
  // matters for the injected seam: a disconnect that outlives the deadline
  // must not exit a second time.
  exitOnce(0);
}

export async function runServe(
  engine: BrainEngine,
  args: string[] = [],
  opts: ServeOptions = {},
) {
  // v0.26+: --http dispatches to the full OAuth 2.1 server (serve-http.ts)
  // with admin dashboard, scope enforcement, SSE feed, and the requireBearerAuth
  // middleware. Master's simpler startHttpTransport from v0.22.7 is superseded
  // — the OAuth provider in serve-http.ts handles bearer auth via
  // verifyAccessToken with legacy access_tokens fallback (so v0.22.7 callers
  // that used `gbrain auth create` keep working unchanged).
  const isHttp = args.includes('--http');

  if (isHttp) {
    const portIdx = args.indexOf('--port');
    const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 3131 : 3131;

    const ttlIdx = args.indexOf('--token-ttl');
    const tokenTtl = ttlIdx >= 0 ? parseInt(args[ttlIdx + 1]) || 3600 : 3600;

    // #1353: --enable-dcr-insecure opts into the consent-bypassing
    // client_credentials grant on the DCR path. It implies --enable-dcr (you
    // can't allow insecure DCR clients without DCR). Plain --enable-dcr keeps
    // the secure default: DCR clients are authorization_code (consent-bearing).
    const enableDcrInsecure = args.includes('--enable-dcr-insecure');
    const enableDcr = args.includes('--enable-dcr') || enableDcrInsecure;

    const publicUrlIdx = args.indexOf('--public-url');
    const publicUrl = publicUrlIdx >= 0 ? args[publicUrlIdx + 1] : undefined;

    // F8 escape hatch: --log-full-params writes raw payloads to mcp_request_log
    // and the admin SSE feed instead of redacted summaries. Off by default
    // (privacy-first); operators running gbrain on their own laptop can flip
    // it on for debug visibility. Loud startup warning fires in serve-http.ts
    // when set so the posture change is visible in stderr.
    const logFullParams = args.includes('--log-full-params');

    // v0.34.1 (#864, D11): `--bind HOST` lets operators choose the network
    // interface to listen on. When unset, runServeHttp defaults to 127.0.0.1
    // (loopback) — server operators who need remote access pass
    // `--bind 0.0.0.0` (or a specific interface IP). `bind` is intentionally
    // left undefined here when the flag is absent so the WARN-on-public-url
    // path in serve-http can distinguish "operator chose loopback explicitly"
    // from "operator didn't set the flag at all."
    const bindIdx = args.indexOf('--bind');
    const bind = bindIdx >= 0 ? args[bindIdx + 1] : undefined;

    // v0.36.x #1024: suppress the printed admin bootstrap token. Pair with
    // GBRAIN_ADMIN_BOOTSTRAP_TOKEN for production deployments that don't
    // want the value leaking into log aggregators on every supervisor
    // restart.
    const suppressBootstrapToken = args.includes('--suppress-bootstrap-token');

    // #2624: by default the generated token only prints on an interactive
    // TTY (never into container log storage). --print-admin-token forces the
    // raw value even on a non-TTY start.
    const printAdminToken = args.includes('--print-admin-token');

    const { runServeHttp } = await import('./serve-http.ts');
    await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken, printAdminToken });

    await finishHttpServe(engine, opts);
    return;
  }

  // stdio path — install lifecycle handlers BEFORE startMcpServer so that
  // an early stdin EOF (parent died before our first read) can still
  // trigger graceful release of the PGLite write lock held by `engine`.
  // The HTTP / OAuth path above has its own lifecycle in serve-http.ts
  // and is intentionally NOT wired into this stdio plumbing.
  console.error('Starting GBrain MCP server (stdio)...');

  installStdioLifecycle(engine, args, opts);

  const start = opts.startMcpServer ?? startMcpServer;

  // Boot-readiness deadline (#3273): never sit on the PGLite write lock
  // forever with a boot that never completes. On expiry: log, release the
  // engine (drops the lock), exit non-zero so supervisors restart with
  // backoff. The disconnect itself is raced against CLEANUP_DEADLINE_MS,
  // same as the graceful-shutdown path, so a wedged WASM close can't trap
  // us either.
  const bootTimeoutMs = opts.bootTimeoutMs ?? resolveBootTimeoutMs();
  let bootDeadline: ReturnType<typeof setTimeout> | null = null;
  if (bootTimeoutMs > 0) {
    const log = opts.log ?? ((msg: string) => console.error(msg));
    const exit = opts.exit ?? ((code?: number) => { process.exit(code); });
    bootDeadline = setTimeout(() => {
      log(
        `GBrain MCP server: boot did not complete within ${bootTimeoutMs}ms — releasing DB lock and exiting so other consumers unblock (check configured provider endpoints; tune via GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS, 0 disables)`,
      );
      const cleanup = setTimeout(() => { exit(1); }, CLEANUP_DEADLINE_MS);
      cleanup.unref?.();
      Promise.resolve()
        .then(() => engine.disconnect())
        .catch((err: unknown) => {
          const msg = err instanceof Error ? err.message : String(err);
          log(`GBrain MCP server: boot-deadline cleanup error: ${msg}`);
        })
        .finally(() => {
          clearTimeout(cleanup);
          exit(1);
        });
    }, bootTimeoutMs);
    bootDeadline.unref?.();
  }

  try {
    await start(engine);
  } finally {
    if (bootDeadline) clearTimeout(bootDeadline);
  }
  // startMcpServer's `await server.connect(transport)` resolves once the
  // SDK has wired up its stdin 'data' listener; that listener keeps the
  // event loop alive. We deliberately do NOT add `await new Promise(() =>
  // {})` here — it would block this async frame and stop the lifecycle
  // hooks from being able to call process.exit() cleanly.
}

// Env resolution for the boot deadline. Lenient (warn + default) rather
// than throw: this is an incident-time escape hatch, and a typo'd env var
// must not turn a boot-safety net into a boot failure of its own.
function resolveBootTimeoutMs(): number {
  const raw = process.env.GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS;
  if (raw === undefined || raw.trim() === '') return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
  const n = Number(raw);
  if (!Number.isFinite(n) || n < 0) {
    console.error(
      `[gbrain serve] ignoring invalid GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS=${JSON.stringify(raw)} — using default ${DEFAULT_BOOT_TIMEOUT_SECONDS}s`,
    );
    return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
  }
  return n * 1000;
}

interface StdioLifecycleDeps {
  stdin: NodeJS.ReadableStream & { isTTY?: boolean };
  signals: Pick<NodeJS.Process, 'on'>;
  exit: (code?: number) => void;
  log: (msg: string) => void;
  getParentPid: () => number;
  setInterval: (fn: () => void, ms: number) => unknown;
  clearInterval: (handle: unknown) => void;
  probeWatchdog: () => boolean;
}

function installStdioLifecycle(
  engine: BrainEngine,
  args: string[],
  opts: ServeOptions,
): void {
  const deps: StdioLifecycleDeps = {
    stdin: opts.stdin ?? process.stdin,
    signals: opts.signals ?? process,
    exit: opts.exit ?? ((code?: number) => { process.exit(code); }),
    log: opts.log ?? ((msg: string) => console.error(msg)),
    getParentPid: opts.getParentPid ?? readLiveParentPid,
    setInterval: opts.setInterval ?? ((fn, ms) => setInterval(fn, ms)),
    clearInterval: opts.clearInterval ?? ((h) => clearInterval(h as ReturnType<typeof setInterval>)),
    probeWatchdog: opts.probeWatchdog ?? probeWatchdogAvailable,
  };

  let shuttingDown = false;
  let parentWatchdog: unknown = null;
  const beginShutdown = (reason: string): void => {
    if (shuttingDown) return;
    shuttingDown = true;

    // Stop the parent-watchdog interval as soon as a shutdown begins so
    // it cannot fire a redundant 'parent-died' shutdown while the first
    // one is still draining the cleanup chain.
    if (parentWatchdog !== null) {
      deps.clearInterval(parentWatchdog);
      parentWatchdog = null;
    }

    deps.log(`GBrain MCP server: graceful exit (${reason})`);

    // Race the cleanup against a deadline. engine.disconnect() does a
    // PGLite WASM close + a synchronous rmSync on the lock dir; both
    // should be sub-second, but a wedged WASM runtime shouldn't be able
    // to trap us forever. If we hit the deadline we still exit; the
    // lock dir is advisory and the next process's stale-lock check
    // (process.kill(pid, 0) → ESRCH) will reclaim it.
    const deadline = setTimeout(() => {
      deps.log(
        `GBrain MCP server: cleanup deadline (${CLEANUP_DEADLINE_MS}ms) exceeded — forcing exit`,
      );
      deps.exit(0);
    }, CLEANUP_DEADLINE_MS);
    deadline.unref?.();

    Promise.resolve()
      .then(() => engine.disconnect())
      .catch((err: unknown) => {
        const msg = err instanceof Error ? err.message : String(err);
        deps.log(`GBrain MCP server: cleanup error: ${msg}`);
      })
      .finally(() => {
        clearTimeout(deadline);
        deps.exit(0);
      });
  };

  // Signal-based termination. SIGTERM: daemon ask. SIGINT: user Ctrl-C.
  // SIGHUP: terminal disconnect / daemon-style "reload" channels — Aragorn
  // observed real-world hosts (Claude Desktop on macOS, hermes-agent
  // restart) send these instead of closing stdin. All three get the same
  // graceful path; the idempotency guard absorbs duplicate signals.
  deps.signals.on('SIGTERM', () => beginShutdown('SIGTERM'));
  deps.signals.on('SIGINT', () => beginShutdown('SIGINT'));
  deps.signals.on('SIGHUP', () => beginShutdown('SIGHUP'));

  // Stdin EOF — the parent closes the pipe but the MCP SDK's
  // StdioServerTransport only listens for 'data'/'error', not 'end' or
  // 'close', so without these hooks the process keeps the engine (and its
  // PGLite write lock) live indefinitely after the parent disconnects.
  // 'end' fires on a clean EOF; 'close' fires when the underlying handle
  // is destroyed (e.g. parent SIGKILL'd while pipe still open). Both
  // converge on the same idempotent shutdown.
  // Skip when stdin is a TTY: interactive `gbrain serve` use shouldn't
  // terminate just because the user hasn't typed anything. Signal /
  // watchdog paths still cover that case if needed.
  // v0.34.1 (#870): when MCP_STDIO=1, the wrapping gateway pipes the
  // JSON-RPC handshake then closes its stdin half. Treating that as a
  // permanent disconnect kills the server before the first tool call.
  // Signal handlers (SIGTERM/SIGINT/SIGHUP), transport.onclose, and the
  // parent-process watchdog below still cover legitimate shutdown paths.
  // `mcpStdio` is the injectable form; default reads the env once at
  // install time so tests stay isolated (no process.env mutation).
  const mcpStdioMode = opts.mcpStdio ?? (process.env.MCP_STDIO === '1');
  if (!deps.stdin.isTTY && !mcpStdioMode) {
    deps.stdin.once('end', () => beginShutdown('stdin-end'));
    deps.stdin.once('close', () => beginShutdown('stdin-close'));
  }

  // Parent-process watchdog. Some hosts (launchd, cron, certain MCP
  // gateways) terminate without closing stdin and without sending a
  // signal — the kernel just re-parents us to whichever ancestor is
  // still alive (PID 1, or any closer subreaper such as launchd, systemd,
  // tmux, or a parent shell with PR_SET_CHILD_SUBREAPER). Polling is the
  // only portable way to notice; see `readLiveParentPid` for why we
  // cannot rely on `process.ppid` (cached at process creation and never
  // refreshed on re-parent in Node or Bun). On Windows the same class of
  // orphan is WORSE in practice: MCP hosts typically launch the server
  // through a `cmd.exe` wrapper (.bat/.cmd), and killing the wrapper
  // does not kill the child — so without a watchdog the orphan holds the
  // PGLite write lock until the machine reboots. `readLiveParentPid`
  // handles the platform split internally (PPID-change on POSIX,
  // parent-liveness on Windows); the comparison below works for both.
  //
  // We capture the initial parent PID once at install time and fire on
  // ANY change, not just reparent-to-PID-1. The PR-#676 author's original
  // `=== 1` check missed reparent-to-subreaper-PID-N, which is the actual
  // observed behavior under launchd / systemd subreapers (codex review
  // finding #3). A process legitimately started under PID 1 (e.g. a
  // systemd service) skips the watchdog: there's no parent-death event
  // to detect, and any reparent FROM 1 doesn't happen. `unref()` keeps
  // the interval from blocking other exit paths.
  //
  // A one-shot startup probe (D2-revisited per codex finding #4) verifies
  // that the underlying mechanism (`spawnSync('ps')`) actually works on
  // this host. Stripped containers / busybox-without-procps environments
  // would silently fall back to the cached `process.ppid` on every tick
  // — the watchdog claims to be installed but never fires. When the probe
  // fails, we skip installing the interval entirely and log loudly so the
  // operator sees the degraded mode instead of a phantom watchdog.
  // `> 1` (was `!== 1`): PID 1 is the documented legitimate-init-child
  // skip; PID 0 is the new "parent already gone at install time" report
  // from the Windows liveness reader — installing an interval that
  // compares 0 to 0 forever would be a phantom watchdog, and stdin
  // 'close' already covers a parent that died before we booted.
  const initialParentPid = deps.getParentPid();
  if (initialParentPid > 1) {
    if (!deps.probeWatchdog()) {
      deps.log(
        '[gbrain serve] watchdog disabled: no parent-liveness mechanism (ps / signal-0 probe failed) — child will rely on stdin EOF / signals only',
      );
    } else {
      parentWatchdog = deps.setInterval(() => {
        if (deps.getParentPid() !== initialParentPid) {
          beginShutdown('parent-died');
        }
      }, PARENT_WATCHDOG_INTERVAL_MS);
      (parentWatchdog as { unref?: () => void } | null)?.unref?.();
    }
  }

  // Optional idle-timeout safety net. Default OFF; opt-in via
  // `--stdio-idle-timeout <seconds>`. The flag is for the rare case where
  // the parent leaks the stdin pipe but never closes it (so 'end' never
  // fires) and never sends another message — we'd otherwise sit on the
  // PGLite lock forever. Off by default because most parents close
  // properly and an over-eager idle timeout would surprise long-poll
  // workloads.
  const idleTimeoutSec = parseStdioIdleTimeout(args);
  if (idleTimeoutSec > 0) {
    let idleTimer: ReturnType<typeof setTimeout> | null = null;
    const armIdle = (): void => {
      if (idleTimer) clearTimeout(idleTimer);
      idleTimer = setTimeout(
        () => beginShutdown(`stdio-idle-timeout (${idleTimeoutSec}s)`),
        idleTimeoutSec * 1000,
      );
      idleTimer.unref?.();
    };
    armIdle();
    // Reset on every chunk. We can't observe SDK-parsed messages from
    // here, but every JSON-RPC frame causes a 'data' event on stdin, so
    // chunk-level granularity is sufficient.
    deps.stdin.on('data', armIdle);
    deps.log(`GBrain MCP server: stdio idle timeout = ${idleTimeoutSec}s`);
  }
}

/**
 * Signal-0 process-liveness probe (`process.kill(pid, 0)` — existence
 * check only, no signal delivered; OpenProcess under the hood on
 * Windows). EPERM means the PID exists but we lack rights to signal it
 * — that is still "alive" for watchdog purposes. Exported for direct
 * unit testing of the Windows watchdog path.
 */
export function isPidAlive(pid: number): boolean {
  if (!Number.isInteger(pid) || pid <= 0) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch (err) {
    return (err as NodeJS.ErrnoException).code === 'EPERM';
  }
}

/**
 * Resolve the live parent PID from the kernel (not the cached startup
 * value). Both Node and Bun expose `process.ppid` as a property captured
 * at process creation, so it does NOT update when the kernel re-parents
 * us to a new ancestor after the original parent dies — which is the
 * exact event the watchdog needs to detect. Empirical evidence on
 * macOS / Bun 1.3.12: `process.ppid` stays at the original parent ID
 * indefinitely while `ps -o ppid= -p $$` reports the new parent within
 * one tick.
 *
 * Windows has no `ps` — the original ps-only implementation made the
 * startup probe fail on every Windows host, so the watchdog was always
 * disabled and an orphaned serve (e.g. its cmd.exe .bat wrapper killed
 * by the MCP host without closing stdin) held the PGLite write lock
 * indefinitely. But Windows also never re-parents orphans, so the
 * cached `process.ppid` stays correct for the process's lifetime and
 * the question inverts from "did the live PPID change?" to "is the
 * original parent still alive?" — answered in-process via signal-0,
 * no external binary needed. Parent dead → report 0 (kernel PID 0 is
 * the System Idle Process, never our parent), which differs from
 * `initialParentPid` and fires the watchdog. Known degraded mode:
 * Windows recycles PIDs aggressively, so a reused parent PID can mask
 * a death — stdin EOF / signals remain the primary shutdown channels
 * and the watchdog stays the backstop, same posture as POSIX.
 *
 * Cost: ~10ms per ps spawn on POSIX, effectively free on Windows.
 * Called every 5s (PARENT_WATCHDOG_INTERVAL_MS), so amortized < 0.5%
 * CPU. Falls back to `process.ppid` if `ps` fails (best-effort safety
 * net for stripped-down containers, etc.); the startup probe at
 * watchdog-install time loud-logs and skips the interval entirely when
 * no mechanism is available, so a per-tick fallback is a redundant
 * safety net rather than a primary mechanism.
 *
 * `platform` is a test seam (defaults to the real platform) so CI on
 * any OS can exercise both branches — signal-0 works everywhere.
 */
export function readLiveParentPid(platform: NodeJS.Platform = process.platform): number {
  if (platform === 'win32') {
    return isPidAlive(process.ppid) ? process.ppid : 0;
  }
  try {
    const r = spawnSync('ps', ['-o', 'ppid=', '-p', String(process.pid)], {
      encoding: 'utf8',
      timeout: 1000,
    });
    if (r.status === 0 && typeof r.stdout === 'string') {
      const n = parseInt(r.stdout.trim(), 10);
      if (Number.isInteger(n) && n >= 0) return n;
    }
  } catch {
    /* fall through */
  }
  return process.ppid;
}

/**
 * One-shot probe at watchdog-install time to confirm the platform's
 * parent-liveness mechanism actually works on this host. POSIX: true
 * iff `spawnSync('ps','-o','ppid=','-p',PID)` exits 0 with a parseable
 * integer. Windows: true iff signal-0 succeeds against our own PID
 * (always alive — verifies the mechanism, not the parent). When it
 * returns false, the caller skips installing the watchdog and emits a
 * loud stderr line — the operator sees "watchdog disabled" instead of
 * an installed-but-never-fires phantom.
 *
 * Why a separate probe rather than relying on the per-tick fallback in
 * `readLiveParentPid`: the per-tick fallback returns the cached
 * `process.ppid` silently, so the watchdog runs every 5s, compares
 * cached PPID to itself, never detects a change, and never fires —
 * while still claiming to be active. The probe surfaces the gap once
 * at install time and lets the caller short-circuit cleanly.
 */
export function probeWatchdogAvailable(platform: NodeJS.Platform = process.platform): boolean {
  if (platform === 'win32') {
    return isPidAlive(process.pid);
  }
  try {
    const r = spawnSync('ps', ['-o', 'ppid=', '-p', String(process.pid)], {
      encoding: 'utf8',
      timeout: 1000,
    });
    if (r.status !== 0 || typeof r.stdout !== 'string') return false;
    const n = parseInt(r.stdout.trim(), 10);
    return Number.isInteger(n) && n >= 0;
  } catch {
    return false;
  }
}

function parseStdioIdleTimeout(args: string[]): number {
  const idx = args.indexOf('--stdio-idle-timeout');
  if (idx < 0) return 0;
  const raw = args[idx + 1];
  // Strict parsing — silent fallback to 0 turns an opt-in safety net into
  // a no-op when an operator typos the value (e.g. `--stdio-idle-timeout
  // 30s`). `Number()` rejects partial parses like `30junk` (returns NaN),
  // unlike `parseInt` which would silently accept it. A missing value
  // (`--stdio-idle-timeout` at end of args) and any non-integer / negative
  // value are surfaced as a CLI error before we install the timer.
  if (raw === undefined) {
    throw new Error(
      '--stdio-idle-timeout requires a non-negative integer (seconds). Got: (missing value)',
    );
  }
  // Reject empty / whitespace-only explicitly: `Number('')` is 0 in JS,
  // which would silently turn `--stdio-idle-timeout ""` into the
  // documented opt-out — the exact silent-fallback failure mode this
  // strict parser exists to prevent.
  if (raw.trim() === '') {
    throw new Error(
      '--stdio-idle-timeout requires a non-negative integer (seconds). Got: (blank value)',
    );
  }
  const n = Number(raw);
  if (!Number.isInteger(n) || n < 0) {
    throw new Error(
      `--stdio-idle-timeout requires a non-negative integer (seconds). Got: ${JSON.stringify(raw)}`,
    );
  }
  return n;
}
