/**
 * CLI Runtime Service
 *
 * Orchestrates per-project CLI sessions: workspace setup, process spawn,
 * stdout/stderr piping, watchdog recovery, and session persistence. The
 * CLI-specific bits (binary discovery, native config files, JSONL parsing,
 * model lookup) live in `./cli-adapters/` — this module wires them
 * together via the adapter registry.
 *
 * Public API is preserved 1:1 with the previous implementation:
 *   detectCli, checkCliAuth, listCliModels,
 *   spawnSession, resumeSession, stopSession,
 *   getSession, listSessions
 *
 * The runtime is now extensible — register a new adapter in
 * `cli-adapters/index.ts` and it shows up here automatically.
 */

import { type ChildProcess, execSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { prisma } from "@bidwright/db";

// Side-effect import: registers all known adapters into the registry.
import "./cli-adapters/index.js";
import {
  getAdapter,
  isRegisteredRuntime,
  listAdapters,
  tryGetAdapter,
} from "./cli-adapters/registry.js";
import { ensureUserAgentHome, getUserAgentHome } from "./agent-home.js";
import { getAgentRuntimeHost } from "./agent-host/index.js";
import { BIDWRIGHT_PERMISSIONS } from "./cli-adapters/shared.js";
import {
  getWorkspaceStorage,
  restoreWorkspaceIfPresent,
  snapshotWorkspaceSafe,
  workspaceStorageKey,
} from "./workspace-storage.js";
import type {
  AgentReasoningEffort,
  ApiKeys,
  CliAuthStatus,
  CliDetectResult,
  CliModelOption,
  McpEnv,
  ParserState,
  RegisteredCliAdapter,
  SpawnPlan,
} from "./cli-adapters/types.js";

// ── Public types ──────────────────────────────────────────────────

/**
 * Identifier of any registered CLI runtime. Type-checked as a plain
 * string so new adapters don't require a TS union update on the call
 * sites; use `isRegisteredRuntime` to validate at runtime.
 */
export type AgentRuntime = string;

export type { CliModelOption, SSEEventData } from "./cli-adapters/types.js";
export { listAdapters, tryGetAdapter, getAdapter, isRegisteredRuntime };

export interface CliSession {
  projectId: string;
  runtime: AgentRuntime;
  process: ChildProcess;
  /** CLI-supplied id used by `--resume` (e.g. Claude session_id, Codex thread_id). */
  sessionId: string;
  status: "running" | "completed" | "stopped" | "failed";
  events: EventEmitter;
  startedAt: string;
  pid: number;
  /** Stashed spawn options for watchdog recovery. */
  _spawnOpts?: Record<string, unknown>;
  /** How many times the watchdog has restarted this session. */
  _recoveryCount?: number;
  /** Suppress the next child-exit assistant message when the process is intentionally interrupted/resumed. */
  _suppressNextExitMessage?: boolean;
}

// ── Module state ──────────────────────────────────────────────────

const sessions = new Map<string, CliSession>();
const interruptingProjects = new Set<string>();
const lastBackgroundInterruptAtByProject = new Map<string, number>();
const BACKGROUND_INTERRUPT_COOLDOWN_MS = 2 * 60_000;
/**
 * Tool-call timing state shared across sessions (preserves prior behavior
 * where the original `toolStartTimes` was a module-level Map).
 */
const parserState: ParserState = { toolStartTimes: new Map() };

// ── Helpers ───────────────────────────────────────────────────────

function cliRunId(prefix = "cli") {
  return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`;
}

function sanitizeRuntimeEventForPersistence(value: unknown): unknown {
  if (typeof value === "string") {
    const redacted = value.replace(/[A-Za-z0-9+/=]{2000,}/g, "[large encoded payload omitted]");
    return redacted.length > 120_000 ? `${redacted.slice(0, 120_000)}\n[truncated]` : redacted;
  }
  if (Array.isArray(value)) return value.map((item) => sanitizeRuntimeEventForPersistence(item));
  if (value && typeof value === "object") {
    const source = value as Record<string, unknown>;
    const next: Record<string, unknown> = {};
    for (const [key, entry] of Object.entries(source)) {
      if (key === "data" && source.type === "base64" && typeof entry === "string") {
        next[key] = "[large encoded payload omitted]";
      } else {
        next[key] = sanitizeRuntimeEventForPersistence(entry);
      }
    }
    return next;
  }
  return value;
}

function attachRuntimeRunPersistence(runId: string, session: Pick<CliSession, "events">) {
  let eventBuffer: any[] = [];
  let saveTimer: ReturnType<typeof setTimeout> | null = null;

  const flushEvents = async () => {
    if (eventBuffer.length === 0) return;
    const toSave = [...eventBuffer];
    eventBuffer = [];
    try {
      const run = await prisma.aiRun.findFirst({ where: { id: runId } });
      const output = run?.output && typeof run.output === "object" && !Array.isArray(run.output)
        ? run.output as Record<string, any>
        : {};
      const existing = Array.isArray(output.events) ? output.events : [];
      await prisma.aiRun.update({
        where: { id: runId },
        data: {
          output: {
            ...output,
            events: [...existing, ...toSave],
          } as any,
        },
      });
    } catch {
      eventBuffer.unshift(...toSave);
    }
  };

  session.events.on("event", (evt: any) => {
    eventBuffer.push(sanitizeRuntimeEventForPersistence({ ...evt, timestamp: evt?.timestamp || new Date().toISOString() }));
    if (!saveTimer) {
      saveTimer = setTimeout(async () => {
        saveTimer = null;
        await flushEvents();
      }, 1000);
    }
  });

  session.events.on("done", async (finalStatus: string) => {
    if (saveTimer) {
      clearTimeout(saveTimer);
      saveTimer = null;
    }
    await flushEvents();
    await prisma.aiRun.update({ where: { id: runId }, data: { status: finalStatus } }).catch(() => null);
  });
}

function normalizeAgentReasoningEffort(value: unknown): AgentReasoningEffort {
  if (
    value === "auto" ||
    value === "low" ||
    value === "medium" ||
    value === "high" ||
    value === "extra_high" ||
    value === "max"
  ) {
    return value;
  }
  return "extra_high";
}

/** Cross-platform process kill — on Windows, child.kill() doesn't kill the tree. */
function killProcess(child: ChildProcess, signal: "SIGINT" | "SIGKILL" = "SIGINT"): void {
  if (!child.pid) return;
  if (process.platform === "win32") {
    try {
      execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: "pipe" });
    } catch (err: any) {
      console.error(
        `[cli:kill] taskkill failed for PID ${child.pid}:`,
        err.stderr?.toString().trim() || err.message,
      );
      try {
        child.kill();
      } catch {
        // ignore
      }
    }
  } else {
    child.kill(signal);
  }
}

async function persistSessionState(
  session: CliSession,
  extra: Record<string, unknown> = {},
): Promise<void> {
  const projectDir =
    typeof session._spawnOpts?.projectDir === "string"
      ? (session._spawnOpts.projectDir as string)
      : null;
  if (!projectDir) return;

  const sessionJsonDir = join(projectDir, ".bidwright");
  await mkdir(sessionJsonDir, { recursive: true });
  await writeFile(
    join(sessionJsonDir, "session.json"),
    JSON.stringify({
      pid: session.process.pid,
      runtime: session.runtime,
      sessionId: session.sessionId,
      startedAt: session.startedAt,
      status: session.status,
      ...extra,
    }),
  );
}

function getMcpServerPath(): string {
  // Resolve from this file's location (apps/api/src/services/ -> 4 levels up to repo root)
  const thisUrl = new URL(".", import.meta.url);
  const thisDir = process.platform === "win32" ? fileURLToPath(thisUrl) : thisUrl.pathname;
  const repoRoot = join(thisDir, "../../../..");

  const paths = [
    join(repoRoot, "packages/mcp-server/src/index.ts"),
    join(process.cwd(), "packages/mcp-server/src/index.ts"),
    join(repoRoot, "packages/mcp-server/dist/index.js"),
    join(process.cwd(), "packages/mcp-server/dist/index.js"),
  ];
  for (const p of paths) {
    if (existsSync(p)) return p;
  }
  throw new Error(`MCP server not found. Checked: ${paths.join(", ")}`);
}

interface McpRunnerInfo {
  mcpRunner: string;
  mcpArgs: string[];
  isWin: boolean;
}

function resolveMcpRunner(): McpRunnerInfo {
  const isWin = process.platform === "win32";
  const npxCmd = isWin ? "npx.cmd" : "npx";
  const nodeCmd = isWin ? "node.exe" : "node";
  const mcpServerPath = getMcpServerPath();
  const mcpRunner =
    existsSync(mcpServerPath) && mcpServerPath.endsWith(".ts") ? npxCmd : nodeCmd;
  const mcpArgs = mcpServerPath.endsWith(".ts") ? ["tsx", mcpServerPath] : [mcpServerPath];
  return { mcpRunner, mcpArgs, isWin };
}

function buildMcpEnv(opts: {
  apiBaseUrl?: string;
  authToken?: string;
  projectId: string;
  revisionId?: string;
  quoteId?: string;
}): McpEnv {
  return {
    BIDWRIGHT_API_URL: opts.apiBaseUrl || "http://localhost:4001",
    BIDWRIGHT_AUTH_TOKEN: opts.authToken || "",
    BIDWRIGHT_PROJECT_ID: opts.projectId,
    BIDWRIGHT_REVISION_ID: opts.revisionId || "",
    BIDWRIGHT_QUOTE_ID: opts.quoteId || "",
  };
}

function buildApiKeys(opts: {
  anthropicApiKey?: string;
  openaiApiKey?: string;
  googleApiKey?: string;
  openrouterApiKey?: string;
}): ApiKeys {
  return {
    anthropic: opts.anthropicApiKey,
    openai: opts.openaiApiKey,
    google: opts.googleApiKey,
    openrouter: opts.openrouterApiKey,
  };
}

async function writeMcpConfigFile(
  projectDir: string,
  mcpRunner: string,
  mcpArgs: string[],
  mcpEnv: McpEnv,
): Promise<string> {
  const mcpConfigPath = join(projectDir, ".bidwright-mcp-config.json");
  const mcpConfigObj = {
    mcpServers: {
      bidwright: {
        command: mcpRunner,
        args: mcpArgs,
        env: mcpEnv,
      },
    },
  };
  await writeFile(mcpConfigPath, JSON.stringify(mcpConfigObj, null, 2), "utf-8");
  return mcpConfigPath;
}

/**
 * Map a runtime + single-key API param into the unified ApiKeys shape.
 * Keeps the legacy `checkCliAuth(runtime, apiKey)` signature working for
 * call sites that pass the runtime-specific key inline.
 */
function legacyApiKeysFor(runtime: AgentRuntime, apiKey?: string): ApiKeys {
  if (!apiKey) return {};
  if (runtime === "claude-code" || runtime === "opencode") return { anthropic: apiKey };
  if (runtime === "codex") return { openai: apiKey };
  if (runtime === "gemini") return { google: apiKey };
  return { anthropic: apiKey };
}

// ── Public detection / models / auth ──────────────────────────────

export function detectCli(
  runtime: AgentRuntime,
  customCliPath?: string,
): CliDetectResult {
  const adapter = tryGetAdapter(runtime);
  if (!adapter) return { available: false, path: "" };
  return adapter.detect(customCliPath);
}

/**
 * Check whether `runtime` is authenticated for a given user.
 *
 * In server mode pass the userId so detection is scoped to that user's
 * per-user agent-home dir; the host's `~/.claude` is never consulted as a
 * fallback (preventing one user's auth from showing up in another user's
 * status pill). In desktop mode `userId` may be omitted and the operator's
 * own `~/.claude` is the source of truth.
 */
export function checkCliAuth(
  runtime: AgentRuntime,
  apiKey?: string,
  userId?: string | null,
): CliAuthStatus {
  const adapter = tryGetAdapter(runtime);
  if (!adapter) return { authenticated: false, method: "none" };
  return adapter.checkAuth({
    apiKeys: legacyApiKeysFor(runtime, apiKey),
    agentHomeDir: getUserAgentHome(userId ?? null),
  });
}

export async function listCliModels(
  runtime: AgentRuntime,
  customCliPath?: string,
): Promise<CliModelOption[]> {
  const adapter = tryGetAdapter(runtime);
  if (!adapter) return [];
  return adapter.listModels({ customPath: customCliPath, apiKeys: {} });
}

// ── Spawn / Resume / Stop ─────────────────────────────────────────

export interface SpawnSessionOpts {
  projectId: string;
  projectDir: string;
  prompt: string;
  runtime: AgentRuntime;
  model?: string;
  authToken?: string;
  apiBaseUrl?: string;
  revisionId?: string;
  quoteId?: string;
  customCliPath?: string;
  anthropicApiKey?: string;
  openaiApiKey?: string;
  googleApiKey?: string;
  openrouterApiKey?: string;
  reasoningEffort?: string;
  completionMessage?: string;
  stoppedMessage?: string;
  failedMessagePrefix?: string;
  emitCompletionMessage?: boolean;
  /**
   * The Bidwright user this session belongs to. In server mode the runtime
   * resolves this to a per-user agent-home dir (CLAUDE_CONFIG_DIR / CODEX_HOME
   * / XDG_DATA_HOME / HOME, depending on adapter) so each user's CLI auth
   * state is isolated from every other user on the host.
   */
  userId?: string | null;
  /**
   * The user's organization id, used to namespace project workspace
   * snapshots (so two orgs with colliding cuids stay separated). When
   * unset, snapshots fall under a generic "_" org bucket.
   */
  organizationId?: string | null;
}

export interface ResumeSessionOpts extends Partial<SpawnSessionOpts> {
  projectId: string;
  projectDir: string;
  prompt?: string;
  runtime?: AgentRuntime;
}

/**
 * Fork a CLI process via the active {@link AgentRuntimeHost}.
 *
 * The actual spawn lives in `agent-host/local-process.ts` (lifted byte-for-
 * byte from this file's old inline `spawnChild`). Going through the host
 * factory means future deployment modes — bubblewrap-isolated multitenant
 * Docker, cloud sandboxes — can swap in stronger isolation without
 * touching this orchestration layer.
 */
async function spawnChild(
  plan: SpawnPlan,
  projectDir: string,
  cliEnv: Record<string, string>,
  isWin: boolean,
  batSuffix: "run" | "resume",
  userId?: string | null,
): Promise<ChildProcess> {
  return getAgentRuntimeHost().spawnProcess({
    plan,
    projectDir,
    cliEnv,
    isWin,
    batSuffix,
    userId,
  });
}

/**
 * Wire stdout/stderr/exit handlers. Stdout is parsed line-by-line through
 * the adapter; stderr respects per-adapter benign filters and multi-line
 * suppression spans.
 */
function wireChildProcess(
  child: ChildProcess,
  session: CliSession,
  adapter: RegisteredCliAdapter,
  events: EventEmitter,
  opts?: {
    onActivity?: () => void;
    completionMessage?: string;
    stoppedMessage?: string;
    failedMessagePrefix?: string;
    emitCompletionMessage?: boolean;
  },
): void {
  let stderrSuppressing = false;

  if (child.stdout) {
    const rl = createInterface({ input: child.stdout });
    rl.on("line", (line) => {
      opts?.onActivity?.();
      if (!line.trim()) return;
      try {
        const parsed = JSON.parse(line);
        const sseEvents = adapter.parseEvent(parsed, parserState);
        for (const evt of sseEvents) {
          events.emit("event", evt);
        }
        const sessionId = adapter.extractSessionId(parsed);
        if (sessionId) {
          session.sessionId = sessionId;
          persistSessionState(session).catch(() => {});
        }
      } catch {
        if (line.trim()) {
          events.emit("event", {
            type: "message",
            data: { role: "system", content: line.trim() },
          });
        }
      }
    });
  }

  if (child.stderr) {
    const rl = createInterface({ input: child.stderr });
    rl.on("line", (line) => {
      opts?.onActivity?.();
      const trimmed = line.trim();
      if (!trimmed) return;

      if (adapter.shouldSuppressStderrLine) {
        const result = adapter.shouldSuppressStderrLine(line, {
          suppressing: stderrSuppressing,
        });
        stderrSuppressing = result.nextSuppressing;
        if (result.suppress) return;
      } else if (adapter.isBenignStderr(trimmed)) {
        return;
      }

      console.error(`[cli:stderr:${session.projectId}]`, trimmed);
      events.emit("event", { type: "error", data: { message: trimmed } });
    });
  }

  child.on("exit", (code, signal) => {
    console.log(`[cli:exit:${session.projectId}] code=${code} signal=${signal}`);
    session.status =
      signal === "SIGINT" ? "stopped" : code === 0 ? "completed" : "failed";
    persistSessionState(session).catch(() => {});

    const completionMsg =
      session.status === "completed"
        ? opts?.completionMessage ?? "Intake complete. Review the estimate worksheets and adjust pricing as needed."
        : session.status === "stopped"
          ? opts?.stoppedMessage ?? "Intake stopped."
          : `${opts?.failedMessagePrefix ?? "Intake failed"} (exit code ${code}).`;
    if (opts?.emitCompletionMessage !== false && !session._suppressNextExitMessage) {
      events.emit("event", {
        type: "message",
        data: { role: "assistant", content: completionMsg },
      });
    }
    events.emit("event", {
      type: "status",
      data: { status: session.status, exitCode: code, signal },
    });
    events.emit("done", session.status);
    setTimeout(
      () => {
        sessions.delete(session.projectId);
      },
      5 * 60 * 1000,
    );
  });

  child.on("error", (err) => {
    console.error(`[cli:error:${session.projectId}]`, err.message);
    session.status = "failed";
    events.emit("event", { type: "error", data: { message: err.message } });
    events.emit("done", "failed");
    setTimeout(
      () => {
        sessions.delete(session.projectId);
      },
      5 * 60 * 1000,
    );
  });
}

/**
 * Spawn a fresh CLI session for a project.
 */
export async function spawnSession(opts: SpawnSessionOpts): Promise<CliSession> {
  const adapter = getAdapter(opts.runtime);
  const reasoningEffort = normalizeAgentReasoningEffort(opts.reasoningEffort);

  const existing = sessions.get(opts.projectId);
  if (existing && existing.status === "running") {
    throw new Error(`Session already running for project ${opts.projectId}`);
  }

  const { mcpRunner, mcpArgs, isWin } = resolveMcpRunner();
  const mcpEnv = buildMcpEnv(opts);
  const apiKeys = buildApiKeys(opts);
  const agentHomeDir = await ensureUserAgentHome(opts.userId);

  // If we're in stateless multi-host mode (R2 / MinIO / S3 backed), pull
  // this project's workspace snapshot down before the adapter scaffolds
  // its native config files. The restore is idempotent and a no-op when
  // there's no snapshot yet (first run for this project) or no storage
  // backend configured (single-host self-host / desktop). Errors here
  // bubble up because spawning against a half-restored workspace is
  // worse than a clear "couldn't restore your project" message.
  const workspaceStorage = getWorkspaceStorage();
  if (workspaceStorage.ready()) {
    const wsKey = workspaceStorageKey({
      organizationId: opts.organizationId,
      projectId: opts.projectId,
    });
    try {
      const restored = await restoreWorkspaceIfPresent({
        storage: workspaceStorage,
        key: wsKey,
        targetDir: opts.projectDir,
      });
      if (restored) {
        console.log(
          `[cli:spawn] restored workspace from snapshot key=${wsKey} project=${opts.projectId}`,
        );
      }
    } catch (err) {
      console.error(
        `[cli:spawn] workspace restore failed for project=${opts.projectId} (key=${wsKey}); proceeding with whatever's on local disk:`,
        err instanceof Error ? err.message : err,
      );
      // Don't throw — a stale local copy is still more useful than no
      // session at all. The next snapshot on session-exit will overwrite.
    }
  }

  // Always materialize the bidwright MCP config file. Adapters that need it
  // (Claude --mcp-config) reference it; others ignore it.
  const mcpConfigPath = await writeMcpConfigFile(
    opts.projectDir,
    mcpRunner,
    mcpArgs,
    mcpEnv,
  );

  const prepResult = await adapter.prepareWorkspace({
    projectDir: opts.projectDir,
    mcpRunner,
    mcpArgs,
    mcpEnv,
    permissions: [...BIDWRIGHT_PERMISSIONS],
    isWin,
    isResume: false,
    agentHomeDir,
  });

  const plan = await adapter.buildSpawnPlan({
    projectDir: opts.projectDir,
    prompt: opts.prompt,
    model: opts.model,
    reasoningEffort,
    customCliPath: opts.customCliPath,
    apiKeys,
    mcpRunner,
    mcpArgs,
    mcpEnv,
    isWin,
    mcpConfigPath,
    agentHomeDir,
  });

  const cliEnv: Record<string, string> = {
    ...mcpEnv,
    ...(prepResult.extraEnv ?? {}),
    ...plan.extraEnv,
  };

  const child = await spawnChild(plan, opts.projectDir, cliEnv, isWin, "run", opts.userId);

  const events = new EventEmitter();
  const session: CliSession = {
    projectId: opts.projectId,
    runtime: opts.runtime,
    process: child,
    sessionId: "",
    status: "running",
    events,
    startedAt: new Date().toISOString(),
    pid: child.pid || 0,
  };
  session._spawnOpts = { ...opts, reasoningEffort };
  session._recoveryCount = 0;
  sessions.set(opts.projectId, session);

  // Snapshot the project workspace to remote storage when the session
  // exits (multi-host hosted SaaS so the user's files follow them across
  // pool members). No-op when WORKSPACE_STORAGE_PROVIDER is unset (every
  // self-host / desktop deploy).
  if (workspaceStorage.ready()) {
    const wsKey = workspaceStorageKey({
      organizationId: opts.organizationId,
      projectId: opts.projectId,
    });
    child.once("exit", () => {
      void snapshotWorkspaceSafe({
        storage: workspaceStorage,
        key: wsKey,
        sourceDir: opts.projectDir,
      });
    });
  }

  // ── Inactivity watchdog ─────────────────────────────────────
  const INACTIVITY_TIMEOUT_MINUTES = 15;
  const INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MINUTES * 60 * 1000;
  const MAX_RECOVERIES = 2;
  let inactivityTimer: ReturnType<typeof setTimeout> | null = null;

  const resetInactivityTimer = () => {
    if (inactivityTimer) clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(async () => {
      if (session.status !== "running") return;
      const recoveries = session._recoveryCount || 0;

      if (recoveries >= MAX_RECOVERIES) {
        console.warn(
          `[cli] Inactivity timeout for project ${opts.projectId} — max recoveries (${MAX_RECOVERIES}) reached, terminating`,
        );
        events.emit("event", {
          type: "error",
          data: {
            message: `Session terminated: no activity for ${INACTIVITY_TIMEOUT_MINUTES} minutes (${MAX_RECOVERIES} recovery attempts exhausted)`,
          },
        });
        killProcess(child, "SIGINT");
        return;
      }

      const savedSessionId = session.sessionId;
      if (!savedSessionId) {
        console.warn(
          `[cli] Inactivity timeout for project ${opts.projectId} — no session ID for recovery, terminating`,
        );
        events.emit("event", {
          type: "error",
          data: {
            message: `Session terminated: no activity for ${INACTIVITY_TIMEOUT_MINUTES} minutes (no session ID for recovery)`,
          },
        });
        killProcess(child, "SIGINT");
        return;
      }

      console.warn(
        `[cli] Inactivity timeout for project ${opts.projectId} — attempting recovery #${recoveries + 1} via --resume`,
      );
      events.emit("event", {
        type: "progress",
        data: {
          phase: "Recovery",
          detail: `No activity for ${INACTIVITY_TIMEOUT_MINUTES} minutes — restarting session (attempt ${recoveries + 1}/${MAX_RECOVERIES})`,
        },
      });

      session.status = "stopped";
      killProcess(child, "SIGKILL");
      await new Promise((r) => setTimeout(r, 2000));

      try {
        const newSession = await resumeSession({
          projectId: opts.projectId,
          projectDir: opts.projectDir,
          runtime: opts.runtime,
          prompt:
            "You were interrupted due to inactivity. Check the current state with getWorkspace, then continue where you left off. Do NOT re-create items that already exist.",
          model: opts.model,
          customCliPath: opts.customCliPath,
          authToken: opts.authToken,
          apiBaseUrl: opts.apiBaseUrl,
          revisionId: opts.revisionId,
          quoteId: opts.quoteId,
          anthropicApiKey: opts.anthropicApiKey,
          openaiApiKey: opts.openaiApiKey,
          googleApiKey: opts.googleApiKey,
          openrouterApiKey: opts.openrouterApiKey,
          reasoningEffort: opts.reasoningEffort,
        });
        newSession._spawnOpts = session._spawnOpts;
        newSession._recoveryCount = recoveries + 1;
        newSession.events.on("event", (evt: any) => events.emit("event", evt));
        newSession.events.on("done", (status: string) => events.emit("done", status));
      } catch (err) {
        console.error(`[cli] Recovery failed for project ${opts.projectId}:`, err);
        events.emit("event", {
          type: "error",
          data: {
            message: `Recovery failed: ${err instanceof Error ? err.message : "unknown error"}`,
          },
        });
        events.emit("done", "failed");
      }
    }, INACTIVITY_TIMEOUT_MS);
  };
  resetInactivityTimer();

  wireChildProcess(child, session, adapter, events, {
    onActivity: resetInactivityTimer,
    completionMessage: opts.completionMessage,
    stoppedMessage: opts.stoppedMessage,
    failedMessagePrefix: opts.failedMessagePrefix,
    emitCompletionMessage: opts.emitCompletionMessage,
  });
  child.on("exit", () => {
    if (inactivityTimer) clearTimeout(inactivityTimer);
  });
  child.on("error", () => {
    if (inactivityTimer) clearTimeout(inactivityTimer);
  });

  await persistSessionState(session);
  return session;
}

/**
 * Resume a session by id. Falls back to reading `.bidwright/session.json`
 * if no in-memory session is known.
 */
export async function resumeSession(opts: ResumeSessionOpts): Promise<CliSession> {
  const session = sessions.get(opts.projectId);
  let sessionId = session?.sessionId;
  let runtime: AgentRuntime | undefined = opts.runtime || session?.runtime;

  if (!sessionId) {
    const sessionJsonPath = join(opts.projectDir, ".bidwright", "session.json");
    if (existsSync(sessionJsonPath)) {
      const saved = JSON.parse(await readFile(sessionJsonPath, "utf-8"));
      if (saved.sessionId) {
        sessionId = saved.sessionId;
        runtime = (saved.runtime as string) || runtime;
      }
    }
  }

  if (!sessionId) {
    throw new Error("No session to resume for this project");
  }

  return spawnResumedSession(
    {
      ...opts,
      runtime: runtime || "claude-code",
      prompt: opts.prompt ?? "",
    },
    sessionId,
  );
}

async function spawnResumedSession(
  opts: SpawnSessionOpts,
  sessionId: string,
): Promise<CliSession> {
  const adapter = getAdapter(opts.runtime);
  const reasoningEffort = normalizeAgentReasoningEffort(opts.reasoningEffort);
  const resumePrompt =
    typeof opts.prompt === "string" && opts.prompt.trim()
      ? opts.prompt.trim()
      : adapter.defaultResumePrompt();

  const { mcpRunner, mcpArgs, isWin } = resolveMcpRunner();
  const mcpEnv = buildMcpEnv(opts);
  const apiKeys = buildApiKeys(opts);
  const agentHomeDir = await ensureUserAgentHome(opts.userId);

  // Resume flow: same workspace-restore semantics as spawnSession. If the
  // user landed on a host that doesn't have the local copy (stateless
  // pool) we pull the snapshot down before the adapter scaffolds.
  const resumeWorkspaceStorage = getWorkspaceStorage();
  if (resumeWorkspaceStorage.ready()) {
    const wsKey = workspaceStorageKey({
      organizationId: opts.organizationId,
      projectId: opts.projectId,
    });
    try {
      const restored = await restoreWorkspaceIfPresent({
        storage: resumeWorkspaceStorage,
        key: wsKey,
        targetDir: opts.projectDir,
      });
      if (restored) {
        console.log(
          `[cli:resume] restored workspace from snapshot key=${wsKey} project=${opts.projectId}`,
        );
      }
    } catch (err) {
      console.error(
        `[cli:resume] workspace restore failed for project=${opts.projectId} (key=${wsKey}); proceeding with whatever's on local disk:`,
        err instanceof Error ? err.message : err,
      );
    }
  }

  const mcpConfigPath = await writeMcpConfigFile(
    opts.projectDir,
    mcpRunner,
    mcpArgs,
    mcpEnv,
  );

  const prepResult = await adapter.prepareWorkspace({
    projectDir: opts.projectDir,
    mcpRunner,
    mcpArgs,
    mcpEnv,
    permissions: [...BIDWRIGHT_PERMISSIONS],
    isWin,
    isResume: true,
    agentHomeDir,
  });

  const plan = await adapter.buildResumePlan({
    projectDir: opts.projectDir,
    prompt: resumePrompt,
    model: opts.model,
    reasoningEffort,
    customCliPath: opts.customCliPath,
    apiKeys,
    mcpRunner,
    mcpArgs,
    mcpEnv,
    isWin,
    mcpConfigPath,
    sessionId,
    agentHomeDir,
  });

  const cliEnv: Record<string, string> = {
    ...mcpEnv,
    ...(prepResult.extraEnv ?? {}),
    ...plan.extraEnv,
  };

  const child = await spawnChild(plan, opts.projectDir, cliEnv, isWin, "resume", opts.userId);

  const events = new EventEmitter();
  const session: CliSession = {
    projectId: opts.projectId,
    runtime: opts.runtime,
    process: child,
    sessionId,
    status: "running",
    events,
    startedAt: new Date().toISOString(),
    pid: child.pid || 0,
  };
  session._spawnOpts = {
    ...opts,
    projectDir: opts.projectDir,
    runtime: opts.runtime,
    customCliPath: opts.customCliPath,
    anthropicApiKey: opts.anthropicApiKey,
    openaiApiKey: opts.openaiApiKey,
    reasoningEffort,
  };

  sessions.set(opts.projectId, session);
  wireChildProcess(child, session, adapter, events);

  // Mirror the spawnSession flow: snapshot the workspace on exit so a
  // resumed session also persists its final state to remote storage.
  // No-op when WORKSPACE_STORAGE_PROVIDER is unset.
  const resumeStorage = getWorkspaceStorage();
  if (resumeStorage.ready()) {
    const wsKey = workspaceStorageKey({
      organizationId: opts.organizationId,
      projectId: opts.projectId,
    });
    child.once("exit", () => {
      void snapshotWorkspaceSafe({
        storage: resumeStorage,
        key: wsKey,
        sourceDir: opts.projectDir,
      });
    });
  }

  await persistSessionState(session, { resumed: true });
  return session;
}

/** Stop a running session. */
export function stopSession(projectId: string, options?: { suppressCompletionMessage?: boolean }): boolean {
  const session = sessions.get(projectId);
  if (!session || session.status !== "running") return false;

  console.log(`[cli:stop:${projectId}] Killing process pid=${session.process.pid}`);
  if (options?.suppressCompletionMessage) session._suppressNextExitMessage = true;
  killProcess(session.process, "SIGINT");

  // Force-kill if it doesn't exit within 3s.
  setTimeout(() => {
    if (session.status === "running") {
      console.log(`[cli:stop:${projectId}] Force killing after timeout`);
      killProcess(session.process, "SIGKILL");
      session.status = "stopped";
      session.events.emit("event", {
        type: "status",
        data: { status: "stopped", exitCode: null, signal: "SIGINT" },
      });
      session.events.emit("done", "stopped");
    }
  }, 3000);

  return true;
}

export function getSession(projectId: string): CliSession | undefined {
  return sessions.get(projectId);
}

export function emitSessionEvent(projectId: string, event: { type: string; data: unknown; timestamp?: string }): boolean {
  const session = sessions.get(projectId);
  if (!session) return false;
  session.events.emit("event", {
    ...event,
    timestamp: event.timestamp ?? new Date().toISOString(),
  });
  return true;
}

export async function interruptAndResumeSession(projectId: string, prompt: string, reason = "Background evidence update"): Promise<{ interrupted: boolean; resumed: boolean; runId?: string; reason?: string }> {
  let session = sessions.get(projectId);
  if (!session || session.status !== "running") return { interrupted: false, resumed: false, reason: "no_running_session" };
  if (!session.sessionId) return { interrupted: false, resumed: false, reason: "missing_cli_session_id" };
  if (interruptingProjects.has(projectId)) return { interrupted: false, resumed: false, reason: "interrupt_already_in_progress" };
  const lastInterruptAt = lastBackgroundInterruptAtByProject.get(projectId) ?? 0;
  if (Date.now() - lastInterruptAt < BACKGROUND_INTERRUPT_COOLDOWN_MS) {
    return { interrupted: false, resumed: false, reason: "interrupt_recently_sent" };
  }
  let spawnOpts = session._spawnOpts as Partial<SpawnSessionOpts> | undefined;
  if (!spawnOpts?.projectDir) return { interrupted: false, resumed: false, reason: "missing_spawn_options" };

  interruptingProjects.add(projectId);
  const runId = cliRunId("cli-background");
  try {
    session = sessions.get(projectId);
    if (!session || session.status !== "running") return { interrupted: false, resumed: false, reason: "session_finished_before_notification" };
    if (!session.sessionId) return { interrupted: false, resumed: false, reason: "missing_cli_session_id" };
    spawnOpts = session._spawnOpts as Partial<SpawnSessionOpts> | undefined;
    if (!spawnOpts?.projectDir) return { interrupted: false, resumed: false, reason: "missing_spawn_options" };
    lastBackgroundInterruptAtByProject.set(projectId, Date.now());

    session.events.emit("event", {
      type: "progress",
      data: {
        phase: "Interrupt",
        detail: reason,
        source: "background-interrupt",
      },
      timestamp: new Date().toISOString(),
    });
    session.events.emit("event", {
      type: "message",
      data: {
        role: "system",
        content: `${reason}. This update has been queued without interrupting the active agent turn; use the refreshed Drawing Evidence Engine on the next evidence pass.`,
        source: "background-evidence-ready",
      },
      timestamp: new Date().toISOString(),
    });

    await prisma.aiRun.create({
      data: {
        id: runId,
        projectId,
        revisionId: String(spawnOpts.revisionId ?? ""),
        kind: "cli-intake",
        status: "completed",
        model: String(spawnOpts.model ?? ""),
        input: {
          runtime: spawnOpts.runtime ?? session.runtime,
          prompt,
          resumed: false,
          backgroundInterrupt: false,
          nonBlockingBackgroundNotification: true,
          reason,
          cliSessionId: session.sessionId,
        } as any,
        output: { events: [{
          type: "message",
          data: { role: "system", content: reason, source: "background-evidence-ready" },
          timestamp: new Date().toISOString(),
        }] } as any,
      },
    }).catch(() => null);

    return { interrupted: false, resumed: false, runId, reason: "background_update_recorded_without_interrupt" };
  } catch (error) {
    await prisma.aiRun.update({
      where: { id: runId },
      data: { status: "failed" },
    }).catch(() => null);
    return {
      interrupted: false,
      resumed: false,
      runId,
      reason: error instanceof Error ? error.message : String(error),
    };
  } finally {
    interruptingProjects.delete(projectId);
  }
}

export function listSessions(): Array<{
  projectId: string;
  status: string;
  runtime: string;
  startedAt: string;
}> {
  return Array.from(sessions.entries()).map(([pid, s]) => ({
    projectId: pid,
    status: s.status,
    runtime: s.runtime,
    startedAt: s.startedAt,
  }));
}
