#!/usr/bin/env bun
/**
 * cele2e-wait — run cele2e and emit a structured JSON result instead
 * of streaming the test's megabytes of ANSI output to the caller.
 *
 * Designed for tools (CI, the `cele2e-runner` Claude subagent, custom
 * harnesses) that need the pass/fail summary but can't afford to read
 * the test's full output stream.
 *
 * Behavior:
 *   1. Generate (or accept) a runId.
 *   2. Spawn `cele2e run <args>` with stdout/stderr redirected to a
 *      log file — the wrapper's caller never sees the raw stream.
 *   3. Poll the SQLite event bus for `e2e.run.completed.<runId>` or
 *      `e2e.run.failed.<runId>` until one arrives, cele2e exits, or
 *      a timeout fires.
 *   4. Print a single JSON object on stdout.
 *
 * Exit codes: 0 success, 1 test failed, 2 cele2e exited without
 * emitting (orphaned), 3 bus is unconfigured.
 */

import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
import { homedir, platform, tmpdir } from 'node:os';
import { join } from 'node:path';
import { waitForRunCompletion, type WaitResult } from '../src/wait-for-run';

interface ParsedArgs {
  runId: string;
  pollMs: number;
  cele2eArgs: string[];
  logPath: string;
  timeoutMs: number;
  busPath: string | null;
}

function parseArgs(argv: string[]): ParsedArgs {
  let runId = process.env.CELE2E_RUN_ID ?? '';
  let pollMs = 1000;
  let timeoutMs = 60 * 60 * 1000;
  let logPath = '';
  const cele2eArgs: string[] = [];

  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--run-id' && i + 1 < argv.length) {
      runId = argv[++i];
      continue;
    }
    if (a === '--poll-ms' && i + 1 < argv.length) {
      pollMs = Number(argv[++i]);
      continue;
    }
    if (a === '--timeout-ms' && i + 1 < argv.length) {
      timeoutMs = Number(argv[++i]);
      continue;
    }
    if (a === '--log' && i + 1 < argv.length) {
      logPath = argv[++i];
      continue;
    }
    cele2eArgs.push(a);
  }

  if (!runId) runId = crypto.randomUUID();
  if (!logPath) {
    const dir = join(tmpdir(), 'cele2e-wait');
    mkdirSync(dir, { recursive: true });
    logPath = join(dir, `${runId}.log`);
  }

  return { runId, pollMs, cele2eArgs, logPath, timeoutMs, busPath: resolveBusPath() };
}

function resolveBusPath(): string | null {
  if (process.env.EVENT_BUS_DB) return process.env.EVENT_BUS_DB;
  let dataDir: string;
  if (process.env.CELILO_DATA_DIR) {
    dataDir = process.env.CELILO_DATA_DIR;
  } else if (platform() === 'darwin') {
    dataDir = join(homedir(), 'Library', 'Application Support', 'celilo');
  } else {
    dataDir = '/var/lib/celilo';
  }
  if (!existsSync(dataDir)) return null;
  return join(dataDir, 'events.db');
}

function done(payload: object, code: number): never {
  process.stdout.write(`${JSON.stringify(payload)}\n`);
  process.exit(code);
}

/**
 * Look up a process's parent PID. Returns null if the process is gone
 * or we can't read its metadata. Used by the orphan-watchdog to walk
 * the ancestor chain at startup.
 */
function getParentPid(pid: number): number | null {
  if (platform() === 'linux') {
    try {
      const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
      // Format: "PID (comm) state PPID ..."
      // `comm` may contain spaces and parens, so parse from the last `)`
      // before splitting on space.
      const lastParen = stat.lastIndexOf(')');
      if (lastParen < 0) return null;
      const fields = stat.slice(lastParen + 2).split(/\s+/);
      const ppid = Number(fields[1]);
      return Number.isFinite(ppid) ? ppid : null;
    } catch {
      return null;
    }
  }
  // macOS / BSD — spawn `ps` once per ancestor at startup. Cheap as a
  // one-shot; we don't call this on the watchdog hot path (we capture
  // the chain once and only ping kill(pid, 0) afterwards).
  try {
    const out = spawnSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
      encoding: 'utf-8',
    });
    if (out.status !== 0) return null;
    const ppid = Number(out.stdout.trim());
    return Number.isFinite(ppid) && ppid > 0 ? ppid : null;
  } catch {
    return null;
  }
}

/**
 * Capture the chain of ancestors at startup, from immediate parent
 * up to (but not including) PID 1. Returns an empty array if we're
 * already at the top of the process tree.
 *
 * Call once at startup; the watchdog re-checks `kill(pid, 0)` for each
 * captured PID periodically — far cheaper than walking the tree every
 * tick.
 */
function captureAncestorChain(): number[] {
  const chain: number[] = [];
  let pid: number | null = process.ppid;
  for (let depth = 0; depth < 32 && pid && pid !== 1; depth++) {
    chain.push(pid);
    pid = getParentPid(pid);
  }
  return chain;
}

/**
 * Check whether every PID in `chain` still exists. We don't care about
 * permissions — `kill(pid, 0)` is a cheap "does this process exist"
 * syscall. If any ancestor has died we treat ourselves as orphaned and
 * the watchdog fires.
 */
function ancestorsAlive(chain: number[]): boolean {
  for (const pid of chain) {
    try {
      process.kill(pid, 0);
    } catch {
      // ESRCH (no such process) or similar — ancestor is gone.
      return false;
    }
  }
  return true;
}

/**
 * Start a 5-second poll watchdog that exits the wait if any ancestor
 * process dies. Prevents the well-known failure mode where Claude
 * Code's harness dies mid-wait, this process gets reparented to init,
 * its stdout buffer accumulates polling output, and a future Claude
 * harness OOMs reading from the stale pipe on resume.
 *
 * Returns a teardown fn the normal-exit path can call so we don't keep
 * the interval alive past `done()`.
 */
function startOrphanWatchdog(
  args: ParsedArgs,
  child: ReturnType<typeof spawn>,
): () => void {
  const chain = captureAncestorChain();
  if (chain.length === 0) {
    // Already orphaned at startup, or running directly under init —
    // either way, the watchdog has nothing useful to check.
    return () => {};
  }
  const interval = setInterval(() => {
    if (!ancestorsAlive(chain)) {
      // Ancestor died → kill cele2e child, emit structured result,
      // exit. Code 130 by convention (SIGINT-style cancellation).
      try {
        child.kill('SIGTERM');
      } catch {
        /* best-effort */
      }
      done(
        {
          runId: args.runId,
          status: 'orphaned',
          reason: 'parent-died',
          error:
            'cele2e-wait parent process tree died (likely Claude harness OOM); exited to avoid orphan accumulation',
          logPath: args.logPath,
        },
        130,
      );
    }
  }, 5000);
  // Don't keep the process alive just because of this interval.
  interval.unref();
  return () => clearInterval(interval);
}

const args = parseArgs(process.argv.slice(2));

if (!args.busPath) {
  done(
    {
      runId: args.runId,
      status: 'unconfigured',
      error:
        'EVENT_BUS_DB not set and no celilo data dir present. Configure the bus before using cele2e-wait.',
    },
    3,
  );
}

// Spawn cele2e with stdout/stderr redirected to a log file. The whole
// point of this wrapper is to keep that output OUT of the parent's
// pipe — readers of this tool's stdout get the structured result only.
const fd = openSync(args.logPath, 'a');
const child = spawn('cele2e', ['run', ...args.cele2eArgs], {
  env: { ...process.env, CELE2E_RUN_ID: args.runId },
  stdio: ['ignore', fd, fd],
  detached: false,
});

// Orphan watchdog: if the parent process tree dies (typically: the
// Claude harness OOMs and gets killed), exit cleanly instead of
// continuing to poll forever. The polling output otherwise accumulates
// in a stale pipe and OOMs the next Claude session that resumes.
const stopWatchdog = startOrphanWatchdog(args, child);

let childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
child.once('exit', (code, signal) => {
  childExit = { code, signal };
});

const start = Date.now();

const result: WaitResult = await waitForRunCompletion({
  runId: args.runId,
  busPath: args.busPath as string,
  pollIntervalMs: args.pollMs,
  timeoutMs: args.timeoutMs,
  onTimeout: () => {
    // Race: the wrapper's poll loop hit timeoutMs. If cele2e is still
    // running, kill it before we exit so we don't leak a long-lived
    // subprocess.
    if (!childExit) {
      try {
        child.kill('SIGTERM');
      } catch {
        /* best-effort */
      }
    }
    return {
      status: 'failed',
      runId: args.runId,
      error: `cele2e-wait timed out after ${args.timeoutMs}ms`,
      durationMs: Date.now() - start,
    };
  },
});

stopWatchdog();

if (result.status === 'completed') {
  done({ ...result, logPath: args.logPath }, result.failed > 0 ? 1 : 0);
}

if (result.status === 'failed') {
  done({ ...result, logPath: args.logPath }, 1);
}

if (result.status === 'timeout') {
  // No event landed AND no onTimeout callback fired — only happens if
  // the caller passes a custom onTimeout that returned 'timeout'. Treat
  // as a failure.
  done({ ...result, logPath: args.logPath }, 1);
}

// If cele2e exited but we never got a lifecycle event, the wait would
// just keep polling until timeoutMs. Detect the orphan case earlier:
// if the child exited and after a 2s grace period there's still no
// event, report orphaned. (waitForRunCompletion handles the polling;
// this is the post-poll fallthrough.)
if (childExit && !((result as WaitResult).status as string)) {
  done(
    {
      runId: args.runId,
      status: 'orphaned',
      error: `cele2e exited (code ${childExit.code}, signal ${childExit.signal}) without emitting a run-completion event`,
      durationMs: Date.now() - start,
      logPath: args.logPath,
    },
    2,
  );
}
