/**
 * WebSocket bridge client for the Chrome extension MCP server.
 * Communicates with the Chrome extension via the office bridge server's /chrome path.
 */

import WebSocket from "ws";

import { SocketConnectionError } from "./mcpSocketClient.js";
import {
  localPlatformLabel,
  type BridgePermissionRequest,
  type ChromeExtensionInfo,
  type ClaudeForChromeContext,
  type PermissionMode,
  type PermissionOverrides,
  type SocketClient,
} from "./types.js";

/** Timeout for list_extensions response from the bridge. */
const DISCOVERY_TIMEOUT_MS = 5000;

/** How long to wait for a peer_connected event when 0 extensions are found. */
const PEER_WAIT_TIMEOUT_MS = 10_000;

interface PendingToolCall {
  resolve: (value: unknown) => void;
  reject: (reason: Error) => void;
  timer: NodeJS.Timeout;
  results: unknown[];
  isTabsContext: boolean;
  onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
  startTime: number;
  toolName: string;
}

export class BridgeClient implements SocketClient {
  private ws: WebSocket | null = null;
  private connected = false;
  private authenticated = false;
  private connecting = false;
  private reconnectTimer: NodeJS.Timeout | null = null;
  private reconnectAttempts = 0;
  private pendingCalls = new Map<string, PendingToolCall>();
  private notificationHandler:
    | ((notification: {
        method: string;
        params?: Record<string, unknown>;
      }) => void)
    | null = null;
  private context: ClaudeForChromeContext;
  private permissionMode: PermissionMode = "ask";
  private allowedDomains: string[] | undefined;
  private tabsContextCollectionTimeoutMs = 2000;
  private toolCallTimeoutMs = 120_000;
  private connectionStartTime: number | null = null;
  private connectionEstablishedTime: number | null = null;
  /** The device_id of the selected Chrome extension for targeted routing. */
  private selectedDeviceId: string | undefined;
  /** True after first discovery attempt completes (success or timeout). */
  private discoveryComplete = false;
  /** Shared promise so concurrent callTool invocations join the same discovery. */
  private discoveryPromise: Promise<void> | null = null;
  /** Pending discovery response from bridge. */
  private pendingDiscovery: {
    resolve: (extensions: ChromeExtensionInfo[]) => void;
    timeout: NodeJS.Timeout;
  } | null = null;
  /** The device_id we had selected before a peer_disconnected — for auto-reselect. */
  private previousSelectedDeviceId: string | undefined;
  /** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */
  private peerConnectedWaiters: Array<(arrived: boolean) => void> = [];
  /** The request_id of the current pending pairing broadcast. */
  private pendingPairingRequestId: string | undefined;
  /** True while a pairing broadcast is in flight and no response yet. */
  private pairingInProgress = false;
  /** The deviceId from a previous persisted pairing. */
  private persistedDeviceId: string | undefined;
  /** Resolve callback for a blocking switchBrowser() call. */
  private pendingSwitchResolve:
    | ((result: { deviceId: string; name: string } | null) => void)
    | null = null;

  constructor(context: ClaudeForChromeContext) {
    this.context = context;
    if (context.initialPermissionMode) {
      this.permissionMode = context.initialPermissionMode;
    }
  }

  public async ensureConnected(): Promise<boolean> {
    const { logger, serverName } = this.context;
    logger.info(
      `[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`,
    );

    if (
      this.connected &&
      this.authenticated &&
      this.ws?.readyState === WebSocket.OPEN
    ) {
      logger.info(`[${serverName}] Already connected and authenticated`);
      return true;
    }

    if (!this.connecting) {
      logger.info(`[${serverName}] Not connecting, starting connection...`);
      await this.connect();
    } else {
      logger.info(`[${serverName}] Already connecting, waiting...`);
    }

    // Wait for authentication with timeout
    return new Promise((resolve) => {
      const timeout = setTimeout(() => {
        logger.info(
          `[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`,
        );
        resolve(false);
      }, 10_000);
      const check = () => {
        if (this.connected && this.authenticated) {
          logger.info(`[${serverName}] Connection successful`);
          clearTimeout(timeout);
          resolve(true);
        } else if (!this.connecting) {
          logger.info(`[${serverName}] No longer connecting, giving up`);
          clearTimeout(timeout);
          resolve(false);
        } else {
          setTimeout(check, 200);
        }
      };
      check();
    });
  }

  public async callTool(
    name: string,
    args: Record<string, unknown>,
    permissionOverrides?: PermissionOverrides,
  ): Promise<unknown> {
    const { logger, serverName, trackEvent } = this.context;

    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      throw new SocketConnectionError(`[${serverName}] Bridge not connected`);
    }

    // Lazy discovery: run on first tool call if no extension selected yet.
    // Use a shared promise so concurrent callers join the same discovery.
    if (!this.selectedDeviceId && !this.discoveryComplete) {
      this.discoveryPromise ??= this.discoverAndSelectExtension().finally(
        () => {
          this.discoveryPromise = null;
        },
      );
      await this.discoveryPromise;
    }

    // TODO: Once all extensions support pairing, throw here for multi-extension
    // cases where pairingInProgress is true. For now, let the bridge handle
    // routing — it auto-routes to a single extension or returns an error for
    // multiple extensions without a target_device_id.

    const toolUseId = crypto.randomUUID();
    const isTabsContext = name === "tabs_context_mcp";
    const startTime = Date.now();
    const timeoutMs = isTabsContext
      ? this.tabsContextCollectionTimeoutMs
      : this.toolCallTimeoutMs;

    // Track tool call start
    trackEvent?.("chrome_bridge_tool_call_started", {
      tool_name: name,
      tool_use_id: toolUseId,
    });

    // Per-call overrides (from session context) take priority over
    // instance values (from set_permission_mode on the singleton).
    const effectivePermissionMode =
      permissionOverrides?.permissionMode ?? this.permissionMode;
    const effectiveAllowedDomains =
      permissionOverrides?.allowedDomains ?? this.allowedDomains;

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        const pending = this.pendingCalls.get(toolUseId);
        if (pending) {
          this.pendingCalls.delete(toolUseId);
          const durationMs = Date.now() - pending.startTime;

          if (isTabsContext && pending.results.length > 0) {
            // For tabs_context, resolve with collected results even on timeout
            trackEvent?.("chrome_bridge_tool_call_completed", {
              tool_name: name,
              tool_use_id: toolUseId,
              duration_ms: durationMs,
            });
            resolve(this.mergeTabsResults(pending.results));
          } else {
            logger.warn(
              `[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`,
            );
            trackEvent?.("chrome_bridge_tool_call_timeout", {
              tool_name: name,
              tool_use_id: toolUseId,
              duration_ms: durationMs,
              timeout_ms: timeoutMs,
            });
            reject(
              new SocketConnectionError(
                `[${serverName}] Tool call timed out: ${name}`,
              ),
            );
          }
        }
      }, timeoutMs);

      this.pendingCalls.set(toolUseId, {
        resolve,
        reject,
        timer,
        results: [],
        isTabsContext,
        onPermissionRequest: permissionOverrides?.onPermissionRequest,
        startTime,
        toolName: name,
      });

      const message: Record<string, unknown> = {
        type: "tool_call",
        tool_use_id: toolUseId,
        client_type: this.context.clientTypeId,
        tool: name,
        args,
      };

      // Target the selected extension for routing
      if (this.selectedDeviceId) {
        message.target_device_id = this.selectedDeviceId;
      }

      // Only include permission fields when a value exists.
      // Priority: per-call override (from session context) > instance value (from set_permission_mode).
      if (effectivePermissionMode) {
        message.permission_mode = effectivePermissionMode;
      }
      if (effectiveAllowedDomains?.length) {
        message.allowed_domains = effectiveAllowedDomains;
      }
      if (permissionOverrides?.onPermissionRequest) {
        message.handle_permission_prompts = true;
      }

      logger.debug(
        `[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`,
      );
      this.ws!.send(JSON.stringify(message));
    });
  }

  public isConnected(): boolean {
    return (
      this.connected &&
      this.authenticated &&
      this.ws?.readyState === WebSocket.OPEN
    );
  }

  public disconnect(): void {
    this.cleanup();
  }

  public setNotificationHandler(
    handler: (notification: {
      method: string;
      params?: Record<string, unknown>;
    }) => void,
  ): void {
    this.notificationHandler = handler;
  }

  public async setPermissionMode(
    mode: PermissionMode,
    allowedDomains?: string[],
  ): Promise<void> {
    this.permissionMode = mode;
    this.allowedDomains = allowedDomains;
  }

  // ===========================================================================
  // Extension discovery and selection
  // ===========================================================================

  /**
   * Discover connected extensions and auto-select one, or broadcast a pairing request.
   * Called lazily on the first tool call.
   */
  private async discoverAndSelectExtension(): Promise<void> {
    const { logger, serverName } = this.context;

    this.persistedDeviceId ??= this.context.getPersistedDeviceId?.();

    let extensions = await this.queryBridgeExtensions();

    if (extensions.length === 0) {
      logger.info(
        `[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`,
      );
      const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS);
      if (peerArrived) {
        extensions = await this.queryBridgeExtensions();
      }
    }

    this.discoveryComplete = true;

    if (extensions.length === 0) {
      // Still nothing — callTool will throw a clear error
      logger.info(`[${serverName}] No extensions found after waiting`);
      return;
    }

    // Single extension: auto-select silently
    if (extensions.length === 1) {
      const ext = extensions[0]!;
      if (!this.isLocalExtension(ext)) {
        this.context.onRemoteExtensionWarning?.(ext);
      }
      this.selectExtension(ext.deviceId);
      return;
    }

    // Multiple extensions: check for persisted selection
    if (this.persistedDeviceId) {
      const persisted = extensions.find(
        (e) => e.deviceId === this.persistedDeviceId,
      );
      if (persisted) {
        logger.info(
          `[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`,
        );
        this.selectExtension(persisted.deviceId);
        return;
      }
    }

    // Multiple extensions, no valid persisted selection: broadcast and fail fast
    this.broadcastPairingRequest();
    this.pairingInProgress = true;
  }

  /**
   * Query the bridge for connected extensions. Returns empty array on timeout.
   * Deduplicates by deviceId, keeping the most recent connection — the bridge
   * may report stale duplicates (e.g. after a service worker restart).
   */
  private async queryBridgeExtensions(): Promise<ChromeExtensionInfo[]> {
    const raw: ChromeExtensionInfo[] = await new Promise((resolve) => {
      const timeout = setTimeout(() => {
        this.pendingDiscovery = null;
        resolve([]);
      }, DISCOVERY_TIMEOUT_MS);

      this.pendingDiscovery = { resolve, timeout };
      this.ws?.send(JSON.stringify({ type: "list_extensions" }));
    });

    const byDeviceId = new Map<string, ChromeExtensionInfo>();
    for (const ext of raw) {
      const existing = byDeviceId.get(ext.deviceId);
      if (!existing || ext.connectedAt > existing.connectedAt) {
        byDeviceId.set(ext.deviceId, ext);
      }
    }
    return [...byDeviceId.values()];
  }

  /**
   * Select an extension by device ID for per-message targeted routing.
   */
  private selectExtension(deviceId: string): void {
    const { logger, serverName } = this.context;
    this.selectedDeviceId = deviceId;
    this.previousSelectedDeviceId = undefined;
    logger.info(
      `[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`,
    );
  }

  /**
   * Check if an extension might be on the same machine as this MCP client
   * by comparing OS platform. Extensions can't provide a real hostname from
   * the service worker sandbox, so platform is a weak heuristic. The profile
   * email is the primary differentiator shown in the selection dialog.
   */
  private isLocalExtension(ext: ChromeExtensionInfo): boolean {
    if (!ext.osPlatform) return false;
    return ext.osPlatform === localPlatformLabel();
  }

  /**
   * Returns a promise that resolves to `true` when a peer_connected event
   * fires, or `false` if the timeout elapses first.
   */
  private waitForPeerConnected(timeoutMs: number): Promise<boolean> {
    return new Promise((resolve) => {
      const timer = setTimeout(() => {
        this.peerConnectedWaiters = this.peerConnectedWaiters.filter(
          (w) => w !== onPeer,
        );
        resolve(false);
      }, timeoutMs);

      const onPeer = (arrived: boolean) => {
        clearTimeout(timer);
        resolve(arrived);
      };

      this.peerConnectedWaiters.push(onPeer);
    });
  }

  /**
   * Broadcast a pairing request to all connected extensions.
   * Non-blocking — the pairing_response handler will select the extension.
   */
  private broadcastPairingRequest(): void {
    const requestId = crypto.randomUUID();
    this.pendingPairingRequestId = requestId;
    this.ws?.send(
      JSON.stringify({
        type: "pairing_request",
        request_id: requestId,
        client_type: this.context.clientTypeId,
      }),
    );
  }

  /**
   * Switch to a different browser. Broadcasts a pairing request and blocks
   * until a response arrives or timeout (120s). Returns the paired extension
   * info, or null on timeout.
   */
  public async switchBrowser(): Promise<
    | {
        deviceId: string;
        name: string;
      }
    | "no_other_browsers"
    | null
  > {
    const extensions = await this.queryBridgeExtensions();
    const currentDeviceId =
      this.selectedDeviceId ?? this.previousSelectedDeviceId;
    if (
      extensions.length === 0 ||
      (extensions.length === 1 &&
        (!currentDeviceId || extensions[0]!.deviceId === currentDeviceId))
    ) {
      return "no_other_browsers";
    }

    this.previousSelectedDeviceId = this.selectedDeviceId;
    this.selectedDeviceId = undefined;
    this.discoveryComplete = false;
    this.pairingInProgress = false;

    const requestId = crypto.randomUUID();
    this.pendingPairingRequestId = requestId;
    if (this.ws?.readyState !== WebSocket.OPEN) {
      return null;
    }
    this.ws.send(
      JSON.stringify({
        type: "pairing_request",
        request_id: requestId,
        client_type: this.context.clientTypeId,
      }),
    );

    // Resolve any previous pending switch so the caller doesn't hang forever
    if (this.pendingSwitchResolve) {
      this.pendingSwitchResolve(null);
    }

    // Block for switch_browser since user is actively engaged
    return new Promise((resolve) => {
      const timer = setTimeout(() => {
        if (this.pendingPairingRequestId === requestId) {
          this.pendingPairingRequestId = undefined;
        }
        this.pendingSwitchResolve = null;
        resolve(null);
      }, 120_000);

      this.pendingSwitchResolve = (result) => {
        clearTimeout(timer);
        this.pendingSwitchResolve = null;
        resolve(result);
      };
    });
  }

  private async connect(): Promise<void> {
    const { logger, serverName, bridgeConfig, trackEvent } = this.context;

    if (!bridgeConfig) {
      logger.error(`[${serverName}] No bridge config provided`);
      return;
    }

    if (this.connecting) {
      return;
    }

    this.connecting = true;
    this.authenticated = false;
    this.connectionStartTime = Date.now();
    this.closeSocket();

    // Get user ID for the connection path
    let userId: string;
    let token: string | undefined;

    if (bridgeConfig.devUserId) {
      userId = bridgeConfig.devUserId;
      logger.debug(`[${serverName}] Using dev user ID for bridge connection`);
    } else {
      logger.debug(`[${serverName}] Fetching user ID for bridge connection`);
      const fetchedUserId = await bridgeConfig.getUserId();
      if (!fetchedUserId) {
        const durationMs = Date.now() - this.connectionStartTime;
        logger.error(
          `[${serverName}] No user ID available after ${durationMs}ms`,
        );
        trackEvent?.("chrome_bridge_connection_failed", {
          duration_ms: durationMs,
          error_type: "no_user_id",
          reconnect_attempt: this.reconnectAttempts,
        });
        this.connecting = false;
        this.context.onAuthenticationError?.();
        return;
      }
      userId = fetchedUserId;

      logger.debug(
        `[${serverName}] Fetching OAuth token for bridge connection`,
      );
      token = await bridgeConfig.getOAuthToken();
      if (!token) {
        const durationMs = Date.now() - this.connectionStartTime;
        logger.error(
          `[${serverName}] No OAuth token available after ${durationMs}ms`,
        );
        trackEvent?.("chrome_bridge_connection_failed", {
          duration_ms: durationMs,
          error_type: "no_oauth_token",
          reconnect_attempt: this.reconnectAttempts,
        });
        this.connecting = false;
        this.context.onAuthenticationError?.();
        return;
      }
    }

    // Connect to user-specific endpoint: /chrome/<user_id>
    const wsUrl = `${bridgeConfig.url}/chrome/${userId}`;
    logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`);

    // Track connection started
    trackEvent?.("chrome_bridge_connection_started", {
      bridge_url: wsUrl,
    });

    try {
      this.ws = new WebSocket(wsUrl);
    } catch (error) {
      const durationMs = Date.now() - this.connectionStartTime;
      logger.error(
        `[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
        error,
      );
      trackEvent?.("chrome_bridge_connection_failed", {
        duration_ms: durationMs,
        error_type: "websocket_error",
        reconnect_attempt: this.reconnectAttempts,
      });
      this.connecting = false;
      this.scheduleReconnect();
      return;
    }

    this.ws.on("open", () => {
      logger.info(
        `[${serverName}] WebSocket connected, sending connect message`,
      );

      // First message must be connect (same format as office path)
      const connectMessage: Record<string, unknown> = {
        type: "connect",
        client_type: this.context.clientTypeId,
      };

      if (bridgeConfig.devUserId) {
        connectMessage.dev_user_id = bridgeConfig.devUserId;
      } else {
        connectMessage.oauth_token = token;
      }

      this.ws?.send(JSON.stringify(connectMessage));
    });

    this.ws.on("message", (data: WebSocket.Data) => {
      try {
        const message = JSON.parse(data.toString()) as Record<string, unknown>;
        logger.debug(
          `[${serverName}] Bridge received: ${JSON.stringify(message)}`,
        );
        this.handleMessage(message);
      } catch (error) {
        logger.error(`[${serverName}] Failed to parse bridge message:`, error);
      }
    });

    this.ws.on("close", (code: number) => {
      const durationSinceConnect = this.connectionEstablishedTime
        ? Date.now() - this.connectionEstablishedTime
        : 0;
      logger.info(
        `[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`,
      );
      trackEvent?.("chrome_bridge_disconnected", {
        close_code: code,
        duration_since_connect_ms: durationSinceConnect,
        reconnect_attempt: this.reconnectAttempts + 1,
      });
      this.connected = false;
      this.authenticated = false;
      this.connecting = false;
      this.connectionEstablishedTime = null;
      this.scheduleReconnect();
    });

    this.ws.on("error", (error: Error) => {
      const durationMs = this.connectionStartTime
        ? Date.now() - this.connectionStartTime
        : 0;
      logger.error(
        `[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`,
      );
      trackEvent?.("chrome_bridge_connection_failed", {
        duration_ms: durationMs,
        error_type: "websocket_error",
        reconnect_attempt: this.reconnectAttempts,
      });
      this.connected = false;
      this.authenticated = false;
      this.connecting = false;
    });
  }

  private handleMessage(message: Record<string, unknown>): void {
    const { logger, serverName, trackEvent } = this.context;

    switch (message.type) {
      case "paired": {
        const durationMs = this.connectionStartTime
          ? Date.now() - this.connectionStartTime
          : 0;
        logger.info(
          `[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`,
        );
        this.connected = true;
        this.authenticated = true;
        this.connecting = false;
        this.reconnectAttempts = 0;
        this.connectionEstablishedTime = Date.now();
        trackEvent?.("chrome_bridge_connection_succeeded", {
          duration_ms: durationMs,
          status: "paired",
        });
        break;
      }

      case "waiting": {
        const durationMs = this.connectionStartTime
          ? Date.now() - this.connectionStartTime
          : 0;
        logger.info(
          `[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`,
        );
        this.connected = true;
        this.authenticated = true;
        this.connecting = false;
        this.reconnectAttempts = 0;
        this.connectionEstablishedTime = Date.now();
        trackEvent?.("chrome_bridge_connection_succeeded", {
          duration_ms: durationMs,
          status: "waiting",
        });
        break;
      }

      case "peer_connected":
        logger.info(`[${serverName}] Chrome extension connected to bridge`);
        trackEvent?.("chrome_bridge_peer_connected", null);
        // If no extension selected, mark discovery as needed (next tool call will discover)
        if (!this.selectedDeviceId) {
          this.discoveryComplete = false;
        }
        // Auto-reselect if the previously selected extension reconnected (e.g., service worker restart)
        if (
          this.previousSelectedDeviceId &&
          message.deviceId === this.previousSelectedDeviceId &&
          !this.pendingSwitchResolve
        ) {
          logger.info(
            `[${serverName}] Previously selected extension reconnected, auto-reselecting`,
          );
          this.selectExtension(this.previousSelectedDeviceId);
          this.previousSelectedDeviceId = undefined;
        }
        if (this.peerConnectedWaiters.length > 0) {
          const waiters = this.peerConnectedWaiters;
          this.peerConnectedWaiters = [];
          for (const waiter of waiters) {
            waiter(true);
          }
        }
        break;

      case "peer_disconnected":
        logger.info(
          `[${serverName}] Chrome extension disconnected from bridge`,
        );
        trackEvent?.("chrome_bridge_peer_disconnected", null);
        // If the selected extension disconnected, clear selection for re-discovery
        if (message.deviceId && message.deviceId === this.selectedDeviceId) {
          logger.info(
            `[${serverName}] Selected extension disconnected, clearing selection`,
          );
          this.previousSelectedDeviceId = this.selectedDeviceId;
          this.selectedDeviceId = undefined;
          this.discoveryComplete = false;
        }
        break;

      case "extensions_list":
        // Response to list_extensions — resolve pending discovery
        if (this.pendingDiscovery) {
          clearTimeout(this.pendingDiscovery.timeout);
          this.pendingDiscovery.resolve(
            (message.extensions as ChromeExtensionInfo[]) ?? [],
          );
          this.pendingDiscovery = null;
        }
        break;

      case "pairing_response": {
        const requestId = message.request_id as string;
        const responseDeviceId = message.device_id as string;
        const responseName = message.name as string;
        if (
          this.pendingPairingRequestId === requestId &&
          responseDeviceId &&
          responseName
        ) {
          this.pendingPairingRequestId = undefined;
          this.pairingInProgress = false;
          this.selectExtension(responseDeviceId);
          this.context.onExtensionPaired?.(responseDeviceId, responseName);
          logger.info(
            `[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`,
          );
          if (this.pendingSwitchResolve) {
            this.pendingSwitchResolve({
              deviceId: responseDeviceId,
              name: responseName,
            });
            this.pendingSwitchResolve = null;
          }
        }
        break;
      }

      case "ping":
        this.ws?.send(JSON.stringify({ type: "pong" }));
        break;

      case "pong":
        // Response to our keepalive, nothing to do
        break;

      case "tool_result":
        this.handleToolResult(message);
        break;

      case "permission_request":
        void this.handlePermissionRequest(message);
        break;

      case "notification":
        if (this.notificationHandler) {
          this.notificationHandler({
            method: message.method as string,
            params: message.params as Record<string, unknown> | undefined,
          });
        }
        break;

      case "error":
        logger.warn(`[${serverName}] Bridge error: ${message.error}`);
        // If we had a selected extension, the error may indicate it's gone
        // (e.g., extension disconnected between list and select). Clear state
        // so the next tool call re-discovers.
        if (this.selectedDeviceId) {
          this.selectedDeviceId = undefined;
          this.discoveryComplete = false;
        }
        break;

      default:
        logger.warn(
          `[${serverName}] Unrecognized bridge message type: ${message.type}`,
        );
    }
  }

  private async handlePermissionRequest(
    message: Record<string, unknown>,
  ): Promise<void> {
    const { logger, serverName } = this.context;
    const toolUseId = message.tool_use_id as string;
    const requestId = message.request_id as string;

    if (!toolUseId || !requestId) {
      logger.warn(
        `[${serverName}] permission_request missing tool_use_id or request_id`,
      );
      return;
    }

    const pending = this.pendingCalls.get(toolUseId);
    if (!pending?.onPermissionRequest) {
      // Don't auto-deny — the bridge broadcasts permission_request to all
      // connected MCP clients, and only the client that made the tool call
      // has the pending entry. Auto-denying here would race with the correct
      // client's handler when multiple Desktop instances are connected.
      logger.debug(
        `[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`,
      );
      return;
    }

    const request: BridgePermissionRequest = {
      toolUseId,
      requestId,
      toolType: (message.tool_type as string) ?? "unknown",
      url: (message.url as string) ?? "",
      actionData: message.action_data as Record<string, unknown> | undefined,
    };

    try {
      const allowed = await pending.onPermissionRequest(request);
      this.sendPermissionResponse(requestId, allowed);
    } catch (error) {
      logger.error(`[${serverName}] Error handling permission request:`, error);
      this.sendPermissionResponse(requestId, false);
    }
  }

  private sendPermissionResponse(requestId: string, allowed: boolean): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      const message: Record<string, unknown> = {
        type: "permission_response",
        request_id: requestId,
        allowed,
      };
      if (this.selectedDeviceId) {
        message.target_device_id = this.selectedDeviceId;
      }
      this.ws.send(JSON.stringify(message));
    }
  }

  private handleToolResult(message: Record<string, unknown>): void {
    const { logger, serverName, trackEvent } = this.context;
    const toolUseId = message.tool_use_id as string;
    if (!toolUseId) {
      logger.warn(`[${serverName}] Received tool_result without tool_use_id`);
      return;
    }

    const pending = this.pendingCalls.get(toolUseId);
    if (!pending) {
      logger.debug(
        `[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`,
      );
      return;
    }

    const durationMs = Date.now() - pending.startTime;

    // Normalize bridge response format to match socket client format.
    // Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean }
    // Socket sends: { result: { content: [...] } } or { error: { content: [...] } }
    const normalized = this.normalizeBridgeResponse(message);
    const isError = Boolean(message.is_error) || "error" in normalized;

    if (pending.isTabsContext && !this.selectedDeviceId) {
      // No extension selected: collect results from all extensions (pre-selection / backward compat)
      pending.results.push(normalized);
      // Don't resolve yet — let the timer handle collection
    } else {
      // For other tools, resolve on first result
      clearTimeout(pending.timer);
      this.pendingCalls.delete(toolUseId);

      if (isError) {
        // Extract error message for telemetry
        const errorContent = (normalized as { error?: { content?: unknown[] } })
          .error?.content;
        let errorMessage = "Unknown error";
        if (Array.isArray(errorContent)) {
          const textItem = errorContent.find(
            (item) =>
              typeof item === "object" && item !== null && "text" in item,
          ) as { text?: string } | undefined;
          if (textItem?.text) {
            errorMessage = textItem.text.slice(0, 200);
          }
        }

        logger.warn(
          `[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`,
        );
        trackEvent?.("chrome_bridge_tool_call_error", {
          tool_name: pending.toolName,
          tool_use_id: toolUseId,
          duration_ms: durationMs,
          error_message: errorMessage,
        });
      } else {
        logger.debug(
          `[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`,
        );
        trackEvent?.("chrome_bridge_tool_call_completed", {
          tool_name: pending.toolName,
          tool_use_id: toolUseId,
          duration_ms: durationMs,
        });
      }

      pending.resolve(normalized);
    }
  }

  private normalizeBridgeResponse(
    message: Record<string, unknown>,
  ): Record<string, unknown> {
    // Already has result/error wrapper (socket format) — pass through
    if (message.result || message.error) {
      return message;
    }

    // Bridge format has content at top level — wrap it
    if (message.content) {
      if (message.is_error) {
        return { error: { content: message.content } };
      }
      return { result: { content: message.content } };
    }

    return message;
  }

  private mergeTabsResults(results: unknown[]): unknown {
    const mergedTabs: unknown[] = [];

    for (const result of results) {
      const msg = result as Record<string, unknown>;
      const resultData = msg.result as
        | { content?: Array<{ type: string; text?: string }> }
        | undefined;
      const content = resultData?.content;

      if (!content || !Array.isArray(content)) continue;

      for (const item of content) {
        if (item.type === "text" && item.text) {
          try {
            const parsed = JSON.parse(item.text);
            if (Array.isArray(parsed)) {
              mergedTabs.push(...parsed);
            } else if (
              parsed?.availableTabs &&
              Array.isArray(parsed.availableTabs)
            ) {
              mergedTabs.push(...parsed.availableTabs);
            }
          } catch {
            // Not JSON, skip
          }
        }
      }
    }

    if (mergedTabs.length > 0) {
      const tabListText = mergedTabs
        .map((t) => {
          const tab = t as { tabId: number; title: string; url: string };
          return `  \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
        })
        .join("\n");

      return {
        result: {
          content: [
            {
              type: "text",
              text: JSON.stringify({ availableTabs: mergedTabs }),
            },
            {
              type: "text",
              text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
            },
          ],
        },
      };
    }

    // Return first result as fallback
    return results[0];
  }

  private scheduleReconnect(): void {
    const { logger, serverName, trackEvent } = this.context;

    if (this.reconnectTimer) return;

    this.reconnectAttempts++;

    if (this.reconnectAttempts > 100) {
      logger.warn(
        `[${serverName}] Giving up bridge reconnection after 100 attempts`,
      );
      trackEvent?.("chrome_bridge_reconnect_exhausted", {
        total_attempts: 100,
      });
      this.reconnectAttempts = 0;
      return;
    }

    const delay = Math.min(
      2000 * Math.pow(1.5, this.reconnectAttempts - 1),
      30_000,
    );

    if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) {
      logger.info(
        `[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`,
      );
    }

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      void this.connect();
    }, delay);
  }

  private closeSocket(): void {
    if (this.ws) {
      this.ws.removeAllListeners();
      this.ws.close();
      this.ws = null;
    }
    this.connected = false;
    this.authenticated = false;
    // Clear extension selection state so reconnections start fresh
    this.selectedDeviceId = undefined;
    this.discoveryComplete = false;
    this.pendingPairingRequestId = undefined;
    this.pairingInProgress = false;
    if (this.pendingSwitchResolve) {
      this.pendingSwitchResolve(null);
      this.pendingSwitchResolve = null;
    }
    if (this.pendingDiscovery) {
      clearTimeout(this.pendingDiscovery.timeout);
      this.pendingDiscovery.resolve([]);
      this.pendingDiscovery = null;
    }
    // Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout
    if (this.peerConnectedWaiters.length > 0) {
      const waiters = this.peerConnectedWaiters;
      this.peerConnectedWaiters = [];
      for (const waiter of waiters) {
        waiter(false);
      }
    }
  }

  private cleanup(): void {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }

    // Reject all pending calls
    for (const [id, pending] of this.pendingCalls) {
      clearTimeout(pending.timer);
      pending.reject(new SocketConnectionError("Bridge client disconnected"));
      this.pendingCalls.delete(id);
    }

    this.closeSocket();
    this.reconnectAttempts = 0;
  }
}

export function createBridgeClient(
  context: ClaudeForChromeContext,
): BridgeClient {
  return new BridgeClient(context);
}
