import type { ServerWebSocket } from "bun";
import { createLogger } from "./logger.js";
import type {
  CLIMessage,
  CLISystemMessage,
  CLIAssistantMessage,
  CLIResultMessage,
  CLIStreamEventMessage,
  CLIToolProgressMessage,
  CLIToolUseSummaryMessage,
  CLIControlRequestMessage,
  CLIControlCancelRequestMessage,
  CLIAuthStatusMessage,
  BrowserOutgoingMessage,
  BrowserIncomingMessage,
  SessionState,
  PermissionRequest,
  BackendType,
  McpServerConfig,
  QueuedMessage,
  InitializeResponseData,
} from "./session-types.js";
import type { SessionStore } from "./session-store.js";
import type { CodexAdapter } from "./codex-adapter.js";

const log = createLogger("ws-bridge");
import type { RecorderManager } from "./recorder.js";
import type {
  Session,
  SocketData,
  CLISocketData,
  BrowserSocketData,
} from "./ws-bridge-types.js";
export type { SocketData } from "./ws-bridge-types.js";
import {
  isDuplicateClientMessage,
  rememberClientMessage,
  isHistoryBackedEvent,
  sequenceEvent,
} from "./ws-bridge-replay.js";
import { attachCodexAdapterHandlers } from "./ws-bridge-codex.js";
import {
  handleInterrupt,
  handleSetModel,
  handleSetPermissionMode,
  handleSetMaxThinkingTokens,
  handleSetOutputStyle,
  handleSetFastMode,
  handleControlResponse,
  sendControlRequest,
  handleMcpGetStatus,
  handleMcpToggle,
  handleMcpReconnect,
  handleMcpSetServers,
  handleMcpMessage,
  handleMcpMessageFromCLI,
  handleRewindFiles,
  sendInitializeRequest,
  handleHookCallback,
  handleUpdateEnv,
} from "./ws-bridge-controls.js";
import type { InitializeOptions } from "./ws-bridge-controls.js";
import {
  handleSessionSubscribe,
  handleSessionAck,
  handlePermissionResponse,
} from "./ws-bridge-browser.js";
import {
  handleSystemMessage as handleSystemMessageImpl,
  type SystemMessageDeps,
} from "./ws-bridge-system.js";
import {
  handleResultMessage as handleResultMessageImpl,
  type ResultMessageDeps,
} from "./ws-bridge-result.js";
import { SessionManager } from "./session-manager.js";
import { notifyPermissionNeeded } from "./push-notifications.js";
import { getName as getSessionName } from "./session-names.js";
import { extractModifiedFiles, trackFilesAndDetectConflicts } from "./ws-bridge-conflicts.js";

/**
 * Core message router between CLI WebSocket (NDJSON) and browser WebSocket (typed JSON).
 *
 * WsBridge is a pure transport layer responsible for:
 * - WebSocket connection handling for both CLI and browser clients
 * - Message dispatch and routing between CLI and browser protocols
 * - Protocol translation: NDJSON (Claude Code) and JSON-RPC (Codex) on the CLI side,
 *   typed JSON messages on the browser side
 * - Per-session event sequencing, replay buffering, and idempotency tracking
 * - Message queue management for auto-sending user messages
 *
 * Session lifecycle (CRUD, state tracking, persistence, orchestration callbacks)
 * is delegated to {@link SessionManager}. WsBridge preserves backward-compatible
 * public API methods that delegate to SessionManager internally.
 */
export class WsBridge {
  /** Maximum sequenced events kept in the per-session event buffer for browser replay. */
  private static readonly EVENT_BUFFER_LIMIT = 600;
  /** Maximum processed client message IDs tracked for idempotency deduplication. */
  private static readonly PROCESSED_CLIENT_MSG_ID_LIMIT = 1000;
  /** Maximum number of messages allowed in the per-session user message queue. */
  static readonly QUEUE_CAP = 50;
  /** Max messages kept in the per-session circular buffer for CLI reconnect replay. */
  static readonly SENT_MESSAGE_BUFFER_CAPACITY = 1000;
  /** Prune the CLI-UUID→seq correlation map when it exceeds this size. */
  private static readonly CLI_MESSAGE_SEQ_MAP_LIMIT = 2000;
  private static readonly IDEMPOTENT_BROWSER_MESSAGE_TYPES = new Set<string>([
    "user_message",
    "permission_response",
    "permission_cancel",
    "interrupt",
    "set_model",
    "set_permission_mode",
    "set_max_thinking_tokens",
    "mcp_get_status",
    "mcp_toggle",
    "mcp_reconnect",
    "mcp_set_servers",
    "mcp_message",
    "queue_add",
    "queue_remove",
    "queue_clear",
    "queue_resume",
    "queue_reorder",
    "queue_edit",
    "update_env",
    "stop_task",
  ]);
  /** All valid BrowserOutgoingMessage type values — used to reject unknown messages early. */
  private static readonly KNOWN_BROWSER_MESSAGE_TYPES = new Set<string>([
    "user_message",
    "permission_response",
    "session_subscribe",
    "session_ack",
    "interrupt",
    "set_model",
    "set_permission_mode",
    "mcp_get_status",
    "mcp_toggle",
    "mcp_reconnect",
    "mcp_set_servers",
    "set_max_thinking_tokens",
    "queue_add",
    "queue_remove",
    "queue_clear",
    "queue_resume",
    "queue_reorder",
    "queue_edit",
    "rewind_files",
    "mcp_message",
    "permission_cancel",
    "update_env",
    "stop_task",
  ]);
  private readonly sm: SessionManager;
  private recorder: RecorderManager | null = null;
  private initOptionsProvider: ((sessionId: string) => InitializeOptions | null) | null = null;

  /**
   * @param sessionManager - Optional pre-configured SessionManager instance.
   *   If omitted, a new SessionManager is created internally.
   */
  constructor(sessionManager?: SessionManager) {
    this.sm = sessionManager ?? new SessionManager();
    this.sm.setBroadcastFn(this.broadcastToBrowsers.bind(this));
  }

  /** Expose the underlying SessionManager for direct access. */
  get sessionManager(): SessionManager {
    return this.sm;
  }

  // ── Session management delegation ────────────────────────────────────────
  //
  // These methods delegate to SessionManager, preserving WsBridge's public API
  // for backward compatibility with existing consumers.

  /** Register a callback for when we learn the CLI's internal session ID. */
  onCLISessionIdReceived(cb: (sessionId: string, cliSessionId: string) => void): void {
    this.sm.onCLISessionIdReceived(cb);
  }

  /** Register a callback for when a browser connects but CLI is dead. */
  onCLIRelaunchNeededCallback(cb: (sessionId: string) => void): void {
    this.sm.onCLIRelaunchNeededCallback(cb);
  }

  /** Register a callback for when a session completes its first turn. */
  onFirstTurnCompletedCallback(cb: (sessionId: string, firstUserMessage: string) => void): void {
    this.sm.onFirstTurnCompletedCallback(cb);
  }

  /** Register a callback for when git info is resolved and branch is known. */
  onSessionGitInfoReadyCallback(cb: (sessionId: string, cwd: string, branch: string) => void): void {
    this.sm.onSessionGitInfoReadyCallback(cb);
  }

  /**
   * Register a provider for initialize options (systemPrompt, appendSystemPrompt).
   * Called when a CLI connects to retrieve session-specific options for the
   * `initialize` control request sent before the first user message.
   */
  onInitializeOptionsNeeded(cb: (sessionId: string) => InitializeOptions | null): void {
    this.initOptionsProvider = cb;
  }

  /**
   * Pre-populate a session with container info so that handleSystemMessage
   * preserves the host cwd instead of overwriting it with /workspace.
   * Call this right after launcher.launch() for containerized sessions.
   */
  markContainerized(sessionId: string, hostCwd: string): void {
    this.sm.markContainerized(sessionId, hostCwd);
  }

  /**
   * Set the USD budget cap on a session so the server can warn at 80%
   * and auto-interrupt at 100%.
   */
  setBudget(sessionId: string, budgetUsd: number): void {
    this.sm.setBudget(sessionId, budgetUsd);
  }

  /**
   * Push a message to all connected browsers for a session.
   * Public entry point used by external components (e.g., PRPoller, REST API).
   *
   * @param sessionId - Target session.
   * @param msg - Message to broadcast.
   */
  broadcastToSession(sessionId: string, msg: BrowserIncomingMessage): void {
    const session = this.sm.getSession(sessionId);
    if (!session) return;
    this.broadcastToBrowsers(session, msg);
  }

  /** Attach a persistent store. Call restoreFromDisk() after. */
  setStore(store: SessionStore): void {
    this.sm.setStore(store);
  }

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

  /** Restore sessions from disk (call once at startup). */
  async restoreFromDisk(): Promise<number> {
    return this.sm.restoreFromDisk();
  }

  // ── Session management ──────────────────────────────────────────────────

  /**
   * Retrieve an existing session or create a new one.
   *
   * @param sessionId - Unique session identifier.
   * @param backendType - Backend type for new sessions (defaults to "claude").
   * @returns The existing or newly created Session.
   */
  getOrCreateSession(sessionId: string, backendType?: BackendType): Session {
    return this.sm.getOrCreateSession(sessionId, backendType);
  }

  getSession(sessionId: string): Session | undefined {
    return this.sm.getSession(sessionId);
  }

  /** @returns An array of all active session states. */
  getAllSessions(): SessionState[] {
    return this.sm.getAllSessions();
  }

  getCodexRateLimits(sessionId: string) {
    return this.sm.getCodexRateLimits(sessionId);
  }

  isCliConnected(sessionId: string): boolean {
    return this.sm.isCliConnected(sessionId);
  }

  removeSession(sessionId: string) {
    this.sm.removeSession(sessionId);
  }

  /**
   * Close all sockets (CLI + browsers) for every active session.
   * Used during graceful shutdown to cleanly tear down connections.
   * Stops keepalive timers, disconnects Codex adapters, and clears the session map.
   */
  closeAllSessions(): void {
    const entries = this.sm.getAllSessionEntries();
    log.info({ count: entries.length }, "Closing all sessions for shutdown");
    for (const session of entries) {
      // Stop keepalive timer
      if (session.keepAliveInterval) {
        clearInterval(session.keepAliveInterval);
        session.keepAliveInterval = null;
      }

      if (session.cliSocket) {
        try { session.cliSocket.close(); } catch (err) {
          log.debug({ sessionId: session.id, err }, "Failed to close CLI socket during shutdown");
        }
      }

      if (session.codexAdapter) {
        session.codexAdapter.disconnect().catch((err: unknown) => {
          log.debug({ sessionId: session.id, err }, "Failed to disconnect Codex adapter during shutdown");
        });
      }

      for (const ws of session.browserSockets) {
        try { ws.close(); } catch (err) {
          log.debug({ sessionId: session.id, err }, "Failed to close browser socket during shutdown");
        }
      }
      session.browserSockets.clear();
    }
    this.sm.clearAll();
  }

  /**
   * Close all sockets (CLI + browsers) for a single session and remove it.
   *
   * @param sessionId - The session to tear down.
   */
  closeSession(sessionId: string) {
    const session = this.sm.getSession(sessionId);
    if (!session) return;

    // Stop keepalive timer
    if (session.keepAliveInterval) {
      clearInterval(session.keepAliveInterval);
      session.keepAliveInterval = null;
    }

    // Close CLI socket (Claude)
    if (session.cliSocket) {
      try { session.cliSocket.close(); } catch (err) {
        log.debug({ sessionId, err }, "Failed to close CLI socket during cleanup");
      }
      session.cliSocket = null;
    }

    // Disconnect Codex adapter
    if (session.codexAdapter) {
      session.codexAdapter.disconnect().catch((err: unknown) => {
        log.debug({ sessionId, err }, "Failed to disconnect Codex adapter during cleanup");
      });
      session.codexAdapter = null;
    }

    // Close all browser sockets
    for (const ws of session.browserSockets) {
      try { ws.close(); } catch (err) {
        log.debug({ sessionId, err }, "Failed to close browser socket during cleanup");
      }
    }
    session.browserSockets.clear();

    this.sm.removeSession(sessionId);
  }

  // ── Codex adapter attachment ────────────────────────────────────────────

  /**
   * Attach a CodexAdapter to a session. The adapter handles all message
   * translation between the Codex app-server (stdio JSON-RPC) and the
   * browser WebSocket protocol.
   */
  attachCodexAdapter(sessionId: string, adapter: CodexAdapter): void {
    const session = this.sm.getOrCreateSession(sessionId, "codex");
    session.backendType = "codex";
    session.state.backend_type = "codex";
    session.codexAdapter = adapter;
    attachCodexAdapterHandlers(sessionId, session, adapter, {
      persistSession: (s) => this.sm.persistSession(s),
      refreshGitInfo: (s, opts) => this.sm.refreshGitInfo(s, opts),
      broadcastToBrowsers: this.broadcastToBrowsers.bind(this),
      onCLISessionId: this.sm.getOnCLISessionId(),
      onFirstTurnCompleted: this.sm.getOnFirstTurnCompleted(),
      autoNamingAttempted: this.sm.getAutoNamingAttempted(),
    });
  }

  // ── CLI WebSocket handlers ──────────────────────────────────────────────

  handleCLIOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
    const session = this.sm.getOrCreateSession(sessionId);
    session.cliSocket = ws;
    log.info({ sessionId }, "CLI connected");
    this.broadcastToBrowsers(session, { type: "cli_connected" });

    // Replay buffered messages if the CLI is reconnecting with a last-request-id.
    // Done before initialize/flush so missed messages arrive first.
    const lastRequestId = (ws.data as CLISocketData).lastRequestId;
    if (lastRequestId) {
      this.replayMessagesAfter(session, lastRequestId);
    }

    // Start sending keep_alive messages to CLI every 10 seconds.
    // The CLI's WebSocket transport expects periodic keepalives and may
    // consider the connection dead if none are received.
    if (session.keepAliveInterval) clearInterval(session.keepAliveInterval);
    session.keepAliveInterval = setInterval(() => {
      if (session.cliSocket?.readyState === 1 /* WebSocket.OPEN */) {
        try {
          session.cliSocket.send('{"type":"keep_alive"}\n');
        } catch {
          // expected: socket may have closed between readyState check and send
        }
      }
    }, 10_000);

    // Flush any messages queued while waiting for the CLI WebSocket.
    // Per the SDK protocol, the `initialize` control request must be sent
    // BEFORE the first `user` message (§7.1). Send it first, then flush
    // queued messages. WebSocket message ordering guarantees the CLI
    // receives `initialize` before any user messages.
    //
    // Skip initialize for Codex sessions — they use a different protocol.
    if (session.backendType !== "codex") {
      const initOptions = this.initOptionsProvider?.(sessionId) ?? null;
      sendInitializeRequest(
        session,
        this.sendToCLI.bind(this),
        initOptions ?? undefined,
        (data) => this.processInitializeResponse(session, data),
      );
    }

    if (session.pendingMessages.length > 0) {
      log.info({ sessionId, count: session.pendingMessages.length }, "Flushing queued messages on CLI connect");
      const queued = session.pendingMessages.splice(0);
      for (const ndjson of queued) {
        this.sendToCLI(session, ndjson);
      }
    }
  }

  handleCLIMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
    const data = typeof raw === "string" ? raw : raw.toString("utf-8");
    const sessionId = (ws.data as CLISocketData).sessionId;
    const session = this.sm.getSession(sessionId);
    if (!session) return;

    // Record raw incoming CLI message before any parsing
    this.recorder?.record(sessionId, "in", data, "cli", session.backendType, session.state.cwd);

    // NDJSON: split on newlines, parse each line
    const lines = data.split("\n").filter((l) => l.trim());
    for (const line of lines) {
      let msg: CLIMessage;
      try {
        msg = JSON.parse(line);
      } catch {
        log.warn({ preview: line.substring(0, 200) }, "Failed to parse CLI message");
        continue;
      }

      // Track CLI message UUID for replay correlation.
      // When the CLI reconnects with X-Last-Request-Id, we look up this UUID
      // to determine which buffered server→CLI messages to replay.
      const uuid = (msg as unknown as Record<string, unknown>).uuid;
      if (typeof uuid === "string") {
        session.cliMessageSeqMap.set(uuid, session.sentMessageSeq);
        if (session.cliMessageSeqMap.size > WsBridge.CLI_MESSAGE_SEQ_MAP_LIMIT) {
          const threshold = session.sentMessageSeq - WsBridge.SENT_MESSAGE_BUFFER_CAPACITY;
          for (const [key, val] of session.cliMessageSeqMap) {
            if (val < threshold) session.cliMessageSeqMap.delete(key);
          }
        }
      }

      this.routeCLIMessage(session, msg);
    }
  }

  /**
   * Process CLI events received via HTTP POST (hybrid transport).
   * Each event is a pre-parsed JSON object from the POST body's `events` array.
   * Routes through the same pipeline as WebSocket messages.
   *
   * @returns `{ found: true, processed: number }` on success,
   *          `{ found: false }` if the session does not exist.
   */
  processCLIEvents(
    sessionId: string,
    events: unknown[],
  ): { found: true; processed: number } | { found: false } {
    const session = this.sm.getSession(sessionId);
    if (!session) return { found: false };

    let processed = 0;
    for (const event of events) {
      if (!event || typeof event !== "object") {
        log.warn({ sessionId }, "Skipping non-object event in POST body");
        continue;
      }

      const msg = event as CLIMessage;

      // Record raw incoming CLI message (serialize back for recording fidelity)
      const raw = JSON.stringify(msg);
      this.recorder?.record(sessionId, "in", raw, "cli", session.backendType, session.state.cwd);

      // Track CLI message UUID for replay correlation (same as handleCLIMessage)
      const uuid = (msg as unknown as Record<string, unknown>).uuid;
      if (typeof uuid === "string") {
        session.cliMessageSeqMap.set(uuid, session.sentMessageSeq);
        if (session.cliMessageSeqMap.size > WsBridge.CLI_MESSAGE_SEQ_MAP_LIMIT) {
          const threshold = session.sentMessageSeq - WsBridge.SENT_MESSAGE_BUFFER_CAPACITY;
          for (const [key, val] of session.cliMessageSeqMap) {
            if (val < threshold) session.cliMessageSeqMap.delete(key);
          }
        }
      }

      this.routeCLIMessage(session, msg);
      processed++;
    }

    return { found: true, processed };
  }

  handleCLIClose(ws: ServerWebSocket<SocketData>) {
    const sessionId = (ws.data as CLISocketData).sessionId;
    const session = this.sm.getSession(sessionId);
    if (!session) return;

    // Stop keepalive timer
    if (session.keepAliveInterval) {
      clearInterval(session.keepAliveInterval);
      session.keepAliveInterval = null;
    }

    session.cliSocket = null;
    log.info({ sessionId }, "CLI disconnected");
    this.broadcastToBrowsers(session, { type: "cli_disconnected" });

    // Cancel any pending permission requests
    for (const [reqId] of session.pendingPermissions) {
      this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
    }
    session.pendingPermissions.clear();
    this.sm.persistSession(session);
  }

  // ── Browser WebSocket handlers ──────────────────────────────────────────

  handleBrowserOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
    const session = this.sm.getOrCreateSession(sessionId);
    const browserData = ws.data as BrowserSocketData;
    browserData.subscribed = false;
    browserData.lastAckSeq = 0;
    session.browserSockets.add(ws);
    log.info({ sessionId, browserCount: session.browserSockets.size }, "Browser connected");

    // Fire-and-forget async git refresh on browser connect. If git info changes,
    // a session_update will be broadcast to this and other connected browsers.
    void this.sm.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });

    // Send current session state as snapshot
    const snapshot: BrowserIncomingMessage = {
      type: "session_init",
      session: session.state,
    };
    this.sendToBrowser(ws, snapshot);

    // Replay message history so the browser can reconstruct the conversation
    if (session.messageHistory.length > 0) {
      this.sendToBrowser(ws, {
        type: "message_history",
        messages: session.messageHistory,
      });
    }

    // Send any pending permission requests
    for (const perm of session.pendingPermissions.values()) {
      this.sendToBrowser(ws, { type: "permission_request", request: perm });
    }

    // Notify if backend is not connected and request relaunch
    const backendConnected = session.backendType === "codex"
      // Treat an attached adapter as "alive" during init.
      // `isConnected()` flips true only after initialize/thread start, and
      // relaunching during that window can kill a healthy startup.
      ? !!session.codexAdapter
      : !!session.cliSocket;

    if (!backendConnected) {
      this.sendToBrowser(ws, { type: "cli_disconnected" });
      const relaunchCb = this.sm.getOnCLIRelaunchNeeded();
      if (relaunchCb) {
        log.info({ sessionId }, "Browser connected but backend is dead, requesting relaunch");
        relaunchCb(sessionId);
      }
    }
  }

  handleBrowserMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
    const data = typeof raw === "string" ? raw : raw.toString("utf-8");
    const sessionId = (ws.data as BrowserSocketData).sessionId;
    const session = this.sm.getSession(sessionId);
    if (!session) return;

    // Record raw incoming browser message
    this.recorder?.record(sessionId, "in", data, "browser", session.backendType, session.state.cwd);

    let parsed: unknown;
    try {
      parsed = JSON.parse(data);
    } catch {
      log.warn({ preview: data.substring(0, 200) }, "Failed to parse browser message");
      return;
    }

    // Runtime validation: ensure the message has a known type before casting
    if (
      typeof parsed !== "object" ||
      parsed === null ||
      !("type" in parsed) ||
      typeof (parsed as Record<string, unknown>).type !== "string"
    ) {
      log.warn({ preview: data.substring(0, 200) }, "Browser message missing or invalid 'type' field");
      return;
    }

    const msgType = (parsed as Record<string, unknown>).type as string;
    if (!WsBridge.KNOWN_BROWSER_MESSAGE_TYPES.has(msgType)) {
      log.warn({ msgType }, "Browser message has unknown type, ignoring");
      return;
    }

    const msg = parsed as BrowserOutgoingMessage;
    this.routeBrowserMessage(session, msg, ws);
  }

  /**
   * Send a user message into a session programmatically (no browser required).
   * Used by the cron scheduler and agent executor to send prompts to autonomous sessions.
   *
   * @param sessionId - Target session.
   * @param content - The user message text.
   */
  injectUserMessage(sessionId: string, content: string): void {
    const session = this.sm.getSession(sessionId);
    if (!session) {
      log.error({ sessionId }, "Cannot inject message: session not found");
      return;
    }
    this.routeBrowserMessage(session, { type: "user_message", content });
  }

  /**
   * Send an MCP set_servers control message into a session programmatically.
   * Used by the agent executor to configure MCP servers for agent sessions.
   *
   * @param sessionId - Target session.
   * @param servers - Map of server name to MCP server configuration.
   */
  injectMcpSetServers(
    sessionId: string,
    servers: Record<string, McpServerConfig>,
  ): void {
    const session = this.sm.getSession(sessionId);
    if (!session) {
      log.error({ sessionId }, "Cannot inject MCP servers: session not found");
      return;
    }
    this.routeBrowserMessage(session, {
      type: "mcp_set_servers",
      servers,
    });
  }

  handleBrowserClose(ws: ServerWebSocket<SocketData>) {
    const sessionId = (ws.data as BrowserSocketData).sessionId;
    const session = this.sm.getSession(sessionId);
    if (!session) return;

    session.browserSockets.delete(ws);
    log.info({ sessionId, browserCount: session.browserSockets.size }, "Browser disconnected");
  }

  // ── CLI message routing ─────────────────────────────────────────────────

  /**
   * Route a parsed CLI message to the appropriate handler based on message type.
   * Dispatches system, assistant, result, stream_event, control, and tool messages.
   */
  private routeCLIMessage(session: Session, msg: CLIMessage) {
    switch (msg.type) {
      case "system":
        this.handleSystemMessage(session, msg);
        break;

      case "assistant":
        this.handleAssistantMessage(session, msg);
        break;

      case "result":
        this.handleResultMessage(session, msg);
        break;

      case "stream_event":
        this.handleStreamEvent(session, msg);
        break;

      case "control_request":
        this.handleControlRequest(session, msg);
        break;

      case "control_cancel_request":
        this.handleControlCancelRequest(session, msg);
        break;

      case "tool_progress":
        this.handleToolProgress(session, msg);
        break;

      case "tool_use_summary":
        this.handleToolUseSummary(session, msg);
        break;

      case "auth_status":
        this.handleAuthStatus(session, msg);
        break;

      case "control_response":
        handleControlResponse(session, msg, (message) => log.warn(message));
        break;

      case "keep_alive":
        // Silently consume keepalives
        break;

      default:
        // Forward unknown messages as-is for debugging
        break;
    }
  }

  private handleSystemMessage(session: Session, msg: CLISystemMessage) {
    const deps: SystemMessageDeps = {
      broadcastToBrowsers: this.broadcastToBrowsers.bind(this),
      appendToHistory: (s, m) => this.sm.appendToHistory(s, m),
      persistSession: (s) => this.sm.persistSession(s),
      refreshGitInfo: (s, opts) => this.sm.refreshGitInfo(s, opts),
      getOnCLISessionId: () => this.sm.getOnCLISessionId(),
      sendToCLI: this.sendToCLI.bind(this),
    };
    handleSystemMessageImpl(session, msg, deps);
  }

  private handleAssistantMessage(session: Session, msg: CLIAssistantMessage) {
    const browserMsg: BrowserIncomingMessage = {
      type: "assistant",
      message: msg.message,
      parent_tool_use_id: msg.parent_tool_use_id,
      timestamp: Date.now(),
    };
    this.sm.appendToHistory(session, browserMsg);
    this.broadcastToBrowsers(session, browserMsg);
    this.sm.persistSession(session);

    // Extract modified file paths from tool_use blocks and detect cross-session conflicts
    if (msg.message.content?.length) {
      const modifiedFiles = extractModifiedFiles(msg.message.content);
      if (modifiedFiles.length > 0) {
        trackFilesAndDetectConflicts(session, modifiedFiles, {
          getAllSessionEntries: () => this.sm.getAllSessionEntries(),
          broadcastToBrowsers: this.broadcastToBrowsers.bind(this),
        });
      }
    }
  }

  private handleResultMessage(session: Session, msg: CLIResultMessage) {
    const deps: ResultMessageDeps = {
      broadcastToBrowsers: this.broadcastToBrowsers.bind(this),
      appendToHistory: (s, m) => this.sm.appendToHistory(s, m),
      persistSession: (s) => this.sm.persistSession(s),
      refreshGitInfo: (s, opts) => this.sm.refreshGitInfo(s, opts),
      getAutoNamingAttempted: () => this.sm.getAutoNamingAttempted(),
      getOnFirstTurnCompleted: () => this.sm.getOnFirstTurnCompleted(),
      processQueue: (s, isError) => this.processQueue(s, isError),
      sendToCLI: this.sendToCLI.bind(this),
    };
    handleResultMessageImpl(session, msg, deps);
  }

  private handleStreamEvent(session: Session, msg: CLIStreamEventMessage) {
    this.broadcastToBrowsers(session, {
      type: "stream_event",
      event: msg.event,
      parent_tool_use_id: msg.parent_tool_use_id,
    });
  }

  private handleControlRequest(session: Session, msg: CLIControlRequestMessage) {
    if (msg.request.subtype === "can_use_tool") {
      const perm: PermissionRequest = {
        request_id: msg.request_id,
        tool_name: msg.request.tool_name,
        input: msg.request.input,
        permission_suggestions: msg.request.permission_suggestions,
        description: msg.request.description,
        tool_use_id: msg.request.tool_use_id,
        agent_id: msg.request.agent_id,
        timestamp: Date.now(),
      };
      session.pendingPermissions.set(msg.request_id, perm);

      this.broadcastToBrowsers(session, {
        type: "permission_request",
        request: perm,
      });
      this.sm.persistSession(session);

      // Send push notification if no browsers are connected
      if (session.browserSockets.size === 0) {
        notifyPermissionNeeded(
          session.id,
          getSessionName(session.id) || "",
          msg.request.tool_name,
        ).catch((err) => log.warn({ err, sessionId: session.id }, "Failed to send push notification"));
      }
    } else if (msg.request.subtype === "hook_callback") {
      handleHookCallback(
        session,
        msg.request_id,
        msg.request.callback_id,
        msg.request.input,
        msg.request.tool_use_id,
        this.sendToCLI.bind(this),
        this.broadcastToBrowsers.bind(this),
      );
    } else if (msg.request.subtype === "mcp_message") {
      handleMcpMessageFromCLI(
        session,
        msg.request_id,
        msg.request.server_name,
        msg.request.message,
        this.sendToCLI.bind(this),
        this.broadcastToBrowsers.bind(this),
      );
    }
  }

  private handleControlCancelRequest(session: Session, msg: CLIControlCancelRequestMessage) {
    const reqId = msg.request_id;
    if (session.pendingPermissions.has(reqId)) {
      session.pendingPermissions.delete(reqId);
      this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
      this.sm.persistSession(session);
      log.info({ sessionId: session.id, requestId: reqId }, "Permission request cancelled by CLI");
    }
  }

  /**
   * Process the response from an `initialize` control request.
   * Stores models, account, commands, and output styles on the session state
   * and broadcasts a `session_initialized` event to all connected browsers.
   */
  private processInitializeResponse(session: Session, data: InitializeResponseData): void {
    log.info(
      { sessionId: session.id, modelCount: data.models?.length ?? 0, email: data.account?.email },
      "Initialize response received",
    );

    // Store on session state
    session.state.available_models = data.models;
    session.state.account = data.account;
    session.state.available_commands = data.commands;
    session.state.available_output_styles = data.available_output_styles;
    session.state.fast_mode = data.fast_mode;

    // Broadcast to browsers
    this.broadcastToBrowsers(session, {
      type: "session_initialized",
      data,
    });
    this.sm.persistSession(session);
  }

  private handlePermissionCancel(session: Session, requestId: string) {
    if (session.pendingPermissions.has(requestId)) {
      session.pendingPermissions.delete(requestId);
      // Notify CLI that the permission request is cancelled
      const ndjson = JSON.stringify({
        type: "control_cancel_request",
        request_id: requestId,
      });
      this.sendToCLI(session, ndjson);
      this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: requestId });
      this.sm.persistSession(session);
      log.info({ sessionId: session.id, requestId }, "Permission request cancelled by browser");
    }
  }

  private handleToolProgress(session: Session, msg: CLIToolProgressMessage) {
    this.broadcastToBrowsers(session, {
      type: "tool_progress",
      tool_use_id: msg.tool_use_id,
      tool_name: msg.tool_name,
      elapsed_time_seconds: msg.elapsed_time_seconds,
    });
  }

  private handleToolUseSummary(session: Session, msg: CLIToolUseSummaryMessage) {
    this.broadcastToBrowsers(session, {
      type: "tool_use_summary",
      summary: msg.summary,
      tool_use_ids: msg.preceding_tool_use_ids,
    });
  }

  private handleAuthStatus(session: Session, msg: CLIAuthStatusMessage) {
    this.broadcastToBrowsers(session, {
      type: "auth_status",
      isAuthenticating: msg.isAuthenticating,
      output: msg.output,
      error: msg.error,
    });
  }

  // ── Browser message routing ─────────────────────────────────────────────

  /** Delegate a browser message to the Codex adapter for Codex sessions. */
  private delegateToCodexAdapter(session: Session, msg: BrowserOutgoingMessage): void {
    // Store user messages in history for replay with stable ID for dedup on reconnect
    if (msg.type === "user_message") {
      const ts = Date.now();
      this.sm.appendToHistory(session, {
        type: "user_message",
        content: msg.content,
        timestamp: ts,
        id: `user-${ts}-${this.sm.nextUserMsgId()}`,
      });
      this.sm.persistSession(session);
    }
    if (msg.type === "permission_response") {
      session.pendingPermissions.delete(msg.request_id);
      this.sm.persistSession(session);
    }

    if (session.codexAdapter) {
      session.codexAdapter.sendBrowserMessage(msg);
    } else {
      // Adapter not yet attached — queue for when it's ready.
      // The adapter itself also queues during init, but this covers
      // the window between session creation and adapter attachment.
      log.info({ sessionId: session.id, msgType: msg.type }, "Codex adapter not yet attached, queuing message");
      session.pendingMessages.push(JSON.stringify(msg));
    }
  }

  private routeBrowserMessage(
    session: Session,
    msg: BrowserOutgoingMessage,
    ws?: ServerWebSocket<SocketData>,
  ) {
    if (msg.type === "session_subscribe") {
      handleSessionSubscribe(
        session,
        ws,
        msg.last_seq,
        this.sendToBrowser.bind(this),
        isHistoryBackedEvent,
      );
      return;
    }

    if (msg.type === "session_ack") {
      handleSessionAck(session, ws, msg.last_seq, (s) => this.sm.persistSession(s));
      return;
    }

    if (
      WsBridge.IDEMPOTENT_BROWSER_MESSAGE_TYPES.has(msg.type)
      && "client_msg_id" in msg
      && msg.client_msg_id
    ) {
      if (isDuplicateClientMessage(session, msg.client_msg_id)) {
        return;
      }
      rememberClientMessage(
        session,
        msg.client_msg_id,
        WsBridge.PROCESSED_CLIENT_MSG_ID_LIMIT,
        (s) => this.sm.persistSession(s),
      );
    }

    // For Codex sessions, delegate entirely to the adapter
    if (session.backendType === "codex") {
      this.delegateToCodexAdapter(session, msg);
      return;
    }

    // Claude Code path (existing logic)
    switch (msg.type) {
      case "user_message":
        this.handleUserMessage(session, msg);
        break;

      case "permission_response":
        handlePermissionResponse(session, msg, this.sendToCLI.bind(this));
        break;

      case "permission_cancel":
        this.handlePermissionCancel(session, msg.request_id);
        break;

      case "interrupt":
        handleInterrupt(session, this.sendToCLI.bind(this));
        break;

      case "set_model":
        handleSetModel(session, msg.model, this.sendToCLI.bind(this));
        break;

      case "set_permission_mode":
        handleSetPermissionMode(session, msg.mode, this.sendToCLI.bind(this));
        break;

      case "set_max_thinking_tokens":
        handleSetMaxThinkingTokens(session, msg.maxThinkingTokens, this.sendToCLI.bind(this));
        break;

      case "set_output_style":
        handleSetOutputStyle(session, msg.outputStyle, this.sendToCLI.bind(this));
        break;

      case "set_fast_mode":
        handleSetFastMode(session, msg.fastMode, this.sendToCLI.bind(this));
        break;

      case "queue_add":
        this.handleQueueAdd(session, msg.content, msg.images);
        break;

      case "queue_remove":
        this.handleQueueRemove(session, msg.messageId);
        break;

      case "queue_clear":
        this.handleQueueClear(session);
        break;

      case "queue_resume":
        this.handleQueueResume(session);
        break;

      case "queue_reorder":
        this.handleQueueReorder(session, msg.orderedIds);
        break;

      case "queue_edit":
        this.handleQueueEdit(session, msg.messageId, msg.content, msg.images);
        break;

      case "mcp_get_status":
        handleMcpGetStatus(
          session,
          (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
          this.broadcastToBrowsers.bind(this),
        );
        break;

      case "mcp_toggle":
        handleMcpToggle(
          (request) => sendControlRequest(session, request, this.sendToCLI.bind(this)),
          msg.serverName,
          msg.enabled,
          () =>
            handleMcpGetStatus(
              session,
              (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
              this.broadcastToBrowsers.bind(this),
            ),
        );
        break;

      case "mcp_reconnect":
        handleMcpReconnect(
          (request) => sendControlRequest(session, request, this.sendToCLI.bind(this)),
          msg.serverName,
          () =>
            handleMcpGetStatus(
              session,
              (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
              this.broadcastToBrowsers.bind(this),
            ),
        );
        break;

      case "mcp_set_servers":
        handleMcpSetServers(
          (request) => sendControlRequest(session, request, this.sendToCLI.bind(this)),
          msg.servers,
          () =>
            handleMcpGetStatus(
              session,
              (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
              this.broadcastToBrowsers.bind(this),
            ),
        );
        break;

      case "mcp_message":
        handleMcpMessage(
          session,
          msg.serverName,
          msg.message,
          (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
          this.broadcastToBrowsers.bind(this),
        );
        break;

      case "rewind_files":
        handleRewindFiles(
          session,
          msg.userMessageId,
          msg.dryRun ?? false,
          (request, onResponse) => sendControlRequest(session, request, this.sendToCLI.bind(this), onResponse),
        ).then((result) => {
          this.broadcastToBrowsers(session, { type: "rewind_result", result });
        });
        break;

      case "update_env":
        handleUpdateEnv(session, msg.variables, this.sendToCLI.bind(this));
        break;

      case "stop_task": {
        const ndjson = JSON.stringify({
          type: "control",
          subtype: "stop_task",
          taskId: msg.task_id,
        });
        this.sendToCLI(session, ndjson);
        break;
      }
    }
  }

  private handleUserMessage(
    session: Session,
    msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] }
  ) {
    // Store user message in history for replay with stable ID for dedup on reconnect
    const ts = Date.now();
    this.sm.appendToHistory(session, {
      type: "user_message",
      content: msg.content,
      timestamp: ts,
      id: `user-${ts}-${this.sm.nextUserMsgId()}`,
    });

    // Build content: if images are present, use content block array; otherwise plain string
    let content: string | unknown[];
    if (msg.images?.length) {
      const blocks: unknown[] = [];
      for (const img of msg.images) {
        blocks.push({
          type: "image",
          source: { type: "base64", media_type: img.media_type, data: img.data },
        });
      }
      blocks.push({ type: "text", text: msg.content });
      content = blocks;
    } else {
      content = msg.content;
    }

    const ndjson = JSON.stringify({
      type: "user",
      message: { role: "user", content },
      parent_tool_use_id: null,
      session_id: msg.session_id || session.state.session_id || "",
    });
    this.sendToCLI(session, ndjson);
    this.sm.persistSession(session);
  }

  // ── Queue operations ─────────────────────────────────────────────

  /** Add a message to the session queue. Returns false if at cap. */
  addToQueue(session: Session, content: string, images?: { media_type: string; data: string }[]): boolean {
    if (session.messageQueue.length >= WsBridge.QUEUE_CAP) {
      return false;
    }
    const queued: QueuedMessage = {
      id: crypto.randomUUID(),
      content,
      ...(images?.length ? { images } : {}),
      queuedAt: Date.now(),
    };
    session.messageQueue.push(queued);
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
    return true;
  }

  /** Remove a single message from the queue by ID. */
  removeFromQueue(session: Session, messageId: string): boolean {
    const idx = session.messageQueue.findIndex((m) => m.id === messageId);
    if (idx === -1) return false;
    session.messageQueue.splice(idx, 1);
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
    return true;
  }

  /** Clear all messages from the queue. */
  clearQueue(session: Session): void {
    session.messageQueue = [];
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
  }

  /** Resume a paused queue and process the next message. */
  resumeQueue(session: Session): void {
    session.queuePaused = false;
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
    this.dequeueNext(session);
  }

  /** Get queue state for a session (used by REST API). */
  getQueueState(sessionId: string): { queue: QueuedMessage[]; paused: boolean } | null {
    const session = this.sm.getSession(sessionId);
    if (!session) return null;
    return { queue: session.messageQueue, paused: session.queuePaused };
  }

  /** Reorder the queue. Returns false if orderedIds don't match current queue. */
  reorderQueue(session: Session, orderedIds: string[]): boolean {
    const idSet = new Set(orderedIds);
    if (idSet.size !== session.messageQueue.length) return false;
    const reordered: QueuedMessage[] = [];
    for (const id of orderedIds) {
      const item = session.messageQueue.find((m) => m.id === id);
      if (!item) return false;
      reordered.push(item);
    }
    session.messageQueue = reordered;
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
    return true;
  }

  /** Edit a queued message's content and/or images. Returns false if not found. */
  editQueueItem(session: Session, messageId: string, content: string, images?: { media_type: string; data: string }[]): boolean {
    const item = session.messageQueue.find((m) => m.id === messageId);
    if (!item) return false;
    item.content = content;
    if (images !== undefined) {
      if (images.length > 0) {
        item.images = images;
      } else {
        delete item.images;
      }
    }
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);
    return true;
  }

  private handleQueueAdd(session: Session, content: string, images?: { media_type: string; data: string }[]): void {
    if (!this.addToQueue(session, content, images)) {
      this.broadcastToBrowsers(session, {
        type: "error",
        message: `Queue is full (max ${WsBridge.QUEUE_CAP} messages)`,
      });
    }
  }

  private handleQueueRemove(session: Session, messageId: string): void {
    this.removeFromQueue(session, messageId);
  }

  private handleQueueClear(session: Session): void {
    this.clearQueue(session);
  }

  private handleQueueResume(session: Session): void {
    this.resumeQueue(session);
  }

  private handleQueueReorder(session: Session, orderedIds: string[]): void {
    this.reorderQueue(session, orderedIds);
  }

  private handleQueueEdit(session: Session, messageId: string, content: string, images?: { media_type: string; data: string }[]): void {
    this.editQueueItem(session, messageId, content, images);
  }

  /**
   * After a result message, decide whether to pause (on error) or
   * auto-send the next queued message (on success).
   */
  private processQueue(session: Session, isError: boolean): void {
    if (session.messageQueue.length === 0) return;

    if (isError) {
      session.queuePaused = true;
      this.broadcastQueueUpdate(session);
      this.sm.persistSession(session);
      return;
    }

    if (!session.queuePaused) {
      this.dequeueNext(session);
    }
  }

  /** Dequeue and send the next message to the CLI. */
  private dequeueNext(session: Session): void {
    if (session.messageQueue.length === 0 || session.queuePaused) return;

    const next = session.messageQueue.shift()!;
    this.broadcastQueueUpdate(session);
    this.sm.persistSession(session);

    // Send as a user message through the normal flow
    this.routeBrowserMessage(session, {
      type: "user_message",
      content: next.content,
      ...(next.images?.length ? { images: next.images } : {}),
    });
  }

  private broadcastQueueUpdate(session: Session): void {
    this.broadcastToBrowsers(session, {
      type: "queue_update",
      sessionId: session.id,
      queue: session.messageQueue,
      paused: session.queuePaused,
    });
  }

  // ── Transport helpers ───────────────────────────────────────────────────

  /**
   * Replay buffered server→CLI messages that were sent after the CLI's last
   * acknowledged request. Called on CLI reconnection when the upgrade request
   * includes an `X-Last-Request-Id` header.
   *
   * The correlation works as follows:
   * 1. Each message sent via sendToCLI is stored in a circular buffer with a
   *    monotonic sequence number.
   * 2. Each CLI→server message with a UUID is recorded in cliMessageSeqMap
   *    with the current sentMessageSeq at time of receipt.
   * 3. On reconnect, the CLI provides the UUID of its last sent message.
   *    We look up the corresponding sent-sequence position and replay all
   *    buffered messages from that point onward.
   */
  private replayMessagesAfter(session: Session, lastRequestId: string): void {
    const targetSeq = session.cliMessageSeqMap.get(lastRequestId);
    if (targetSeq === undefined) {
      log.warn(
        { sessionId: session.id, lastRequestId },
        "Replay requested but last request ID not found in correlation map",
      );
      return;
    }

    const cap = WsBridge.SENT_MESSAGE_BUFFER_CAPACITY;
    const oldestAvailable = Math.max(0, session.sentMessageSeq - cap);
    const startSeq = Math.max(targetSeq, oldestAvailable);

    let replayCount = 0;
    for (let seq = startSeq; seq < session.sentMessageSeq; seq++) {
      const entry = session.sentMessageBuffer[seq % cap];
      if (!entry || entry.seq !== seq) continue; // slot was overwritten
      try {
        session.cliSocket!.send(entry.data + "\n");
        replayCount++;
      } catch (err) {
        log.error({ sessionId: session.id, seq, err }, "Failed to replay message to CLI");
        break;
      }
    }

    if (replayCount > 0) {
      log.info(
        { sessionId: session.id, replayCount, lastRequestId, fromSeq: startSeq },
        "Replayed buffered messages after CLI reconnection",
      );
    }
  }

  /**
   * Send an NDJSON message to the CLI WebSocket.
   * If the CLI socket is not yet connected, the message is queued for later flushing.
   * Successfully sent messages are stored in a circular replay buffer.
   */
  private sendToCLI(session: Session, ndjson: string) {
    if (!session.cliSocket) {
      // Queue the message — CLI might still be starting up.
      // Don't record here; the message will be recorded when flushed.
      log.info({ sessionId: session.id }, "CLI not yet connected, queuing message");
      session.pendingMessages.push(ndjson);
      return;
    }
    // Record raw outgoing CLI message (only when actually sending, not when queuing)
    this.recorder?.record(session.id, "out", ndjson, "cli", session.backendType, session.state.cwd);
    try {
      // NDJSON requires a newline delimiter
      session.cliSocket.send(ndjson + "\n");
      // Store in circular buffer for replay on CLI reconnection
      const cap = WsBridge.SENT_MESSAGE_BUFFER_CAPACITY;
      const idx = session.sentMessageSeq % cap;
      session.sentMessageBuffer[idx] = { seq: session.sentMessageSeq, data: ndjson };
      session.sentMessageSeq++;
    } catch (err) {
      log.error({ sessionId: session.id, err }, "Failed to send to CLI");
    }
  }

  /** Push a session name update to all connected browsers for a session. */
  broadcastNameUpdate(sessionId: string, name: string): void {
    const session = this.sm.getSession(sessionId);
    if (!session) return;
    this.broadcastToBrowsers(session, { type: "session_name_update", name });
  }

  /**
   * Broadcast a message to all connected browser sockets for a session.
   * Messages are sequenced, recorded, and serialized before sending.
   * Dead sockets are removed on send failure.
   */
  private broadcastToBrowsers(session: Session, msg: BrowserIncomingMessage) {
    // Debug: warn when assistant messages are broadcast to 0 browsers (they may be lost)
    if (session.browserSockets.size === 0 && (msg.type === "assistant" || msg.type === "stream_event" || msg.type === "result")) {
      log.warn({ sessionId: session.id, msgType: msg.type, storedInHistory: msg.type === "assistant" || msg.type === "result" }, "Broadcasting to 0 browsers");
    }
    const sequenced = sequenceEvent(session, msg, WsBridge.EVENT_BUFFER_LIMIT);
    const json = JSON.stringify(sequenced);

    // Record using recordJson to embed the already-serialized JSON directly,
    // avoiding a second JSON.stringify that would re-escape all quotes.
    this.recorder?.recordJson(session.id, "out", json, "browser", session.backendType, session.state.cwd);

    for (const ws of session.browserSockets) {
      try {
        ws.send(json);
      } catch (err) {
        log.debug({ sessionId: session.id, err }, "Failed to send to browser socket, removing");
        session.browserSockets.delete(ws);
      }
    }
  }

  private sendToBrowser(ws: ServerWebSocket<SocketData>, msg: BrowserIncomingMessage) {
    try {
      ws.send(JSON.stringify(msg));
    } catch (err) {
      log.debug({ err, msgType: msg.type }, "Failed to send to browser socket — will be cleaned up on close");
    }
  }
}
