/**
 * CDP Client - Wrapper around puppeteer-core for Chrome DevTools Protocol
 */

import puppeteer, { Browser, BrowserContext, Page, Target, CDPSession } from 'puppeteer-core';
import { getChromeLauncher } from '../chrome/launcher';
import { getGlobalConfig } from '../config/global';
import { smartGoto } from '../utils/smart-goto';
import { getTargetId } from '../utils/puppeteer-helpers';
import { getRefIdManager } from '../utils/ref-id-manager';
import { safeAsyncListener } from '../utils/safe-listener';
import {
  DEFAULT_VIEWPORT,
  DEFAULT_NAVIGATION_TIMEOUT_MS,
  DEFAULT_PROTOCOL_TIMEOUT_MS,
  DEFAULT_COOKIE_SCAN_TIMEOUT_MS,
  DEFAULT_COOKIE_SCAN_PER_TARGET_TIMEOUT_MS,
  DEFAULT_COOKIE_SCAN_MAX_CANDIDATES,
  DEFAULT_COOKIE_COPY_TIMEOUT_MS,
  DEFAULT_NEW_PAGE_TIMEOUT_MS,
  DEFAULT_PAGE_CONFIG_TIMEOUT_MS,
  DEFAULT_PUPPETEER_CONNECT_TIMEOUT_MS,
  DEFAULT_HEARTBEAT_PING_TIMEOUT_MS,
  DEFAULT_CONNECT_VERIFY_STALENESS_MS,
  DEFAULT_CDP_SEND_TIMEOUT_MS,
  DEFAULT_HEARTBEAT_INTERVAL_MS,
  DEFAULT_MAX_RECONNECT_ATTEMPTS,
  DEFAULT_RECONNECT_DELAY_MS,
  DEFAULT_SESSION_INIT_MIN_ATTEMPT_MS,
} from '../config/defaults';
import { Budget, isLegacyBudgetMode } from '../utils/budget';
import { withTimeout } from '../utils/with-timeout';
import { getMetricsCollector } from '../metrics/collector';
import { OpenChromeConnectionError } from '../errors/connection';
import { getStealthFingerprintDefenseScript, getStealthStackSanitizationScript } from '../stealth/fingerprint-defense';
import { getIdleState } from '../utils/idle-state';
import { assertDomainAllowed, isInternalBrowserUrl } from '../security/domain-guard';
import { applyRegisteredPreloads } from './preload-injector';
import { TargetPageIndex } from './target-page-index';

// Cookie type shared across methods
type CookieEntry = {
  name: string;
  value: string;
  domain: string;
  path: string;
  expires: number;
  httpOnly: boolean;
  secure: boolean;
  sameSite?: string;
};

export type CookieScanStatus = 'complete' | 'partial' | 'no_candidates' | 'no_cookies';

export interface CookieScanResult {
  status: CookieScanStatus;
  targetId: string | null;
  scanned: number;
  total: number;
  elapsedMs: number;
  warning?: string;
}

export interface CDPClientOptions {
  port?: number;
  maxReconnectAttempts?: number;
  reconnectDelayMs?: number;
  heartbeatIntervalMs?: number;
  /** If true, auto-launch Chrome when not running (default: false) */
  autoLaunch?: boolean;
}

export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';

export interface ConnectionEvent {
  type: 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'reconnect_failed';
  timestamp: number;
  attempt?: number;
  error?: string;
}


function parseEnvInt(name: string, fallback: number): number {
  const raw = process.env[name];
  if (raw === undefined) return fallback;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed < 0) {
    console.error(`[CDPClient] Invalid value for ${name}="${raw}", using default ${fallback}`);
    return fallback;
  }
  // Special case: 0 means Infinity for max-attempts (infinite reconnection)
  if (name === 'OPENCHROME_MAX_RECONNECT_ATTEMPTS' && parsed === 0) {
    return Infinity;
  }
  return parsed;
}

export class CDPClient {
  private browser: Browser | null = null;
  private sessions: Map<string, CDPSession> = new Map();
  private port: number;
  private maxReconnectAttempts: number;
  private reconnectDelayMs: number;
  private heartbeatIntervalMs: number;
  private heartbeatTimer: NodeJS.Timeout | null = null;
  private connectionState: ConnectionState = 'disconnected';
  private eventListeners: ((event: ConnectionEvent) => void)[] = [];
  private targetDestroyedListeners: ((targetId: string, page?: Page) => void)[] = [];
  private reconnectAttempts = 0;
  private consecutiveHeartbeatFailures = 0;
  private consecutiveHeartbeatSuccesses = 0;
  private checkConnectionInFlight = false;
  private autoLaunch: boolean;
  private cookieSourceCache: Map<string, { targetId: string; timestamp: number }> = new Map();
  private cookieDataCache: Map<string, { cookies: CookieEntry[]; timestamp: number }> = new Map();
  private targetIdIndex = new TargetPageIndex();
  private targetActivityAt: Map<string, number> = new Map();
  private inFlightCookieScans: Map<string, Promise<CookieScanResult>> = new Map();
  private lastCookieScanResult: CookieScanResult | null = null;
  /** Coalesces concurrent connect() calls — only one connectInternal() runs at a time. */
  private pendingConnect: Promise<void> | null = null;
  /**
   * Effective `autoLaunch` of the currently in-flight `pendingConnect`. Used to
   * reject coalescing when a new caller's effective `autoLaunch` differs — e.g.
   * the startup readiness probe must not silently ride a tool-call's launch
   * attempt and inadvertently spawn Chrome, nor must a tool call silently ride
   * the probe's `false` and fail to launch.
   */
  private pendingConnectAutoLaunch: boolean | undefined = undefined;
  /** Invalidates stale async connection attempts that resolve after a reconnect. */
  private connectionGeneration = 0;
  /** Timestamp of last successful connection verification (heartbeat or active probe). */
  private lastVerifiedAt = 0;

  // Adaptive heartbeat state
  private heartbeatMode: 'idle' | 'active' | 'heavy' | 'recovery' = 'active';
  private lastCommandAt = 0;
  private heartbeatModeTimer: NodeJS.Timeout | null = null;

  // Connection health metrics
  private reconnectCount = 0;
  private pingLatencies: number[] = []; // rolling window
  private static readonly MAX_PING_SAMPLES = 60; // ~5 min at 5s interval

  private static readonly COOKIE_CACHE_TTL = 300000; // 5 minutes

  // Bounded cookie cache sizes (see issue #647).
  // Entries are evicted FIFO on write once at cap, and on read-miss when stale.
  // Rationale:
  //   - 64 source entries ≈ 64 distinct domains per instance (edge case fan-out
  //     degrades gracefully to the existing fresh-scan slow path).
  //   - 16 data entries ≈ typical open-tab count; one entry holds a full cookie
  //     array (~50–100 KB) so this is the dominant memory line.
  private static readonly COOKIE_SOURCE_CACHE_MAX = 64;
  private static readonly COOKIE_DATA_CACHE_MAX = 16;

  // Reconnection progress (exposed via getConnectionMetrics)
  private reconnecting = false;
  private reconnectingAttempt = 0;
  private reconnectNextRetryAt = 0;
  /** Set by disconnect() to abort any in-progress reconnection loop. */
  private disconnectRequested = false;

  constructor(options: CDPClientOptions = {}) {
    const globalConfig = getGlobalConfig();
    this.port = options.port || globalConfig.port;
    this.maxReconnectAttempts = options.maxReconnectAttempts ?? parseEnvInt('OPENCHROME_MAX_RECONNECT_ATTEMPTS', DEFAULT_MAX_RECONNECT_ATTEMPTS);
    this.reconnectDelayMs = options.reconnectDelayMs ?? parseEnvInt('OPENCHROME_RECONNECT_DELAY_MS', DEFAULT_RECONNECT_DELAY_MS);
    this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? parseEnvInt('OPENCHROME_HEARTBEAT_INTERVAL_MS', DEFAULT_HEARTBEAT_INTERVAL_MS);
    // Use explicit option if provided, otherwise use global config
    this.autoLaunch = options.autoLaunch !== undefined ? options.autoLaunch : globalConfig.autoLaunch;
  }

  /**
   * Get current connection state
   */
  getConnectionState(): ConnectionState {
    return this.connectionState;
  }

  /**
   * Whether the client is currently in a reconnection loop.
   */
  isReconnecting(): boolean {
    return this.reconnecting || this.connectionState === 'reconnecting';
  }

  /**
   * Estimated milliseconds until the next reconnection attempt completes.
   * Returns 0 if not reconnecting.
   */
  estimatedRetryMs(): number {
    if (!this.isReconnecting()) return 0;
    return this.reconnectNextRetryAt > 0
      ? Math.max(0, this.reconnectNextRetryAt - Date.now())
      : this.reconnectDelayMs; // fallback to base delay
  }

  /**
   * Add connection event listener
   */
  addConnectionListener(listener: (event: ConnectionEvent) => void): void {
    this.eventListeners.push(listener);
  }

  /**
   * Remove connection event listener
   */
  removeConnectionListener(listener: (event: ConnectionEvent) => void): void {
    const index = this.eventListeners.indexOf(listener);
    if (index !== -1) {
      this.eventListeners.splice(index, 1);
    }
  }

  /**
   * Add target destroyed listener
   */
  addTargetDestroyedListener(listener: (targetId: string, page?: Page) => void): void {
    this.targetDestroyedListeners.push(listener);
  }

  /**
   * Remove target destroyed listener
   */
  removeTargetDestroyedListener(listener: (targetId: string, page?: Page) => void): void {
    const index = this.targetDestroyedListeners.indexOf(listener);
    if (index !== -1) {
      this.targetDestroyedListeners.splice(index, 1);
    }
  }

  /**
   * Handle target destroyed event
   */
  private onTargetDestroyed(targetId: string): void {
    this.sessions.delete(targetId);
    // Clean up cookie source cache entries pointing to this target
    for (const [key, entry] of this.cookieSourceCache) {
      if (entry.targetId === targetId) {
        this.cookieSourceCache.delete(key);
      }
    }
    // Clean up cookie data cache for this target
    this.cookieDataCache.delete(targetId);
    this.targetActivityAt.delete(targetId);
    // Look up page BEFORE deleting from index so listeners can use it
    const page = this.targetIdIndex.get(targetId);
    this.targetIdIndex.delete(targetId);
    for (const listener of this.targetDestroyedListeners) {
      try {
        listener(targetId, page);
      } catch (e) {
        console.error('[CDPClient] Target destroyed listener error:', e);
      }
    }
  }

  private touchTargetActivity(targetId: string): void {
    this.targetActivityAt.set(targetId, Date.now());
  }

  /**
   * Insert or refresh an entry in cookieSourceCache with a FIFO size cap.
   * When the cache is at capacity AND the key is new, the oldest-inserted
   * entry is evicted. Existing keys update in place without eviction.
   * See issue #647.
   */
  private setCookieSourceCacheEntry(
    key: string,
    value: { targetId: string; timestamp: number },
  ): void {
    if (
      this.cookieSourceCache.size >= CDPClient.COOKIE_SOURCE_CACHE_MAX &&
      !this.cookieSourceCache.has(key)
    ) {
      const firstKey = this.cookieSourceCache.keys().next().value;
      if (firstKey !== undefined) {
        this.cookieSourceCache.delete(firstKey);
      }
    }
    this.cookieSourceCache.set(key, value);
  }

  /**
   * Insert or refresh an entry in cookieDataCache with a FIFO size cap.
   * When the cache is at capacity AND the key is new, the oldest-inserted
   * entry is evicted. Existing keys update in place without eviction.
   * See issue #647.
   */
  private setCookieDataCacheEntry(
    key: string,
    value: { cookies: CookieEntry[]; timestamp: number },
  ): void {
    if (
      this.cookieDataCache.size >= CDPClient.COOKIE_DATA_CACHE_MAX &&
      !this.cookieDataCache.has(key)
    ) {
      const firstKey = this.cookieDataCache.keys().next().value;
      if (firstKey !== undefined) {
        this.cookieDataCache.delete(firstKey);
      }
    }
    this.cookieDataCache.set(key, value);
  }

  getLastCookieScanResult(): CookieScanResult | null {
    return this.lastCookieScanResult;
  }

  /**
   * Emit connection event
   */
  private emitConnectionEvent(event: ConnectionEvent): void {
    for (const listener of this.eventListeners) {
      try {
        listener(event);
      } catch (e) {
        console.error('[CDPClient] Event listener error:', e);
      }
    }
  }

  /**
   * Start heartbeat monitoring
   */
  private startHeartbeat(): void {
    this.stopHeartbeat();
    let lastHeartbeatTime = Date.now();
    this.heartbeatTimer = setInterval(() => {
      const now = Date.now();
      const elapsed = now - lastHeartbeatTime;
      lastHeartbeatTime = now;

      // Clock jump detection: if elapsed >> heartbeat interval, system likely slept/woke.
      // Immediately force reconnect instead of waiting for 2× probe failure (35-40s).
      if (elapsed > this.heartbeatIntervalMs * 3) {
        // Guard: skip if reconnect is already in progress (prevents concurrent forceReconnect calls)
        if (this.connectionState === 'reconnecting' || this.connectionState === 'connecting') {
          return;
        }
        // Stop heartbeat to prevent further ticks during the reconnect attempt
        this.stopHeartbeat();
        console.error(`[CDPClient] Sleep/wake detected (${elapsed}ms gap, expected ~${this.heartbeatIntervalMs}ms). Force reconnecting...`);
        this.forceReconnect().catch(err => {
          console.error('[CDPClient] Post-wake reconnect failed:', err);
        });
        return;
      }

      this.checkConnection();
    }, this.getEffectiveHeartbeatInterval());
  }

  /**
   * Stop heartbeat monitoring
   */
  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  /**
   * Set heartbeat mode. Restarts the heartbeat timer with the new interval.
   */
  setHeartbeatMode(mode: 'idle' | 'active' | 'heavy' | 'recovery'): void {
    if (this.heartbeatMode === mode) return;
    const oldMode = this.heartbeatMode;
    this.heartbeatMode = mode;
    console.error(`[CDPClient] Heartbeat mode: ${oldMode} → ${mode} (interval: ${this.getEffectiveHeartbeatInterval()}ms)`);

    // Restart heartbeat with new interval
    if (this.heartbeatTimer) {
      this.startHeartbeat();
    }

    // Auto-transition from recovery to active after 30s
    if (this.heartbeatModeTimer) {
      clearTimeout(this.heartbeatModeTimer);
      this.heartbeatModeTimer = null;
    }
    if (mode === 'recovery') {
      this.heartbeatModeTimer = setTimeout(() => {
        this.heartbeatModeTimer = null;
        if (this.heartbeatMode === 'recovery') {
          this.setHeartbeatMode('active');
        }
      }, 30000);
      this.heartbeatModeTimer.unref();
    }
  }

  /**
   * Get effective heartbeat interval based on current mode.
   */
  private getEffectiveHeartbeatInterval(): number {
    switch (this.heartbeatMode) {
      case 'idle': return Math.max(this.heartbeatIntervalMs * 3, 15000); // 3x base or 15s min
      case 'active': return this.heartbeatIntervalMs; // default (5s)
      case 'heavy': return Math.max(Math.floor(this.heartbeatIntervalMs / 2), 2000); // half or 2s min
      case 'recovery': return 1000; // 1s fixed during recovery
    }
  }

  /**
   * Record that a command was executed (for idle detection).
   * @deprecated Idle transitions are now managed by MCPServer via setTimeout. This method
   * is retained for API compatibility but the internal idle-check in startHeartbeat() has
   * been removed as dead code (#347).
   */
  recordCommandActivity(): void {
    this.lastCommandAt = Date.now();
    if (this.heartbeatMode === 'idle') {
      this.setHeartbeatMode('active');
    }
  }

  /**
   * Get current heartbeat mode.
   */
  getHeartbeatMode(): 'idle' | 'active' | 'heavy' | 'recovery' {
    return this.heartbeatMode;
  }

  /**
   * Get the Chrome process PID, if available.
   * Returns null when connecting to an already-running Chrome (no process spawned by puppeteer).
   */
  getChromePid(): number | null {
    return this.browser?.process()?.pid ?? null;
  }

  /**
   * Return whether the connected Chrome is managed by OpenChrome or attached.
   * Used by session cleanup to avoid closing user-owned tabs in attach mode.
   */
  getChromeLifecycleMode(): 'isolated' | 'attach' | undefined {
    return getChromeLauncher(this.port).getInstance()?.launchMode;
  }

  /**
   * Get connection health metrics.
   */
  getConnectionMetrics(): {
    msSinceLastVerified: number;
    reconnectCount: number;
    avgPingLatencyMs: number;
    heartbeatMode: string;
    consecutiveSuccesses: number;
    lastVerifiedAt: number;
    reconnecting: boolean;
    reconnectAttempt: number;
    reconnectNextRetryInMs: number;
  } {
    const avgLatency = this.pingLatencies.length > 0
      ? Math.round(this.pingLatencies.reduce((a, b) => a + b, 0) / this.pingLatencies.length)
      : 0;

    return {
      msSinceLastVerified: this.lastVerifiedAt > 0 ? Date.now() - this.lastVerifiedAt : 0,
      reconnectCount: this.reconnectCount,
      avgPingLatencyMs: avgLatency,
      heartbeatMode: this.heartbeatMode,
      consecutiveSuccesses: this.consecutiveHeartbeatSuccesses,
      lastVerifiedAt: this.lastVerifiedAt,
      reconnecting: this.reconnecting,
      reconnectAttempt: this.reconnectingAttempt,
      reconnectNextRetryInMs: this.reconnectNextRetryAt > 0
        ? Math.max(0, this.reconnectNextRetryAt - Date.now())
        : 0,
    };
  }

  /**
   * Check connection health.
   * Sends an active CDP probe (Browser.getVersion) to detect half-open WebSocket
   * connections that browser.isConnected() misses (e.g., after macOS sleep/wake).
   */
  private async checkConnection(): Promise<boolean> {
    if (!this.browser) {
      return false;
    }
    if (this.checkConnectionInFlight) {
      return true; // Prior check still in progress
    }
    this.checkConnectionInFlight = true;

    try {
      if (!this.browser.isConnected()) {
        console.error('[CDPClient] Heartbeat: Connection flag lost, attempting reconnect...');
        await this.handleDisconnect();
        return false;
      }

      // Active probe: round-trip CDP command to detect dead WebSocket connections.
      // browser.isConnected() only checks a local flag — half-open TCP connections
      // (macOS sleep/wake, Chrome crash) pass the flag check but hang on real commands.
      const pingStart = Date.now();
      let pingTid: ReturnType<typeof setTimeout>;
      await Promise.race([
        this.browser.version().finally(() => clearTimeout(pingTid)),
        new Promise<never>((_, reject) => {
          pingTid = setTimeout(
            () => reject(new Error('heartbeat ping timeout')),
            DEFAULT_HEARTBEAT_PING_TIMEOUT_MS,
          );
        }),
      ]);
      this.lastVerifiedAt = Date.now();
      const pingLatency = Date.now() - pingStart;
      this.pingLatencies.push(pingLatency);
      if (this.pingLatencies.length > CDPClient.MAX_PING_SAMPLES) {
        this.pingLatencies.shift();
      }
      this.consecutiveHeartbeatSuccesses++;
      this.consecutiveHeartbeatFailures = 0;
      return true;
    } catch (error) {
      this.consecutiveHeartbeatSuccesses = 0;
      this.consecutiveHeartbeatFailures++;
      if (this.consecutiveHeartbeatFailures < 2) {
        // First failure: warn but don't disconnect. Chrome may be under heavy load.
        console.error(`[CDPClient] Heartbeat probe failed (strike ${this.consecutiveHeartbeatFailures}/2), will retry next interval:`, error);
        return true; // Report as healthy to avoid premature disconnect
      }
      // Two consecutive failures: connection is truly dead
      console.error(`[CDPClient] Heartbeat failed ${this.consecutiveHeartbeatFailures} times consecutively, disconnecting:`, error);
      this.consecutiveHeartbeatFailures = 0;
      await this.handleDisconnect();
      return false;
    } finally {
      this.checkConnectionInFlight = false;
    }
  }

  /**
   * Handle disconnection with automatic reconnection
   */
  private async handleDisconnect(): Promise<void> {
    if (this.connectionState === 'reconnecting' || this.connectionState === 'connecting') {
      return; // Already reconnecting or connecting
    }

    this.reconnectAttempts = 0; // Reset counter on each new disconnect event
    this.connectionState = 'reconnecting';
    this.reconnecting = true;
    this.emitConnectionEvent({
      type: 'disconnected',
      timestamp: Date.now(),
    });

    // Clear heartbeat mode timer to prevent 30s recovery timer from leaking
    if (this.heartbeatModeTimer) {
      clearTimeout(this.heartbeatModeTimer);
      this.heartbeatModeTimer = null;
    }

    // Clear existing sessions and stale state
    this.sessions.clear();
    this.targetIdIndex.clear();
    this.inFlightCookieScans.clear();
    this.lastVerifiedAt = 0;

    // Remove listeners first (prevent re-entry), then force-close the WebSocket
    // to immediately reject all in-flight CDP operations.
    if (this.browser) {
      this.browser.removeAllListeners('disconnected');
      this.browser.removeAllListeners('targetdestroyed');
      this.browser.removeAllListeners('targetchanged');
      this.browser.removeAllListeners('targetcreated');
      try {
        this.browser.disconnect();
      } catch {
        // Ignore — browser may already be disconnected
      }
    }
    this.browser = null;

    const generation = ++this.connectionGeneration;

    // Attempt reconnection — do NOT auto-launch Chrome.
    // If Chrome was closed by the user, we should stay disconnected and only
    // re-launch when the next tool call arrives (lazy launch). This prevents
    // the "Chrome keeps reopening" loop reported in issue #159.
    while (!this.disconnectRequested && (this.maxReconnectAttempts === Infinity || this.reconnectAttempts < this.maxReconnectAttempts)) {
      this.reconnectAttempts++;
      this.reconnectingAttempt = this.reconnectAttempts;
      const maxLabel = this.maxReconnectAttempts === Infinity ? '∞' : String(this.maxReconnectAttempts);
      console.error(`[CDPClient] Reconnect attempt ${this.reconnectAttempts}/${maxLabel}...`);

      // Invalidate launcher cache at start of each attempt so ensureChrome()
      // re-probes Chrome's HTTP endpoint to discover the new WebSocket UUID
      // after a relaunch by the process watchdog
      getChromeLauncher(this.port).invalidateInstance();

      this.emitConnectionEvent({
        type: 'reconnecting',
        timestamp: Date.now(),
        attempt: this.reconnectAttempts,
      });

      try {
        const connected = await this.connectInternal({ autoLaunch: false, generation });
        if (connected === false) {
          this.reconnecting = false;
          this.reconnectingAttempt = 0;
          this.reconnectNextRetryAt = 0;
          return;
        }
        console.error('[CDPClient] Reconnection successful');
        this.reconnectAttempts = 0;
        this.reconnecting = false;
        this.reconnectingAttempt = 0;
        this.reconnectNextRetryAt = 0;
        this.reconnectCount++;
        try { getMetricsCollector().inc('openchrome_reconnect_total'); } catch { /* best-effort */ }
        this.setHeartbeatMode('recovery');

        // Restore browser state (cookies) from last snapshot after reconnection.
        // Uses dynamic import() to avoid circular dependency with browser-state module.
        // Restore is best-effort — failure must not block reconnection.
        try {
          const { getBrowserStateManager } = await import('../browser-state');
          const stateManager = getBrowserStateManager();
          const cookies = await stateManager.getLatestCookies();
          await this.restoreCookiesAfterReconnect(cookies);
        } catch (err) {
          console.error('[CDPClient] Cookie restore after reconnection failed (non-fatal):', err);
        }

        this.emitConnectionEvent({
          type: 'reconnected',
          timestamp: Date.now(),
        });
        return;
      } catch (error) {
        console.error(`[CDPClient] Reconnect attempt ${this.reconnectAttempts} failed:`, error);

        if (this.maxReconnectAttempts === Infinity || this.reconnectAttempts < this.maxReconnectAttempts) {
          // Exponential backoff with jitter: baseDelay * 2^(attempt-1) + random(0..baseDelay/2)
          // Exponent capped at 6 to prevent Number overflow on high attempt counts (infinite mode)
          const backoffCap = this.maxReconnectAttempts === Infinity ? 60000 : 30000;
          const backoffDelay = Math.min(
            this.reconnectDelayMs * Math.pow(2, Math.min(this.reconnectAttempts - 1, 6)) + Math.floor(Math.random() * this.reconnectDelayMs / 2),
            backoffCap,
          );
          this.reconnectNextRetryAt = Date.now() + backoffDelay;
          console.error(`[CDPClient] Waiting ${backoffDelay}ms before next attempt (exponential backoff)...`);
          await new Promise(resolve => setTimeout(resolve, backoffDelay));
          if (this.disconnectRequested) {
            return;
          }
        }
      }
    }

    // All attempts failed — Chrome is not running. Stay disconnected until
    // the next tool call triggers a fresh connect() with autoLaunch.
    this.connectionState = 'disconnected';
    this.reconnecting = false;
    this.reconnectingAttempt = 0;
    this.reconnectNextRetryAt = 0;
    this.stopHeartbeat();
    this.emitConnectionEvent({
      type: 'reconnect_failed',
      timestamp: Date.now(),
      error: `Failed after ${this.maxReconnectAttempts} attempts`,
    });

    console.error('[CDPClient] All reconnection attempts failed. Chrome will be re-launched on next tool call.');
    this.reconnectAttempts = 0;
  }

  /**
   * Internal connect logic.
   *
   * When `budget` is provided and OPENCHROME_SESSION_INIT_BUDGET_MODE is NOT
   * `legacy`, the retry loop is driven by budget.remaining() rather than a
   * fixed retry count. Per-attempt WebSocket timeout and inter-attempt backoff
   * are both capped by the remaining budget so the total wall-clock time
   * stays within the caller's deadline.
   */
  private isCurrentConnectionGeneration(generation?: number): boolean {
    return generation === undefined || generation === this.connectionGeneration;
  }

  private async connectInternal(options?: { autoLaunch?: boolean; budget?: Budget; generation?: number }): Promise<boolean> {
    this.disconnectRequested = false;
    const launcher = getChromeLauncher(this.port);
    const autoLaunch = options?.autoLaunch ?? this.autoLaunch;
    const budget = options?.budget;
    const generation = options?.generation;
    const budgetDriven = !!budget && !isLegacyBudgetMode();

    // Retry loop: after macOS sleep/wake, Chrome's WebSocket listener may be in a
    // half-open TCP state. The HTTP endpoint (/json/version) works because it's
    // stateless, but the WebSocket handshake hangs. The first failed attempt sends
    // a TCP RST that clears Chrome's stale state, so the second attempt succeeds.
    const maxConnectRetries = 3;
    let lastError: Error | null = null;
    let attempt = 0;

    while (budgetDriven || attempt < maxConnectRetries) {
      attempt++;

      if (budgetDriven) {
        // Give up early if not enough time is left to run a meaningful attempt.
        budget!.requireRemaining(DEFAULT_SESSION_INIT_MIN_ATTEMPT_MS, 'connectInternal.attempt-gate');
      }

      // Re-fetch instance on each attempt — Chrome may have regenerated its UUID
      const instance = await launcher.ensureChrome({ autoLaunch });
      if (!this.isCurrentConnectionGeneration(generation)) {
        return false;
      }

      const wsTimeoutMs = budgetDriven
        ? Math.max(1, Math.min(DEFAULT_PUPPETEER_CONNECT_TIMEOUT_MS, budget!.remaining()))
        : DEFAULT_PUPPETEER_CONNECT_TIMEOUT_MS;

      try {
        let wsConnectTid: ReturnType<typeof setTimeout>;
        const connectedBrowser = await Promise.race([
          puppeteer.connect({
            browserWSEndpoint: instance.wsEndpoint,
            defaultViewport: null,
            protocolTimeout: parseInt(process.env.OPENCHROME_PROTOCOL_TIMEOUT_MS || '', 10) || DEFAULT_PROTOCOL_TIMEOUT_MS,
          }).finally(() => clearTimeout(wsConnectTid)),
          new Promise<never>((_, reject) => {
            wsConnectTid = setTimeout(
              () => reject(new Error(`puppeteer.connect() timed out after ${wsTimeoutMs}ms (WebSocket to ${instance.wsEndpoint})`)),
              wsTimeoutMs,
            );
          }),
        ]) as Browser;

        if (!this.isCurrentConnectionGeneration(generation)) {
          connectedBrowser.removeAllListeners('disconnected');
          connectedBrowser.removeAllListeners('targetdestroyed');
          connectedBrowser.removeAllListeners('targetchanged');
          connectedBrowser.removeAllListeners('targetcreated');
          connectedBrowser.disconnect().catch(() => {});
          return false;
        }

        this.browser = connectedBrowser;

        if (attempt > 1) {
          const remainingStr = budgetDriven ? `, budget remaining=${budget!.remaining()}ms` : `/${maxConnectRetries}`;
          console.error(`[CDPClient] connectInternal succeeded on attempt ${attempt}${remainingStr}`);
        }
        break; // Success — exit retry loop
      } catch (err) {
        if (!this.isCurrentConnectionGeneration(generation)) {
          return false;
        }

        // Clean up any partially-connected browser from this attempt to prevent
        // orphaned event listeners from firing handleDisconnect on an old browser.
        if (this.browser) {
          this.browser.removeAllListeners('disconnected');
          this.browser.removeAllListeners('targetdestroyed');
          this.browser.removeAllListeners('targetchanged');
          this.browser.removeAllListeners('targetcreated');
          this.browser.disconnect().catch(() => {});
          this.browser = null;
        }
        lastError = err instanceof Error ? err : new Error(String(err));

        if (budgetDriven) {
          // If the budget can't fit another meaningful attempt, surface it as
          // SessionInitBudgetExhausted (caller distinguishes budget vs. real failure).
          if (budget!.remaining() < DEFAULT_SESSION_INIT_MIN_ATTEMPT_MS) {
            budget!.requireRemaining(DEFAULT_SESSION_INIT_MIN_ATTEMPT_MS, 'connectInternal.post-attempt');
          }
          // Invalidate launcher cache so next ensureChrome() re-checks via HTTP
          launcher.invalidateInstance();
          // Backoff: up to 1s, but not more than the remaining budget minus one more attempt.
          const pauseMs = Math.max(0, Math.min(1000, budget!.remaining() - DEFAULT_SESSION_INIT_MIN_ATTEMPT_MS));
          console.error(`[CDPClient] connectInternal attempt ${attempt} failed (budget remaining=${budget!.remaining()}ms), retrying in ${pauseMs}ms: ${lastError.message}`);
          if (pauseMs > 0) {
            await new Promise(resolve => setTimeout(resolve, pauseMs));
          }
        } else if (attempt < maxConnectRetries) {
          console.error(`[CDPClient] connectInternal attempt ${attempt}/${maxConnectRetries} failed, retrying in 1s: ${lastError.message}`);
          // Invalidate launcher cache so next ensureChrome() re-checks via HTTP
          launcher.invalidateInstance();
          // Brief pause: TCP RST from the timeout needs time to reach Chrome's listener
          await new Promise(resolve => setTimeout(resolve, 1000));
        } else {
          throw lastError;
        }
      }
    }

    // Set up disconnect handler
    // Non-null assertion: the retry loop above either sets this.browser and breaks, or throws.
    this.browser!.on('disconnected', () => {
      // Inbound CDP event — reset idle window (issue #649 Part A).
      getIdleState().notifyActive();
      console.error('[CDPClient] Browser disconnected');
      this.handleDisconnect().catch((err) => {
        console.error('[CDPClient] handleDisconnect failed:', err);
      });
    });

    // Set up target destroyed handler
    this.browser!.on('targetdestroyed', safeAsyncListener('targetdestroyed', async (target: Target) => {
      // Inbound CDP event — reset idle window (issue #649 Part A).
      getIdleState().notifyActive();
      const targetId = getTargetId(target);
      console.error(`[CDPClient] Target destroyed: ${targetId}`);
      this.onTargetDestroyed(targetId);
    }));

    this.browser!.on('targetchanged', safeAsyncListener('targetchanged', async (target: Target) => {
      // Inbound CDP event — reset idle window (issue #649 Part A).
      getIdleState().notifyActive();
      if (target.type() !== 'page') return;
      const url = target.url();
      if (!isInternalBrowserUrl(url)) {
        try {
          assertDomainAllowed(url);
        } catch (err) {
          const targetId = getTargetId(target);
          if (targetId) {
            await this.closePage(targetId).catch(() => {});
          }
          console.error(`[CDPClient] Blocked changed target by domain policy (URL: ${url}): ${err instanceof Error ? err.message : String(err)}`);
          return;
        }
      }
      const targetId = getTargetId(target);
      if (this.targetIdIndex.has(targetId)) {
        console.error(`[CDPClient] Target changed: ${targetId}`);
      }
    }));

    // Note: We intentionally do NOT call target.page() for EVERY targetcreated event.
    // Eagerly calling target.page() on every new target can materialize Chrome's internal
    // targets (prerender, speculative navigation, new-tab-page) as visible about:blank
    // ghost tabs. OpenChrome-created pages are indexed directly in createPage() instead.
    // Non-OpenChrome pages are found via fallback scan in getPageByTargetId().
    //
    // However, we DO selectively track page-type targets opened by already-managed pages
    // (popup/window.open). This makes OAuth redirects, popups, and cross-origin navigations
    // visible without materializing unrelated Chrome-internal targets.
    this.browser!.on('targetcreated', safeAsyncListener('targetcreated', async (target: Target) => {
      // Inbound CDP event — reset idle window (issue #649 Part A).
      getIdleState().notifyActive();
      // Only track 'page' type targets (skip service_worker, browser, etc.)
      if (target.type() !== 'page') return;

      const url = target.url();
      // New Chrome pages are commonly born as about:blank before createPage()
      // or popup scripts navigate them. Do not close these provisional targets;
      // targetchanged enforces the allowlist once they reach a real URL.
      if (isInternalBrowserUrl(url)) return;
      try {
        assertDomainAllowed(url);
      } catch (err) {
        const targetId = getTargetId(target);
        if (targetId) {
          await this.closePage(targetId).catch(() => {});
        }
        console.error(`[CDPClient] Blocked popup target by domain policy (URL: ${url}): ${err instanceof Error ? err.message : String(err)}`);
        return;
      }
      // Filter out Chrome internal pages and blank pages
      // Check if this target was opened by a tracked page (popup/window.open)
      const opener = target.opener();
      if (!opener) return; // Not a popup - skip to avoid ghost tabs

      // Get the opener's target ID to check if it's managed
      const openerTargetId = getTargetId(opener);
      if (!openerTargetId) return;

      // Check if opener is managed by SessionManager (dynamic import to avoid circular dep)
      const { getSessionManager } = await import('../session-manager');
      const sessionManager = getSessionManager();
      const ownerInfo = sessionManager.getTargetOwner(openerTargetId);
      if (!ownerInfo) return; // Opener not tracked, skip

      // This is a popup from a managed page - track it
      const targetId = getTargetId(target);
      if (!targetId) return;

      // Register in the same worker as opener and inherit the opener's
      // named-context mapping (#848 Codex P1) so popups count toward the
      // same context's tab total instead of slipping into the default.
      await sessionManager.registerExternalTarget(targetId, ownerInfo.sessionId, ownerInfo.workerId, {
        inheritContextFromTargetId: openerTargetId,
      });

      // target.page() can race with target close — keep the inner try/catch
      // as a localized best-effort, but any *other* failure in this handler
      // now surfaces via safeAsyncListener → openchrome_listener_errors_total.
      try {
        const page = await target.page();
        if (page) {
          this.targetIdIndex.set(targetId, page);
          this.touchTargetActivity(targetId);
          this.configurePageDefenses(page);
          await applyRegisteredPreloads(page);
          console.error(`[CDPClient] Indexed popup target ${targetId} (URL: ${url})`);
        }
      } catch {
        // Target may have already closed — expected race, not an error.
      }
    }, (_err, args) => {
      const target = args[0];
      const targetId = getTargetId(target as Target);
      if (!targetId) return;
      import('../session-manager')
        .then(({ getSessionManager }) => {
          getSessionManager().evictTarget(targetId, 'listener_error');
        })
        .catch(() => {
          // best-effort cleanup only
        });
    }));

    this.connectionState = 'connected';
    this.emitConnectionEvent({
      type: 'connected',
      timestamp: Date.now(),
    });
    return true;
  }

  /**
   * Connect to Chrome instance.
   * Uses promise coalescing: concurrent callers share a single connectInternal() call
   * instead of each independently opening a WebSocket (which would orphan event listeners
   * and heartbeat timers from the first connection).
   *
   * When `budget` is provided, it is forwarded to `connectInternal()` so the
   * retry loop stays within the caller's deadline (A-3). Coalesced callers
   * share whatever budget the first caller supplied.
   */
  async connect(options?: { budget?: Budget; autoLaunch?: boolean }): Promise<void> {
    const budget = options?.budget;
    const autoLaunch = options?.autoLaunch;
    // Resolve the effective autoLaunch the same way connectInternal() does so
    // coalescing comparisons (below) are apples-to-apples — callers who would
    // both end up with the same behavior coalesce, callers who would diverge
    // never silently inherit each other's choice.
    const effectiveAutoLaunch = autoLaunch ?? this.autoLaunch;
    if (this.browser && this.browser.isConnected()) {
      // Skip active probe if recently verified by heartbeat (avoids per-call overhead)
      if (Date.now() - this.lastVerifiedAt < DEFAULT_CONNECT_VERIFY_STALENESS_MS) {
        return;
      }

      // Active probe: lightweight CDP round-trip to detect dead WebSocket connections.
      // Replaces the previous ensureChrome() call which added 2-7s HTTP overhead.
      // browser.isConnected() only checks a local flag — half-open TCP connections
      // (macOS sleep/wake, Chrome crash) pass the flag check but hang on real commands.
      try {
        let probeTid: ReturnType<typeof setTimeout>;
        await Promise.race([
          this.browser.version().finally(() => clearTimeout(probeTid)),
          new Promise<never>((_, reject) => {
            probeTid = setTimeout(
              () => reject(new Error('connection probe timeout')),
              DEFAULT_HEARTBEAT_PING_TIMEOUT_MS,
            );
          }),
        ]);
        this.lastVerifiedAt = Date.now();
        return;
      } catch {
        console.error('[CDPClient] Connection probe failed, reconnecting...');
        // forceReconnect() internally pins connectInternal() to autoLaunch:false
        // and does not accept an autoLaunch override; the caller's autoLaunch is
        // intentionally NOT forwarded here. The probe-fail path therefore never
        // auto-launches Chrome regardless of caller preference — Chrome must be
        // re-attached by an explicit tool call, not by a heartbeat-triggered
        // reconnect.
        await this.forceReconnect({ budget });
        return;
      }
    }

    // Coalesce concurrent connect() calls — only one connectInternal() runs at a time.
    // Without this, parallel tool calls (e.g., ultrapilot workflows) each trigger
    // connectInternal(), creating duplicate WebSocket connections where the second
    // overwrites this.browser and orphans the first's event listeners + heartbeat.
    //
    // BUT: only coalesce when the in-flight call's effective autoLaunch matches
    // ours. The startup readiness probe (autoLaunch:false) must never ride a
    // tool-call's autoLaunch:true attempt, and a tool call must never silently
    // ride the probe's autoLaunch:false and fail to spawn Chrome. On mismatch,
    // wait for the in-flight call to settle, then re-enter so the already-
    // connected fast path can short-circuit if Chrome happens to be attached.
    if (this.pendingConnect) {
      if (effectiveAutoLaunch === this.pendingConnectAutoLaunch) {
        console.error('[CDPClient] Coalescing concurrent connect() call');
        return this.pendingConnect;
      }
      console.error(
        '[CDPClient] Concurrent connect() with conflicting autoLaunch — waiting for in-flight call',
      );
      await this.pendingConnect.catch(() => { /* swallow — caller's own attempt below decides */ });
      return this.connect(options);
    }

    this.connectionState = 'connecting';
    const generation = ++this.connectionGeneration;
    const pendingConnect = (async () => {
      try {
        const connected = await this.connectInternal({ budget, autoLaunch, generation });
        if (connected === false) {
          return;
        }
        this.lastVerifiedAt = Date.now();
        this.startHeartbeat();
        console.error('[CDPClient] Connected to Chrome');
      } catch (err) {
        if (this.isCurrentConnectionGeneration(generation)) {
          this.connectionState = 'disconnected';
        }
        throw err;
      }
    })();
    this.pendingConnect = pendingConnect;
    this.pendingConnectAutoLaunch = effectiveAutoLaunch;

    try {
      await pendingConnect;
    } finally {
      if (this.pendingConnect === pendingConnect) {
        this.pendingConnect = null;
        this.pendingConnectAutoLaunch = undefined;
      }
    }
  }

  /**
   * Force reconnect by disconnecting and reconnecting.
   * Invalidates any pending connect() promise — the old connection attempt
   * will still resolve but its result is discarded because this.browser is replaced.
   *
   * Clears ALL stale state (sessions, targetIdIndex, cookie scans) to prevent
   * post-reconnect operations from using orphaned page references that would
   * hang until protocolTimeout (30s).
   */
  async forceReconnect(options?: { budget?: Budget }): Promise<void> {
    const budget = options?.budget;
    const generation = ++this.connectionGeneration;
    // Invalidate any in-flight connect() — we're replacing the connection entirely
    this.pendingConnect = null;
    this.stopHeartbeat();
    if (this.heartbeatModeTimer) {
      clearTimeout(this.heartbeatModeTimer);
      this.heartbeatModeTimer = null;
    }

    if (this.browser) {
      try {
        this.browser.removeAllListeners('disconnected');
        this.browser.removeAllListeners('targetdestroyed');
        this.browser.removeAllListeners('targetchanged');
        this.browser.removeAllListeners('targetcreated');
        await this.browser.disconnect();
      } catch {
        // Ignore disconnect errors
      }
      this.browser = null;
      this.sessions.clear();
      this.targetIdIndex.clear();
      this.targetActivityAt.clear();
      this.inFlightCookieScans.clear();
      this.lastCookieScanResult = null;
    }

    if (this.disconnectRequested) {
      return;
    }

    this.connectionState = 'reconnecting';
    this.lastVerifiedAt = 0;
    try {
      // Do NOT auto-launch Chrome on heartbeat-triggered reconnect.
      // If Chrome was closed, stay disconnected until the next tool call.
      const connected = await this.connectInternal({ autoLaunch: false, budget, generation });
      if (connected === false) {
        return;
      }
      this.lastVerifiedAt = Date.now();
      this.consecutiveHeartbeatFailures = 0;
      this.reconnecting = false;
      this.reconnectingAttempt = 0;
      this.reconnectNextRetryAt = 0;
      this.startHeartbeat();
      this.emitConnectionEvent({ type: 'reconnected', timestamp: Date.now() });
      console.error('[CDPClient] Reconnected to Chrome');

      // Restore cookies from snapshot after reconnection (best-effort)
      try {
        const { getBrowserStateManager } = await import('../browser-state');
        const stateManager = getBrowserStateManager();
        const cookies = await stateManager.getLatestCookies();
        await this.restoreCookiesAfterReconnect(cookies);
      } catch (err) {
        console.error('[CDPClient] Cookie restore after reconnection failed (non-fatal):', err);
      }
    } catch (err) {
      if (this.isCurrentConnectionGeneration(generation)) {
        this.connectionState = 'disconnected';
      }
      this.emitConnectionEvent({
        type: 'reconnect_failed',
        timestamp: Date.now(),
        error: err instanceof Error ? err.message : String(err),
      });
      throw err;
    }
  }

  /**
   * Restore cookies to the browser after reconnection.
   * Filters out about:blank and chrome:// pages before selecting the CDP target
   * to avoid setting cookies on ghost tabs or pool placeholders.
   */
  private async restoreCookiesAfterReconnect(cookies: any[] | null): Promise<void> {
    if (!cookies || cookies.length === 0) return;
    try {
      const pages = await this.browser!.pages();
      const validPage = pages.find(p => {
        const url = p.url();
        return url && url !== 'about:blank' && !url.startsWith('chrome://');
      }) || pages[0]; // fallback to pages[0] if no valid page
      if (!validPage) return;
      const client = await validPage.createCDPSession();
      try {
        await client.send('Network.setCookies', { cookies });
        console.error(`[CDPClient] Restored ${cookies.length} cookies from snapshot after reconnection`);
      } finally {
        await client.detach();
      }
    } catch (err) {
      console.error('[CDPClient] Cookie restore failed:', err);
    }
  }

  /**
   * Disconnect from Chrome
   */
  async disconnect(): Promise<void> {
    this.disconnectRequested = true;
    this.stopHeartbeat();

    if (this.heartbeatModeTimer) {
      clearTimeout(this.heartbeatModeTimer);
      this.heartbeatModeTimer = null;
    }

    if (this.browser) {
      try {
        this.browser.removeAllListeners('disconnected');
        this.browser.removeAllListeners('targetdestroyed');
        this.browser.removeAllListeners('targetchanged');
        this.browser.removeAllListeners('targetcreated');
        await this.browser.disconnect();
      } catch {
        // Browser might already be disconnected
      }
      this.browser = null;
      this.sessions.clear();
      this.connectionState = 'disconnected';
      console.error('[CDPClient] Disconnected from Chrome');
    }
  }

  /**
   * Get browser instance
   */
  getBrowser(): Browser {
    if (!this.browser) {
      throw new Error('Not connected to Chrome. Call connect() first.');
    }
    return this.browser;
  }

  // Default viewport for consistent debugging experience
  static readonly DEFAULT_VIEWPORT = DEFAULT_VIEWPORT;


  /**
   * Create a new isolated browser context for session isolation
   * Each context has its own cookies, localStorage, sessionStorage
   */
  async createBrowserContext(): Promise<BrowserContext> {
    const browser = this.getBrowser();
    const context = await browser.createBrowserContext();
    console.error(`[CDPClient] Created new browser context`);
    return context;
  }

  /**
   * Close a browser context and all its pages
   */
  async closeBrowserContext(context: BrowserContext): Promise<void> {
    try {
      await context.close();
      console.error(`[CDPClient] Closed browser context`);
    } catch (e) {
      // Context may already be closed
      console.error(`[CDPClient] Error closing browser context:`, e);
    }
  }

  /**
   * Check if a hostname is localhost
   */
  private isLocalhost(url: string): boolean {
    try {
      const hostname = new URL(url).hostname;
      return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
    } catch {
      return false;
    }
  }

  /**
   * Calculate domain match score between two URLs
   * Higher score = better match
   */
  private domainMatchScore(candidateUrl: string, targetDomain: string): number {
    try {
      const candidateHostname = new URL(candidateUrl).hostname;
      const candidateParts = candidateHostname.split('.').reverse();
      const targetParts = targetDomain.split('.').reverse();

      // Exact match
      if (candidateHostname === targetDomain) {
        return 100;
      }

      // Count matching TLD parts from right to left
      let matchingParts = 0;
      for (let i = 0; i < Math.min(candidateParts.length, targetParts.length); i++) {
        if (candidateParts[i] === targetParts[i]) {
          matchingParts++;
        } else {
          break;
        }
      }

      // Subdomain match (e.g., api.example.com matches example.com)
      if (matchingParts >= 2) {
        return 50 + matchingParts * 10;
      }

      // Same TLD only (e.g., both .com)
      if (matchingParts === 1) {
        return 10;
      }

      return 0;
    } catch {
      return 0;
    }
  }

  /**
   * Find an authenticated page with cookies to copy from.
   * Returns the targetId of a page that has cookies in Chrome's default context.
   *
   * Promise coalescing: concurrent callers for the same domain share one probe
   * instead of independently hammering Chrome with 20 simultaneous scans.
   *
   * @param targetDomain Optional domain to prioritize when selecting cookie source
   */
  async findAuthenticatedPageTarget(targetDomain?: string): Promise<CookieScanResult> {
    // Check cache first (stale targetId is handled gracefully: copyCookiesViaCDP returns 0)
    const cacheKey = targetDomain || '*';
    const cached = this.cookieSourceCache.get(cacheKey);
    if (cached && Date.now() - cached.timestamp < CDPClient.COOKIE_CACHE_TTL) {
      console.error(`[CDPClient] Cache hit for cookie source (domain: ${cacheKey}): ${cached.targetId.slice(0, 8)}`);
      const result: CookieScanResult = {
        status: 'complete',
        targetId: cached.targetId,
        scanned: 0,
        total: 0,
        elapsedMs: 0,
      };
      this.lastCookieScanResult = result;
      return result;
    }
    if (cached) {
      // Stale entry: drop it so the cache does not retain expired data when
      // the fresh scan path below produces a new result (see issue #647).
      this.cookieSourceCache.delete(cacheKey);
    }

    // Promise coalescing: if a scan for this domain is already in-flight, reuse it
    const existing = this.inFlightCookieScans.get(cacheKey);
    if (existing) {
      console.error(`[CDPClient] Coalescing cookie scan for domain: ${cacheKey}`);
      return existing;
    }

    // Start the scan and register it so concurrent callers share this promise
    const scanPromise = this._doFindAuthenticatedPageTarget(targetDomain, cacheKey);
    this.inFlightCookieScans.set(cacheKey, scanPromise);
    try {
      return await scanPromise;
    } finally {
      this.inFlightCookieScans.delete(cacheKey);
    }
  }

  async findAuthenticatedPageTargetId(targetDomain?: string): Promise<string | null> {
    const result = await this.findAuthenticatedPageTarget(targetDomain);
    return result.targetId;
  }

  /**
   * Internal implementation of the authenticated-page probe.
   * Uses Target.attachToTarget (multiplexed CDP) instead of raw WebSocket connections.
   * Uses Target.getTargets result directly instead of /json/list HTTP calls.
   */
  private async _doFindAuthenticatedPageTarget(targetDomain: string | undefined, cacheKey: string): Promise<CookieScanResult> {
    const scanStart = Date.now();
    const browser = this.getBrowser();
    const session = await browser.target().createCDPSession();
    let targetsScanned = 0;

    const buildResult = (
      status: CookieScanStatus,
      totalCandidates: number,
      targetId: string | null,
      warning?: string,
    ): CookieScanResult => ({
      status,
      targetId,
      scanned: targetsScanned,
      total: totalCandidates,
      elapsedMs: Date.now() - scanStart,
      warning,
    });

    const recordOutcome = (result: CookieScanResult) => {
      this.lastCookieScanResult = result;
      const durationSec = result.elapsedMs / 1000;
      try {
        const m = getMetricsCollector();
        m.inc('openchrome_cookie_scan_total', { status: result.status });
        m.observe('openchrome_cookie_scan_duration_seconds', { status: result.status }, durationSec);
        m.observe('openchrome_cookie_scan_targets_scanned', { status: result.status }, targetsScanned);
      } catch {
        // Metrics collector unavailable — scan behavior must not depend on it.
      }
      if (result.status === 'partial' && !result.targetId) {
        console.error(
          `[CDPClient] Cookie scan partial: scanned ${targetsScanned}/${result.total} targets ` +
          `in ${(durationSec * 1000).toFixed(0)}ms before ${DEFAULT_COOKIE_SCAN_TIMEOUT_MS}ms timeout — ` +
          `no authenticated tab matched among scanned targets; remaining ${result.total - targetsScanned} were skipped.`,
        );
      }
    };

    try {
      const { targetInfos } = await session.send('Target.getTargets') as {
        targetInfos: Array<{ targetId: string; browserContextId?: string; type: string; url: string }>;
      };

      // Filter to candidate pages (not chrome://, not login pages, etc.)
      let candidates = targetInfos.filter(target =>
        target.type === 'page' &&
        !target.url.startsWith('chrome://') &&
        !target.url.startsWith('chrome-extension://') &&
        target.url !== 'about:blank' &&
        !target.url.includes('/login') &&
        !target.url.includes('/signin') &&
        !target.url.includes('/auth')
      );

      if (candidates.length === 0) {
        console.error('[CDPClient] No candidate pages found for cookie source');
        const result = buildResult('no_candidates', 0, null);
        recordOutcome(result);
        return result;
      }

      // If targeting an external domain (not localhost), exclude localhost pages
      if (targetDomain && !this.isLocalhost(`https://${targetDomain}`)) {
        const externalCandidates = candidates.filter(c => !this.isLocalhost(c.url));
        if (externalCandidates.length > 0) {
          console.error(`[CDPClient] Filtered out ${candidates.length - externalCandidates.length} localhost pages for external domain target`);
          candidates = externalCandidates;
        }
      }

      // Sort candidates by domain match score first, then by best-known recent
      // activity. This makes actively-used OpenChrome pages win ties while still
      // prioritizing explicit domain affinity for cookie restoration.
      candidates.sort((a, b) => {
        const scoreA = targetDomain ? this.domainMatchScore(a.url, targetDomain) : 0;
        const scoreB = targetDomain ? this.domainMatchScore(b.url, targetDomain) : 0;
        if (scoreA !== scoreB) return scoreB - scoreA;
        const activityA = this.targetActivityAt.get(a.targetId) ?? 0;
        const activityB = this.targetActivityAt.get(b.targetId) ?? 0;
        return activityB - activityA;
      });
      if (targetDomain) {
        console.error(`[CDPClient] Sorted ${candidates.length} candidates by domain match and recent activity for ${targetDomain}`);
      } else {
        console.error(`[CDPClient] Sorted ${candidates.length} candidates by recent activity`);
      }

      // Limit candidates to prevent N×30s cascading timeouts in parallel sessions.
      // Best-match candidates are already sorted first, so truncating is safe.
      if (candidates.length > DEFAULT_COOKIE_SCAN_MAX_CANDIDATES) {
        console.error(`[CDPClient] Limiting cookie scan from ${candidates.length} to ${DEFAULT_COOKIE_SCAN_MAX_CANDIDATES} candidates`);
        candidates = candidates.slice(0, DEFAULT_COOKIE_SCAN_MAX_CANDIDATES);
      }

      // Check each candidate to find one with actual cookies (in priority order).
      // Uses Target.attachToTarget over the existing multiplexed session — no raw WebSocket,
      // no /json/list HTTP round-trip.
      for (const candidate of candidates) {
        // Check overall scan timeout to prevent cascading hangs
        if (Date.now() - scanStart > DEFAULT_COOKIE_SCAN_TIMEOUT_MS) {
          console.error(`[CDPClient] Cookie scan timed out after ${Date.now() - scanStart}ms`);
          const warning =
            `Cookie scan timed out after scanning ${targetsScanned}/${candidates.length} targets; ` +
            `a matching authenticated page may still exist in the skipped remainder.`;
          const result = buildResult('partial', candidates.length, null, warning);
          recordOutcome(result);
          return result;
        }
        targetsScanned += 1;

        let attachedSessionId: string | null = null;
        try {
          // Per-candidate timeout to skip unresponsive tabs quickly
          let attachTid: ReturnType<typeof setTimeout>;
          const { sessionId } = await Promise.race([
            session.send('Target.attachToTarget', {
              targetId: candidate.targetId,
              flatten: true,
            }).finally(() => clearTimeout(attachTid)),
            new Promise<never>((_, reject) => {
              attachTid = setTimeout(() => reject(new Error('cookie scan: attach timeout')), DEFAULT_COOKIE_SCAN_PER_TARGET_TIMEOUT_MS);
            }),
          ]) as { sessionId: string };
          attachedSessionId = sessionId;

          // Send Network.getAllCookies through the flat CDP session (with per-target timeout)
          let cookiesTid: ReturnType<typeof setTimeout>;
          const result = await Promise.race([
            (session.send('Network.getAllCookies' as any, undefined, { sessionId } as any) as Promise<{ cookies: CookieEntry[] }>).finally(() => clearTimeout(cookiesTid)),
            new Promise<never>((_, reject) => {
              cookiesTid = setTimeout(() => reject(new Error('cookie scan: getAllCookies timeout')), DEFAULT_COOKIE_SCAN_PER_TARGET_TIMEOUT_MS);
            }),
          ]) as { cookies: CookieEntry[] };
          const cookieCount = result?.cookies?.length || 0;

          if (cookieCount > 0) {
            const domainScore = targetDomain ? this.domainMatchScore(candidate.url, targetDomain) : 0;
            console.error(`[CDPClient] Found authenticated page ${candidate.targetId.slice(0, 8)} at ${candidate.url.slice(0, 50)} (${cookieCount} cookies, domain score: ${domainScore})`);
            this.setCookieSourceCacheEntry(cacheKey, { targetId: candidate.targetId, timestamp: Date.now() });
            this.touchTargetActivity(candidate.targetId);
            const status: CookieScanStatus = targetsScanned < candidates.length ? 'partial' : 'complete';
            const resultSummary = buildResult(status, candidates.length, candidate.targetId);
            recordOutcome(resultSummary);
            return resultSummary;
          }
        } catch {
          // Target may be unresponsive, timed out, or already detached — skip
        } finally {
          if (attachedSessionId) {
            await session.send('Target.detachFromTarget', { sessionId: attachedSessionId }).catch(() => {});
          }
        }
      }

      console.error('[CDPClient] No pages with cookies found');
      const result = buildResult('no_cookies', candidates.length, null);
      recordOutcome(result);
      return result;
    } finally {
      await session.detach().catch(() => {});
    }
  }

  /**
   * Copy all cookies from authenticated page to destination page.
   * Uses Target.attachToTarget (multiplexed CDP) to bypass Puppeteer's context isolation —
   * no raw WebSocket connections, no /json/list HTTP calls.
   */
  async copyCookiesViaCDP(sourceTargetId: string, destPage: Page): Promise<number> {
    console.error(`[CDPClient] copyCookiesViaCDP called with sourceTargetId: ${sourceTargetId.slice(0, 8)}`);

    try {
      // Check cookie data cache first — avoids re-probing Chrome entirely
      const cachedData = this.cookieDataCache.get(sourceTargetId);
      if (cachedData && Date.now() - cachedData.timestamp < CDPClient.COOKIE_CACHE_TTL) {
        console.error(`[CDPClient] Cache hit for cookie data (${cachedData.cookies.length} cookies), skipping CDP attach`);
        const destSession = await destPage.createCDPSession();
        try {
          const cookiesToSet = cachedData.cookies.map(c => ({
            name: c.name,
            value: c.value,
            domain: c.domain,
            path: c.path,
            expires: c.expires,
            httpOnly: c.httpOnly,
            secure: c.secure,
            sameSite: c.sameSite as 'Strict' | 'Lax' | 'None' | undefined,
          }));
          await destSession.send('Network.setCookies', { cookies: cookiesToSet });
          console.error(`[CDPClient] Successfully copied ${cachedData.cookies.length} cookies (from cache)`);
          return cachedData.cookies.length;
        } finally {
          await destSession.detach().catch(() => {});
        }
      }
      if (cachedData) {
        // Stale entry: drop it so the cache does not retain expired data when
        // the fresh scan path below produces a new result (see issue #647).
        this.cookieDataCache.delete(sourceTargetId);
      }

      // Attach to the source target via the multiplexed browser CDP session
      const browser = this.getBrowser();
      const browserSession = await browser.target().createCDPSession();
      let attachedSessionId: string | null = null;

      try {
        // Verify the target exists before attaching
        const { targetInfos } = await browserSession.send('Target.getTargets') as {
          targetInfos: Array<{ targetId: string; url: string }>;
        };
        const sourceInfo = targetInfos.find(t => t.targetId === sourceTargetId);
        if (!sourceInfo) {
          console.error(`[CDPClient] Source target not found: ${sourceTargetId.slice(0, 8)}`);
          console.error(`[CDPClient] Available targets: ${targetInfos.map(t => t.targetId.slice(0, 8) + ' ' + t.url.slice(0, 40)).join(', ')}`);
          return 0;
        }

        console.error(`[CDPClient] Attaching to source target at ${sourceInfo.url.slice(0, 50)}`);

        const { sessionId } = await browserSession.send('Target.attachToTarget', {
          targetId: sourceTargetId,
          flatten: true,
        }) as { sessionId: string };
        attachedSessionId = sessionId;

        // Fetch cookies through the flat session (no raw WebSocket, no /json/list)
        const result = await browserSession.send('Network.getAllCookies' as any, undefined, { sessionId } as any) as {
          cookies: CookieEntry[];
        };
        const cookies: CookieEntry[] = result?.cookies || [];

        // Store in cookie data cache
        this.setCookieDataCacheEntry(sourceTargetId, { cookies, timestamp: Date.now() });

        if (cookies.length === 0) {
          console.error('[CDPClient] No cookies found in source page');
          return 0;
        }

        console.error(`[CDPClient] Found ${cookies.length} cookies, setting on destination page`);

        // Set cookies on destination page via its own CDPSession
        const destSession = await destPage.createCDPSession();
        try {
          const cookiesToSet = cookies.map(c => ({
            name: c.name,
            value: c.value,
            domain: c.domain,
            path: c.path,
            expires: c.expires,
            httpOnly: c.httpOnly,
            secure: c.secure,
            sameSite: c.sameSite as 'Strict' | 'Lax' | 'None' | undefined,
          }));
          await destSession.send('Network.setCookies', { cookies: cookiesToSet });
          console.error(`[CDPClient] Successfully copied ${cookies.length} cookies`);
          return cookies.length;
        } finally {
          await destSession.detach().catch(() => {});
        }
      } finally {
        if (attachedSessionId) {
          await browserSession.send('Target.detachFromTarget', { sessionId: attachedSessionId }).catch(() => {});
        }
        await browserSession.detach().catch(() => {});
      }

    } catch (error) {
      const msg = error instanceof Error ? error.message : String(error);
      // Distinguish connection-level errors (browser disconnected, WebSocket closed)
      // from benign cases (no cookies, target not found) for diagnostic clarity.
      const isConnectionLevel = /disconnect|websocket|not connected|protocol error|session closed/i.test(msg);
      if (isConnectionLevel) {
        console.error(`[CDPClient] copyCookiesViaCDP failed due to connection error (cookies will be missing): ${msg}`);
      } else {
        console.error(`[CDPClient] copyCookiesViaCDP failed (proceeding without cookies): ${msg}`);
      }
      return 0;
    }
  }

  /**
   * Create a new page with default viewport
   * @param url Optional URL to navigate to
   * @param context Optional browser context for session isolation (null/undefined = use Chrome's default context with cookies)
   * @param skipCookieBridge If true, skip cookie bridging from authenticated pages (used for pool pre-warming)
   */
  async createPage(url?: string, context?: BrowserContext | null, skipCookieBridge?: boolean): Promise<Page> {
    let page: Page;
    const browser = this.getBrowser();

    // Extract domain from URL for cookie source prioritization
    let targetDomain: string | undefined;
    if (url) {
      try {
        targetDomain = new URL(url).hostname;
        console.error(`[CDPClient] createPage targeting domain: ${targetDomain}`);
      } catch {
        // Invalid URL, proceed without domain preference
      }
    }

    if (context) {
      // Create page in isolated context (for worker isolation)
      let newPageTid1: ReturnType<typeof setTimeout>;
      page = await Promise.race([
        context.newPage().finally(() => clearTimeout(newPageTid1)),
        new Promise<never>((_, reject) => {
          newPageTid1 = setTimeout(() => reject(new Error(`newPage() timed out after ${DEFAULT_NEW_PAGE_TIMEOUT_MS}ms`)), DEFAULT_NEW_PAGE_TIMEOUT_MS);
        }),
      ]) as Page;
    } else {
      const startupPage = await this.findReusableStartupPage(browser);
      if (startupPage) {
        page = startupPage;
        skipCookieBridge = true;
        console.error(`[CDPClient] Reused startup tab ${getTargetId(page.target())} for first default-context page`);
      } else {
        // Create page in Chrome's default context
        let newPageTid2: ReturnType<typeof setTimeout>;
        page = await Promise.race([
          browser.newPage().finally(() => clearTimeout(newPageTid2)),
          new Promise<never>((_, reject) => {
            newPageTid2 = setTimeout(() => reject(new Error(`newPage() timed out after ${DEFAULT_NEW_PAGE_TIMEOUT_MS}ms`)), DEFAULT_NEW_PAGE_TIMEOUT_MS);
          }),
        ]) as Page;
      }

      // Copy cookies from an authenticated page (skip for pool pre-warming to avoid
      // CDP session conflicts and unnecessary overhead on about:blank pages).
      // The global skipCookieBridge flag serves as a manual override escape hatch.
      // Overall timeout prevents cascading hangs from unresponsive source tabs.
      if (!skipCookieBridge && !getGlobalConfig().skipCookieBridge) {
        const cookieScan = await this.findAuthenticatedPageTarget(targetDomain);
        if (cookieScan.status === 'partial' && !cookieScan.targetId) {
          console.error(
            `[CDPClient] Cookie bridge proceeding without copied cookies: ${cookieScan.warning ?? 'cookie scan incomplete'}`,
          );
        }
        if (cookieScan.targetId) {
          let cookieCopyTid: ReturnType<typeof setTimeout> | undefined;
          await Promise.race([
            this.copyCookiesViaCDP(cookieScan.targetId, page),
            new Promise<void>((resolve) => {
              cookieCopyTid = setTimeout(() => {
                console.error(`[CDPClient] Cookie copy timed out after ${DEFAULT_COOKIE_COPY_TIMEOUT_MS}ms, proceeding without cookies`);
                resolve();
              }, DEFAULT_COOKIE_COPY_TIMEOUT_MS);
            }),
          ]).finally(() => {
            if (cookieCopyTid) clearTimeout(cookieCopyTid);
          });
        }
      }
    }

    // Index page for O(1) target-to-page lookups (replaces eager targetcreated indexing)
    {
      const pageTargetId = getTargetId(page.target());
      this.targetIdIndex.set(pageTargetId, page);
      this.touchTargetActivity(pageTargetId);
    }

    this.configurePageDefenses(page);
    await applyRegisteredPreloads(page);

    // Set default viewport for consistent debugging experience (non-critical; swallow timeout)
    let pageConfigTid: ReturnType<typeof setTimeout> | undefined;
    await Promise.race([
      page.setViewport(CDPClient.DEFAULT_VIEWPORT),
      new Promise<void>((resolve) => {
        pageConfigTid = setTimeout(resolve, DEFAULT_PAGE_CONFIG_TIMEOUT_MS);
      }),
    ]).finally(() => {
      if (pageConfigTid) clearTimeout(pageConfigTid);
    });

    if (url) {
      try {
        assertDomainAllowed(url);
        await smartGoto(page, url, { timeout: DEFAULT_NAVIGATION_TIMEOUT_MS });
        assertDomainAllowed(page.url());
      } catch (err) {
        // Close the page to prevent about:blank ghost tabs on navigation failure.
        // When `page` came from findReusableStartupPage the closed tab is the
        // startup NTP — that's intentional: reuse is gated to isolated-mode
        // (OpenChrome owns Chrome), so the user does not see a stuck about:blank
        // from a failed first navigation.
        const targetId = getTargetId(page.target());
        this.targetIdIndex.delete(targetId);
        await page.close().catch(() => {});
        throw err;
      }
    }

    return page;
  }


  private async findReusableStartupPage(browser: Browser): Promise<Page | null> {
    if (this.targetIdIndex.size !== 0 || this.getChromeLifecycleMode() !== 'isolated') return null;
    const candidates = browser.targets().filter((target) => {
      if (target.type() !== 'page') return false;
      const targetId = getTargetId(target);
      if (!targetId || this.targetIdIndex.has(targetId)) return false;
      const targetUrl = target.url();
      return targetUrl === ''
        || targetUrl === 'about:blank'
        || targetUrl === 'chrome://newtab/'
        || targetUrl.startsWith('chrome://new-tab-page');
    });
    if (candidates.length !== 1) return null;
    try {
      return await candidates[0].page();
    } catch {
      return null;
    }
  }

  /**
   * Open a new tab via Chrome's HTTP debug API without attaching CDP during load.
   * This avoids the Runtime.enable serialization artifacts that Cloudflare Turnstile
   * and similar anti-bot systems detect. The tab loads (and Turnstile runs) with no
   * CDP observer attached. CDP is attached only after the settle window expires.
   *
   * @param url      URL to open in the new tab
   * @param settleMs Total settle time in ms — used for navigation timeout and post-nav wait (default 8000)
   * @returns        The Puppeteer Page and its targetId
   */
  async createTargetStealth(url: string, settleMs: number = 8000): Promise<{ page: Page; targetId: string }> {
    const browser = this.getBrowser();

    // Stealth v3 architecture (#450):
    // Instead of navigating directly to the target URL (which lets anti-bot scripts
    // fingerprint the raw browser before defenses are applied), we:
    //   1. Create tab with about:blank (no anti-bot scripts, no network request)
    //   2. Attach CDP immediately and register all defenses via evaluateOnNewDocument
    //   3. Navigate to the real URL — defenses fire at document_start, BEFORE any page JS
    // This closes the fingerprint timing gap that caused Access Denied on Coupang/Reddit.

    // Step 1: Create target with about:blank — invisible to anti-bot systems
    const cdp = await browser.target().createCDPSession();
    let targetId: string;
    try {
      const result = await cdp.send('Target.createTarget', { url: 'about:blank' }) as { targetId: string };
      targetId = result.targetId;
    } catch (createErr) {
      await cdp.detach().catch(() => {});
      throw new Error(`Stealth navigation: failed to create target: ${createErr instanceof Error ? createErr.message : String(createErr)}`);
    }

    console.error(`[CDPClient] Stealth tab created: ${targetId} (about:blank), will navigate to ${url}`);

    // Warn if headless — anti-bot detection is nearly guaranteed in headless mode
    {
      const { headless } = getGlobalConfig();
      let isHeadless = !!headless;
      if (!isHeadless) {
        try {
          const version = await browser.version();
          isHeadless = version.toLowerCase().includes('headless');
        } catch {
          // Version check failed — continue
        }
      }
      if (isHeadless) {
        console.error('[CDPClient] WARNING: Stealth mode in headless Chrome is unlikely to bypass anti-bot systems. Use headed Chrome (--visible) for anti-bot pages.');
      }
    }

    // Step 2: Attach CDP immediately (about:blank has no anti-bot to detect it)
    try {
      await cdp.send('Target.attachToTarget', { targetId, flatten: true });
    } catch (attachErr) {
      await cdp.send('Target.closeTarget', { targetId }).catch(() => {});
      await cdp.detach().catch(() => {});
      throw new Error(`Stealth navigation: failed to attach to target ${targetId}: ${attachErr instanceof Error ? attachErr.message : String(attachErr)}`);
    }

    let target: Target;
    try {
      target = await browser.waitForTarget(
        t => getTargetId(t) === targetId,
        { timeout: 5000 }
      );
    } catch {
      await cdp.send('Target.closeTarget', { targetId }).catch(() => {});
      await cdp.detach().catch(() => {});
      throw new Error(`Stealth navigation: target ${targetId} not found after attach (waitForTarget timed out)`);
    }

    let page: Page | null;
    try {
      page = await target.page();
    } catch {
      page = null;
    }

    if (!page) {
      await cdp.send('Target.closeTarget', { targetId }).catch(() => {});
      await cdp.detach().catch(() => {});
      throw new Error(`Stealth navigation: could not get page for target ${targetId}`);
    }

    await cdp.detach().catch(() => {});

    // Step 3: Register ALL defense scripts BEFORE navigation.
    // evaluateOnNewDocument scripts persist on the CDP session and execute at
    // document_start of every new document — before any <script> tag on the page.
    this.targetIdIndex.set(targetId, page);
    this.touchTargetActivity(targetId);
    this.configurePageDefenses(page);
    await applyRegisteredPreloads(page);

    // Stealth-only fingerprint defenses (WebGL, Canvas, Audio, hardware, screen, webdriver)
    const fpScript = getStealthFingerprintDefenseScript();
    const stackScript = getStealthStackSanitizationScript();
    await page.evaluateOnNewDocument(fpScript).catch(() => {});
    await page.evaluateOnNewDocument(stackScript).catch(() => {});

    // Step 4: Navigate to the real URL — all defenses now fire at document_start
    assertDomainAllowed(url);
    console.error(`[CDPClient] Stealth tab ${targetId}: navigating to ${url} with defenses pre-registered`);
    try {
      await page.goto(url, {
        waitUntil: 'domcontentloaded',
        timeout: Math.max(settleMs, 30000),
      });
    } catch (navErr) {
      // Navigation timeout is not fatal — the page may still be usable
      // (e.g., Turnstile challenge pages load slowly but are interactive)
      console.error(`[CDPClient] Stealth navigation warning: ${navErr instanceof Error ? navErr.message : String(navErr)}`);
    }

    // Step 5: Post-navigation settle period for challenge completion (Turnstile, etc.)
    // The page has loaded with defenses active; this extra wait lets async challenges finish.
    const postNavSettleMs = Math.max(settleMs - 5000, 2000); // At least 2s, reduced from total settle
    await new Promise<void>(resolve => setTimeout(resolve, postNavSettleMs));

    try {
      assertDomainAllowed(page.url());
    } catch (err) {
      this.targetIdIndex.delete(targetId);
      await page.close().catch(() => {});
      throw err;
    }

    // Step 6: Defense-in-depth — apply patches directly to the current page.
    // evaluateOnNewDocument should have handled this at document_start, but we
    // re-apply as fallback in case of edge cases (e.g., SPA soft-navigation).
    await page.evaluate(fpScript).catch(() => {});
    await page.evaluate(stackScript).catch(() => {});

    await page.evaluate(() => {
      // 1. navigator.webdriver — prototype-level deletion (less detectable than defineProperty)
      try {
        delete (Object.getPrototypeOf(navigator) as any).webdriver;
      } catch {
        Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
      }

      // 2. chrome.runtime shape
      if ((window as any).chrome) {
        const originalChrome = (window as any).chrome;
        if (!originalChrome.runtime) {
          Object.defineProperty(originalChrome, 'runtime', {
            get: () => ({ id: undefined }),
            configurable: true,
          });
        }
      }

      // 3. Permissions API — prevent "denied" responses that flag automation
      if (navigator.permissions) {
        const originalQuery = navigator.permissions.query.bind(navigator.permissions);
        Object.defineProperty(navigator.permissions, 'query', {
          value: (params: { name: string }) => {
            if (params.name === 'notifications') {
              return Promise.resolve(Object.assign(new EventTarget(), { state: 'prompt', onchange: null }) as any);
            }
            return originalQuery(params as PermissionDescriptor);
          },
          writable: true,
          configurable: true,
        });
      }

      // 4. navigator.plugins — headless Chrome has 0 plugins
      if (navigator.plugins.length === 0) {
        Object.defineProperty(navigator, 'plugins', {
          get: () => {
            const plugins = [
              { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
              { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
              { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
            ];
            const arr = typeof PluginArray !== 'undefined' ? Object.create(PluginArray.prototype) : [];
            for (let i = 0; i < plugins.length; i++) {
              arr[i] = plugins[i];
            }
            Object.defineProperty(arr, 'length', { value: plugins.length });
            arr.item = (i: number) => arr[i] || null;
            arr.namedItem = (name: string) => plugins.find(p => p.name === name) || null;
            arr.refresh = () => {};
            return arr;
          },
          configurable: true,
        });
      }

      // 5. navigator.languages — ensure populated
      if (!navigator.languages || navigator.languages.length === 0) {
        Object.defineProperty(navigator, 'languages', {
          get: () => ['en-US', 'en'],
          configurable: true,
        });
      }

      // 6. window dimensions — headless Chrome returns 0
      if (window.outerWidth === 0) {
        Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth, configurable: true });
      }
      if (window.outerHeight === 0) {
        Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85, configurable: true });
      }

      // 7. navigator.mimeTypes — headless has 0 mimeTypes
      if (navigator.mimeTypes.length === 0) {
        Object.defineProperty(navigator, 'mimeTypes', {
          get: () => {
            const mt = typeof MimeTypeArray !== 'undefined' ? Object.create(MimeTypeArray.prototype) : [];
            mt[0] = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' };
            Object.defineProperty(mt, 'length', { value: 1 });
            mt.item = (i: number) => mt[i] || null;
            mt.namedItem = (name: string) => name === 'application/pdf' ? mt[0] : null;
            return mt;
          },
          configurable: true,
        });
      }

      // 8. chrome.app and chrome.loadTimes stubs
      if ((window as any).chrome) {
        const c = (window as any).chrome;
        if (!c.app) {
          c.app = { isInstalled: false, getDetails: () => null, getIsInstalled: () => false, installState: () => 'disabled' };
        }
        if (!c.loadTimes) {
          c.loadTimes = () => ({});
        }
      }
    }).catch(() => {});

    console.error(`[CDPClient] Stealth tab ${targetId} ready (defenses pre-injected, page loaded)`);
    return { page, targetId };
  }

  /**
   * Register defense handlers on a page: dialog auto-dismiss, crash eviction,
   * print suppression, download deny. Idempotent — safe to call multiple times.
   */
  private configurePageDefenses(page: Page): void {
    // Idempotent guard — prevent double-registration
    if ((page as any).__defensesConfigured) return;
    (page as any).__defensesConfigured = true;

    // Auto-dismiss native JavaScript dialogs (alert/confirm/prompt/beforeunload).
    // Without this, any dialog fired by page JS blocks ALL subsequent CDP commands
    // indefinitely, freezing the tab until the user manually dismisses it in Chrome.
    page.on('dialog', async (dialog) => {
      console.error(`[CDPClient] Auto-dismissing ${dialog.type()} dialog: "${dialog.message().slice(0, 100)}"`);
      // For beforeunload, accept() allows navigation/close to proceed.
      // For alert/confirm/prompt, dismiss() is the safe non-blocking choice.
      if (dialog.type() === 'beforeunload') {
        await dialog.accept().catch(() => {});
      } else {
        await dialog.dismiss().catch(() => {});
      }
    });

    // Handle renderer crashes — evict the crashed page immediately.
    // targetdestroyed does NOT fire for renderer crashes, so without this
    // the zombie page stays in session maps and the next command hangs for 30s.
    page.on('error', (err) => {
      const targetId = getTargetId(page.target());
      console.error(`[CDPClient] Page renderer crashed (${targetId}): ${err.message}`);
      this.onTargetDestroyed(targetId);
    });

    // Suppress window.print() — native OS print dialog is NOT caught by
    // page.on('dialog') and blocks the renderer indefinitely.
    // Does not affect page.pdf() which uses CDP Page.printToPDF.
    page.evaluateOnNewDocument(() => {
      window.print = () => { console.warn('[OpenChrome] window.print() suppressed'); };
    }).catch(() => {});

    // Remove navigator.webdriver flag that CDP sets automatically.
    // The --disable-blink-features=AutomationControlled launch flag prevents Blink from
    // setting this flag in headed mode. For headless mode (where the flag may not work),
    // we delete the property from the prototype rather than using Object.defineProperty
    // on the instance — the defineProperty approach is detectable via
    // Object.getOwnPropertyDescriptor(navigator, 'webdriver'). (#247, #446)
    page.evaluateOnNewDocument(() => {
      try {
        delete (Object.getPrototypeOf(navigator) as any).webdriver;
      } catch {
        // Fallback: defineProperty if prototype deletion fails
        Object.defineProperty(navigator, 'webdriver', {
          get: () => undefined,
          configurable: true,
        });
      }
    }).catch(() => {});

    // Mask Chrome automation artifacts that anti-bot systems scan for.
    // These cover the most common fingerprinting vectors after navigator.webdriver.
    page.evaluateOnNewDocument(() => {
      // 1. Ensure chrome.runtime exists with expected shape (real Chrome has it even without extensions)
      if ((window as any).chrome) {
        const originalChrome = (window as any).chrome;
        // chrome.runtime should exist in real Chrome but have specific shape
        if (!originalChrome.runtime) {
          // In real Chrome, chrome.runtime exists but has limited properties without extensions
          Object.defineProperty(originalChrome, 'runtime', {
            get: () => ({ id: undefined }),
            configurable: true,
          });
        }
      }

      // 2. Override Permissions API to prevent "denied" responses that flag automation
      if (navigator.permissions) {
        const originalQuery = navigator.permissions.query.bind(navigator.permissions);
        Object.defineProperty(navigator.permissions, 'query', {
          value: (params: { name: string }) => {
            // Notifications permission is commonly checked by anti-bot
            if (params.name === 'notifications') {
              return Promise.resolve(Object.assign(new EventTarget(), { state: 'prompt', onchange: null }) as any);
            }
            return originalQuery(params as PermissionDescriptor);
          },
          writable: true,
          configurable: true,
        });
      }

      // 3. Ensure plugins array is non-empty (headless Chrome has 0 plugins).
      // Individual entries are plain objects, not Plugin instances — sophisticated
      // detectors using instanceof Plugin will see through this. Turnstile and
      // most anti-bot systems only check plugins.length > 0.
      if (navigator.plugins.length === 0) {
        Object.defineProperty(navigator, 'plugins', {
          get: () => {
            const plugins = [
              { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
              { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
              { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
            ];
            // Mimic PluginArray behavior (guard for environments where PluginArray is unavailable)
            const arr = typeof PluginArray !== 'undefined' ? Object.create(PluginArray.prototype) : [];
            for (let i = 0; i < plugins.length; i++) {
              arr[i] = plugins[i];
            }
            Object.defineProperty(arr, 'length', { value: plugins.length });
            arr.item = (i: number) => arr[i] || null;
            arr.namedItem = (name: string) => plugins.find(p => p.name === name) || null;
            arr.refresh = () => {};
            return arr;
          },
          configurable: true,
        });
      }

      // 4. Ensure languages array is populated
      if (!navigator.languages || navigator.languages.length === 0) {
        Object.defineProperty(navigator, 'languages', {
          get: () => ['en-US', 'en'],
          configurable: true,
        });
      }
    }).catch(() => {});

    // Defense 4: window dimensions + chrome stubs (anti-headless, #361)
    page.evaluateOnNewDocument(() => {
      // outerWidth/outerHeight — headless Chrome returns 0
      if (window.outerWidth === 0) {
        Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth, configurable: true });
      }
      if (window.outerHeight === 0) {
        Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85, configurable: true });
      }

      // navigator.mimeTypes — headless has 0 mimeTypes
      if (navigator.mimeTypes.length === 0) {
        Object.defineProperty(navigator, 'mimeTypes', {
          get: () => {
            const mt = typeof MimeTypeArray !== 'undefined' ? Object.create(MimeTypeArray.prototype) : [];
            mt[0] = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' };
            Object.defineProperty(mt, 'length', { value: 1 });
            mt.item = (i: number) => mt[i] || null;
            mt.namedItem = (name: string) => name === 'application/pdf' ? mt[0] : null;
            return mt;
          },
          configurable: true,
        });
      }

      // chrome.app and chrome.loadTimes stubs
      if ((window as any).chrome) {
        const c = (window as any).chrome;
        if (!c.app) {
          c.app = { isInstalled: false, getDetails: () => null, getIsInstalled: () => false, installState: () => 'disabled' };
        }
        if (!c.loadTimes) {
          c.loadTimes = () => ({});
        }
      }
    }).catch(() => {});

    // Deny file downloads by default — Content-Disposition: attachment
    // responses block the navigation promise indefinitely.
    this.send(page, 'Page.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});

    // Clear refs when main frame navigates (SPA navigation invalidates all backendDOMNodeIds)
    page.on('framenavigated', (frame) => {
      if (frame === page.mainFrame()) {
        try {
          const targetId = getTargetId(page.target());
          getRefIdManager().clearTargetRefsAllSessions(targetId);
        } catch {
          // Ignore errors during cleanup
        }
      }
    });
  }

  /**
   * Get all page targets
   */
  async getPages(): Promise<Page[]> {
    const browser = this.getBrowser();
    return browser.pages();
  }

  /**
   * Rebuild the targetIdIndex from currently alive browser targets.
   * Called after CDP reconnection to restore O(1) target-to-page lookups
   * for targets that survived the disconnect.
   */
  async rebuildTargetIdIndex(): Promise<number> {
    // Build into a fresh Map, then swap atomically to avoid a window
    // where concurrent getPageByTargetId() calls miss the fast path.
    const newIndex = new TargetPageIndex();
    let indexed = 0;
    try {
      const browser = this.getBrowser();
      const pages = await browser.pages();
      for (const page of pages) {
        if (!page.isClosed()) {
          const targetId = getTargetId(page.target());
          newIndex.set(targetId, page);
          this.touchTargetActivity(targetId);
          indexed++;
        }
      }
    } catch (err) {
      console.error('[CDPClient] rebuildTargetIdIndex failed, will rebuild lazily:', err);
    }
    this.targetIdIndex = newIndex; // atomic swap
    return indexed;
  }

  /**
   * Get page by target ID
   */
  async getPageByTargetId(targetId: string): Promise<Page | null> {
    // Fast path: check index first (O(1))
    const indexed = this.targetIdIndex.get(targetId);
    if (indexed && !indexed.isClosed()) {
      this.touchTargetActivity(targetId);
      return indexed;
    }

    // Fallback: linear scan (for pages created before indexing started)
    const browser = this.getBrowser();
    const targets = browser.targets();

    for (const target of targets) {
      if (getTargetId(target) === targetId && target.type() === 'page') {
        let targetPageTid: ReturnType<typeof setTimeout> | undefined;
        const page = await Promise.race([
          target.page(),
          new Promise<null>((resolve) => {
            targetPageTid = setTimeout(() => resolve(null), 5000);
          }),
        ]).finally(() => {
          if (targetPageTid) clearTimeout(targetPageTid);
        });
        if (page) {
          // Populate index for future lookups
          this.targetIdIndex.set(targetId, page);
          this.touchTargetActivity(targetId);
          this.configurePageDefenses(page);
        }
        return page;
      }
    }

    // Clean stale index entry
    this.targetIdIndex.delete(targetId);
    return null;
  }

  /**
   * Index an externally-created page (e.g., from headed fallback) so it is
   * accessible via getPageByTargetId() and passes the stale-target guards
   * in getCDPSession()/send(). (#485)
   */
  indexExternalPage(targetId: string, page: Page): void {
    this.targetIdIndex.set(targetId, page);
    this.touchTargetActivity(targetId);
    this.configurePageDefenses(page);
    page.once('close', () => {
      this.targetIdIndex.delete(targetId);
      this.targetActivityAt.delete(targetId);
      // sessions and cookie cache cleanup handled by onTargetDestroyed via browser targetdestroyed event
    });
  }

  /**
   * Get CDP session for a page
   */
  async getCDPSession(page: Page): Promise<CDPSession> {
    const target = page.target();
    const targetId = getTargetId(target);

    // Fail fast if the target is no longer valid (browser may have reconnected)
    if (targetId && !this.targetIdIndex.has(targetId)) {
      console.error(`[CDPClient] Rejecting getCDPSession() for stale target ${targetId} — page reference is no longer valid`);
      throw new OpenChromeConnectionError(
        `Target ${targetId} is no longer valid (browser disconnected or reconnected). ` +
        `Retry the operation to get a fresh page reference.`,
        targetId
      );
    }

    let session = this.sessions.get(targetId);
    if (!session) {
      session = await page.createCDPSession();
      this.sessions.set(targetId, session);
    }

    return session;
  }

  /**
   * Execute CDP command on a page.
   * Wrapped with per-call timeout to prevent hung renderers from blocking indefinitely.
   */
  async send<T = unknown>(
    page: Page,
    method: string,
    params?: Record<string, unknown>
  ): Promise<T> {
    // Fail fast if the target is no longer valid (browser may have reconnected)
    const targetId = getTargetId(page.target());
    if (targetId && !this.targetIdIndex.has(targetId)) {
      console.error(`[CDPClient] Rejecting send() for stale target ${targetId} — page reference is no longer valid`);
      throw new OpenChromeConnectionError(
        `Target ${targetId} is no longer valid (browser disconnected or reconnected). ` +
        `Retry the operation to get a fresh page reference.`,
        targetId
      );
    }

    const session = await this.getCDPSession(page);
    return withTimeout(
      session.send(method as any, params as any) as Promise<T>,
      DEFAULT_CDP_SEND_TIMEOUT_MS,
      `CDP ${method}`
    );
  }

  /**
   * Get all targets
   */
  getTargets(): Target[] {
    return this.getBrowser().targets();
  }

  /**
   * Find target by ID
   */
  findTarget(targetId: string): Target | undefined {
    return this.getTargets().find((t) => getTargetId(t) === targetId);
  }

  /**
   * Trigger garbage collection on a page (best-effort)
   */
  async triggerGC(page: Page): Promise<void> {
    try {
      const session = await this.getCDPSession(page);
      await session.send('HeapProfiler.collectGarbage' as any);
    } catch {
      // Best-effort: silently ignore GC failures
    }
  }

  /**
   * Close a page by target ID
   */
  async closePage(targetId: string): Promise<void> {
    const page = await this.getPageByTargetId(targetId);
    if (page) {
      await this.triggerGC(page);
      await page.close();
      this.sessions.delete(targetId);
    }
  }

  /**
   * Check if connected
   */
  isConnected(): boolean {
    return this.browser !== null && this.browser.isConnected();
  }

  /**
   * Get the port this client is connected to
   */
  getPort(): number {
    return this.port;
  }

  /**
   * Create a CDPClient instance for a specific port
   */
  static createForPort(port: number, options?: CDPClientOptions): CDPClient {
    return new CDPClient({ ...options, port });
  }
}

// Singleton instance
let clientInstance: CDPClient | null = null;

export function getCDPClient(options?: CDPClientOptions): CDPClient {
  if (!clientInstance) {
    clientInstance = new CDPClient(options);
  }
  return clientInstance;
}

export function _resetCDPClientForTesting(): void {
  clientInstance = null;
}

/**
 * Factory for managing multiple CDPClient instances (one per Chrome port)
 */
export class CDPClientFactory {
  private clients: Map<number, CDPClient> = new Map();

  /**
   * Get an existing client for the given port, or create a new one
   */
  getOrCreate(port: number, options?: CDPClientOptions): CDPClient {
    let client = this.clients.get(port);
    if (!client) {
      client = CDPClient.createForPort(port, options);
      this.clients.set(port, client);
    }
    return client;
  }

  /**
   * Get an existing client for the given port, or undefined if not found
   */
  get(port: number): CDPClient | undefined {
    return this.clients.get(port);
  }

  /**
   * Get all managed client instances
   */
  getAll(): CDPClient[] {
    return Array.from(this.clients.values());
  }

  /**
   * Disconnect all managed clients
   */
  async disconnectAll(): Promise<void> {
    const disconnectPromises = Array.from(this.clients.values()).map(client =>
      client.disconnect().catch(err =>
        console.error(`[CDPClientFactory] Error disconnecting client on port ${client.getPort()}:`, err)
      )
    );
    await Promise.all(disconnectPromises);
    this.clients.clear();
  }
}

// Singleton factory instance
let factoryInstance: CDPClientFactory | null = null;

export function getCDPClientFactory(): CDPClientFactory {
  if (!factoryInstance) {
    factoryInstance = new CDPClientFactory();
  }
  return factoryInstance;
}

export function _resetCDPClientFactoryForTesting(): void {
  factoryInstance = null;
}
