import { randomUUID } from "node:crypto";
import {
  mkdirSync,
  existsSync,
  copyFileSync,
  cpSync,
  realpathSync,
} from "node:fs";
import { join, resolve } from "node:path";
import type { Subprocess } from "bun";
import { createLogger } from "./logger.js";
import { config } from "./config.js";
import { getSettings } from "./settings-manager.js";
import type { SessionStore } from "./session-store.js";
import type { SdkSessionInfo, LaunchOptions } from "./session-types.js";
import type { RecorderManager } from "./recorder.js";

export type { SdkSessionInfo, LaunchOptions };
import { CodexAdapter } from "./codex-adapter.js";
import { resolveBinaryAsync, getEnrichedPath } from "./path-resolver.js";
import { containerManager } from "./container-manager.js";
import {
  getLegacyCodexHome,
  resolveZencodeCodexSessionHome,
} from "./codex-home.js";
import {
  binaryNotFoundMessage,
  cliExitedImmediatelyMessage,
  containerBinaryNotFoundMessage,
  containerStateMessage,
} from "./user-error-messages.js";

const log = createLogger("cli-launcher");

/** Build environment variables for advanced launch options that lack CLI flags. */
function buildAdvancedEnvVars(options: LaunchOptions): Record<string, string> {
  const env: Record<string, string> = {};
  if (options.enableFileCheckpointing) {
    env.CLAUDE_CODE_ENABLE_FILE_CHECKPOINTING = "1";
  }
  if (options.appendSystemPrompt) {
    env.CLAUDE_CODE_APPEND_SYSTEM_PROMPT = options.appendSystemPrompt;
  }
  if (options.thinking) {
    if (options.thinking.type === "enabled") {
      env.CLAUDE_CODE_MAX_THINKING_TOKENS = String(options.thinking.budgetTokens);
    } else if (options.thinking.type === "disabled") {
      env.CLAUDE_CODE_THINKING_ENABLED = "false";
    }
    // adaptive is the default — no env var needed
  }
  if (options.effort) {
    env.CLAUDE_CODE_EFFORT = options.effort;
  }
  if (options.maxBudgetUsd != null) {
    env.CLAUDE_CODE_MAX_BUDGET_USD = String(options.maxBudgetUsd);
  }
  if (options.betas && options.betas.length > 0) {
    env.CLAUDE_CODE_BETAS = options.betas.join(",");
  }
  if (options.language) {
    env.CLAUDE_CODE_LANGUAGE = options.language;
  }
  return env;
}

function sanitizeSpawnArgsForLog(args: string[]): string {
  const secretKeyPattern = /(token|key|secret|password)/i;
  const out = [...args];
  for (let i = 0; i < out.length; i++) {
    if (out[i] === "-e" && i + 1 < out.length) {
      const envPair = out[i + 1];
      const eqIdx = envPair.indexOf("=");
      if (eqIdx > 0) {
        const k = envPair.slice(0, eqIdx);
        if (secretKeyPattern.test(k)) {
          out[i + 1] = `${k}=***`;
        }
      }
    }
  }
  return out.join(" ");
}

/**
 * Manages CLI backend processes (Claude Code via --sdk-url WebSocket,
 * or Codex via app-server stdio).
 */
export class CliLauncher {
  private sessions = new Map<string, SdkSessionInfo>();
  private processes = new Map<string, Subprocess>();
  /** Runtime-only env vars per session (kept out of persisted launcher state). */
  private sessionEnvs = new Map<string, Record<string, string>>();
  private port: number;
  private hostname: string;
  private store: SessionStore | null = null;
  private recorder: RecorderManager | null = null;
  private onCodexAdapter: ((sessionId: string, adapter: CodexAdapter) => void) | null = null;
  private exitHandlers: ((sessionId: string, exitCode: number | null) => void)[] = [];

  constructor(port: number, hostname?: string) {
    this.port = port;
    const host = hostname || "localhost";
    // Use "localhost" so the CLI resolves to whichever loopback the server
    // is actually listening on (IPv4 127.0.0.1 OR IPv6 ::1).  Hardcoding
    // "127.0.0.1" breaks on Windows where Bun may bind only to ::1.
    this.hostname = host === "0.0.0.0" ? "localhost" : host;
  }

  /** Register a callback for when a CodexAdapter is created (WsBridge needs to attach it). */
  onCodexAdapterCreated(cb: (sessionId: string, adapter: CodexAdapter) => void): void {
    this.onCodexAdapter = cb;
  }

  /** Register a callback for when a CLI/Codex process exits. */
  onSessionExited(cb: (sessionId: string, exitCode: number | null) => void): void {
    this.exitHandlers.push(cb);
  }

  /** Attach a persistent store for surviving server restarts. */
  setStore(store: SessionStore): void {
    this.store = store;
  }

  /** Attach a recorder for raw message capture. */
  setRecorder(recorder: RecorderManager): void {
    this.recorder = recorder;
  }

  /** Persist launcher state to disk. */
  private persistState(): void {
    if (!this.store) return;
    const data = Array.from(this.sessions.values());
    this.store.saveLauncher(data);
  }

  /**
   * Restore sessions from disk and check which PIDs are still alive.
   * Returns the number of recovered sessions.
   */
  restoreFromDisk(): number {
    if (!this.store) return 0;
    const data = this.store.loadLauncher<SdkSessionInfo[]>();
    if (!data || !Array.isArray(data)) return 0;

    let recovered = 0;
    for (const info of data) {
      if (this.sessions.has(info.sessionId)) continue;

      // Check if the process is still alive
      if (info.pid && info.state !== "exited") {
        try {
          process.kill(info.pid, 0); // signal 0 = just check if alive
          info.state = "starting"; // WS not yet re-established, wait for CLI to reconnect
          this.sessions.set(info.sessionId, info);
          recovered++;
        } catch (err) {
          log.debug({ sessionId: info.sessionId, pid: info.pid, err }, "Process is dead, marking as exited");
          info.state = "exited";
          info.exitCode = -1;
          this.sessions.set(info.sessionId, info);
        }
      } else {
        // Already exited or no PID
        this.sessions.set(info.sessionId, info);
      }
    }
    if (recovered > 0) {
      log.info({ count: recovered }, "Recovered live sessions from disk");
    }
    return recovered;
  }

  /**
   * Launch a new CLI session (Claude Code or Codex).
   */
  async launch(options: LaunchOptions = {}): Promise<SdkSessionInfo> {
    const sessionId = randomUUID();
    const cwd = options.cwd || process.cwd();
    const backendType = options.backendType || "claude";

    const info: SdkSessionInfo = {
      sessionId,
      state: "starting",
      model: options.model,
      permissionMode: options.permissionMode,
      cwd,
      createdAt: Date.now(),
      backendType,
    };

    if (options.resumeSessionAt) {
      info.resumeSessionAt = options.resumeSessionAt;
      info.forkSession = options.forkSession === true;
    }

    if (backendType === "codex") {
      info.codexInternetAccess = options.codexInternetAccess === true;
      info.codexSandbox = options.codexSandbox;
    }

    // Store system prompt options for the initialize control request
    if (options.systemPrompt) {
      info.systemPrompt = options.systemPrompt;
    }
    if (options.appendSystemPrompt) {
      info.appendSystemPrompt = options.appendSystemPrompt;
    }

    // Store container metadata if provided
    if (options.containerId) {
      info.containerId = options.containerId;
      info.containerName = options.containerName;
      info.containerImage = options.containerImage;
      info.containerCwd = options.containerCwd || "/workspace";
    }

    this.sessions.set(sessionId, info);
    if (options.env) {
      this.sessionEnvs.set(sessionId, { ...options.env });
    }

    if (backendType === "codex") {
      await this.spawnCodex(sessionId, info, options);
    } else {
      await this.spawnCLI(sessionId, info, options);
    }

    // If the spawn method marked the session as exited (binary not found, etc.),
    // throw so callers (e.g. session-creator) can surface a clear error to the user.
    if (info.state === "exited") {
      const binaryName = backendType === "codex" ? "codex" : "claude";
      const exitReason = info.exitCode === 127
        ? binaryNotFoundMessage(binaryName)
        : cliExitedImmediatelyMessage(binaryName, info.exitCode ?? null);
      throw new Error(exitReason);
    }

    return info;
  }

  /**
   * Relaunch a CLI process for an existing session.
   * Kills the old process if still alive, then spawns a fresh CLI
   * that connects back to the same session in the WsBridge.
   */
  async relaunch(sessionId: string): Promise<{ ok: boolean; error?: string }> {
    const info = this.sessions.get(sessionId);
    if (!info) return { ok: false, error: "Session not found" };

    // Kill old process if still alive
    const oldProc = this.processes.get(sessionId);
    if (oldProc) {
      try {
        // On Windows, SIGTERM is not supported — use SIGKILL
        oldProc.kill(process.platform === "win32" ? "SIGKILL" : "SIGTERM");
        await Promise.race([
          oldProc.exited,
          new Promise((r) => setTimeout(r, 2000)),
        ]);
      } catch (err) {
        log.debug({ sessionId, err }, "Failed to kill old process during relaunch");
      }
      this.processes.delete(sessionId);
    } else if (info.pid) {
      // Process from a previous server instance — kill by PID
      try {
        process.kill(info.pid, process.platform === "win32" ? "SIGKILL" : "SIGTERM");
      } catch (err) {
        log.debug({ sessionId, pid: info.pid, err }, "Failed to kill previous server instance process by PID");
      }
    }

    // Pre-flight validation for containerized sessions
    if (info.containerId) {
      const containerLabel = info.containerName || info.containerId.slice(0, 12);
      const containerState = await containerManager.isContainerAlive(info.containerId);

      if (containerState === "missing") {
        log.error({ containerLabel, sessionId }, "Container no longer exists");
        info.state = "exited";
        info.exitCode = 1;
        this.persistState();
        return {
          ok: false,
          error: containerStateMessage(containerLabel, "missing"),
        };
      }

      if (containerState === "stopped") {
        try {
          await containerManager.startContainer(info.containerId);
          log.info({ containerLabel, sessionId }, "Restarted stopped container");
        } catch (e) {
          info.state = "exited";
          info.exitCode = 1;
          this.persistState();
          return {
            ok: false,
            error: `Container "${containerLabel}" is stopped and could not be restarted: ${e instanceof Error ? e.message : String(e)}`,
          };
        }
      }

      // Validate the CLI binary exists inside the container
      const binary = info.backendType === "codex" ? "codex" : "claude";
      if (!(await containerManager.hasBinaryInContainer(info.containerId, binary))) {
        log.error({ binary, containerLabel, sessionId }, "Binary not found in container");
        info.state = "exited";
        info.exitCode = 127;
        this.persistState();
        return {
          ok: false,
          error: containerBinaryNotFoundMessage(binary, containerLabel),
        };
      }
    }

    info.state = "starting";

    const runtimeEnv = this.sessionEnvs.get(sessionId);

    if (info.backendType === "codex") {
      await this.spawnCodex(sessionId, info, {
        model: info.model,
        permissionMode: info.permissionMode,
        cwd: info.cwd,
        codexSandbox: info.codexSandbox,
        codexInternetAccess: info.codexInternetAccess,
        containerId: info.containerId,
        containerName: info.containerName,
        containerImage: info.containerImage,
        containerCwd: info.containerCwd,
        env: runtimeEnv,
      });
    } else {
      await this.spawnCLI(sessionId, info, {
        model: info.model,
        permissionMode: info.permissionMode,
        cwd: info.cwd,
        resumeSessionId: info.cliSessionId,
        containerId: info.containerId,
        containerName: info.containerName,
        containerImage: info.containerImage,
        env: runtimeEnv,
      });
    }
    return { ok: true };
  }

  /**
   * Get all sessions in "starting" state (awaiting CLI WebSocket connection).
   */
  getStartingSessions(): SdkSessionInfo[] {
    return Array.from(this.sessions.values()).filter((s) => s.state === "starting");
  }

  private async spawnCLI(sessionId: string, info: SdkSessionInfo, options: LaunchOptions & { resumeSessionId?: string }): Promise<void> {
    const isContainerized = !!options.containerId;

    // For containerized sessions, the CLI binary lives inside the container.
    // For host sessions, resolve the binary on the host.
    let binary = options.claudeBinary || "claude";
    if (!isContainerized) {
      const resolved = await resolveBinaryAsync(binary);
      if (resolved) {
        binary = resolved;
      } else {
        log.error({ binary }, "Binary not found in PATH");
        info.state = "exited";
        info.exitCode = 127;
        this.persistState();
        return;
      }
    }

    // Allow overriding the host alias used by containerized Claude sessions.
    // Useful when host.docker.internal is unavailable in a given Docker setup.
    const containerSdkHost = config.containerSdkHost;

    // When running inside a container, the SDK URL should target the host alias
    // so the CLI can connect back to the Hono server running on the host.
    const sdkUrl = isContainerized
      ? `ws://${containerSdkHost}:${this.port}/ws/cli/${sessionId}`
      : `ws://${this.hostname}:${this.port}/ws/cli/${sessionId}`;

    // Claude Code rejects bypassPermissions when running with root/sudo. Most
    // container images run as root by default, so downgrade to acceptEdits unless
    // explicitly forced.
    let effectivePermissionMode = options.permissionMode;
    if (
      isContainerized
      && options.permissionMode === "bypassPermissions"
      && !config.forceBypassInContainer
    ) {
      log.warn(
        { sessionId, from: "bypassPermissions", to: "acceptEdits" },
        "Downgrading container permission mode (set ZENCODE_FORCE_BYPASS_IN_CONTAINER=1 to force bypass)",
      );
      effectivePermissionMode = "acceptEdits";
      info.permissionMode = "acceptEdits";
    }

    const args: string[] = [
      "--sdk-url", sdkUrl,
      "--print",
      "--output-format", "stream-json",
      "--input-format", "stream-json",
      // Required on newer Claude Code versions to emit streaming chunk events.
      "--include-partial-messages",
      "--verbose",
    ];

    if (options.model) {
      args.push("--model", options.model);
    }
    if (effectivePermissionMode) {
      args.push("--permission-mode", effectivePermissionMode);
    }
    if (options.allowedTools) {
      for (const tool of options.allowedTools) {
        args.push("--allowedTools", tool);
      }
    }
    if (options.resumeSessionAt) {
      args.push("--resume-session-at", options.resumeSessionAt);
    }
    if (options.forkSession) {
      args.push("--fork-session");
    }
    if (options.maxTurns != null) {
      args.push("--max-turns", String(options.maxTurns));
    }
    if (options.systemPrompt) {
      args.push("--system-prompt", options.systemPrompt);
    }
    if (options.disallowedTools) {
      for (const tool of options.disallowedTools) {
        args.push("--disallowedTools", tool);
      }
    }
    if (options.additionalDirectories) {
      for (const dir of options.additionalDirectories) {
        args.push("--add-dir", dir);
      }
    }

    // Attribution flag from global settings
    if (getSettings().attribution) {
      args.push("--attribution");
    }

    // Always pass -p "" for headless mode. When relaunching, also pass --resume
    // to restore the CLI's conversation context.
    if (options.resumeSessionId) {
      args.push("--resume", options.resumeSessionId);
    }
    args.push("-p", "");

    let spawnCmd: string[];
    let spawnEnv: Record<string, string | undefined>;
    let spawnCwd: string | undefined;

    if (isContainerized) {
      // Run CLI inside the container via docker exec -i.
      // Keeping stdin open avoids premature EOF-driven exits in SDK mode.
      // Environment variables are passed via -e flags to docker exec.
      const dockerArgs = ["docker", "exec", "-i"];

      // Pass env vars via -e flags
      const advancedEnv = buildAdvancedEnvVars(options);
      if (options.env) {
        for (const [k, v] of Object.entries(options.env)) {
          dockerArgs.push("-e", `${k}=${v}`);
        }
      }
      for (const [k, v] of Object.entries(advancedEnv)) {
        dockerArgs.push("-e", `${k}=${v}`);
      }
      // Ensure CLAUDECODE is unset inside container
      dockerArgs.push("-e", "CLAUDECODE=");

      dockerArgs.push(options.containerId!);
      // Use a login shell so ~/.bashrc is sourced and nvm/bun/deno/etc are on PATH
      const innerCmd = [binary, ...args].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
      dockerArgs.push("bash", "-lc", innerCmd);

      spawnCmd = dockerArgs;
      // Host env for the docker CLI itself
      spawnEnv = { ...process.env, PATH: getEnrichedPath() };
      spawnCwd = undefined; // cwd is set inside the container via -w at creation
    } else {
      // Host-based spawn (original behavior)
      spawnCmd = [binary, ...args];
      spawnEnv = {
        ...process.env,
        CLAUDECODE: undefined,
        ...options.env,
        ...buildAdvancedEnvVars(options),
        PATH: getEnrichedPath(),
      };
      spawnCwd = info.cwd;
    }

    log.info(
      { sessionId, containerized: isContainerized, cmd: sanitizeSpawnArgsForLog(spawnCmd) },
      "Spawning session",
    );

    const proc = Bun.spawn(spawnCmd, {
      cwd: spawnCwd,
      env: spawnEnv,
      stdout: "pipe",
      stderr: "pipe",
    });

    info.pid = proc.pid;
    this.processes.set(sessionId, proc);

    // Stream stdout/stderr for debugging
    this.pipeOutput(sessionId, proc);

    // Monitor process exit
    const spawnedAt = Date.now();
    proc.exited.then((exitCode) => {
      log.info({ sessionId, exitCode }, "Session exited");
      const session = this.sessions.get(sessionId);
      if (session) {
        session.state = "exited";
        session.exitCode = exitCode;

        // If the process exited almost immediately with --resume, the resume likely failed.
        // Clear cliSessionId so the next relaunch starts fresh.
        const uptime = Date.now() - spawnedAt;
        if (uptime < 5000 && options.resumeSessionId) {
          log.error({ sessionId, uptimeMs: uptime }, "Session exited immediately after --resume, clearing cliSessionId for fresh start");
          session.cliSessionId = undefined;
        }
      }
      this.processes.delete(sessionId);
      this.persistState();
      for (const handler of this.exitHandlers) {
        try { handler(sessionId, exitCode); } catch (err) {
          log.warn({ sessionId, err }, "Exit handler threw an error");
        }
      }
    });

    this.persistState();
  }

  /**
   * Spawn a Codex app-server subprocess for a session.
   * Unlike Claude Code (which connects back via WebSocket), Codex uses stdio.
   */
  private prepareCodexHome(codexHome: string): void {
    mkdirSync(codexHome, { recursive: true });

    const legacyHome = getLegacyCodexHome();
    if (resolve(legacyHome) === resolve(codexHome) || !existsSync(legacyHome)) {
      return;
    }

    // Bootstrap only the user-level artifacts Codex needs (auth/config/skills),
    // while intentionally skipping sessions/sqlite to avoid stale rollout indexes.
    const fileSeeds = ["auth.json", "config.toml", "models_cache.json", "version.json"];
    for (const name of fileSeeds) {
      try {
        const src = join(legacyHome, name);
        const dest = join(codexHome, name);
        if (!existsSync(dest) && existsSync(src)) {
          copyFileSync(src, dest);
        }
      } catch (e) {
        log.warn({ fileName: name, err: e instanceof Error ? e.message : String(e) }, "Failed to bootstrap file from legacy home");
      }
    }

    const dirSeeds = ["skills", "vendor_imports", "prompts", "rules"];
    for (const name of dirSeeds) {
      try {
        const src = join(legacyHome, name);
        const dest = join(codexHome, name);
        if (!existsSync(dest) && existsSync(src)) {
          cpSync(src, dest, { recursive: true });
        }
      } catch (e) {
        log.warn({ dirName: name, err: e instanceof Error ? e.message : String(e) }, "Failed to bootstrap dir from legacy home");
      }
    }
  }

  private async spawnCodex(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): Promise<void> {
    const isContainerized = !!options.containerId;

    let binary = options.codexBinary || "codex";
    if (!isContainerized) {
      const resolved = await resolveBinaryAsync(binary);
      if (resolved) {
        binary = resolved;
      } else {
        log.error({ binary }, "Binary not found in PATH");
        info.state = "exited";
        info.exitCode = 127;
        this.persistState();
        return;
      }
    }

    const args: string[] = ["app-server"];
    const internetEnabled = options.codexInternetAccess !== false;
    args.push("-c", `tools.webSearch=${internetEnabled ? "true" : "false"}`);
    const codexHome = resolveZencodeCodexSessionHome(
      sessionId,
      options.codexHome,
    );
    if (!isContainerized) {
      this.prepareCodexHome(codexHome);
    }

    let spawnCmd: string[];
    let spawnEnv: Record<string, string | undefined>;
    let spawnCwd: string | undefined;

    if (isContainerized) {
      // Run Codex inside the container via docker exec -i (stdin required for JSON-RPC)
      const dockerArgs = ["docker", "exec", "-i"];
      if (options.env) {
        for (const [k, v] of Object.entries(options.env)) {
          dockerArgs.push("-e", `${k}=${v}`);
        }
      }
      dockerArgs.push("-e", "CLAUDECODE=");
      // Point Codex at /root/.codex where container-manager seeded auth/config
      dockerArgs.push("-e", "CODEX_HOME=/root/.codex");
      dockerArgs.push(options.containerId!);
      // Use a login shell so ~/.bashrc is sourced and nvm/bun/deno/etc are on PATH
      const innerCmd = [binary, ...args].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
      dockerArgs.push("bash", "-lc", innerCmd);

      spawnCmd = dockerArgs;
      spawnEnv = { ...process.env, PATH: getEnrichedPath() };
      spawnCwd = undefined;
    } else {
      // Host-based spawn — resolve node/shebang issues
      // The codex binary is a Node.js script with `#!/usr/bin/env node` shebang.
      // When Bun.spawn executes it, the kernel resolves `node` via /usr/bin/env
      // which may find the system Node (e.g. v12) instead of the nvm-managed one.
      // To guarantee the correct Node version, we resolve the `node` binary that
      // lives alongside `codex` and spawn `node <codex.js>` directly.
      const binaryDir = resolve(binary, "..");
      const siblingNode = join(binaryDir, "node");
      const enrichedPath = getEnrichedPath();
      const spawnPath = [binaryDir, ...enrichedPath.split(":")].filter(Boolean).join(":");

      if (existsSync(siblingNode)) {
        let codexScript: string;
        try {
          codexScript = realpathSync(binary);
        } catch (err) {
          log.debug({ binary, err }, "realpathSync failed, using original binary path");
          codexScript = binary;
        }
        spawnCmd = [siblingNode, codexScript, ...args];
      } else {
        spawnCmd = [binary, ...args];
      }

      spawnEnv = {
        ...process.env,
        CLAUDECODE: undefined,
        ...options.env,
        CODEX_HOME: codexHome,
        PATH: spawnPath,
      };
      spawnCwd = info.cwd;
    }

    log.info(
      { sessionId, containerized: isContainerized, cmd: sanitizeSpawnArgsForLog(spawnCmd) },
      "Spawning Codex session",
    );

    const proc = Bun.spawn(spawnCmd, {
      cwd: spawnCwd,
      env: spawnEnv,
      stdin: "pipe",
      stdout: "pipe",
      stderr: "pipe",
    });

    info.pid = proc.pid;
    this.processes.set(sessionId, proc);

    // Pipe stderr for debugging (stdout is used for JSON-RPC)
    const stderr = proc.stderr;
    if (stderr && typeof stderr !== "number") {
      this.pipeStream(sessionId, stderr, "stderr");
    }

    // Create the CodexAdapter which handles JSON-RPC and message translation
    // Pass the raw permission mode — the adapter maps it to Codex's approval policy
    const adapter = new CodexAdapter(proc, sessionId, {
      model: options.model,
      cwd: info.cwd,
      executionCwd: options.containerId ? (info.containerCwd || "/workspace") : info.cwd,
      approvalMode: options.permissionMode,
      threadId: info.cliSessionId,
      sandbox: options.codexSandbox,
      recorder: this.recorder ?? undefined,
    });

    // Handle init errors — mark session as exited so UI shows failure.
    // Also clear cliSessionId so the next relaunch starts a fresh thread
    // instead of trying to resume one whose rollout may be missing.
    adapter.onInitError((error) => {
      log.error({ sessionId, error }, "Codex session init failed");
      const session = this.sessions.get(sessionId);
      if (session) {
        session.state = "exited";
        session.exitCode = 1;
        session.cliSessionId = undefined;
      }
      this.persistState();
    });

    // Notify the WsBridge to attach this adapter
    if (this.onCodexAdapter) {
      this.onCodexAdapter(sessionId, adapter);
    }

    // Mark as connected immediately (no WS handshake needed for stdio)
    info.state = "connected";

    // Monitor process exit
    proc.exited.then((exitCode) => {
      log.info({ sessionId, exitCode }, "Codex session exited");
      const session = this.sessions.get(sessionId);
      if (session) {
        session.state = "exited";
        session.exitCode = exitCode;
      }
      this.processes.delete(sessionId);
      this.persistState();
      for (const handler of this.exitHandlers) {
        try { handler(sessionId, exitCode); } catch (err) {
          log.warn({ sessionId, err }, "Exit handler threw an error");
        }
      }
    });

    this.persistState();
  }


  /**
   * Mark a session as connected (called when CLI establishes WS connection).
   */
  markConnected(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session && (session.state === "starting" || session.state === "connected")) {
      session.state = "connected";
      log.info({ sessionId }, "Session connected via WebSocket");
      this.persistState();
    }
  }

  /**
   * Store the CLI's internal session ID (from system.init message).
   * This is needed for --resume on relaunch.
   */
  setCLISessionId(sessionId: string, cliSessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.cliSessionId = cliSessionId;
      this.persistState();
    }
  }

  /**
   * Kill a session's CLI process.
   */
  async kill(sessionId: string): Promise<boolean> {
    const proc = this.processes.get(sessionId);
    if (!proc) return false;

    // On Windows, SIGTERM is not supported — use SIGKILL directly
    if (process.platform === "win32") {
      proc.kill("SIGKILL");
    } else {
      proc.kill("SIGTERM");
    }

    // Wait up to 5s for graceful exit, then force kill
    const exited = await Promise.race([
      proc.exited.then(() => true),
      new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)),
    ]);

    if (!exited) {
      log.info({ sessionId }, "Force-killing session");
      proc.kill("SIGKILL");
    }

    const session = this.sessions.get(sessionId);
    if (session) {
      session.state = "exited";
      session.exitCode = -1;
    }
    this.processes.delete(sessionId);
    this.persistState();
    return true;
  }

  /**
   * List all sessions (active + recently exited).
   */
  listSessions(): SdkSessionInfo[] {
    return Array.from(this.sessions.values());
  }

  /**
   * Get a specific session.
   */
  getSession(sessionId: string): SdkSessionInfo | undefined {
    return this.sessions.get(sessionId);
  }

  /**
   * Check if a session exists and is alive (not exited).
   */
  isAlive(sessionId: string): boolean {
    const session = this.sessions.get(sessionId);
    return !!session && session.state !== "exited";
  }

  /**
   * Set the archived flag on a session.
   */
  setArchived(sessionId: string, archived: boolean): void {
    const info = this.sessions.get(sessionId);
    if (info) {
      info.archived = archived;
      this.persistState();
    }
  }

  /**
   * Remove a session from the internal map (after kill or cleanup).
   */
  removeSession(sessionId: string) {
    this.sessions.delete(sessionId);
    this.processes.delete(sessionId);
    this.sessionEnvs.delete(sessionId);
    this.persistState();
  }

  /**
   * Remove exited sessions from the list.
   */
  pruneExited(): number {
    let pruned = 0;
    for (const [id, session] of this.sessions) {
      if (session.state === "exited") {
        this.sessions.delete(id);
        this.sessionEnvs.delete(id);
        pruned++;
      }
    }
    return pruned;
  }

  /**
   * Kill all sessions.
   */
  async killAll(): Promise<void> {
    const ids = [...this.processes.keys()];
    await Promise.all(ids.map((id) => this.kill(id)));
  }

  private async pipeStream(
    sessionId: string,
    stream: ReadableStream<Uint8Array> | null,
    label: "stdout" | "stderr",
  ): Promise<void> {
    if (!stream) return;
    const reader = stream.getReader();
    const decoder = new TextDecoder();
    const pipeLog = label === "stdout" ? log.info.bind(log) : log.error.bind(log);
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const text = decoder.decode(value);
        if (text.trim()) {
          pipeLog({ sessionId, label }, text.trimEnd());
        }
      }
    } catch (err) {
      log.debug({ sessionId, label, err }, "Output stream closed");
    }
  }

  private pipeOutput(sessionId: string, proc: Subprocess): void {
    const stdout = proc.stdout;
    const stderr = proc.stderr;
    if (stdout && typeof stdout !== "number") {
      this.pipeStream(sessionId, stdout, "stdout");
    }
    if (stderr && typeof stderr !== "number") {
      this.pipeStream(sessionId, stderr, "stderr");
    }
  }
}
