/**
 * Leader sync manager — broadcasts agent events and snapshots to followers
 * over WebRTC data channels using the typed tray sync protocol.
 */

import type { AgentEvent, ChatMessage } from '../ui/types.js';
import { stripLocalPathsForRemote } from '../core/attachments.js';
import type { MessageAttachment } from '../core/attachments.js';
import type { TrayDataChannelLike } from './tray-webrtc.js';
import {
  createLeaderSyncChannel,
  sendCDPResponse,
  reassembleCDPResponse,
  sendSnapshot,
  type LeaderToFollowerMessage,
  type FollowerToLeaderMessage,
  type TraySyncChannel,
  type RemoteTargetInfo,
  type TrayTargetEntry,
  type TrayFsRequest,
  type TrayFsResponse,
  type ScoopSummary,
  type SprinkleSummary,
} from './tray-sync-protocol.js';
import { handleFsRequest } from './tray-fs-handler.js';
import type { VirtualFS } from '../fs/virtual-fs.js';
import { TrayTargetRegistry } from './tray-target-registry.js';
import type { CDPTransport } from '../cdp/transport.js';
import type { BrowserAPI } from '../cdp/browser-api.js';
import { RemoteCDPTransport, type RemoteCDPSender } from '../cdp/remote-cdp-transport.js';
import { DataChannelKeepalive } from './data-channel-keepalive.js';
import { createLogger } from '../core/logger.js';

const log = createLogger('tray-leader-sync');

export interface LeaderSyncManagerOptions {
  /** Get current chat messages for the active scoop. */
  getMessages: () => ChatMessage[];
  /** Get messages for an arbitrary scoop (used when a follower views a non-active scoop). */
  getMessagesForScoop?: (scoopJid: string) => ChatMessage[] | Promise<ChatMessage[]>;
  /** Get the active scoop JID. */
  getScoopJid: () => string;
  /** Get summaries for every registered scoop. Optional — when omitted, scoops list won't be broadcast. */
  getScoops?: () => ScoopSummary[];
  /** Get summaries for every available sprinkle. Optional — when omitted, sprinkles list won't be broadcast. */
  getSprinkles?: () => SprinkleSummary[];
  /** Resolve a sprinkle's raw .shtml content for follower-side rendering. */
  readSprinkleContent?: (sprinkleName: string) => Promise<string | null> | string | null;
  /** Forward a sprinkle lick (from a follower's open or inline sprinkle) to the leader's lick router. */
  onSprinkleLick?: (sprinkleName: string, body: unknown, targetScoop?: string) => void;
  /** Handle a user message arriving from a follower. */
  onFollowerMessage: (text: string, messageId: string, attachments?: MessageAttachment[]) => void;
  /** Handle an abort request from a follower. */
  onFollowerAbort: () => void;
  /** Optional CDP transport for executing local CDP commands (leader's browser). */
  browserTransport?: CDPTransport;
  /** Optional BrowserAPI instance for session-aware browser commands (e.g. cookie capture). */
  browserAPI?: BrowserAPI;
  /** Called when a follower's data channel is considered dead (missed keepalive pongs). */
  onFollowerDead?: (bootstrapId: string) => void;
  /** VirtualFS instance for handling remote fs requests targeting the leader. */
  vfs?: VirtualFS;
  /** Called whenever a follower is added or removed (incl. via dead detection or stop). */
  onFollowerCountChanged?: (count: number) => void;
}

/** Derived float type from the runtime string (e.g. 'slicc-standalone' → 'standalone'). */
export type FloatType = 'standalone' | 'extension' | 'electron' | 'unknown';

/** Derive a FloatType from the follower's runtime string. */
function deriveFloatType(runtime?: string): FloatType {
  if (!runtime) return 'unknown';
  if (runtime.includes('standalone')) return 'standalone';
  if (runtime.includes('extension')) return 'extension';
  if (runtime.includes('electron')) return 'electron';
  return 'unknown';
}

interface ConnectedFollower {
  bootstrapId: string;
  sync: TraySyncChannel<LeaderToFollowerMessage, FollowerToLeaderMessage>;
  unsubscribe: () => void;
  keepalive: DataChannelKeepalive;
  runtime?: string;
  connectedAt?: string;
  lastActivity: number;
  floatType: FloatType;
  /**
   * The scoop this follower has currently selected for viewing.
   * Defaults to the leader's active scoop until the follower sends `scoops.select`.
   */
  selectedScoopJid?: string;
}

/** Tracks a CDP request being routed through the leader. */
interface PendingCDPRoute {
  /** bootstrapId of the follower that originated the request */
  requesterBootstrapId: string;
  /** The original requestId from the requester */
  requestId: string;
}

/** Tracks a tab.open request being routed through the leader. */
interface PendingTabOpenRoute {
  /** bootstrapId of the follower that originated the request (or '__leader__') */
  requesterBootstrapId: string;
  /** The original requestId from the requester */
  requestId: string;
}

/** Tracks an fs request being routed through the leader. */
interface PendingFsRoute {
  /** bootstrapId of the follower that originated the request (or '__leader__') */
  requesterBootstrapId: string;
  /** The original requestId from the requester */
  requestId: string;
  /** Accumulated chunked responses (for multi-chunk file reads). */
  chunks: TrayFsResponse[];
  /** Expected total chunks (set from first response). */
  totalChunks: number;
}

export class LeaderSyncManager {
  private readonly followers = new Map<string, ConnectedFollower>();
  private readonly registry = new TrayTargetRegistry();
  /** Maps runtimeId → bootstrapId so we can clean up registry on disconnect. */
  private readonly runtimeToBootstrap = new Map<string, string>();
  /** Maps requestId → routing info for CDP requests in flight through the leader. */
  private readonly pendingCDPRoutes = new Map<string, PendingCDPRoute>();
  /** Chunk buffers for reassembling chunked CDP responses from followers. */
  private readonly cdpChunkBuffers = new Map<
    string,
    { chunks: string[]; received: number; totalChunks: number }
  >();
  /** Active RemoteCDPTransport instances for the leader's own BrowserAPI (keyed by runtimeId:localTargetId). */
  private readonly remoteTransports = new Map<string, RemoteCDPTransport>();
  /** Maps requestId → routing info for tab.open requests in flight through the leader. */
  private readonly pendingTabOpenRoutes = new Map<string, PendingTabOpenRoute>();
  /** Resolvers for leader-originated tab.open requests. */
  private readonly tabOpenResolvers = new Map<
    string,
    { resolve: (targetId: string) => void; reject: (err: Error) => void }
  >();
  /** Maps requestId → routing info for fs requests in flight through the leader. */
  private readonly pendingFsRoutes = new Map<string, PendingFsRoute>();
  /** Resolvers for leader-originated fs requests. */
  private readonly fsResolvers = new Map<
    string,
    {
      resolve: (responses: TrayFsResponse[]) => void;
      reject: (err: Error) => void;
      responses: TrayFsResponse[];
    }
  >();
  constructor(private readonly options: LeaderSyncManagerOptions) {}

  /**
   * Add a connected follower's data channel.
   * Sends an initial snapshot and subscribes to follower messages.
   */
  addFollower(
    bootstrapId: string,
    channel: TrayDataChannelLike,
    meta?: { runtime?: string; connectedAt?: string }
  ): void {
    // Clean up existing connection for same bootstrap
    this.removeFollower(bootstrapId);

    const sync = createLeaderSyncChannel(channel);

    const unsubscribe = sync.onMessage((message: FollowerToLeaderMessage) => {
      this.handleFollowerMessage(bootstrapId, message);
    });

    const keepalive = new DataChannelKeepalive({
      sendPing: () => sync.send({ type: 'ping' }),
      onDead: () => {
        log.warn('Follower keepalive dead, removing follower', { bootstrapId });
        this.removeFollower(bootstrapId);
        this.options.onFollowerDead?.(bootstrapId);
      },
    });
    keepalive.start();

    this.followers.set(bootstrapId, {
      bootstrapId,
      sync,
      unsubscribe,
      keepalive,
      runtime: meta?.runtime,
      connectedAt: meta?.connectedAt,
      lastActivity: Date.now(),
      floatType: deriveFloatType(meta?.runtime),
    });
    log.info('Follower added to sync', { bootstrapId, followerCount: this.followers.size });
    this.options.onFollowerCountChanged?.(this.followers.size);

    // Send initial snapshot
    void this.sendSnapshotToFollower(bootstrapId);

    // Send scoops list and sprinkles list so the follower can populate its UI
    this.sendScoopsListToFollower(bootstrapId);
    this.sendSprinklesListToFollower(bootstrapId);

    // Send current target registry to the new follower
    const entries = this.getConnectedEntries();
    if (entries.length > 0) {
      sync.send({ type: 'targets.registry', targets: entries });
    }
  }

  /**
   * Remove a follower's data channel and clean up.
   */
  removeFollower(bootstrapId: string): void {
    const follower = this.followers.get(bootstrapId);
    if (!follower) return;
    follower.keepalive.stop();
    follower.unsubscribe();
    follower.sync.close();
    this.followers.delete(bootstrapId);
    // Clear the broadcast-error throttle entry so the map doesn't
    // grow unbounded across reconnects (followers are keyed by
    // bootstrapId; a reconnect mints a fresh one).
    this.followerBroadcastErrorLogAt.delete(bootstrapId);

    // Remove this follower's targets from the registry
    // Find the runtimeId that maps to this bootstrapId
    for (const [runtimeId, bId] of this.runtimeToBootstrap) {
      if (bId === bootstrapId) {
        // Clean up any cached RemoteCDPTransport instances for this runtime
        this.cleanupRemoteTransports(runtimeId);
        this.registry.removeRuntime(runtimeId);
        this.runtimeToBootstrap.delete(runtimeId);
        break;
      }
    }
    if (this.registry.hasChanged()) {
      this.broadcastTargetRegistry();
    }

    log.info('Follower removed from sync', { bootstrapId, followerCount: this.followers.size });
    this.options.onFollowerCountChanged?.(this.followers.size);
  }

  /**
   * Per-follower throttle for broadcast send failures. A stuck channel
   * (closed/closing, full SCTP buffer) would otherwise log on every
   * broadcast — and broadcasters include per-pi-event traffic during
   * tool streaming, so a single bad follower could produce hundreds
   * of identical error logs in a single turn before keepalive evicts
   * it. Cleared on success and on `removeFollower`.
   */
  private readonly followerBroadcastErrorLogAt = new Map<string, number>();
  private static readonly BROADCAST_ERROR_THROTTLE_MS = 60_000;

  /**
   * Send a message to every connected follower. Each `follower.sync.send`
   * is wrapped in its own try/catch so a single dead/closing channel
   * doesn't abort the iteration and silently strand subsequent
   * siblings without the message (`RTCDataChannel.send()` throws
   * `InvalidStateError` for closed/closing channels and
   * `OperationError` when the SCTP send buffer overflows).
   *
   * Failures are throttled per-follower (~1 log per 60s) so a stuck
   * channel can't flood logs during a high-event turn. Successful
   * sends clear the throttle so a recovered channel logs immediately
   * if it fails again. Does NOT auto-remove the broken follower —
   * keepalive timeout owns that decision; ripping a follower out
   * mid-broadcast risks deadlocking the next iteration if it
   * observes a partial `followers` map.
   */
  private broadcastToAllFollowers(message: LeaderToFollowerMessage): void {
    const now = performance.now();
    for (const [bootstrapId, follower] of this.followers) {
      try {
        follower.sync.send(message);
        // Clear throttle on success so a follower that just recovered
        // logs immediately if its channel fails again.
        this.followerBroadcastErrorLogAt.delete(bootstrapId);
      } catch (err) {
        const lastLogAt =
          this.followerBroadcastErrorLogAt.get(bootstrapId) ?? Number.NEGATIVE_INFINITY;
        if (now - lastLogAt > LeaderSyncManager.BROADCAST_ERROR_THROTTLE_MS) {
          this.followerBroadcastErrorLogAt.set(bootstrapId, now);
          log.error('Broadcast send to follower failed (channel may be stuck)', {
            bootstrapId,
            messageType: message.type,
            error: err instanceof Error ? err.message : String(err),
          });
        }
      }
    }
  }

  /**
   * Broadcast an agent event to all connected followers.
   * Called from the orchestrator callback wiring in main.ts.
   */
  broadcastEvent(event: AgentEvent): void {
    if (this.followers.size === 0) return;
    const scoopJid = this.options.getScoopJid();
    const message: LeaderToFollowerMessage = { type: 'agent_event', event, scoopJid };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Broadcast a user message to all connected followers.
   * Called when any user message enters the leader (local or from a follower).
   *
   * Attachments are scrubbed of leader-local VFS paths via
   * `stripLocalPathsForRemote` before going on the wire. The follower-
   * originated path already scrubs in `handleFollowerMessage` (defense
   * in depth — that scrub stays), so the second pass here is idempotent.
   * The leader-originated path (the panel chat `setOnLocalUserMessage`
   * hook) was the gap: leader paths like
   * `/tmp/attachment-<stamp>-<seq>-<rand>-<name>` (the off-load shape
   * produced by `attachment-vfs.ts:makeAttachmentPath`) would have
   * shipped raw to every follower, where they're meaningless.
   */
  broadcastUserMessage(text: string, messageId: string, attachments?: MessageAttachment[]): void {
    if (this.followers.size === 0) return;
    const scoopJid = this.options.getScoopJid();
    const safeAttachments = attachments?.length
      ? stripLocalPathsForRemote(attachments)
      : attachments;
    const message: LeaderToFollowerMessage = {
      type: 'user_message_echo',
      text,
      messageId,
      scoopJid,
      attachments: safeAttachments,
    };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Broadcast a status change to all connected followers.
   */
  broadcastStatus(status: string): void {
    if (this.followers.size === 0) return;
    const message: LeaderToFollowerMessage = { type: 'status', scoopStatus: status };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Send a snapshot of current messages to a specific follower.
   * Automatically chunks large snapshots to avoid exceeding SCTP message size limits.
   *
   * If `scoopJid` is provided (or the follower has a selected scoop), the snapshot
   * is loaded for that specific scoop via `getMessagesForScoop`. Otherwise the
   * leader's currently active scoop (`getMessages`) is used.
   */
  private async sendSnapshotToFollower(bootstrapId: string, scoopJid?: string): Promise<void> {
    const follower = this.followers.get(bootstrapId);
    if (!follower) return;

    const targetJid = scoopJid ?? follower.selectedScoopJid ?? this.options.getScoopJid();
    let messages: ChatMessage[];
    if (this.options.getMessagesForScoop && targetJid !== this.options.getScoopJid()) {
      try {
        messages = await Promise.resolve(this.options.getMessagesForScoop(targetJid));
      } catch (err) {
        log.warn('getMessagesForScoop failed, falling back to active scoop', {
          targetJid,
          error: err instanceof Error ? err.message : String(err),
        });
        messages = this.options.getMessages();
      }
    } else {
      messages = this.options.getMessages();
    }

    follower.selectedScoopJid = targetJid;
    sendSnapshot(follower.sync, messages, targetJid);
    log.debug('Snapshot sent to follower', {
      bootstrapId,
      messageCount: messages.length,
      scoopJid: targetJid,
    });
  }

  /**
   * Send the scoop list to a specific follower, so its scoop picker / swipe view
   * has up-to-date metadata. No-op when the leader didn't supply `getScoops`.
   */
  private sendScoopsListToFollower(bootstrapId: string): void {
    const follower = this.followers.get(bootstrapId);
    if (!follower) return;
    const getScoops = this.options.getScoops;
    if (!getScoops) return;
    try {
      const scoops = getScoops();
      const activeScoopJid = this.options.getScoopJid();
      follower.sync.send({ type: 'scoops.list', scoops, activeScoopJid });
    } catch (err) {
      log.warn('Failed to send scoops.list', {
        bootstrapId,
        error: err instanceof Error ? err.message : String(err),
      });
    }
  }

  /**
   * Send the sprinkles list to a specific follower.
   * No-op when the leader didn't supply `getSprinkles`.
   */
  private sendSprinklesListToFollower(bootstrapId: string): void {
    const follower = this.followers.get(bootstrapId);
    if (!follower) return;
    const getSprinkles = this.options.getSprinkles;
    if (!getSprinkles) return;
    try {
      const sprinkles = getSprinkles();
      follower.sync.send({ type: 'sprinkles.list', sprinkles });
    } catch (err) {
      log.warn('Failed to send sprinkles.list', {
        bootstrapId,
        error: err instanceof Error ? err.message : String(err),
      });
    }
  }

  /**
   * Broadcast the current scoop list to every connected follower.
   * Call when scoops are added/removed or the active selection changes.
   */
  broadcastScoopsList(): void {
    if (this.followers.size === 0) return;
    const getScoops = this.options.getScoops;
    if (!getScoops) return;
    let scoops: ScoopSummary[];
    try {
      scoops = getScoops();
    } catch (err) {
      log.warn('Failed to compute scoops list', {
        error: err instanceof Error ? err.message : String(err),
      });
      return;
    }
    const activeScoopJid = this.options.getScoopJid();
    const message: LeaderToFollowerMessage = { type: 'scoops.list', scoops, activeScoopJid };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Broadcast the current sprinkle list to every connected follower.
   * Call when sprinkles are added/removed or visibility changes.
   */
  broadcastSprinklesList(): void {
    if (this.followers.size === 0) return;
    const getSprinkles = this.options.getSprinkles;
    if (!getSprinkles) return;
    let sprinkles: SprinkleSummary[];
    try {
      sprinkles = getSprinkles();
    } catch (err) {
      log.warn('Failed to compute sprinkles list', {
        error: err instanceof Error ? err.message : String(err),
      });
      return;
    }
    const message: LeaderToFollowerMessage = { type: 'sprinkles.list', sprinkles };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Push a sprinkle update payload to every connected follower.
   * Mirrors `SprinkleManager.sendToSprinkle` so a follower's open sprinkle
   * gets the same data that the leader's local instance would receive.
   */
  broadcastSprinkleUpdate(sprinkleName: string, data: unknown): void {
    if (this.followers.size === 0) return;
    const message: LeaderToFollowerMessage = {
      type: 'sprinkle.update',
      sprinkleName,
      data,
    };
    this.broadcastToAllFollowers(message);
  }

  /** Chunk size for sprinkle content responses. Mirrors snapshot chunking. */
  private static readonly SPRINKLE_CHUNK_SIZE = 32 * 1024; // 32 KB
  private static readonly SPRINKLE_CHUNK_THRESHOLD = 64 * 1024; // 64 KB

  /**
   * Handle a follower's `sprinkle.fetch` request: load the .shtml content from
   * the leader's VFS via `readSprinkleContent` and reply with a `sprinkle.content`
   * message (chunked when oversized).
   */
  private async handleSprinkleFetch(
    bootstrapId: string,
    requestId: string,
    sprinkleName: string
  ): Promise<void> {
    const follower = this.followers.get(bootstrapId);
    if (!follower) return;

    const reader = this.options.readSprinkleContent;
    if (!reader) {
      follower.sync.send({
        type: 'sprinkle.content',
        requestId,
        sprinkleName,
        content: '',
        error: 'Leader has no sprinkle content reader',
      });
      return;
    }

    let content: string | null = null;
    try {
      content = await Promise.resolve(reader(sprinkleName));
    } catch (err) {
      follower.sync.send({
        type: 'sprinkle.content',
        requestId,
        sprinkleName,
        content: '',
        error: err instanceof Error ? err.message : String(err),
      });
      return;
    }

    if (content === null || content === undefined) {
      follower.sync.send({
        type: 'sprinkle.content',
        requestId,
        sprinkleName,
        content: '',
        error: `Sprinkle not found: ${sprinkleName}`,
      });
      return;
    }

    if (content.length <= LeaderSyncManager.SPRINKLE_CHUNK_THRESHOLD) {
      follower.sync.send({
        type: 'sprinkle.content',
        requestId,
        sprinkleName,
        content,
      });
      return;
    }

    const chunkSize = LeaderSyncManager.SPRINKLE_CHUNK_SIZE;
    const totalChunks = Math.ceil(content.length / chunkSize);
    for (let i = 0; i < totalChunks; i++) {
      const slice = content.slice(i * chunkSize, (i + 1) * chunkSize);
      follower.sync.send({
        type: 'sprinkle.content',
        requestId,
        sprinkleName,
        content: slice,
        chunkIndex: i,
        totalChunks,
      });
    }
  }

  /**
   * Handle incoming messages from a follower.
   */
  private handleFollowerMessage(bootstrapId: string, message: FollowerToLeaderMessage): void {
    switch (message.type) {
      case 'user_message': {
        log.info('Follower user message received', { bootstrapId, messageId: message.messageId });
        // Defense in depth: even though followers strip their local
        // `path` values before sending, scrub again here so older or
        // mis-behaving peers cannot trick the cone into trying to read
        // a follower-local path that does not exist on this runtime.
        const safeAttachments = message.attachments?.length
          ? stripLocalPathsForRemote(message.attachments)
          : message.attachments;
        this.options.onFollowerMessage(message.text, message.messageId, safeAttachments);
        break;
      }
      case 'abort':
        log.info('Follower abort received', { bootstrapId });
        this.options.onFollowerAbort();
        break;
      case 'request_snapshot':
        log.info('Follower snapshot request received', {
          bootstrapId,
          scoopJid: message.scoopJid,
        });
        void this.sendSnapshotToFollower(bootstrapId, message.scoopJid);
        break;
      case 'scoops.select': {
        log.info('Follower selected scoop', { bootstrapId, scoopJid: message.scoopJid });
        const follower = this.followers.get(bootstrapId);
        if (follower) {
          follower.selectedScoopJid = message.scoopJid;
          void this.sendSnapshotToFollower(bootstrapId, message.scoopJid);
        }
        break;
      }
      case 'sprinkles.refresh':
        log.info('Follower requested sprinkles refresh', { bootstrapId });
        this.sendSprinklesListToFollower(bootstrapId);
        break;
      case 'sprinkle.fetch':
        void this.handleSprinkleFetch(bootstrapId, message.requestId, message.sprinkleName);
        break;
      case 'sprinkle.lick':
        log.info('Follower sprinkle lick received', {
          bootstrapId,
          sprinkleName: message.sprinkleName,
        });
        try {
          this.options.onSprinkleLick?.(message.sprinkleName, message.body, message.targetScoop);
        } catch (err) {
          log.warn('onSprinkleLick handler threw', {
            error: err instanceof Error ? err.message : String(err),
          });
        }
        break;
      case 'targets.advertise': {
        log.info('Follower targets advertised', {
          bootstrapId,
          runtimeId: message.runtimeId,
          targetCount: message.targets.length,
        });
        // Clean up stale remote transports for runtimeIds that are no longer in runtimeToBootstrap
        // (e.g. a follower reconnected with a new runtimeId but old transports linger)
        for (const key of [...this.remoteTransports.keys()]) {
          const runtimeId = key.substring(0, key.indexOf(':'));
          if (
            runtimeId !== 'leader' &&
            !this.runtimeToBootstrap.has(runtimeId) &&
            runtimeId !== message.runtimeId
          ) {
            const transport = this.remoteTransports.get(key);
            transport?.disconnect();
            this.remoteTransports.delete(key);
            log.debug('Cleaned up orphaned remote transport on advertise', { key });
          }
        }
        this.runtimeToBootstrap.set(message.runtimeId, bootstrapId);
        this.registry.setTargets(message.runtimeId, message.targets);
        this.broadcastTargetRegistry();
        break;
      }
      case 'cdp.request': {
        const { requestId, targetRuntimeId, localTargetId, method, params, sessionId } = message;
        if (targetRuntimeId === 'leader') {
          this.executeLocalCDP(requestId, localTargetId, method, params, sessionId, bootstrapId);
        } else {
          this.forwardCDPRequest(
            requestId,
            targetRuntimeId,
            localTargetId,
            method,
            params,
            sessionId,
            bootstrapId
          );
        }
        break;
      }
      case 'cdp.response': {
        this.handleCDPResponse(message);
        break;
      }
      case 'cdp.event': {
        this.handleCDPEvent(bootstrapId, message.method, message.params, message.sessionId);
        break;
      }
      case 'tab.open': {
        const { requestId, targetRuntimeId, url } = message;
        if (targetRuntimeId === 'leader') {
          this.executeLocalTabOpen(requestId, url, bootstrapId);
        } else {
          this.forwardTabOpen(requestId, targetRuntimeId, url, bootstrapId);
        }
        break;
      }
      case 'tab.opened': {
        this.handleTabOpenResponse(message.requestId, message.targetId);
        break;
      }
      case 'tab.open.error': {
        this.handleTabOpenError(message.requestId, message.error);
        break;
      }
      case 'fs.request': {
        const { requestId, targetRuntimeId, request } = message;
        if (targetRuntimeId === 'leader') {
          this.executeLocalFs(requestId, request, bootstrapId);
        } else {
          this.forwardFsRequest(requestId, targetRuntimeId, request, bootstrapId);
        }
        break;
      }
      case 'fs.response': {
        this.handleFsResponse(message.requestId, message.response);
        break;
      }
      case 'ping': {
        // Follower is pinging us — respond with pong and treat as liveness signal
        const follower = this.followers.get(bootstrapId);
        if (follower) {
          follower.keepalive.receivePing();
          follower.lastActivity = Date.now();
          follower.sync.send({ type: 'pong' });
        }
        break;
      }
      case 'pong': {
        // Follower responded to our ping
        const follower = this.followers.get(bootstrapId);
        if (follower) {
          follower.keepalive.receivePong();
          follower.lastActivity = Date.now();
        }
        break;
      }
    }
  }

  /**
   * Feed the leader's own local browser targets into the registry.
   * Broadcasts the updated registry if targets changed.
   */
  setLocalTargets(targets: RemoteTargetInfo[]): void {
    this.registry.setTargets('leader', targets);
    if (this.registry.hasChanged()) {
      this.broadcastTargetRegistry();
    }
  }

  /**
   * Broadcast the merged target registry to all connected followers.
   */
  broadcastTargetRegistry(): void {
    if (this.followers.size === 0) return;
    const entries = this.getConnectedEntries();
    const message: LeaderToFollowerMessage = { type: 'targets.registry', targets: entries };
    this.broadcastToAllFollowers(message);
  }

  /**
   * Get the merged target registry entries.
   * Used to implement TrayTargetProvider for the leader's BrowserAPI.
   */
  getTargets(): TrayTargetEntry[] {
    return this.getConnectedEntries();
  }

  private getConnectedEntries(): TrayTargetEntry[] {
    return this.registry.getEntries().filter((target) => {
      if (target.runtimeId === 'leader') return true;
      const bootstrapId = this.runtimeToBootstrap.get(target.runtimeId);
      return bootstrapId ? this.followers.has(bootstrapId) : false;
    });
  }

  /**
   * Create a RemoteCDPTransport that routes CDP commands from the leader's
   * BrowserAPI to a follower that owns the target.
   */
  createRemoteTransport(targetRuntimeId: string, localTargetId: string): RemoteCDPTransport {
    const sender: RemoteCDPSender = {
      sendCDPRequest: (requestId, method, params, sessionId) => {
        const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
        const targetFollower = targetBootstrapId
          ? this.followers.get(targetBootstrapId)
          : undefined;
        if (!targetFollower) {
          // Immediately resolve as error — the transport will handle it
          const transport = this.remoteTransports.get(`${targetRuntimeId}:${localTargetId}`);
          transport?.handleResponse(
            requestId,
            undefined,
            `Target runtime "${targetRuntimeId}" not connected`
          );
          return;
        }
        // Track the route so the response can be delivered to the RemoteCDPTransport
        this.pendingCDPRoutes.set(requestId, { requesterBootstrapId: '__leader__', requestId });
        targetFollower.sync.send({
          type: 'cdp.request',
          requestId,
          localTargetId,
          method,
          params,
          sessionId,
        });
      },
    };
    const transport = new RemoteCDPTransport(sender);
    this.remoteTransports.set(`${targetRuntimeId}:${localTargetId}`, transport);
    return transport;
  }

  /**
   * Remove a remote transport created for the leader's BrowserAPI.
   */
  removeRemoteTransport(targetRuntimeId: string, localTargetId: string): void {
    const key = `${targetRuntimeId}:${localTargetId}`;
    const transport = this.remoteTransports.get(key);
    if (transport) {
      transport.disconnect();
      this.remoteTransports.delete(key);
    }
  }

  /**
   * Clean up all cached RemoteCDPTransport instances for a given runtimeId.
   * Called when a follower disconnects to prevent stale transports from lingering.
   */
  private cleanupRemoteTransports(runtimeId: string): void {
    const prefix = `${runtimeId}:`;
    for (const key of [...this.remoteTransports.keys()]) {
      if (key.startsWith(prefix)) {
        const transport = this.remoteTransports.get(key);
        transport?.disconnect();
        this.remoteTransports.delete(key);
        log.debug('Cleaned up stale remote transport', { key });
      }
    }
  }

  /**
   * Return the list of connected follower runtimeIds with metadata.
   */
  getConnectedFollowers(): {
    runtimeId: string;
    runtime?: string;
    connectedAt?: string;
    lastActivity?: number;
    floatType?: FloatType;
  }[] {
    return [...this.runtimeToBootstrap.entries()].map(([runtimeId, bootstrapId]) => {
      const follower = this.followers.get(bootstrapId);
      return {
        runtimeId,
        runtime: follower?.runtime,
        connectedAt: follower?.connectedAt,
        lastActivity: follower?.lastActivity,
        floatType: follower?.floatType,
      };
    });
  }

  /**
   * Find the best follower for a cookie teleport.
   * Prefers standalone floats, then sorts by most recent activity.
   * Returns null if no alive followers exist.
   */
  getBestFollowerForTeleport(): {
    runtimeId: string;
    bootstrapId: string;
    floatType: FloatType;
  } | null {
    const candidates: {
      runtimeId: string;
      bootstrapId: string;
      floatType: FloatType;
      lastActivity: number;
    }[] = [];
    for (const [runtimeId, bootstrapId] of this.runtimeToBootstrap) {
      const follower = this.followers.get(bootstrapId);
      if (!follower) continue;
      candidates.push({
        runtimeId,
        bootstrapId,
        floatType: follower.floatType,
        lastActivity: follower.lastActivity,
      });
    }
    if (candidates.length === 0) return null;
    // Prefer standalone, then sort by most recent activity
    const standalone = candidates.filter((c) => c.floatType === 'standalone');
    const pool = standalone.length > 0 ? standalone : candidates;
    pool.sort((a, b) => b.lastActivity - a.lastActivity);
    return pool[0];
  }

  /**
   * Check if there are any connected followers.
   */
  get hasFollowers(): boolean {
    return this.followers.size > 0;
  }

  /**
   * Stop all follower connections.
   */
  stop(): void {
    for (const bootstrapId of [...this.followers.keys()]) {
      this.removeFollower(bootstrapId);
    }
  }

  // ---------------------------------------------------------------------------
  // CDP routing
  // ---------------------------------------------------------------------------

  /**
   * Execute a CDP command on the leader's own browser transport.
   * Sends the response back to the requesting follower, chunking if necessary.
   */
  private async executeLocalCDP(
    requestId: string,
    localTargetId: string,
    method: string,
    params: Record<string, unknown> | undefined,
    sessionId: string | undefined,
    requesterBootstrapId: string
  ): Promise<void> {
    const follower = this.followers.get(requesterBootstrapId);
    if (!follower) return;

    const transport = this.options.browserTransport;
    if (!transport) {
      follower.sync.send({
        type: 'cdp.response',
        requestId,
        error: 'Leader has no browser transport',
      });
      return;
    }

    try {
      const result = await transport.send(method, params, sessionId);
      sendCDPResponse(follower.sync, requestId, result);
    } catch (err) {
      follower.sync.send({
        type: 'cdp.response',
        requestId,
        error: err instanceof Error ? err.message : String(err),
      });
    }
  }

  /**
   * Forward a CDP request from one follower to another follower that owns the target.
   */
  private forwardCDPRequest(
    requestId: string,
    targetRuntimeId: string,
    localTargetId: string,
    method: string,
    params: Record<string, unknown> | undefined,
    sessionId: string | undefined,
    requesterBootstrapId: string
  ): void {
    const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
    const targetFollower = targetBootstrapId ? this.followers.get(targetBootstrapId) : undefined;
    const requester = this.followers.get(requesterBootstrapId);

    if (!targetFollower) {
      if (requester) {
        requester.sync.send({
          type: 'cdp.response',
          requestId,
          error: `Target runtime "${targetRuntimeId}" not connected`,
        });
      }
      return;
    }

    // Track the pending route so we can return the response to the requester
    this.pendingCDPRoutes.set(requestId, { requesterBootstrapId, requestId });

    // Forward to the target follower (without targetRuntimeId — it's always for their local target)
    targetFollower.sync.send({
      type: 'cdp.request',
      requestId,
      localTargetId,
      method,
      params,
      sessionId,
    });
  }

  /**
   * Handle a CDP response from a follower (forwarding back to the original requester).
   * Supports chunked responses: reassembles chunks before forwarding, then re-chunks
   * for the outbound channel.
   */
  private handleCDPResponse(message: FollowerToLeaderMessage & { type: 'cdp.response' }): void {
    const { requestId, result, error, chunkData, chunkIndex, totalChunks } = message;
    const route = this.pendingCDPRoutes.get(requestId);
    if (!route) return;

    // Reassemble chunked response from the follower
    const assembled = reassembleCDPResponse(this.cdpChunkBuffers, message);
    if (!assembled) return; // Still waiting for more chunks

    this.pendingCDPRoutes.delete(requestId);

    // Route to the leader's own RemoteCDPTransport if the requester is the leader itself
    if (route.requesterBootstrapId === '__leader__') {
      for (const transport of this.remoteTransports.values()) {
        transport.handleResponse(requestId, assembled.result, assembled.error);
      }
      return;
    }

    // Forward to the requesting follower, re-chunking if necessary
    const requester = this.followers.get(route.requesterBootstrapId);
    if (requester) {
      sendCDPResponse(requester.sync, requestId, assembled.result, assembled.error);
    }
  }

  /**
   * Handle a CDP event from a follower. Routes the event to the leader's
   * RemoteCDPTransport for that follower so that `remoteTransport.on(event, handler)` fires.
   */
  private handleCDPEvent(
    bootstrapId: string,
    method: string,
    params: Record<string, unknown>,
    sessionId?: string
  ): void {
    // Find the runtimeId for this follower
    let followerRuntimeId: string | undefined;
    for (const [runtimeId, bId] of this.runtimeToBootstrap) {
      if (bId === bootstrapId) {
        followerRuntimeId = runtimeId;
        break;
      }
    }
    if (!followerRuntimeId) return;

    // Deliver the event to all RemoteCDPTransports for this follower's runtime
    const prefix = `${followerRuntimeId}:`;
    for (const [key, transport] of this.remoteTransports) {
      if (key.startsWith(prefix)) {
        transport.handleEvent(method, params);
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Tab open routing
  // ---------------------------------------------------------------------------

  /**
   * Open a tab on a remote runtime from the leader's own code.
   * Returns a promise that resolves with the composite targetId ("{runtimeId}:{localTargetId}").
   */
  openRemoteTab(targetRuntimeId: string, url: string): Promise<string> {
    const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
    const targetFollower = targetBootstrapId ? this.followers.get(targetBootstrapId) : undefined;

    if (!targetFollower) {
      return Promise.reject(new Error(`Target runtime "${targetRuntimeId}" not connected`));
    }

    const requestId = `tab-open-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    return new Promise<string>((resolve, reject) => {
      this.tabOpenResolvers.set(requestId, { resolve, reject });
      this.pendingTabOpenRoutes.set(requestId, { requesterBootstrapId: '__leader__', requestId });
      targetFollower.sync.send({ type: 'tab.open', requestId, url });
    });
  }

  /**
   * Execute a tab.open on the leader's own browser transport.
   */
  private async executeLocalTabOpen(
    requestId: string,
    url: string,
    requesterBootstrapId: string
  ): Promise<void> {
    const follower = this.followers.get(requesterBootstrapId);
    if (!follower) return;

    const transport = this.options.browserTransport;
    if (!transport) {
      follower.sync.send({
        type: 'tab.open.error',
        requestId,
        error: 'Leader has no browser transport',
      });
      return;
    }

    try {
      const result = await transport.send('Target.createTarget', { url, background: true });
      const targetId = result['targetId'] as string;
      follower.sync.send({ type: 'tab.opened', requestId, targetId: `leader:${targetId}` });
    } catch (err) {
      follower.sync.send({
        type: 'tab.open.error',
        requestId,
        error: err instanceof Error ? err.message : String(err),
      });
    }
  }

  /**
   * Forward a tab.open request from one follower to another.
   */
  private forwardTabOpen(
    requestId: string,
    targetRuntimeId: string,
    url: string,
    requesterBootstrapId: string
  ): void {
    const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
    const targetFollower = targetBootstrapId ? this.followers.get(targetBootstrapId) : undefined;
    const requester = this.followers.get(requesterBootstrapId);

    if (!targetFollower) {
      if (requester) {
        requester.sync.send({
          type: 'tab.open.error',
          requestId,
          error: `Target runtime "${targetRuntimeId}" not connected`,
        });
      }
      return;
    }

    this.pendingTabOpenRoutes.set(requestId, { requesterBootstrapId, requestId });
    targetFollower.sync.send({ type: 'tab.open', requestId, url });
  }

  /**
   * Handle a tab.opened response from a follower.
   */
  private handleTabOpenResponse(requestId: string, targetId: string): void {
    const route = this.pendingTabOpenRoutes.get(requestId);
    if (!route) return;
    this.pendingTabOpenRoutes.delete(requestId);

    if (route.requesterBootstrapId === '__leader__') {
      const resolver = this.tabOpenResolvers.get(requestId);
      if (resolver) {
        this.tabOpenResolvers.delete(requestId);
        resolver.resolve(targetId);
      }
      return;
    }

    const requester = this.followers.get(route.requesterBootstrapId);
    if (requester) {
      requester.sync.send({ type: 'tab.opened', requestId, targetId });
    }
  }

  /**
   * Handle a tab.open.error response from a follower.
   */
  private handleTabOpenError(requestId: string, error: string): void {
    const route = this.pendingTabOpenRoutes.get(requestId);
    if (!route) return;
    this.pendingTabOpenRoutes.delete(requestId);

    if (route.requesterBootstrapId === '__leader__') {
      const resolver = this.tabOpenResolvers.get(requestId);
      if (resolver) {
        this.tabOpenResolvers.delete(requestId);
        resolver.reject(new Error(error));
      }
      return;
    }

    const requester = this.followers.get(route.requesterBootstrapId);
    if (requester) {
      requester.sync.send({ type: 'tab.open.error', requestId, error });
    }
  }

  // ---------------------------------------------------------------------------
  // FS routing
  // ---------------------------------------------------------------------------

  /**
   * Execute an fs request on the leader's own VFS.
   * Sends the response(s) back to the requesting follower.
   */
  private async executeLocalFs(
    requestId: string,
    request: TrayFsRequest,
    requesterBootstrapId: string
  ): Promise<void> {
    const follower = this.followers.get(requesterBootstrapId);
    if (!follower) return;

    const vfs = this.options.vfs;
    if (!vfs) {
      follower.sync.send({
        type: 'fs.response',
        requestId,
        response: { ok: false, error: 'Leader has no VFS' },
      });
      return;
    }

    const responses = await handleFsRequest(vfs, request);
    for (const response of responses) {
      follower.sync.send({ type: 'fs.response', requestId, response });
    }
  }

  /**
   * Forward an fs request from one follower to another follower that owns the target runtime.
   */
  private forwardFsRequest(
    requestId: string,
    targetRuntimeId: string,
    request: TrayFsRequest,
    requesterBootstrapId: string
  ): void {
    const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
    const targetFollower = targetBootstrapId ? this.followers.get(targetBootstrapId) : undefined;
    const requester = this.followers.get(requesterBootstrapId);

    if (!targetFollower) {
      if (requester) {
        requester.sync.send({
          type: 'fs.response',
          requestId,
          response: { ok: false, error: `Target runtime "${targetRuntimeId}" not connected` },
        });
      }
      return;
    }

    // Track the pending route so we can return the response to the requester
    this.pendingFsRoutes.set(requestId, {
      requesterBootstrapId,
      requestId,
      chunks: [],
      totalChunks: 1,
    });

    // Forward to the target follower
    targetFollower.sync.send({ type: 'fs.request', requestId, request });
  }

  /**
   * Handle an fs response from a follower (forwarding back to the original requester).
   * Supports chunked responses — accumulates chunks and forwards each one.
   */
  private handleFsResponse(requestId: string, response: TrayFsResponse): void {
    const route = this.pendingFsRoutes.get(requestId);
    if (!route) {
      // Check if this is for a leader-originated request
      const resolver = this.fsResolvers.get(requestId);
      if (resolver) {
        resolver.responses.push(response);
        const totalChunks = (response.ok && response.totalChunks) || 1;
        if (resolver.responses.length >= totalChunks) {
          this.fsResolvers.delete(requestId);
          resolver.resolve(resolver.responses);
        }
      }
      return;
    }

    // Route to the leader's own fsResolvers if the requester is the leader itself
    if (route.requesterBootstrapId === '__leader__') {
      const resolver = this.fsResolvers.get(requestId);
      if (resolver) {
        resolver.responses.push(response);
        const totalChunks = (response.ok && response.totalChunks) || 1;
        if (resolver.responses.length >= totalChunks) {
          this.fsResolvers.delete(requestId);
          this.pendingFsRoutes.delete(requestId);
          resolver.resolve(resolver.responses);
        }
      }
      return;
    }

    const requester = this.followers.get(route.requesterBootstrapId);
    if (requester) {
      requester.sync.send({ type: 'fs.response', requestId, response });
    }

    // Track chunks and clean up route when all chunks received
    route.chunks.push(response);
    const totalChunks = (response.ok && response.totalChunks) || 1;
    route.totalChunks = totalChunks;
    if (route.chunks.length >= route.totalChunks) {
      this.pendingFsRoutes.delete(requestId);
    }
  }

  /**
   * Send an fs request to a remote runtime from the leader's own code.
   * Returns a promise that resolves with the response(s).
   */
  sendFsRequest(targetRuntimeId: string, request: TrayFsRequest): Promise<TrayFsResponse[]> {
    if (targetRuntimeId === 'leader') {
      const vfs = this.options.vfs;
      if (!vfs) return Promise.resolve([{ ok: false, error: 'Leader has no VFS' }]);
      return handleFsRequest(vfs, request);
    }

    const targetBootstrapId = this.runtimeToBootstrap.get(targetRuntimeId);
    const targetFollower = targetBootstrapId ? this.followers.get(targetBootstrapId) : undefined;

    if (!targetFollower) {
      return Promise.resolve([
        { ok: false, error: `Target runtime "${targetRuntimeId}" not connected` },
      ]);
    }

    const requestId = `fs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    return new Promise<TrayFsResponse[]>((resolve, reject) => {
      this.fsResolvers.set(requestId, { resolve, reject, responses: [] });
      this.pendingFsRoutes.set(requestId, {
        requesterBootstrapId: '__leader__',
        requestId,
        chunks: [],
        totalChunks: 1,
      });
      targetFollower.sync.send({ type: 'fs.request', requestId, request });
    });
  }
}
