/**
 * Session Manager - Manages lifecycle of parallel Claude Code sessions
 * Supports multiple Workers within a single session for parallel browser operations
 */

import path from 'path';
import { Page, Target, BrowserContext, Browser } from 'puppeteer-core';
import { Session, SessionInfo, SessionCreateOptions, SessionEvent, Worker, WorkerInfo, WorkerCreateOptions } from './types/session';
import { TargetOwnershipRegistry } from './session/target-registry';
import { TargetLeaseConflictError, TargetLeaseRegistry, type TargetLeaseRecord } from './session/target-lease-registry';
import { TargetQueueManager } from './session/target-command-queue';
import { CDPClient, getCDPClient, CDPClientFactory, getCDPClientFactory } from './cdp/client';
import { CDPConnectionPool, getCDPConnectionPool, PoolStats } from './cdp/connection-pool';
import { ChromePool, getChromePool } from './chrome/pool';
import {
  DEFAULT_CONTEXT_NAME,
  DefaultNamedContextRegistry,
  assertValidContextName,
  getNamedContextRegistry,
} from './chrome/contexts';
import { getGlobalConfig } from './config/global';
import { RequestQueueManager } from './utils/request-queue';
import { getRefIdManager } from './utils/ref-id-manager';
import { smartGoto } from './utils/smart-goto';
import { DEFAULT_NAVIGATION_TIMEOUT_MS, DEFAULT_MAX_TARGETS_PER_WORKER, DEFAULT_MEMORY_PRESSURE_THRESHOLD, DEFAULT_CREATE_TARGET_TIMEOUT_MS, DEFAULT_COOKIE_CONTEXT_TIMEOUT_MS, DEFAULT_WATCHDOG_INTERVAL_MS } from './config/defaults';
import * as os from 'os';
import { BrowserRouter } from './router';
import { BrowserBackend, HybridConfig, RouteReason } from './types/browser-backend';
import { StorageStateManager } from './storage-state';
import { StorageStateConfig } from './config';
import { assertDomainAllowed } from './security/domain-guard';
import { getTargetId } from './utils/puppeteer-helpers';
import { safeTitle } from './utils/safe-title';
import { getMetricsCollector } from './metrics/collector';
import { getLifecycleBus } from './core/lifecycle';
import { flush as flushRecorderBuffer } from './core/skill-memory/recorder-buffer';
import type { LifecycleEvent, SessionDestroyReason } from './core/lifecycle';
import { getTenantManager, isStrictTenantIsolationEnabled } from './tenant/registry';
import type { TenantManager } from './tenant/manager';
import { DEFAULT_TENANT_ID, type TenantId } from './tenant/types';
import { currentRequestContext } from './observability/request-id';
import { Budget, isLegacyBudgetMode } from './utils/budget';
import {
  DEFAULT_SESSION_INIT_BUDGET_LAUNCH_FRACTION,
  DEFAULT_SESSION_INIT_BUDGET_CONNECT_FRACTION,
} from './config/defaults';

/** The primary session ID used by most single-agent workflows. */
const DEFAULT_SESSION_ID = 'default';

export interface SessionManagerConfig {
  /** Session TTL in milliseconds (default: 30 minutes) */
  sessionTTL?: number;
  /** Auto-cleanup interval in milliseconds (default: 1 minute) */
  cleanupInterval?: number;
  /**
   * Idle TTL for a managed target lease, in ms (default: 30 minutes; 0 disables).
   * Sliding — refreshed on every executeCDP call. A non-default-session lease that
   * goes silent past this window is treated as a disconnected/crashed owner and its
   * tab is reclaimed by auto-cleanup. The "default" session is exempt (mirrors the
   * sessionTTL protection), and `preserve`-policy leases are never auto-closed.
   */
  targetLeaseTtl?: number;
  /** Enable auto-cleanup (default: true) */
  autoCleanup?: boolean;
  /** Maximum number of sessions (default: 100) */
  maxSessions?: number;
  /** Maximum workers per session (default: 20) */
  maxWorkersPerSession?: number;
  /** Maximum targets (tabs) per worker (default: 5). Oldest closed when exceeded. */
  maxTargetsPerWorker?: number;
  /** Memory pressure threshold in bytes. Below this free memory, aggressive cleanup triggers. (default: 500MB) */
  memoryPressureThreshold?: number;
  /** Use connection pool for page management (default: false for worker isolation) */
  useConnectionPool?: boolean;
  /** Use default browser context (shares cookies/sessions with Chrome profile) */
  useDefaultContext?: boolean;
  /** Enable Chrome pool for origin-aware instance distribution (default: false) */
  usePool?: boolean;
  /** Storage state persistence config (default: disabled) */
  storageState?: StorageStateConfig;
  /**
   * TenantManager used to resolve per-tenant BrowserContexts (#7). When
   * omitted, the process-wide singleton from tenant/registry is used.
   */
  tenantManager?: TenantManager;
  /**
   * Force strict tenant isolation. When true, `useDefaultContext` is rejected
   * at session creation time and every session is pinned to a tenant context.
   * Defaults to reading OPENCHROME_STRICT_TENANT_ISOLATION.
   */
  strictTenantIsolation?: boolean;
}

export interface SessionManagerStats {
  activeSessions: number;
  totalTargets: number;
  totalWorkers: number;
  totalSessionsCreated: number;
  totalSessionsCleaned: number;
  uptime: number;
  lastCleanup: number | null;
  memoryUsage: number;
  connectionPool?: PoolStats;
}

const DEFAULT_CONFIG: Required<Omit<SessionManagerConfig, 'tenantManager' | 'strictTenantIsolation'>> = {
  sessionTTL: 30 * 60 * 1000,      // 30 minutes
  cleanupInterval: 60 * 1000,       // 1 minute
  targetLeaseTtl: 30 * 60 * 1000,   // 30 minutes (sliding idle TTL; 0 disables)
  autoCleanup: true,
  maxSessions: 100,
  maxWorkersPerSession: 50,
  maxTargetsPerWorker: DEFAULT_MAX_TARGETS_PER_WORKER,
  memoryPressureThreshold: DEFAULT_MEMORY_PRESSURE_THRESHOLD,
  useConnectionPool: true,          // Enabled by default for faster page creation
  useDefaultContext: true,          // Use Chrome profile's cookies/sessions by default
  usePool: false,                   // Disabled by default; enable for multi-Chrome origin isolation
  storageState: { enabled: false },
};

export class SessionManager {
  private sessions: Map<string, Session> = new Map();
  private targetToWorker = new TargetOwnershipRegistry();
  private targetLeases = new TargetLeaseRegistry();
  /**
   * Maps targetId → `{browser, name}` for the owning named context (#848).
   * Targets opened in the default Chrome context are not present here;
   * tools can treat absence as `'default'`. The browser is recorded so the
   * registry's `(browser, name)` keying receives the correct browser when
   * the tab closes — same name on a different Chrome instance must not
   * cross-decrement.
   */
  private targetToContext: Map<string, { browser: Browser; name: string }> = new Map();
  /** Named BrowserContext registry shared with the tabs_create tool. */
  private namedContextRegistry: DefaultNamedContextRegistry = getNamedContextRegistry();
  private cdpClient: CDPClient;
  private connectionPool: CDPConnectionPool | null = null;
  private chromePool: ChromePool | null = null;
  private cdpFactory: CDPClientFactory;
  private queueManager: RequestQueueManager;
  private targetQueueManager = new TargetQueueManager();
  private eventListeners: ((event: SessionEvent) => void)[] = [];
  private browserRouter: BrowserRouter | null = null;
  /**
   * Side-channel for the most-recent BrowserRouter decision keyed by
   * targetId. Filled inside `getPage()` whenever the router runs; read by
   * tool result builders (via `getLastRouting`) to surface
   * `meta.path_taken` without changing the hot `getPage()` signature.
   *
   * Per #1359 §Pillar C (facts before decisions): the path the router took
   * is a fact the host should be able to read directly. Cleared when the
   * target closes.
   */
  private lastRoutingByTarget = new Map<
    string,
    { path_taken: RouteReason; backend: BrowserBackend; fallback: boolean; at: number }
  >();
  private storageStateManagers = new Map<string, StorageStateManager>();
  private storageStateConfig: StorageStateConfig | null = null;
  private pendingCreations = new Map<string, Promise<Session>>();
  private externalTargetRegistrationLocks = new Map<string, Promise<void>>();

  // Stealth mode tracking — targets opened via createTargetStealth
  private stealthTargets = new Set<string>();

  // TTL & Stats
  private config: Required<Omit<SessionManagerConfig, 'tenantManager' | 'strictTenantIsolation'>>;
  // Tenant isolation (#7) — lazily bound to the process-wide TenantManager
  // singleton unless an override was provided via config.
  private tenantManagerOverride: TenantManager | null;
  private strictTenantIsolation: boolean;
  private cleanupTimer: NodeJS.Timeout | null = null;
  private startTime: number = Date.now();
  private totalSessionsCreated: number = 0;
  private totalSessionsCleaned: number = 0;
  private lastCleanupTime: number | null = null;

  constructor(cdpClient?: CDPClient, config?: SessionManagerConfig) {
    this.cdpClient = cdpClient || getCDPClient();
    this.queueManager = new RequestQueueManager();
    const { tenantManager: tenantMgrOverride, strictTenantIsolation, ...rest } = config ?? {};
    this.config = { ...DEFAULT_CONFIG, ...rest };
    this.tenantManagerOverride = tenantMgrOverride ?? null;
    this.strictTenantIsolation = isStrictTenantIsolationEnabled(strictTenantIsolation);
    this.cdpFactory = getCDPClientFactory();

    if (this.config.useConnectionPool) {
      this.connectionPool = getCDPConnectionPool();
    }

    if (this.config.usePool) {
      this.chromePool = getChromePool({ autoLaunch: getGlobalConfig().autoLaunch });
    }

    if (this.config.autoCleanup) {
      this.startAutoCleanup();
    }

    // Register target destroyed listener
    this.cdpClient.addTargetDestroyedListener((targetId) => {
      this.onTargetClosed(targetId);
    });

    // Validate stale targets after reconnection
    this.cdpClient.addConnectionListener((event) => {
      if (event.type === 'reconnected') {
        this.validateTargetsAfterReconnect().catch((err) => {
          console.error('[SessionManager] Post-reconnect target validation failed:', err);
        });
      }
      if (event.type === 'reconnect_failed') {
        // Chrome is gone — purge all stale target mappings
        console.error('[SessionManager] Reconnect failed, clearing stale target mappings');
        for (const targetId of Array.from(this.targetToWorker.keys())) {
          this.onTargetClosed(targetId);
          // Safety: force-delete in case session is already gone and
          // onTargetClosed skipped it. The lease release mirrors the
          // targetToWorker.delete below so the lease registry never
          // outlives the legacy ownership map — leases without a TTL
          // would otherwise survive indefinitely after Chrome disappears.
          this.targetLeases.release(targetId);
          this.targetToWorker.delete(targetId);
        }
      }
    });

    // Store storage state config if enabled
    if (this.config.storageState?.enabled) {
      this.storageStateConfig = this.config.storageState;
    }
  }

  /**
   * Lazily initialize ChromePool when needed (e.g., first multi-profile request).
   */
  private ensurePool(): ChromePool {
    if (!this.chromePool) {
      this.chromePool = getChromePool({ autoLaunch: getGlobalConfig().autoLaunch });
      console.error('[SessionManager] ChromePool lazily initialized for multi-profile support');
    }
    return this.chromePool;
  }

  /**
   * Parse URL origin safely, returning undefined on failure (P1-4 fix).
   */
  private static safeParseOrigin(url: string | undefined): string | undefined {
    if (!url) return undefined;
    try { return new URL(url).origin; } catch { return undefined; }
  }

  /**
   * Get the CDPClient for a specific worker (may be on a different Chrome instance)
   */
  private getCDPClientForWorker(sessionId: string, workerId: string): CDPClient {
    const worker = this.getWorker(sessionId, workerId);
    if (worker?.port) {
      const client = this.cdpFactory.get(worker.port);
      if (client) return client;
    }
    return this.cdpClient;
  }

  private acquireTargetLease(
    targetId: string,
    sessionId: string,
    workerId: string,
    contextName?: string,
    parentTargetId?: string,
  ): void {
    // #1359 backlog item 7: arm the sliding idle TTL so a disconnected/crashed
    // owner's lease eventually expires and its tab is reclaimed. The "default"
    // session is exempt (it persists like the session itself); a configured TTL
    // of 0 disables expiry globally.
    const configuredTtl = this.config.targetLeaseTtl;
    const ttlMs = sessionId === DEFAULT_SESSION_ID || !configuredTtl ? undefined : configuredTtl;
    if (parentTargetId && this.targetLeases.inherit(targetId, parentTargetId, { sessionId, workerId, contextName, ttlMs })) {
      return;
    }
    try {
      this.targetLeases.acquire({ targetId, sessionId, workerId, contextName, ttlMs });
    } catch (err) {
      // #1359 backlog item 3: a conflicting lease means a stale or rogue
      // owner still holds the registry entry — log loudly so operators see
      // the duplicate-controller signal, then transfer ownership to the
      // caller. This keeps the legacy targetToWorker map (which has already
      // recorded the new owner) consistent with the registry and prevents
      // the conflict from killing the caller's tool invocation.
      if (err instanceof TargetLeaseConflictError) {
        console.error(
          `[SessionManager] Target ${targetId.slice(0, 8)} lease conflict: previous owner session=${err.existing.sessionId} worker=${err.existing.workerId ?? 'unknown'}; transferring to session=${sessionId} worker=${workerId}`,
        );
        this.targetLeases.release(targetId);
        this.targetLeases.acquire({ targetId, sessionId, workerId, contextName, ttlMs });
        return;
      }
      throw err;
    }
  }

  getTargetLease(targetId: string): TargetLeaseRecord | undefined {
    const lease = this.targetLeases.get(targetId);
    return lease ? { ...lease } : undefined;
  }

  getTargetLeaseSnapshot(): TargetLeaseRecord[] {
    return this.targetLeases.snapshot();
  }

  getTargetQueueStats(): ReturnType<TargetQueueManager['getStats']> {
    return this.targetQueueManager.getStats();
  }

  getTargetDiagnostics(tenantId: TenantId = DEFAULT_TENANT_ID): { leases: Array<Record<string, unknown>>; queues: Array<Record<string, unknown>> } {
    const visibleSessionIds = new Set<string>();
    for (const [sessionId, session] of this.sessions) {
      if ((session.tenantId ?? DEFAULT_TENANT_ID) === tenantId) visibleSessionIds.add(sessionId);
    }
    const visibleTargetIds = new Set<string>();
    const leases = this.targetLeases.snapshot()
      .filter((lease) => visibleSessionIds.has(lease.sessionId))
      .map((lease) => {
        visibleTargetIds.add(lease.targetId);
        return {
          targetId: lease.targetId,
          sessionId: lease.sessionId,
          workerId: lease.workerId,
          laneId: lease.laneId,
          contextName: lease.contextName,
          cleanupPolicy: lease.cleanupPolicy,
          createdAt: lease.createdAt,
          lastActivityAt: lease.lastActivityAt,
          leaseExpiresAt: lease.leaseExpiresAt,
        };
      });
    const queues = this.targetQueueManager.getStats()
      .filter((queue) => visibleTargetIds.has(queue.targetId))
      .map((queue) => ({
        targetId: queue.targetId,
        pending: queue.pending,
        processing: queue.processing,
        closed: queue.closed,
        enqueued: queue.enqueued,
        completed: queue.completed,
        rejected: queue.rejected,
        cancelled: queue.cancelled,
        averageWaitMs: queue.completed > 0 ? Math.round(queue.totalWaitMs / queue.completed) : 0,
        averageExecutionMs: queue.completed > 0 ? Math.round(queue.totalExecutionMs / queue.completed) : 0,
      }));
    return { leases, queues };
  }

  /**
   * Start automatic cleanup interval
   */
  private startAutoCleanup(): void {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
    }

    this.cleanupTimer = setInterval(async () => {
      try {
        const deleted = await this.cleanupInactiveSessions(this.config.sessionTTL);
        if (deleted.length > 0) {
          console.error(`[SessionManager] Auto-cleanup: removed ${deleted.length} inactive session(s)`);
        }
        this.lastCleanupTime = Date.now();

        // Memory pressure monitoring: aggressive cleanup when free RAM is low
        const freeMemory = os.freemem();
        if (freeMemory < this.config.memoryPressureThreshold) {
          console.error(`[SessionManager] Memory pressure detected: ${Math.round(freeMemory / 1024 / 1024)}MB free (threshold: ${Math.round(this.config.memoryPressureThreshold / 1024 / 1024)}MB)`);
          const aggressiveTTL = 5 * 60 * 1000; // 5-minute TTL instead of normal 30-minute
          const aggressiveDeleted = await this.cleanupInactiveSessions(aggressiveTTL, { force: true });
          if (aggressiveDeleted.length > 0) {
            console.error(`[SessionManager] Memory pressure cleanup: removed ${aggressiveDeleted.length} session(s) (5-min TTL)`);
          }
        }
      } catch (error) {
        console.error('[SessionManager] Auto-cleanup error:', error);
      }
    }, this.config.cleanupInterval);

    // Don't prevent process exit
    this.cleanupTimer.unref();
  }

  /**
   * Stop automatic cleanup
   */
  stopAutoCleanup(): void {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }
  }

  /**
   * Get session manager statistics
   */
  getStats(): SessionManagerStats {
    let totalTargets = 0;
    let totalWorkers = 0;

    for (const session of this.sessions.values()) {
      totalWorkers += session.workers.size;
      for (const worker of session.workers.values()) {
        totalTargets += worker.targets.size;
      }
      // Also count legacy targets
      totalTargets += session.targets.size;
    }

    const stats: SessionManagerStats = {
      activeSessions: this.sessions.size,
      totalTargets,
      totalWorkers,
      totalSessionsCreated: this.totalSessionsCreated,
      totalSessionsCleaned: this.totalSessionsCleaned,
      uptime: Date.now() - this.startTime,
      lastCleanup: this.lastCleanupTime,
      memoryUsage: process.memoryUsage().heapUsed,
    };

    if (this.connectionPool) {
      stats.connectionPool = this.connectionPool.getStats();
    }

    return stats;
  }

  /**
   * Get current configuration
   */
  getConfig(): Required<Omit<SessionManagerConfig, 'tenantManager' | 'strictTenantIsolation'>> {
    return { ...this.config };
  }

  /**
   * Update configuration
   */
  updateConfig(config: Partial<SessionManagerConfig>): void {
    this.config = { ...this.config, ...config };

    // Restart cleanup timer if interval changed
    if (config.cleanupInterval !== undefined || config.autoCleanup !== undefined) {
      this.stopAutoCleanup();
      if (this.config.autoCleanup) {
        this.startAutoCleanup();
      }
    }
  }

  /**
   * Ensure connected to Chrome.
   *
   * When `budget` is supplied and budget mode is not legacy, a child budget
   * covering launch + puppeteer.connect share (~55% of the parent) is carved
   * and passed to `cdpClient.connect()`. This keeps the overall session-init
   * stage-time sliced as described in A-3 §3-2.
   */
  async ensureConnected(budget?: Budget): Promise<void> {
    if (!this.cdpClient.isConnected()) {
      if (budget && !isLegacyBudgetMode()) {
        const connectFraction = DEFAULT_SESSION_INIT_BUDGET_LAUNCH_FRACTION
          + DEFAULT_SESSION_INIT_BUDGET_CONNECT_FRACTION;
        const connectBudget = budget.slice(Math.min(connectFraction, 1), 'connect');
        await this.cdpClient.connect({ budget: connectBudget });
      } else {
        await this.cdpClient.connect();
      }
    }
  }

  // ==================== SESSION MANAGEMENT ====================

  /**
   * Resolve the TenantManager used by this session manager instance. Prefers
   * the constructor-injected override so tests can stub context creation.
   */
  private getTenantManager(): TenantManager {
    return this.tenantManagerOverride ?? getTenantManager({ cdpClient: this.cdpClient });
  }

  /**
   * Resolve the BrowserContext to assign to a newly created session / worker
   * based on the requested tenant and strict-isolation policy (#7).
   *
   * - STRICT on + `useDefaultContext=true`  → reject (throws)
   * - STRICT on                             → tenant-scoped context for any tenant
   * - STRICT off + non-default tenant       → tenant-scoped context
   * - STRICT off + default tenant           → preserves legacy behavior:
   *    `useDefaultContext=true`  → null (shares Chrome profile cookies)
   *    `useDefaultContext=false` → fresh anonymous incognito context
   */
  private async resolveSessionContext(
    tenantId: TenantId,
    useDefaultContext: boolean,
    forceTenantContext = false,
  ): Promise<BrowserContext | null> {
    if (this.strictTenantIsolation) {
      if (useDefaultContext && !forceTenantContext) {
        throw new Error(
          `[SessionManager] STRICT tenant isolation is enabled; ` +
            `useDefaultContext=true is rejected because it would share the Chrome profile across tenants. ` +
            `Disable OPENCHROME_STRICT_TENANT_ISOLATION or set useDefaultContext=false.`,
        );
      }
      const tenant = await this.getTenantManager().getOrCreate(tenantId);
      return tenant.browserContext;
    }
    if (tenantId !== DEFAULT_TENANT_ID) {
      const tenant = await this.getTenantManager().getOrCreate(tenantId);
      return tenant.browserContext;
    }
    return useDefaultContext ? null : await this.cdpClient.createBrowserContext();
  }

  /**
   * Create a new session with a default worker
   */
  async createSession(options: SessionCreateOptions = {}): Promise<Session> {
    const budget = options.budget as Budget | undefined;
    await this.ensureConnected(budget);

    const id = options.id || crypto.randomUUID();

    if (this.sessions.has(id)) {
      return this.sessions.get(id)!;
    }

    // Check max sessions limit
    if (this.sessions.size >= this.config.maxSessions) {
      const deleted = await this.cleanupInactiveSessions(this.config.sessionTTL);
      if (deleted.length === 0 && this.sessions.size >= this.config.maxSessions) {
        throw new Error(`Maximum session limit (${this.config.maxSessions}) reached.`);
      }
    }

    const name = options.name || `Session ${id.slice(0, 8)}`;
    const defaultWorkerId = 'default';
    const tenantId = options.tenantId
      ?? (currentRequestContext()?.tenantId as TenantId | undefined)
      ?? DEFAULT_TENANT_ID;
    const forceTenantContext = options.tenantId !== undefined
      ? tenantId !== DEFAULT_TENANT_ID
      : tenantId !== DEFAULT_TENANT_ID && currentRequestContext()?.tenantId === tenantId;

    // Resolve tenant-scoped context (#7). Falls back to legacy behavior for
    // the default tenant when STRICT mode is off so stdio callers see no
    // change in behavior.
    const defaultContext = await this.resolveSessionContext(tenantId, this.config.useDefaultContext, forceTenantContext);
    const defaultWorker: Worker = {
      id: defaultWorkerId,
      name: 'Default Worker',
      targets: new Set(),
      context: defaultContext,
      createdAt: Date.now(),
      lastActivityAt: Date.now(),
    };

    const session: Session = {
      id,
      workers: new Map([[defaultWorkerId, defaultWorker]]),
      defaultWorkerId,
      targets: new Set(),  // Legacy support
      createdAt: Date.now(),
      lastActivityAt: Date.now(),
      name,
      context: defaultContext,  // Legacy support
      tenantId,
    };

    this.sessions.set(id, session);
    this.totalSessionsCreated++;
    this.emitEvent({ type: 'session:created', sessionId: id, timestamp: Date.now() });
    this.emitLifecycle({ kind: 'session:create', sessionId: id, tenantId: String(tenantId), ts: Date.now() });

    console.error(`[SessionManager] Created session ${id} with default worker (tenant=${tenantId})`);
    return session;
  }

  /**
   * Get or create a session.
   *
   * `budget` (A-3) flows through to `createSession()` on cold-start. If a
   * concurrent creation is already in flight, the pending promise is
   * returned as-is — the second caller inherits whatever budget the first
   * caller supplied (or none).
   */
  async getOrCreateSession(sessionId: string, budget?: Budget): Promise<Session> {
    const existing = this.sessions.get(sessionId);
    if (existing) {
      this.touchSession(sessionId);
      return existing;
    }

    // Deduplicate concurrent creation requests for the same sessionId
    const pending = this.pendingCreations.get(sessionId);
    if (pending) {
      return pending;
    }

    const creation = this.createSession({ id: sessionId, budget }).finally(() => {
      this.pendingCreations.delete(sessionId);
    });
    this.pendingCreations.set(sessionId, creation);
    return creation;
  }

  /**
   * Get an existing session
   */
  getSession(sessionId: string): Session | undefined {
    return this.sessions.get(sessionId);
  }

  /**
   * Update last activity timestamp
   */
  touchSession(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.lastActivityAt = Date.now();
    }
  }

  /**
   * Delete a session and clean up all workers.
   *
   * @param sessionId session to delete
   * @param reason lifecycle-bus reason for the destroy event (#857). Defaults
   *   to `'close'` for user/API-initiated deletes; TTL cleanup passes `'ttl'`
   *   and full-shutdown cleanup passes `'shutdown'` so consumers can
   *   distinguish operator action from background cleanup.
   */
  async deleteSession(sessionId: string, reason: SessionDestroyReason = 'close'): Promise<void> {
    const session = this.sessions.get(sessionId);
    if (!session) {
      return;
    }

    // Save storage state before cleanup (save first, then stop watchdog).
    // #848: flush ONE representative tab per named context so per-context
    // cookies / localStorage are partitioned in their own snapshot files.
    const manager = this.storageStateManagers.get(sessionId);
    if (manager) {
      try {
        const flushedContexts = new Set<string>();
        for (const worker of session.workers.values()) {
          for (const tid of worker.targets) {
            const ctxName = this.targetToContext.get(tid)?.name ?? DEFAULT_CONTEXT_NAME;
            if (flushedContexts.has(ctxName)) continue;
            const cdpClient = this.getCDPClientForWorker(sessionId, worker.id);
            const p = await cdpClient.getPageByTargetId(tid);
            if (p) {
              await manager.save(p, cdpClient, this.getStorageStatePath(sessionId, ctxName));
              flushedContexts.add(ctxName);
            }
          }
        }
      } catch {
        // Best-effort: don't block deletion on storage state errors
      }
      manager.stopWatchdog();
      this.storageStateManagers.delete(sessionId);
    }

    // Delete all workers
    for (const workerId of session.workers.keys()) {
      await this.deleteWorkerInternal(session, workerId);
    }

    // Clean up all worker queues
    for (const workerId of session.workers.keys()) {
      this.queueManager.deleteQueue(`${sessionId}:${workerId}`);
    }
    this.queueManager.deleteQueue(sessionId);

    // Clean up ref IDs
    getRefIdManager().clearSessionRefs(sessionId);

    // Remove session
    this.sessions.delete(sessionId);
    this.targetLeases.releaseSession(sessionId);
    this.emitEvent({ type: 'session:deleted', sessionId, timestamp: Date.now() });
    this.emitLifecycle({ kind: 'session:destroy', sessionId, reason, ts: Date.now() });

    console.error(`[SessionManager] Deleted session ${sessionId}`);
  }

  /**
   * Clean up inactive sessions
   */
  async cleanupInactiveSessions(maxAgeMs: number, options?: { force?: boolean }): Promise<string[]> {
    const now = Date.now();
    const deletedSessions: string[] = [];
    // force=true means memory pressure — clean everything including "default".
    const isMemoryPressure = options?.force === true;

    for (const [sessionId, session] of this.sessions) {
      // Protect the "default" session from normal TTL expiry — it's the
      // primary session for most single-agent workflows. Under memory
      // pressure (force=true) we still clean it up to prevent OOM.
      if (sessionId === DEFAULT_SESSION_ID && !isMemoryPressure) {
        continue;
      }
      if (now - session.lastActivityAt > maxAgeMs) {
        // TTL-driven cleanup — #857 lifecycle bus distinguishes this from a
        // user-initiated `deleteSession()` call so consumers (recorder,
        // future journal) can attribute the destroy correctly.
        await this.deleteSession(sessionId, 'ttl');
        deletedSessions.push(sessionId);
        this.totalSessionsCleaned++;
      }
    }

    const expiredLeases = this.targetLeases.expire(now);
    for (const lease of expiredLeases) {
      this.targetQueueManager.cancelTarget(lease.targetId);
      // #1359 backlog item 7: reclaim the orphaned tab of an idle/crashed owner.
      // The lease is a sliding idle TTL refreshed on every executeCDP call, so it
      // only reaches expiry when the owner has gone silent past the TTL. Close the
      // tab best-effort unless the owner asked to preserve it; reconcile/GC handle
      // anything already gone.
      if (lease.cleanupPolicy !== 'preserve') {
        try {
          await this.closeTarget(lease.sessionId, lease.targetId);
          console.error(
            `[SessionManager] Reclaimed idle target ${lease.targetId.slice(0, 8)} ` +
            `(lease expired; owner session=${lease.sessionId} silent > TTL)`,
          );
        } catch {
          // best-effort; reconcileAliveTargetIds / GC handle already-gone targets
        }
      }
    }

    // Trigger browser-level GC after bulk cleanup
    if (deletedSessions.length > 0) {
      try {
        const pages = await this.cdpClient.getPages();
        if (pages.length > 0) {
          await this.cdpClient.triggerGC(pages[0]);
        }
      } catch {
        // Best-effort GC
      }
    }

    return deletedSessions;
  }

  /**
   * Force cleanup all sessions (including "default").
   * Unlike cleanupInactiveSessions, this is a forced full teardown (called on shutdown).
   */
  async cleanupAllSessions(): Promise<number> {
    const count = this.sessions.size;
    const sessionIds = Array.from(this.sessions.keys());

    for (const sessionId of sessionIds) {
      // Full-process teardown — #857 lifecycle bus tags this as `shutdown`
      // so consumers can correlate the burst of destroys with intentional
      // server shutdown rather than TTL pressure or operator API calls.
      await this.deleteSession(sessionId, 'shutdown');
      this.totalSessionsCleaned++;
    }

    // Clean up Chrome pool and factory connections
    if (this.chromePool) {
      await this.chromePool.cleanup();
    }
    await this.cdpFactory.disconnectAll();

    return count;
  }

  // ==================== WORKER MANAGEMENT ====================

  /**
   * Create a new worker within a session
   * Each worker has its own isolated browser context (cookies, localStorage, etc.)
   */
  async createWorker(sessionId: string, options: WorkerCreateOptions = {}): Promise<Worker> {
    await this.ensureConnected();

    const session = await this.getOrCreateSession(sessionId);

    // Check max workers limit
    if (session.workers.size >= this.config.maxWorkersPerSession) {
      throw new Error(`Maximum workers per session (${this.config.maxWorkersPerSession}) reached.`);
    }

    const workerId = options.id || `worker-${crypto.randomUUID().slice(0, 8)}`;

    if (session.workers.has(workerId)) {
      return session.workers.get(workerId)!;
    }

    const name = options.name || `Worker ${workerId}`;

    // Acquire Chrome instance from pool BEFORE creating browser context (P1-1 fix).
    // Context must be created on the correct CDP client (profile-specific or primary).
    let workerPort: number | undefined;
    let workerPoolOrigin: string | undefined;
    let workerProfileDirectory: string | undefined;

    if (options.profileDirectory && !options.port) {
      // Multi-profile: lazily enable pool and acquire profile-specific instance
      try {
        const pool = this.ensurePool();
        const origin = SessionManager.safeParseOrigin(options.targetUrl); // P1-4 fix
        const poolInstance = await pool.acquireInstanceForProfile(options.profileDirectory, origin);
        workerPort = poolInstance.port;
        workerPoolOrigin = origin;
        workerProfileDirectory = options.profileDirectory;

        const workerCdpClient = this.cdpFactory.getOrCreate(workerPort, {
          autoLaunch: getGlobalConfig().autoLaunch,
        });
        if (!workerCdpClient.isConnected()) {
          await workerCdpClient.connect();
        }

        console.error(`[SessionManager] Worker ${workerId} assigned to profile "${options.profileDirectory}" on port ${workerPort}`);
      } catch (err) {
        console.error(`[SessionManager] Profile acquisition failed for "${options.profileDirectory}":`, err);
        throw err; // Propagate — caller explicitly requested a profile
      }
    } else if (options.port) {
      // Explicit port: external Chrome instance (e.g., headed fallback) — no pool allocation
      workerPort = options.port;
      try {
        const workerCdpClient = this.cdpFactory.getOrCreate(workerPort, {
          autoLaunch: false,
        });
        if (!workerCdpClient.isConnected()) {
          await workerCdpClient.connect();
        }
        console.error(`[SessionManager] Worker ${workerId} assigned to external Chrome on port ${workerPort}`);
      } catch (err) {
        console.error(`[SessionManager] External Chrome connection failed on port ${workerPort}:`, err);
        throw err;
      }
    } else if (this.chromePool && options.targetUrl) {
      // Origin isolation: existing pool behavior
      try {
        const origin = SessionManager.safeParseOrigin(options.targetUrl); // P1-4 fix
        if (origin) {
          const poolInstance = await this.chromePool.acquireInstance(origin);
          workerPort = poolInstance.port;
          workerPoolOrigin = origin;

          const workerCdpClient = this.cdpFactory.getOrCreate(workerPort, {
            autoLaunch: getGlobalConfig().autoLaunch,
          });
          if (!workerCdpClient.isConnected()) {
            await workerCdpClient.connect();
          }

          console.error(`[SessionManager] Worker ${workerId} assigned to Chrome instance on port ${workerPort} for origin ${origin}`);
        }
      } catch (err) {
        console.error(`[SessionManager] Pool acquisition failed, falling back to default:`, err);
        workerPort = undefined;
        workerPoolOrigin = undefined;
      }
    }

    // P1-1 fix: Create browser context on the CORRECT CDP client (profile-specific or primary)
    const effectiveCdpClient = workerPort
      ? (this.cdpFactory.get(workerPort) ?? this.cdpClient)
      : this.cdpClient;
    const context = options.shareCookies
      ? null
      : await effectiveCdpClient.createBrowserContext();

    const worker: Worker = {
      id: workerId,
      name,
      targets: new Set(),
      context,
      createdAt: Date.now(),
      lastActivityAt: Date.now(),
      port: workerPort,
      poolOrigin: workerPoolOrigin,
      profileDirectory: workerProfileDirectory,
    };

    session.workers.set(workerId, worker);
    this.touchSession(sessionId);

    this.emitEvent({
      type: 'worker:created',
      sessionId,
      workerId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'worker:create', sessionId, workerId, ts: Date.now() });

    console.error(`[SessionManager] Created worker ${workerId} in session ${sessionId}`);
    return worker;
  }

  /**
   * Get a worker by ID
   */
  getWorker(sessionId: string, workerId: string): Worker | undefined {
    const session = this.sessions.get(sessionId);
    if (!session) return undefined;
    return session.workers.get(workerId);
  }

  /**
   * Get or create a worker
   */
  async getOrCreateWorker(sessionId: string, workerId?: string, options?: { profileDirectory?: string; targetUrl?: string; port?: number; shareCookies?: boolean }): Promise<Worker> {
    const session = await this.getOrCreateSession(sessionId);

    // If no workerId specified, use default worker
    const targetWorkerId = workerId || session.defaultWorkerId;

    let worker = session.workers.get(targetWorkerId);
    if (!worker) {
      worker = await this.createWorker(sessionId, {
        id: targetWorkerId,
        ...(options?.profileDirectory && { profileDirectory: options.profileDirectory }),
        ...(options?.targetUrl && { targetUrl: options.targetUrl }),
        ...(options?.port != null && { port: options.port }),
        ...(options?.shareCookies != null && { shareCookies: options.shareCookies }),
      });
    }

    return worker;
  }

  /**
   * Number of active tenant-scoped BrowserContexts currently held by the tenant manager.
   * Includes the default tenant only when strict tenant isolation or explicit tenant
   * allocation has created a dedicated BrowserContext for it.
   */
  get tenantContextCount(): number {
    try {
      return this.getTenantManager().stats().active;
    } catch {
      return 0;
    }
  }

  /**
   * List all workers in a session
   */
  getWorkers(sessionId: string): WorkerInfo[] {
    const session = this.sessions.get(sessionId);
    if (!session) return [];

    const workers: WorkerInfo[] = [];
    for (const worker of session.workers.values()) {
      workers.push({
        id: worker.id,
        name: worker.name,
        targetCount: worker.targets.size,
        createdAt: worker.createdAt,
        lastActivityAt: worker.lastActivityAt,
        ...(worker.profileDirectory && { profileDirectory: worker.profileDirectory }),
      });
    }

    return workers;
  }

  /**
   * Delete a worker and its resources
   */
  async deleteWorker(sessionId: string, workerId: string): Promise<void> {
    const session = this.sessions.get(sessionId);
    if (!session) return;

    // Can't delete default worker
    if (workerId === session.defaultWorkerId) {
      throw new Error('Cannot delete the default worker. Delete the session instead.');
    }

    await this.deleteWorkerInternal(session, workerId);

    this.emitEvent({
      type: 'worker:deleted',
      sessionId,
      workerId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'worker:destroy', sessionId, workerId, ts: Date.now() });
  }

  /**
   * Internal worker deletion (also used for cleanup)
   */
  private async deleteWorkerInternal(session: Session, workerId: string): Promise<void> {
    const worker = session.workers.get(workerId);
    if (!worker) return;

    // Determine which CDPClient to use for this worker
    const workerCdpClient = worker.port
      ? (this.cdpFactory.get(worker.port) || this.cdpClient)
      : this.cdpClient;

    // Close all pages in this worker (return to pool if available)
    for (const targetId of worker.targets) {
      try {
        if (this.connectionPool && this.config.useConnectionPool) {
          const page = await workerCdpClient.getPageByTargetId(targetId);
          if (page && !page.isClosed()) {
            await this.connectionPool.releasePage(page);
          } else {
            await workerCdpClient.closePage(targetId);
          }
        } else {
          await workerCdpClient.closePage(targetId);
        }
      } catch {
        // Page might already be closed
      }
      this.targetToWorker.delete(targetId);
      // #1359 backlog item 3: closePage triggers targetdestroyed → onTargetClosed
      // asynchronously, but targetToWorker.delete above runs first, so by the
      // time the event handler fires it cannot resolve the owner. Release the
      // lease here so the registry stays consistent with the legacy map.
      this.targetLeases.release(targetId, session.id);
    }

    // Close the browser context (only if it's an isolated context, not the default)
    if (worker.context) {
      try {
        await workerCdpClient.closeBrowserContext(worker.context);
      } catch {
        // Context might already be closed
      }
    }

    // Release Chrome pool instance if worker had one (P1-2 fix: handle profile workers without poolOrigin)
    if (worker.port && this.chromePool) {
      if (worker.poolOrigin) {
        this.chromePool.releaseInstance(worker.port, worker.poolOrigin);
        console.error(`[SessionManager] Released pool instance port ${worker.port} for origin ${worker.poolOrigin}`);
      } else if (worker.profileDirectory) {
        this.chromePool.releaseProfileInstance(worker.port);
        console.error(`[SessionManager] Released profile instance port ${worker.port} for profile "${worker.profileDirectory}"`);
      }
    }

    // Clean up ref IDs for this worker
    for (const targetId of worker.targets) {
      getRefIdManager().clearTargetRefs(session.id, targetId);
    }

    session.workers.delete(workerId);
    console.error(`[SessionManager] Deleted worker ${workerId} from session ${session.id}`);
  }

  // ==================== TARGET/PAGE MANAGEMENT ====================

  /**
   * Create a new page/target for a worker
   * @param sessionId Session ID
   * @param url Optional URL to navigate to
   * @param workerId Optional worker ID (uses default worker if not specified)
   */
  async createTarget(
    sessionId: string,
    url?: string,
    workerId?: string,
    profileDirectory?: string,
    isolatedContext?: string,
  ): Promise<{ targetId: string; page: Page; workerId: string; contextName: string; isolated: boolean }> {
    let createTargetTid: ReturnType<typeof setTimeout>;
    return Promise.race([
      this._createTargetImpl(sessionId, url, workerId, profileDirectory, isolatedContext).finally(() => clearTimeout(createTargetTid)),
      new Promise<never>((_, reject) => {
        createTargetTid = setTimeout(() => reject(new Error(`createTarget timed out after ${DEFAULT_CREATE_TARGET_TIMEOUT_MS}ms`)), DEFAULT_CREATE_TARGET_TIMEOUT_MS);
      }),
    ]);
  }

  private async _createTargetImpl(
    sessionId: string,
    url?: string,
    workerId?: string,
    profileDirectory?: string,
    isolatedContext?: string,
  ): Promise<{ targetId: string; page: Page; workerId: string; contextName: string; isolated: boolean }> {
    await this.ensureConnected();

    // Validate isolatedContext name early — before any session/worker
    // mutation — so a malformed name never leaves us with a partially
    // constructed worker. (#848)
    if (isolatedContext !== undefined && isolatedContext !== DEFAULT_CONTEXT_NAME) {
      assertValidContextName(isolatedContext);
    }
    const useNamedContext = !!isolatedContext && isolatedContext !== DEFAULT_CONTEXT_NAME;

    const worker = await this.getOrCreateWorker(sessionId, workerId, {
      profileDirectory,
      targetUrl: url,
    });

    // Enforce per-worker tab limit: close oldest tab when limit reached
    if (worker.targets.size >= this.config.maxTargetsPerWorker) {
      // Set iterates in insertion order (ES2015+), which corresponds to creation order
      // as long as targets are only added (never removed and re-added).
      const oldestTargetId = worker.targets.values().next().value;
      if (oldestTargetId) {
        console.error(`[SessionManager] Worker ${worker.id} reached tab limit (${this.config.maxTargetsPerWorker}), closing oldest tab ${oldestTargetId}`);
        await this.closeTarget(sessionId, oldestTargetId);
      }
    }

    // Create page — try connection pool first for pre-warmed pages, fall back to direct creation
    const cdpClient = this.getCDPClientForWorker(sessionId, worker.id);
    let page: Page;

    // #848: when an isolatedContext is requested, mint or look up the
    // named BrowserContext on the same Chrome process and route the new
    // page through it. The connection pool serves pages from the default
    // context, so we bypass it for named contexts.
    let namedContext: import('puppeteer-core').BrowserContext | null = null;
    if (useNamedContext) {
      namedContext = await this.namedContextRegistry.getOrCreate(
        cdpClient.getBrowser(),
        isolatedContext!,
      );
    }

    // Snapshot existing target IDs before page creation.
    // Chrome's Site Isolation can create orphan about:blank targets during cross-origin
    // navigation (renderer process swap). We detect and close these after navigation.
    const existingTargetIds = new Set(
      cdpClient.getBrowser().targets()
        .filter(t => t.type() === 'page')
        .map(t => getTargetId(t))
    );
    const shouldPruneStartupBlankTargets =
      Array.from(this.targetToWorker.keys()).length === 0 &&
      existingTargetIds.size === 1 &&
      cdpClient.getChromeLifecycleMode() === 'isolated';

    if (namedContext) {
      // Named-context path: bypass the pool (which serves the default
      // context) and create directly inside the named BrowserContext.
      page = await cdpClient.createPage(url, namedContext);
    } else if (this.connectionPool && this.config.useConnectionPool) {
      let poolPage: Page | null = null;
      try {
        poolPage = await this.connectionPool.acquirePage();
        // Navigate the pre-warmed page to the target URL
        if (url) {
          // #857: capture the from-URL BEFORE navigation so the lifecycle
          // bus reports the transition the operator actually drove (pool
          // pages typically start at 'about:blank' but a recycled page may
          // carry its prior URL until smartGoto resolves).
          const fromUrl = poolPage.url();
          await smartGoto(poolPage, url, { timeout: DEFAULT_NAVIGATION_TIMEOUT_MS });
          const navTargetId = getTargetId(poolPage.target());
          this.emitLifecycle({
            kind: 'target:navigate',
            sessionId,
            workerId: worker.id,
            targetId: navTargetId,
            fromUrl,
            toUrl: url,
            ts: Date.now(),
          });
        }
        // Copy cookies from the worker's browser context if available
        // (pool pages start blank — replicate what cdpClient.createPage() does for contexts)
        try {
          await Promise.race([
            (async () => {
              if (worker.context) {
                const cookies = await worker.context.cookies();
                if (cookies.length > 0) {
                  await poolPage.setCookie(...cookies);
                }
              }
            })(),
            new Promise<void>((resolve) => setTimeout(resolve, DEFAULT_COOKIE_CONTEXT_TIMEOUT_MS)),
          ]);
        } catch (err) {
          console.error(`[SessionManager] Cookie context copy failed, continuing without cookies: ${err instanceof Error ? err.message : String(err)}`);
        }
        page = poolPage;
        console.error(`[SessionManager] Acquired page from pool for session ${sessionId}`);
      } catch (err) {
        // Close the acquired pool page to prevent about:blank ghost tabs.
        // Close first (removes from Chrome), then release (cleans pool tracking).
        // Do NOT just releasePage — that returns it to pool as about:blank.
        if (poolPage) {
          await poolPage.close().catch(() => {});
          this.connectionPool.releasePage(poolPage).catch(() => {});
        }
        console.error(`[SessionManager] Pool acquire/navigate failed, falling back to direct creation:`, err);
        page = await cdpClient.createPage(url, worker.context);
      }
    } else {
      page = await cdpClient.createPage(url, worker.context);
    }

    const targetId = getTargetId(page.target());

    // Clean up blank targets that Chrome can leave visible around first navigation.
    // - New, untracked about:blank targets can be Site Isolation renderer ghosts.
    // - A freshly launched managed Chrome also creates an initial chrome://newtab/
    //   page before OpenChrome creates the requested page. Prune that startup tab
    //   only for isolated/managed Chrome so attach mode never closes user tabs.
    // Runs after a brief delay to catch async target creation by Chrome.
    const cleanupExistingIds = existingTargetIds;
    const cleanupTargetId = targetId;
    const cleanupBrowser = cdpClient.getBrowser();
    const cleanupStartupBlankTargets = shouldPruneStartupBlankTargets;
    setTimeout(async () => {
      try {
        const orphans = cleanupBrowser.targets().filter(t => {
          if (t.type() !== 'page') return false;
          const candidateTargetId = getTargetId(t);
          if (candidateTargetId === cleanupTargetId) return false;
          if (this.targetToWorker.has(candidateTargetId)) return false;

          const candidateUrl = t.url();
          const isStartupNewTab =
            candidateUrl === 'chrome://newtab/' ||
            candidateUrl.startsWith('chrome://new-tab-page');
          const isBlankLike = candidateUrl === 'about:blank' || isStartupNewTab;
          if (!isBlankLike) return false;

          if (isStartupNewTab) return cleanupStartupBlankTargets && cleanupExistingIds.has(candidateTargetId);
          return !cleanupExistingIds.has(candidateTargetId);
        });
        for (const t of orphans) {
          try {
            const orphanPage = await t.page();
            if (orphanPage && !orphanPage.isClosed()) {
              await orphanPage.close();
              console.error(`[SessionManager] Closed orphan blank ghost tab: ${getTargetId(t)} (${t.url()})`);
            }
          } catch { /* target may already be destroyed */ }
        }
      } catch { /* best-effort cleanup */ }
    }, 500);

    worker.targets.add(targetId);
    worker.lastActivityAt = Date.now();

    this.targetToWorker.set(targetId, { sessionId, workerId: worker.id });

    // #848: book-keep the named-context association and increment the
    // registry's tab count so the lifecycle hook (onTargetClosed →
    // decrementTabCount) can auto-destroy the context when it goes idle.
    let resolvedContextName: string = DEFAULT_CONTEXT_NAME;
    let resolvedIsolated = false;
    if (useNamedContext && isolatedContext) {
      const ownerBrowser = cdpClient.getBrowser();
      this.targetToContext.set(targetId, { browser: ownerBrowser, name: isolatedContext });
      this.namedContextRegistry.incrementTabCount(ownerBrowser, isolatedContext);
      resolvedContextName = isolatedContext;
      resolvedIsolated = true;
    }
    this.acquireTargetLease(targetId, sessionId, worker.id, resolvedContextName);

    this.emitEvent({
      type: 'session:target-added',
      sessionId,
      workerId: worker.id,
      targetId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'target:create', sessionId, workerId: worker.id, targetId, url: url ?? '', ts: Date.now() });

    this.touchSession(sessionId);

    // Restore storage state on first target for this session
    const session = this.sessions.get(sessionId)!;
    const allTargetsCount = Array.from(session.workers.values()).reduce((sum, w) => sum + w.targets.size, 0);
    if (this.storageStateConfig?.enabled && allTargetsCount === 1) {
      try {
        const ssManager = new StorageStateManager();
        this.storageStateManagers.set(sessionId, ssManager);
        const filePath = this.getStorageStatePath(sessionId, resolvedContextName);
        await ssManager.restore(page, this.cdpClient, filePath);

        const intervalMs = this.storageStateConfig?.watchdogIntervalMs ||
          Number(process.env.OPENCHROME_WATCHDOG_INTERVAL_MS) || DEFAULT_WATCHDOG_INTERVAL_MS;
        ssManager.startWatchdog(page, this.cdpClient, {
          intervalMs,
          filePath,
        });
      } catch (err) {
        console.error(`[SessionManager] Storage state restore failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
        // Clean up the inconsistent manager entry so deleteSession doesn't operate on an uninitialized manager
        this.storageStateManagers.delete(sessionId);
      }
    }

    return { targetId, page, workerId: worker.id, contextName: resolvedContextName, isolated: resolvedIsolated };
  }

  /**
   * CDP-free stealth navigation: opens a new tab via Chrome's HTTP debug API without
   * attaching Puppeteer/CDP during page load, letting anti-bot checks (e.g. Cloudflare
   * Turnstile) complete without CDP signals present. CDP attaches after settleMs.
   *
   * @param sessionId  Session to register the new target under
   * @param url        URL to navigate to
   * @param workerId   Optional worker ID (uses default worker if omitted)
   * @param settleMs   How long to wait before attaching CDP (default 8000, range 1000-30000)
   * @returns          Registered targetId, Page, and workerId
   */
  async createTargetStealth(
    sessionId: string,
    url: string,
    workerId?: string,
    settleMs: number = 8000,
    profileDirectory?: string
  ): Promise<{ targetId: string; page: Page; workerId: string }> {
    await this.ensureConnected();

    const worker = await this.getOrCreateWorker(sessionId, workerId, {
      profileDirectory,
      targetUrl: url,
    });

    // Enforce per-worker tab limit: close oldest tab when limit reached
    if (worker.targets.size >= this.config.maxTargetsPerWorker) {
      const oldestTargetId = worker.targets.values().next().value;
      if (oldestTargetId) {
        console.error(`[SessionManager] Worker ${worker.id} reached tab limit (${this.config.maxTargetsPerWorker}), closing oldest tab ${oldestTargetId}`);
        await this.closeTarget(sessionId, oldestTargetId);
      }
    }

    // Use the worker's CDPClient (may be on a different Chrome instance)
    const cdpClient = this.getCDPClientForWorker(sessionId, worker.id);

    // Open tab without CDP, wait for settle, then attach
    const { page, targetId } = await cdpClient.createTargetStealth(url, settleMs);

    worker.targets.add(targetId);
    worker.lastActivityAt = Date.now();
    this.targetToWorker.set(targetId, { sessionId, workerId: worker.id });
    this.acquireTargetLease(targetId, sessionId, worker.id);

    // Track as stealth target for human-behavior integration in tools
    this.stealthTargets.add(targetId);

    this.emitEvent({
      type: 'session:target-added',
      sessionId,
      workerId: worker.id,
      targetId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'target:create', sessionId, workerId: worker.id, targetId, url: url ?? '', ts: Date.now() });

    this.touchSession(sessionId);

    return { targetId, page, workerId: worker.id };
  }

  /**
   * Register a pre-acquired page as a target for a worker.
   * Used by workflow engine when pages are batch-acquired from the pool
   * to avoid per-page replenishment (about:blank proliferation fix).
   */
  registerExistingTarget(sessionId: string, workerId: string, targetId: string): void {
    const session = this.sessions.get(sessionId);
    if (!session) {
      throw new Error(`Session ${sessionId} not found`);
    }

    const worker = session.workers.get(workerId);
    if (!worker) {
      throw new Error(`Worker ${workerId} not found in session ${sessionId}`);
    }

    worker.targets.add(targetId);
    worker.lastActivityAt = Date.now();
    this.targetToWorker.set(targetId, { sessionId, workerId });
    this.acquireTargetLease(targetId, sessionId, workerId);

    this.emitEvent({
      type: 'session:target-added',
      sessionId,
      workerId,
      targetId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'target:create', sessionId, workerId, targetId, url: '', ts: Date.now() });

    this.touchSession(sessionId);
  }

  /**
   * Check if a target is still valid (page not closed)
   */
  async isTargetValid(targetId: string): Promise<boolean> {
    try {
      const page = await this.cdpClient.getPageByTargetId(targetId);
      return page !== null && !page.isClosed();
    } catch {
      return false;
    }
  }

  /**
   * Get page for a target
   * @param sessionId Session ID
   * @param targetId Target/Tab ID
   * @param workerId Optional worker ID for validation
   * @param toolName Optional MCP tool name for hybrid BrowserRouter routing
   */
  async getPage(sessionId: string, targetId: string, workerId?: string, toolName?: string): Promise<Page | null> {
    const ownerInfo = this.targetToWorker.get(targetId);

    if (!ownerInfo) {
      // Fallback: target may exist in Chrome but not in our tracking map.
      // This happens after cross-origin navigation (e.g., OAuth redirect) where
      // Chrome replaces the renderer process, creating a new target that we missed
      // (we skip targetcreated indexing to prevent ghost tabs).
      const recovered = await this.tryRecoverTarget(sessionId, targetId, workerId);
      if (recovered) return recovered;
      throw new Error(this.buildStaleTargetError(sessionId, targetId));
    }

    if (ownerInfo.sessionId !== sessionId) {
      throw new Error(this.buildStaleTargetError(sessionId, targetId));
    }

    if (workerId && ownerInfo.workerId !== workerId) {
      throw new Error(`Target ${targetId} does not belong to worker ${workerId}`);
    }

    // Refresh session TTL only after ownership is confirmed (hottest path)
    this.touchSession(sessionId);
    // Slide the target lease forward here too: getPage() — not executeCDP — is the
    // path virtually every Puppeteer-based tool (navigate/interact/read_page/act/…)
    // takes, so without this an actively used non-default-session tab would still
    // reach the idle-TTL sweep and be reclaimed mid-task (#1359 backlog item 7).
    this.targetLeases.touch(targetId);

    const cdpClient = this.getCDPClientForWorker(sessionId, ownerInfo.workerId);

    // Validate target is still valid
    try {
      const page = await cdpClient.getPageByTargetId(targetId);
      if (!page || page.isClosed()) {
        this.onTargetClosed(targetId);
        return null;
      }

      // Centralized domain blocklist check — protects ALL tools that call getPage()
      assertDomainAllowed(page.url());

      // Route through BrowserRouter if hybrid mode is active and toolName provided
      if (this.browserRouter && toolName) {
        const result = await this.browserRouter.route(toolName, page);
        // Stash the routing decision so tool result builders can surface
        // `meta.path_taken` without changing the hot `getPage()` signature
        // (A3-PR2a of #1359 §Pillar C facts-before-decisions).
        this.lastRoutingByTarget.set(targetId, {
          path_taken: result.reason,
          backend: result.backend,
          fallback: result.fallback,
          at: Date.now(),
        });
        return result.page;
      }

      return page;
    } catch (error) {
      // Re-throw domain guard errors — they must not be silently swallowed
      if (error instanceof Error && (
        error.message.includes('blocked by security policy') ||
        error.message.includes('blocked when domain restrictions are active')
      )) {
        throw error;
      }
      console.error(`[SessionManager] getPage failed for target ${targetId.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`);
      this.onTargetClosed(targetId);
      return null;
    }
  }

  /**
   * Attempt to recover an untracked target that exists in Chrome.
   * Cross-origin navigations (OAuth, SSO) can cause Chrome to replace the target
   * without OpenChrome tracking the new one (we skip targetcreated indexing to
   * prevent ghost tabs). This fallback re-registers valid targets.
   */
  private async tryRecoverTarget(sessionId: string, targetId: string, workerId?: string): Promise<Page | null> {
    try {
      const page = await this.cdpClient.getPageByTargetId(targetId);
      if (!page || page.isClosed()) return null;

      // Safety: reject internal Chrome pages to prevent session hijacking
      const pageUrl = page.url();
      if (pageUrl.startsWith('chrome://') || pageUrl.startsWith('chrome-extension://')) {
        console.error(`[SessionManager] Rejecting recovery of internal Chrome page: ${pageUrl.slice(0, 50)}`);
        return null;
      }

      const session = this.sessions.get(sessionId);
      if (!session) return null;

      const resolvedWorkerId = workerId || session.defaultWorkerId;
      const worker = session.workers.get(resolvedWorkerId);
      if (!worker) return null;

      // Safety: only recover into sessions that have at least one active target,
      // confirming they have been actively used (not a stale or rogue session).
      if (worker.targets.size === 0 && session.workers.size <= 1) {
        console.error(`[SessionManager] Rejecting recovery into empty session ${sessionId}`);
        return null;
      }

      // Re-register the target
      worker.targets.add(targetId);
      this.targetToWorker.set(targetId, { sessionId, workerId: resolvedWorkerId });
      // #1359 backlog item 3: keep the lease registry in sync with the
      // recovered ownership so reconcile/expire/diagnostics observe the same
      // session/worker the legacy targetToWorker map records. Recovery
      // intentionally transfers ownership, so drop any stale lease the
      // previous owner left behind before acquiring fresh.
      this.targetLeases.release(targetId);
      this.acquireTargetLease(targetId, sessionId, resolvedWorkerId);
      console.error(`[SessionManager] Recovered untracked target ${targetId.slice(0, 8)} (${pageUrl.slice(0, 50)}) into session ${sessionId} worker ${resolvedWorkerId}`);

      return page;
    } catch (err) {
      console.error(`[SessionManager] tryRecoverTarget failed for ${targetId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
      return null;
    }
  }

  /**
   * Get all pages for a worker
   */
  async getWorkerPages(sessionId: string, workerId: string): Promise<Page[]> {
    const worker = this.getWorker(sessionId, workerId);
    if (!worker) return [];

    const cdpClient = this.getCDPClientForWorker(sessionId, workerId);
    const pages: Page[] = [];
    for (const targetId of worker.targets) {
      const page = await cdpClient.getPageByTargetId(targetId);
      if (page) {
        pages.push(page);
      }
    }

    return pages;
  }

  /**
   * Get target IDs for a session (all workers)
   */
  getSessionTargetIds(sessionId: string): string[] {
    const session = this.sessions.get(sessionId);
    if (!session) return [];

    const allTargets: string[] = [];
    for (const worker of session.workers.values()) {
      allTargets.push(...worker.targets);
    }

    return allTargets;
  }

  /**
   * Get target IDs for a specific worker
   */
  getWorkerTargetIds(sessionId: string, workerId: string): string[] {
    const worker = this.getWorker(sessionId, workerId);
    if (!worker) return [];
    return Array.from(worker.targets);
  }

  /**
   * Validate target ownership (legacy method, checks session only)
   */
  validateTargetOwnership(sessionId: string, targetId: string): boolean {
    const ownerInfo = this.targetToWorker.get(targetId);
    return ownerInfo?.sessionId === sessionId;
  }

  /**
   * Get the worker ID that owns a target
   */
  getTargetWorkerId(targetId: string): string | undefined {
    return this.targetToWorker.get(targetId)?.workerId;
  }

  /**
   * Get the session and worker that own a target.
   * Used by CDPClient's targetcreated listener to determine popup ownership.
   */
  getTargetOwner(targetId: string): { sessionId: string; workerId: string } | undefined {
    return this.targetToWorker.get(targetId);
  }

  /**
   * Register a headed fallback page directly into the session manager.
   * Injects the page into the main CDPClient's targetIdIndex so all tools
   * (read_page, interact, screenshot, etc.) work without a separate connection. (#485)
   */
  async registerHeadedPage(targetId: string, sessionId: string, workerId: string, page: Page): Promise<void> {
    // Register target ownership (no parent — headed pages are top-level navigations).
    await this.registerExternalTarget(targetId, sessionId, workerId);

    // Inject the page into the main CDPClient's index so getPageByTargetId()
    // returns it and the stale-target guards in getCDPSession()/send() pass.
    this.cdpClient.indexExternalPage(targetId, page);
  }

  /**
   * Register an externally-created target (e.g., popup via window.open) into a worker.
   * Only registers if the target is not already tracked, to avoid overwriting ownership.
   *
   * Codex P1 follow-up (#848): when `opts.inheritContextFromTargetId` is
   * provided AND the parent target has a named-context association, the
   * popup inherits that `{browser, name}` mapping and the registry's tab
   * count is bumped so closing the parent tab cannot trigger
   * `maybeDestroy` on a context that still has popups attached.
   */
  async registerExternalTarget(
    targetId: string,
    sessionId: string,
    workerId: string,
    opts?: { inheritContextFromTargetId?: string },
  ): Promise<void> {
    const lockKey = `${sessionId}:${workerId}`;
    const previous = this.externalTargetRegistrationLocks.get(lockKey) ?? Promise.resolve();
    const next = previous.catch(() => undefined).then(() =>
      this.registerExternalTargetLocked(targetId, sessionId, workerId, opts),
    );

    this.externalTargetRegistrationLocks.set(lockKey, next);
    try {
      await next;
    } finally {
      if (this.externalTargetRegistrationLocks.get(lockKey) === next) {
        this.externalTargetRegistrationLocks.delete(lockKey);
      }
    }
  }

  private async registerExternalTargetLocked(
    targetId: string,
    sessionId: string,
    workerId: string,
    opts?: { inheritContextFromTargetId?: string },
  ): Promise<void> {
    // Don't overwrite existing entries
    if (this.targetToWorker.has(targetId)) return;

    const session = this.sessions.get(sessionId);
    if (!session) return;

    const worker = session.workers.get(workerId);
    if (!worker) return;

    // Enforce per-worker tab limit for externally-created targets too
    // (popups, headed fallback pages, and other out-of-band registrations).
    // Use closeTarget(), not evictTarget(), so the browser tab is actually
    // closed and cannot leak after the ownership record is removed. The public
    // wrapper serializes this block per worker so concurrent popups cannot all
    // close the same oldest target and then overfill the worker.
    if (worker.targets.size >= this.config.maxTargetsPerWorker) {
      const oldestTargetId = worker.targets.values().next().value;
      if (oldestTargetId) {
        console.error(`[SessionManager] Worker ${worker.id} reached tab limit (${this.config.maxTargetsPerWorker}), closing oldest external tab ${oldestTargetId}`);
        await this.closeTarget(sessionId, oldestTargetId);
      }
    }

    worker.targets.add(targetId);
    worker.lastActivityAt = Date.now();
    this.targetToWorker.set(targetId, { sessionId, workerId });
    this.acquireTargetLease(targetId, sessionId, workerId, undefined, opts?.inheritContextFromTargetId);

    // #848 Codex P1: inherit named-context mapping from the opener so popup
    // tab accounting matches the parent. Skip when the parent lives in the
    // default BrowserContext (no entry in `targetToContext`).
    if (opts?.inheritContextFromTargetId) {
      const parent = this.targetToContext.get(opts.inheritContextFromTargetId);
      if (parent) {
        this.targetToContext.set(targetId, { browser: parent.browser, name: parent.name });
        this.namedContextRegistry.incrementTabCount(parent.browser, parent.name);
      }
    }

    this.emitEvent({
      type: 'session:target-added',
      sessionId,
      workerId,
      targetId,
      timestamp: Date.now(),
    });
    this.emitLifecycle({ kind: 'target:create', sessionId, workerId, targetId, url: '', ts: Date.now() });

    this.touchSession(sessionId);
    console.error(`[SessionManager] Registered external target ${targetId} in worker ${workerId} of session ${sessionId}`);
  }

  /**
   * Close a specific target/tab
   * @param sessionId Session ID
   * @param targetId Target/Tab ID to close
   * @returns true if closed, false if not found
   */
  async closeTarget(sessionId: string, targetId: string): Promise<boolean> {
    const ownerInfo = this.targetToWorker.get(targetId);

    if (!ownerInfo || ownerInfo.sessionId !== sessionId) {
      return false;
    }

    try {
      // Close the page via CDP (use worker's CDPClient if on pool)
      const cdpClient = this.getCDPClientForWorker(sessionId, ownerInfo.workerId);

      if (this.connectionPool && this.config.useConnectionPool) {
        // Return the page to the pool for reuse instead of destroying it
        try {
          const page = await cdpClient.getPageByTargetId(targetId);
          if (page && !page.isClosed()) {
            await this.connectionPool.releasePage(page);
          } else {
            await cdpClient.closePage(targetId);
          }
        } catch {
          // If pool release fails, fall back to direct close
          await cdpClient.closePage(targetId);
        }
      } else {
        // closePage() already triggers GC internally before closing
        await cdpClient.closePage(targetId);
      }

      // Clean up internal state
      const session = this.sessions.get(sessionId);
      if (session) {
        const worker = session.workers.get(ownerInfo.workerId);
        if (worker) {
          worker.targets.delete(targetId);
        }
      }

      // Clean up ref IDs
      getRefIdManager().clearTargetRefs(sessionId, targetId);

      // Remove from mapping
      this.targetToWorker.delete(targetId);
      this.targetLeases.release(targetId, sessionId);
      this.targetQueueManager.cancelTarget(targetId);

      // #848: drop named-context association on graceful close.
      const ctxEntry = this.targetToContext.get(targetId);
      if (ctxEntry) {
        this.targetToContext.delete(targetId);
        this.namedContextRegistry.decrementTabCount(ctxEntry.browser, ctxEntry.name).catch((err) => {
          console.error(`[SessionManager] decrementTabCount(${ctxEntry.name}) failed:`, err);
        });
      }

      this.emitEvent({
        type: 'session:target-closed',
        sessionId,
        workerId: ownerInfo.workerId,
        targetId,
        timestamp: Date.now(),
      });
      this.emitLifecycle({ kind: 'target:close', sessionId, workerId: ownerInfo.workerId, targetId, ts: Date.now() });

      return true;
    } catch (error) {
      // Page might already be closed
      this.onTargetClosed(targetId);
      return true;
    }
  }

  /**
   * Close all tabs in a worker (without deleting the worker)
   * @param sessionId Session ID
   * @param workerId Worker ID
   * @returns Number of tabs closed
   */
  async closeWorkerTabs(sessionId: string, workerId: string): Promise<number> {
    const worker = this.getWorker(sessionId, workerId);
    if (!worker) return 0;

    const targetIds = Array.from(worker.targets);
    let closedCount = 0;

    for (const targetId of targetIds) {
      if (await this.closeTarget(sessionId, targetId)) {
        closedCount++;
      }
    }

    return closedCount;
  }

  /**
   * Execute a CDP command through the session's queue
   */
  async executeCDP<T = unknown>(
    sessionId: string,
    targetId: string,
    method: string,
    params?: Record<string, unknown>
  ): Promise<T> {
    if (!this.validateTargetOwnership(sessionId, targetId)) {
      throw new Error(this.buildStaleTargetError(sessionId, targetId));
    }

    this.touchSession(sessionId);
    // Slide the target lease forward on activity so an actively used tab is never
    // reclaimed by the idle-TTL sweep (#1359 backlog item 7).
    this.targetLeases.touch(targetId);

    const ownerInfo = this.targetToWorker.get(targetId);
    const cdpClient = ownerInfo
      ? this.getCDPClientForWorker(sessionId, ownerInfo.workerId)
      : this.cdpClient;

    return this.targetQueueManager.enqueue(targetId, async () => {
      const page = await cdpClient.getPageByTargetId(targetId);
      if (!page) {
        throw new Error(`Page not found for target ${targetId}`);
      }
      return cdpClient.send<T>(page, method, params);
    });
  }

  /**
   * Handle target closed event
   */
  onTargetClosed(targetId: string): void {
    flushRecorderBuffer(targetId);
    const ownerInfo = this.targetToWorker.get(targetId);
    if (ownerInfo) {
      const session = this.sessions.get(ownerInfo.sessionId);
      if (session) {
        const worker = session.workers.get(ownerInfo.workerId);
        if (worker) {
          worker.targets.delete(targetId);
        }

        // Clean up ref IDs before removing from targetToWorker mapping
        getRefIdManager().clearTargetRefs(ownerInfo.sessionId, targetId);

        this.targetToWorker.delete(targetId);
        this.targetLeases.release(targetId, ownerInfo.sessionId);
        this.targetQueueManager.cancelTarget(targetId);
        this.stealthTargets.delete(targetId);
        this.lastRoutingByTarget.delete(targetId);

        // #848: drop the named-context association and let the registry
        // GC the BrowserContext when the last tab closes AND no
        // oc_session_resume token still pins it.
        const ctxEntry = this.targetToContext.get(targetId);
        if (ctxEntry) {
          this.targetToContext.delete(targetId);
          this.namedContextRegistry.decrementTabCount(ctxEntry.browser, ctxEntry.name).catch((err) => {
            console.error(`[SessionManager] decrementTabCount(${ctxEntry.name}) failed:`, err);
          });
        }

        this.emitEvent({
          type: 'session:target-removed',
          sessionId: ownerInfo.sessionId,
          workerId: ownerInfo.workerId,
          targetId,
          timestamp: Date.now(),
        });
        this.emitLifecycle({ kind: 'target:close', sessionId: ownerInfo.sessionId, workerId: ownerInfo.workerId, targetId, ts: Date.now() });
      }
    }
  }

  /**
   * Returns the named-context association for a tab. Targets opened in
   * Chrome's default BrowserContext return `'default'`. (#848)
   */
  getTargetContextName(targetId: string): string {
    return this.targetToContext.get(targetId)?.name ?? DEFAULT_CONTEXT_NAME;
  }

  /**
   * Pin the named context that owns `targetId` against auto-destroy because
   * an oc_session_resume token references the tab. Pair with
   * {@link releaseContextResumeRef}. Targets in the default BrowserContext
   * are a no-op. (#848)
   *
   * Codex P1 follow-up: this takes a targetId rather than a bare name so the
   * `(browser, name)` registry receives the correct browser. The same name
   * on a different Chrome instance must not cross-pin.
   */
  pinContextForResume(targetId: string): void {
    const entry = this.targetToContext.get(targetId);
    if (!entry) return; // default context — nothing to pin
    this.namedContextRegistry.addResumeRef(entry.browser, entry.name);
  }

  /** Release a previously-added resume pin for the tab `targetId`. (#848) */
  async releaseContextResumeRef(targetId: string): Promise<void> {
    const entry = this.targetToContext.get(targetId);
    if (!entry) return;
    await this.namedContextRegistry.releaseResumeRef(entry.browser, entry.name);
  }

  /** Test/diagnostic accessor for the named-context registry. (#848) */
  getNamedContextRegistry(): DefaultNamedContextRegistry {
    return this.namedContextRegistry;
  }

  /**
   * Evict a tracked target after out-of-band listener or cleanup failures.
   * Removes SessionManager ownership state and records a cleanup metric when
   * the target was actually tracked.
   */
  evictTarget(targetId: string, reason = 'listener_error'): boolean {
    const hadOwner = this.targetToWorker.has(targetId);
    this.onTargetClosed(targetId);
    if (hadOwner) {
      try {
        getMetricsCollector().inc('openchrome_zombie_targets_cleaned_total', { reason });
      } catch {
        // best-effort observability
      }
    }
    return hadOwner;
  }

  /**
   * Check whether a target was opened via stealth navigation.
   * Tools use this to decide whether to apply human-like behavior simulation.
   */
  isStealthTarget(targetId: string): boolean {
    return this.stealthTargets.has(targetId);
  }

  /**
   * Build an enriched error message for stale target IDs, including available tab IDs
   * so the LLM can select the correct one without an extra tabs_context round trip.
   */
  private buildStaleTargetError(sessionId: string, targetId: string): string {
    const session = this.sessions.get(sessionId);
    const availableTabIds: string[] = [];

    if (session) {
      for (const worker of session.workers.values()) {
        for (const tid of worker.targets) {
          availableTabIds.push(tid);
        }
      }
    }

    const tabInfo = availableTabIds.length > 0
      ? ` Available tabIds: [${availableTabIds.map(id => `"${id}"`).join(', ')}]. Use tabs_context to see their URLs and titles.`
      : ' No tabs available in this session. Use navigate to open a new page.';

    return `Target ${targetId} not found in session ${sessionId}. The tab may have been closed or Chrome may have been restarted.${tabInfo}`;
  }

  /**
   * Get available targets for a session, formatted for error messages.
   * Returns an array of { tabId, url, title } for each live target.
   */
  async getAvailableTargets(sessionId: string): Promise<Array<{ tabId: string; url: string; title: string }>> {
    const session = this.sessions.get(sessionId);
    if (!session) return [];

    const results: Array<{ tabId: string; url: string; title: string }> = [];
    for (const [workerId, worker] of session.workers.entries()) {
      const cdpClient = this.getCDPClientForWorker(sessionId, workerId);
      for (const targetId of worker.targets) {
        try {
          const page = await cdpClient.getPageByTargetId(targetId);
          if (page && !page.isClosed()) {
            results.push({
              tabId: targetId,
              url: page.url(),
              title: await safeTitle(page),
            });
          }
        } catch {
          // Target may have closed between iteration steps — skip it
        }
      }
    }
    return results;
  }

  /**
   * Public wrapper for validateTargetsAfterReconnect().
   * Called by MCP server before retrying a tool after reconnect.
   */
  async reconcileAfterReconnect(): Promise<void> {
    await this.validateTargetsAfterReconnect();
  }

  /**
   * Validate all tracked targets after a reconnection.
   * Performs bidirectional reconciliation:
   * 1. Removes targets that no longer exist in Chrome
   * 2. Re-maps dead target IDs to new live targets by URL matching
   *    (Chrome may reassign different target IDs to the same logical tabs)
   */
  private async validateTargetsAfterReconnect(): Promise<void> {
    const trackedTargetIds = Array.from(this.targetToWorker.keys());
    if (trackedTargetIds.length === 0) return;

    // Get currently alive targets from Chrome
    let browser;
    try {
      browser = this.cdpClient.getBrowser();
    } catch {
      // Browser not yet available after reconnect — skip validation
      return;
    }

    const aliveTargets = browser.targets().filter(t => t.type() === 'page');
    const aliveTargetIds = new Set(aliveTargets.map(t => getTargetId(t)));
    this.targetLeases.reconcileAliveTargetIds(aliveTargetIds);
    // #1359 backlog item 4: drop per-target queues whose targetId no longer
    // exists post-reconnect so closed/expired targets stop holding queue
    // state and metrics in memory.
    this.targetQueueManager.reconcileAliveTargetIds(aliveTargetIds);

    // Build a map of untracked live targets by URL for re-mapping
    const untrackedByUrl = new Map<string, Target>();
    for (const target of aliveTargets) {
      const tid = getTargetId(target);
      if (!this.targetToWorker.has(tid)) {
        const url = target.url();
        // Skip internal pages that are unlikely to be our managed tabs
        if (url && url !== 'about:blank' && !url.startsWith('chrome://')) {
          untrackedByUrl.set(url, target);
        }
      }
    }

    // Phase 1: Identify dead targets and attempt URL-based re-mapping
    let removed = 0;
    let remapped = 0;
    const deadTargetIds: string[] = [];

    for (const targetId of trackedTargetIds) {
      if (!this.targetToWorker.has(targetId)) continue; // Already cleaned by targetdestroyed
      if (aliveTargetIds.has(targetId)) continue; // Still alive, no action needed

      // Target is dead — try to find a live replacement by URL
      const ownerInfo = this.targetToWorker.get(targetId);
      if (!ownerInfo) continue;

      // Get the last known URL for this target from the CDP client's index
      let lastUrl: string | undefined;
      try {
        const page = await this.cdpClient.getPageByTargetId(targetId);
        if (page) lastUrl = page.url();
      } catch {
        // Page already gone, can't get URL
      }

      if (lastUrl && lastUrl !== 'about:blank' && untrackedByUrl.has(lastUrl)) {
        // Found a matching live target — re-map
        const newTarget = untrackedByUrl.get(lastUrl)!;
        const newTargetId = getTargetId(newTarget);
        untrackedByUrl.delete(lastUrl); // Consume the match

        // Update targetToWorker mapping
        this.targetToWorker.delete(targetId);
        this.targetToWorker.set(newTargetId, ownerInfo);
        // #1359 backlog item 3: reconcileAliveTargetIds above already dropped
        // the old targetId from the lease registry. Acquire a fresh lease
        // for the re-mapped targetId so diagnostics and cleanup observe the
        // same ownership the legacy map records.
        this.acquireTargetLease(newTargetId, ownerInfo.sessionId, ownerInfo.workerId);

        // Update worker's target set
        const session = this.sessions.get(ownerInfo.sessionId);
        if (session) {
          const worker = session.workers.get(ownerInfo.workerId);
          if (worker) {
            worker.targets.delete(targetId);
            worker.targets.add(newTargetId);
          }
        }

        // Clear refs for old target — backendDOMNodeIds are invalidated after Chrome restart.
        // The LLM will get fresh refs on the next read_page call.
        getRefIdManager().clearTargetRefs(ownerInfo.sessionId, targetId);

        console.error(`[SessionManager] Re-mapped target ${targetId} → ${newTargetId} (URL: ${lastUrl})`);
        remapped++;
      } else {
        // No match found — mark for removal
        deadTargetIds.push(targetId);
      }
    }

    // Phase 2: Remove truly dead targets (no URL match found)
    for (const targetId of deadTargetIds) {
      this.onTargetClosed(targetId);
      removed++;
    }

    // Rebuild the CDP client's targetIdIndex from surviving targets.
    // The index was cleared during disconnect (handleDisconnect / forceReconnect)
    // and needs to be restored for O(1) lookups to work.
    const indexed = await this.cdpClient.rebuildTargetIdIndex();

    // Refresh TTL for all sessions that still have live targets,
    // so they aren't immediately reaped by the next cleanup cycle.
    const touchedSessions = new Set<string>();
    for (const ownerInfo of this.targetToWorker.values()) {
      if (!touchedSessions.has(ownerInfo.sessionId)) {
        this.touchSession(ownerInfo.sessionId);
        touchedSessions.add(ownerInfo.sessionId);
      }
    }

    const surviving = trackedTargetIds.length - removed;
    console.error(`[SessionManager] Post-reconnect reconciliation: ${removed} removed, ${remapped} re-mapped, ${surviving} surviving, ${indexed} indexed`);
  }

  // ==================== SESSION INFO ====================

  /**
   * Get session info (for serialization)
   */
  getSessionInfo(sessionId: string): SessionInfo | undefined {
    const session = this.sessions.get(sessionId);
    if (!session) return undefined;

    let totalTargets = 0;
    const workers: WorkerInfo[] = [];

    for (const worker of session.workers.values()) {
      totalTargets += worker.targets.size;
      workers.push({
        id: worker.id,
        name: worker.name,
        targetCount: worker.targets.size,
        createdAt: worker.createdAt,
        lastActivityAt: worker.lastActivityAt,
      });
    }

    return {
      id: session.id,
      targetCount: totalTargets,
      workerCount: session.workers.size,
      workers,
      createdAt: session.createdAt,
      lastActivityAt: session.lastActivityAt,
      name: session.name,
      tenantId: session.tenantId,
    };
  }

  /**
   * Get all session infos
   */
  getAllSessionInfos(): SessionInfo[] {
    const infos: SessionInfo[] = [];
    for (const sessionId of this.sessions.keys()) {
      const info = this.getSessionInfo(sessionId);
      if (info) {
        infos.push(info);
      }
    }
    return infos;
  }

  // ==================== EVENT HANDLING ====================

  /**
   * Add event listener
   */
  addEventListener(listener: (event: SessionEvent) => void): void {
    this.eventListeners.push(listener);
  }

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

  /**
   * Emit event to all listeners
   */
  private emitEvent(event: SessionEvent): void {
    for (const listener of this.eventListeners) {
      try {
        listener(event);
      } catch (e) {
        console.error('Session event listener error:', e);
      }
    }
  }

  /**
   * Mirror a session-manager transition onto the process-wide lifecycle bus
   * (issue #857). Each existing `emitEvent` call site that maps to a
   * lifecycle event also invokes this helper, so legacy `SessionEvent`
   * subscribers stay intact while new consumers (trace recorder, future
   * journal) attach via the bus only. Never throws — `getLifecycleBus().emit`
   * is contractually no-throw, but we defend in depth.
   */
  private emitLifecycle(event: LifecycleEvent): void {
    try {
      getLifecycleBus().emit(event);
    } catch {
      /* bus emit is no-throw; defence in depth */
    }
  }

  /**
   * Get the number of active sessions
   */
  get sessionCount(): number {
    return this.sessions.size;
  }

  /**
   * Force-save storage state for all active sessions.
   * Called during graceful shutdown to preserve cookies across restarts.
   */
  async saveAllStorageState(): Promise<void> {
    if (!this.storageStateConfig?.enabled) return;

    for (const [sessionId, session] of this.sessions) {
      const manager = this.storageStateManagers.get(sessionId);
      if (!manager) continue;

      try {
        // #848: flush per named context (default + each isolatedContext)
        const flushedContexts = new Set<string>();
        for (const worker of session.workers.values()) {
          for (const tid of worker.targets) {
            const ctxName = this.targetToContext.get(tid)?.name ?? DEFAULT_CONTEXT_NAME;
            if (flushedContexts.has(ctxName)) continue;
            const cdpClient = this.getCDPClientForWorker(sessionId, worker.id);
            const p = await cdpClient.getPageByTargetId(tid);
            if (p) {
              await manager.save(p, cdpClient, this.getStorageStatePath(sessionId, ctxName));
              console.error(`[SessionManager] Storage state saved for session ${sessionId} (context=${ctxName}) on shutdown`);
              flushedContexts.add(ctxName);
            }
          }
        }
      } catch (err) {
        console.error(`[SessionManager] Storage state save failed for session ${sessionId} (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
      }
    }
  }

  /**
   * Get the storage state file path for a session.
   *
   * #848: when a `contextName` other than the reserved DEFAULT_CONTEXT_NAME
   * is supplied, the path is partitioned per named BrowserContext so
   * cookies / localStorage / sessionStorage flushed from one context
   * never overwrite another's snapshot.
   */
  private getStorageStatePath(sessionId: string, contextName: string = DEFAULT_CONTEXT_NAME): string {
    if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
      throw new Error(`Invalid sessionId for storage path: ${sessionId}`);
    }
    const dir = this.storageStateConfig?.dir || path.join(os.homedir(), '.openchrome', 'storage-state');
    if (contextName === DEFAULT_CONTEXT_NAME) {
      return path.join(dir, `${sessionId}.json`);
    }
    // Validation already enforced upstream, but assert here too because
    // this function is reachable from cleanup paths that may use a
    // recovered context name from internal state.
    if (!/^[A-Za-z0-9_-]{1,64}$/.test(contextName)) {
      throw new Error(`Invalid contextName for storage path: ${contextName}`);
    }
    return path.join(dir, `${sessionId}__ctx__${contextName}.json`);
  }

  /**
   * Get CDPClient
   */
  getCDPClient(): CDPClient {
    return this.cdpClient;
  }

  /**
   * Expose the internal sessions map for persistence snapshots.
   * Returns a read-only reference — callers must not mutate the map or its values.
   */
  getSessions(): Map<string, Session> {
    return this.sessions;
  }

  /**
   * Initialize hybrid mode with BrowserRouter
   */
  async initHybrid(config: HybridConfig): Promise<void> {
    if (this.browserRouter) return; // Already initialized
    this.browserRouter = new BrowserRouter(config);
    await this.browserRouter.initialize();
    console.error('[SessionManager] Hybrid mode initialized');
  }

  /**
   * Get the BrowserRouter (for stats/escalation)
   */
  /**
   * Most-recent BrowserRouter decision for a given target, or `null` if
   * either the router never ran for this target or the entry has been
   * evicted (target closed). Pure read; never throws.
   *
   * Returned shape mirrors `meta.path_taken` / `meta.fallback_reason` that
   * tool result builders attach to their JSON payloads.
   */
  getLastRouting(targetId: string): {
    path_taken: RouteReason;
    backend: BrowserBackend;
    fallback: boolean;
  } | null {
    const entry = this.lastRoutingByTarget.get(targetId);
    if (!entry) return null;
    return { path_taken: entry.path_taken, backend: entry.backend, fallback: entry.fallback };
  }

  getBrowserRouter(): BrowserRouter | null {
    return this.browserRouter;
  }

  /**
   * Cleanup hybrid mode
   */
  async cleanupHybrid(): Promise<void> {
    if (this.browserRouter) {
      await this.browserRouter.cleanup();
      this.browserRouter = null;
      // Hybrid routing decisions are only meaningful while the router is
      // active. Drop stale side-channel entries so later tool calls do not
      // surface old `meta.path_taken` after hybrid mode is disabled.
      this.lastRoutingByTarget.clear();
      console.error('[SessionManager] Hybrid mode cleaned up');
    }
  }
}

// Singleton instance
let sessionManagerInstance: SessionManager | null = null;

export function getSessionManager(): SessionManager {
  if (!sessionManagerInstance) {
    // Read storage state config from environment variables
    // These are set by CLI (cli/index.ts) before server startup
    const storageDisabled = process.env.OC_PERSIST_STORAGE === '0';
    const storageState = storageDisabled
      ? undefined
      : {
          enabled: true as const,
          dir: process.env.OC_STORAGE_DIR || undefined,
        };

    sessionManagerInstance = new SessionManager(undefined, {
      storageState,
    });
  }
  return sessionManagerInstance;
}

/** Reset singleton for testing. Do not use in production code. */
export function _resetSessionManagerForTesting(): void {
  sessionManagerInstance = null;
}
