import {
  ClientEvent,
  KnownMembership,
  MatrixClient,
  MatrixEvent,
  MatrixEventEvent,
  Room,
  RoomStateEvent,
} from 'matrix-js-sdk';
import {
  ClientWidgetApi,
  IRoomEvent,
  IWidget,
  Widget,
  WidgetApiFromWidgetAction,
  WidgetApiToWidgetAction,
  WidgetDriver,
} from 'matrix-widget-api';
import { CallWidgetDriver } from './CallWidgetDriver';
import { trimTrailingSlash } from '../../utils/common';
import { NoiseSuppressionMode } from '../../state/settings';
import {
  ElementCallIntent,
  ElementCallThemeKind,
  ElementMediaStateDetail,
  ElementWidgetActions,
} from './types';
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';

// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
// iframe has hung (e.g. blocked network, crashed widget, blank "Loading...")
// and surface a recoverable error instead of an indefinite spinner.
const CALL_LOAD_WATCHDOG_MS = 25_000;

export type CallLoadErrorReason = 'timeout' | 'iframe';

/** Payload entry of the fork's io.lotus.call_state widget event (#2). */
export interface LotusCallParticipant {
  id: string;
  userId: string;
  speaking: boolean;
  audioEnabled: boolean;
  videoEnabled: boolean;
}

export class CallEmbed {
  private mx: MatrixClient;

  public readonly call: ClientWidgetApi;

  public readonly iframe: HTMLIFrameElement;

  public readonly room: Room;

  public joined = false;

  // C-M4: set once dispose() has run so the hangup fallback timer can tell
  // whether the embed was already torn down by the normal Close/Hangup echo.
  public disposed = false;

  // [lotus #2] Latest per-participant state from io.lotus.call_state, or null
  // until the fork sends the first one. When non-null, the speaker/mute hooks
  // read it instead of scraping the EC iframe DOM.
  private lotusParticipants: LotusCallParticipant[] | null = null;

  private lotusCallStateListeners = new Set<() => void>();

  public readonly control: CallControl;

  private readonly container: HTMLElement;

  private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID

  private eventsToFeed = new WeakSet<MatrixEvent>();

  private readonly disposables: Array<() => void> = [];

  private readonly initialState: CallControlState;

  private styleRetryObserver?: MutationObserver;

  private themeKind: ElementCallThemeKind = 'dark';

  // Watchdog: detects an iframe that never reaches a usable state.
  private loadWatchdog?: ReturnType<typeof setTimeout>;

  private loadSettled = false;

  private loadError?: CallLoadErrorReason;

  private readonly loadErrorListeners = new Set<
    (reason: CallLoadErrorReason | undefined) => void
  >();

  // Arrow-function class fields so dispose() passes the exact same reference to mx.off()
  private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);

  private readonly boundOnEventDecrypted = (ev: MatrixEvent) => this.onEventDecrypted(ev);

  private readonly boundOnStateUpdate = (ev: MatrixEvent) => this.onStateUpdate(ev);

  private readonly boundOnToDeviceEvent = (ev: MatrixEvent) => this.onToDeviceEvent(ev);

  static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
    if (dm && ongoing) {
      return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
    }
    if (dm) {
      return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
    }

    if (ongoing) {
      return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
    }
    return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
  }

  static dmCall(intent: ElementCallIntent): boolean {
    return (
      intent === ElementCallIntent.JoinExistingDM ||
      intent === ElementCallIntent.JoinExistingDMVoice ||
      intent === ElementCallIntent.StartCallDM ||
      intent === ElementCallIntent.StartCallDMVoice
    );
  }

  static startingCall(intent: ElementCallIntent): boolean {
    return (
      intent === ElementCallIntent.StartCallDM ||
      intent === ElementCallIntent.StartCallDMVoice ||
      intent === ElementCallIntent.StartCall ||
      intent === ElementCallIntent.StartCallVoice
    );
  }

  static getWidget(
    mx: MatrixClient,
    room: Room,
    intent: ElementCallIntent,
    themeKind: ElementCallThemeKind,
    denoiseMode: NoiseSuppressionMode = 'browser',
    denoiseModel: string = 'rnnoise',
    // [lotus] "Series suppression": also run EC's built-in WebRTC NS before the
    // in-source ML model (opt-in test aid for stacking browser NS + ML).
    denoiseNativeNS: boolean = false,
    denoiseGate: boolean = false,
    denoiseGateThreshold: number = -45,
    initialAudio = true,
    initialVideo = false,
  ): Widget {
    const userId = mx.getSafeUserId();
    const deviceId = mx.getDeviceId() ?? '';
    const clientOrigin = window.location.origin;
    const widgetId = 'call-embed';

    const params = new URLSearchParams({
      widgetId,
      parentUrl: clientOrigin,
      baseUrl: mx.baseUrl,
      roomId: room.roomId,
      userId,
      deviceId,
      intent,

      skipLobby: 'true',
      confineToRoom: 'true',
      appPrompt: 'false',
      perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
      lang: 'en-EN',
      theme: themeKind,
      // EC's built-in WebRTC suppressor: on for the 'browser' tier, and for the
      // 'ml' tier only when "series suppression" is opted into (stack browser NS
      // before the fork's in-source ML model). Plain 'ml' keeps it OFF so the
      // fork's TrackProcessor (lotusDenoiseSource) gets a raw mic.
      noiseSuppression: (
        denoiseMode === 'browser' ||
        (denoiseMode === 'ml' && denoiseNativeNS)
      ).toString(),
      // Turn the browser's auto gain control OFF for the ML tier only: its
      // dynamic gain fights the in-source ML denoiser (pumping). Browser/off
      // tiers keep the browser's normal capture pipeline (AGC on).
      autoGainControl: (denoiseMode !== 'ml').toString(),
      audio: initialAudio.toString(),
      video: initialVideo.toString(),
      header: 'none',
      // [lotus] Activate the self-built fork's in-source features (each is a
      // no-op on the EC side unless its flag/action is present):
      //  - call-state stream (speaking/mute events) -> useCallSpeakers
      //  - transparent background so the room wallpaper shows through natively
      lotusCallState: 'true',
      lotusTransparent: 'true',
      // [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
      // soundboard can publish clips into the call. Dormant until the host
      // sends io.lotus.inject_audio (only on an explicit user click), so
      // arming it for every call is safe.
      lotusAudioInject: 'true',
    });

    if (denoiseMode === 'ml') {
      // [lotus] In-source ML denoise: the fork runs RNNoise/Speex/DTLN/DFN as a
      // real LiveKit audio TrackProcessor (survives reconnects — fixes A7),
      // replacing the old build-time getUserMedia shim. The shim injection was
      // removed from vite.config.js; the denoise/ assets are still shipped and
      // loaded by the processor. lotusDenoiseSource (not lotusDenoise=ml) gates
      // it so the two engines can never both run.
      params.append('lotusDenoiseSource', 'true');
      params.append('lotusModel', denoiseModel);
      params.append('lotusGate', denoiseGate.toString());
      params.append('lotusGateThreshold', denoiseGateThreshold.toString());

      // [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are
      // actually served under public/element-call/denoise/ (they're copied by
      // vite.config.js at build time). Warns once if the copy step regressed;
      // never blocks call start.
      verifyDenoiseAssets(denoiseModel).catch(() => undefined);
    }

    if (CallEmbed.startingCall(intent)) {
      params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
    }

    const widgetUrl = new URL(
      `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
      window.location.origin,
    );
    widgetUrl.search = params.toString();

    const options: IWidget = {
      id: widgetId,
      creatorUserId: userId,
      name: 'Call',
      type: 'm.call',
      url: widgetUrl.href,
      waitForIframeLoad: false,
      data: {},
    };

    const widget: Widget = new Widget(options);

    return widget;
  }

  static getIframe(url: string): HTMLIFrameElement {
    const iframe = document.createElement('iframe');

    iframe.title = 'Call Embed';
    iframe.sandbox =
      'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
    iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
    iframe.src = url;

    iframe.style.width = '100%';
    iframe.style.height = '100%';
    iframe.style.border = 'none';

    return iframe;
  }

  constructor(
    mx: MatrixClient,
    room: Room,
    widget: Widget,
    container: HTMLElement,
    initialControlState?: CallControlState,
    themeKind: ElementCallThemeKind = 'dark',
  ) {
    const iframe = CallEmbed.getIframe(
      widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }),
    );
    container.append(iframe);

    const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
    const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);

    this.mx = mx;
    this.call = call;
    this.room = room;
    this.iframe = iframe;
    this.container = container;
    this.themeKind = themeKind;

    const controlState = initialControlState ?? new CallControlState(true, false, true);
    this.control = new CallControl(controlState, call, iframe);
    this.initialState = controlState;
    this.control.startObserving();
    iframe.onload = () => {
      this.control.startObserving();
    };
    // If the iframe document itself fails to load, fail fast.
    iframe.onerror = () => {
      this.settleLoad('iframe');
    };

    // Clear the watchdog as soon as the call reaches any usable state. The
    // happy path (onCallJoined) clears it too; these cover earlier signals so a
    // user sitting in the lobby/prescreen isn't flagged as an error.
    this.disposables.push(this.onReady(() => this.settleLoad()));
    this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
    this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));

    this.startLoadWatchdog();

    let initialMediaEvent = true;
    this.disposables.push(
      this.listenAction<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
        if (initialMediaEvent) {
          initialMediaEvent = false;
          this.control.applyState();
          return;
        }
        this.control.onMediaState(evt);
      }),
    );

    this.start();
  }

  get roomId(): string {
    return this.room.roomId;
  }

  get document(): Document | undefined {
    return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
  }

  public setTheme(theme: ElementCallThemeKind) {
    this.themeKind = theme;
    const doc = this.document;
    if (doc && this.joined) {
      const styleEl = doc.getElementById('lotus-ec-styles');
      if (styleEl) styleEl.textContent = this.buildStyleContent();
    }
    return this.call.transport
      .send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
      .catch(() => {
        // Widget transport not ready yet; theme URL param is the fallback
      });
  }

  public hangup() {
    return this.call.transport.send(ElementWidgetActions.HangupCall, {});
  }

  public onPreparing(callback: () => void) {
    return this.listenEvent('preparing', callback);
  }

  public onPreparingError(callback: (error: any) => void) {
    return this.listenEvent('error:preparing', callback);
  }

  public onReady(callback: () => void) {
    return this.listenEvent('ready', callback);
  }

  public onCapabilitiesNotified(callback: () => void) {
    return this.listenEvent('capabilitiesNotified', callback);
  }

  private start() {
    // Room widgets get locked to the room they were added in
    this.call.setViewedRoomId(this.roomId);
    this.disposables.push(
      this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)),
    );
    // Acknowledge set_always_on_screen; we manage PiP ourselves so just return success
    this.disposables.push(
      this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, () => {}),
    );
    // [lotus #2] Consume the fork's per-participant call-state stream. listenAction
    // auto-replies {} so the fork's transport doesn't time out. Stored for the
    // speaker/mute hooks (which prefer this over DOM scraping).
    this.disposables.push(
      this.listenAction('io.lotus.call_state', (evt) => {
        const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
        this.lotusParticipants = Array.isArray(data?.participants)
          ? (data!.participants as LotusCallParticipant[])
          : [];
        this.lotusCallStateListeners.forEach((l) => l());
      }),
    );

    // Populate the map of "read up to" events for this widget with the current event in every room.
    // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
    // requests timeline capabilities in other rooms down the road. It's just easier to manage here.
    this.mx.getRooms().forEach((room) => {
      // Timelines are most recent last
      const events = room.getLiveTimeline()?.getEvents() || [];
      const roomEvent = events[events.length - 1];
      if (!roomEvent) return; // force later code to think the room is fresh
      this.readUpToMap[room.roomId] = roomEvent.getId()!;
    });

    // Attach listeners for feeding events - the underlying widget classes handle permissions for us
    this.mx.on(ClientEvent.Event, this.boundOnEvent);
    this.mx.on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
    this.mx.on(RoomStateEvent.Events, this.boundOnStateUpdate);
    this.mx.on(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
  }

  /**
   * Stops the widget messaging for if it is started. Skips stopping if it is an active
   * widget.
   * @param opts
   */
  public dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    this.disposables.forEach((disposable) => {
      disposable();
    });
    this.clearLoadWatchdog();
    this.loadErrorListeners.clear();
    this.styleRetryObserver?.disconnect();
    this.call.stop();
    this.container.removeChild(this.iframe);
    this.control.dispose();

    this.mx.off(ClientEvent.Event, this.boundOnEvent);
    this.mx.off(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
    this.mx.off(RoomStateEvent.Events, this.boundOnStateUpdate);
    this.mx.off(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);

    // Clear internal state
    this.readUpToMap = {};
    this.eventsToFeed = new WeakSet<MatrixEvent>();
  }

  private startLoadWatchdog(): void {
    if (this.loadWatchdog !== undefined) return;
    this.loadWatchdog = setTimeout(() => {
      this.settleLoad('timeout');
    }, CALL_LOAD_WATCHDOG_MS);
  }

  private clearLoadWatchdog(): void {
    if (this.loadWatchdog !== undefined) {
      clearTimeout(this.loadWatchdog);
      this.loadWatchdog = undefined;
    }
  }

  private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
    this.loadErrorListeners.forEach((cb) => {
      try {
        cb(reason);
      } catch {
        // a misbehaving subscriber must not block the others
      }
    });
  }

  /**
   * Marks the load lifecycle as settled.
   *
   * - Failure (reason set): the FIRST failure wins; a later success can still
   *   heal it (below). Once we've genuinely succeeded, later spurious failures
   *   are ignored.
   * - Success (no reason): always clears the watchdog. Crucially, if we had
   *   previously settled as a failure (e.g. the 25s watchdog fired on a slow
   *   network but EC then finished loading), we self-heal: clear the error and
   *   notify subscribers with `undefined` so the recovery UI dismisses itself
   *   instead of stranding the user on an error screen over a live call.
   */
  private settleLoad(reason?: CallLoadErrorReason): void {
    if (reason) {
      if (this.loadSettled) return;
      this.loadSettled = true;
      this.clearLoadWatchdog();
      this.loadError = reason;
      this.notifyLoadListeners(reason);
      return;
    }

    this.clearLoadWatchdog();
    const wasFailed = this.loadError !== undefined;
    this.loadSettled = true;
    this.loadError = undefined;
    if (wasFailed) {
      this.notifyLoadListeners(undefined);
    }
  }

  /**
   * Whether the call failed to load within the watchdog window or errored.
   */
  public get loadFailed(): CallLoadErrorReason | undefined {
    return this.loadError;
  }

  /**
   * Subscribe to load-failure events (watchdog timeout or iframe error). If the
   * load has already failed by the time of subscription, the callback fires
   * immediately so late subscribers still see the error.
   * @returns an unsubscribe function.
   */
  public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
    this.loadErrorListeners.add(callback);
    if (this.loadError) callback(this.loadError);
    return () => {
      this.loadErrorListeners.delete(callback);
    };
  }

  private onCallJoined(): void {
    this.settleLoad();
    this.applyStyles();
    this.control.startObserving();

    // C-H1: EC fires JoinCall again on an EC reconnect (this action has no
    // once-guard). forceState() would reset live mic/video/deafen back to the
    // join-time snapshot, so only run it on the FIRST join. On a rejoin we just
    // re-apply styles/observers (above) and re-push the sticky fork state
    // (deafen/quality), leaving the user's live media state untouched.
    if (this.joined) {
      this.control.resendForkState();
      return;
    }
    this.joined = true;
    // EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
    this.control.forceState(this.initialState);
  }

  private buildStyleContent(): string {
    return [
      'html, body { background: none !important; }',
      `:root { color-scheme: ${this.themeKind}; }`,
      '[style*="height: 0"][style*="z-index: 1"][style*="align-self: center"] { display: none !important; }',
      // EC 0.19.4: avatar uses line-height centering which breaks in some tile sizes;
      // override with flexbox for reliable centering of the initial letter.
      '._avatarContainer_1mrho_40 ._avatar_va14e_8 { display: flex !important; align-items: center !important; justify-content: center !important; line-height: 1 !important; }',
    ].join('\n');
  }

  private applyStyles(): void {
    const doc = this.document;
    if (!doc) return;

    doc.body.style.setProperty('background', 'none', 'important');

    if (!doc.getElementById('lotus-ec-styles')) {
      const style = doc.createElement('style');
      style.id = 'lotus-ec-styles';
      style.textContent = this.buildStyleContent();
      (doc.head ?? doc.body).appendChild(style);
    } else {
      const styleEl = doc.getElementById('lotus-ec-styles');
      if (styleEl) styleEl.textContent = this.buildStyleContent();
    }

    // Hide EC built-in controls (we provide our own)
    const leaveBtn = doc.body.querySelector('[data-testid="incall_leave"]');
    if (leaveBtn) {
      this.styleRetryObserver?.disconnect();
      this.styleRetryObserver = undefined;
      const controls = leaveBtn.parentElement?.parentElement;
      if (controls) {
        controls.style.setProperty('position', 'absolute');
        controls.style.setProperty('visibility', 'hidden');
      }
    } else if (!this.styleRetryObserver) {
      // Controls not in DOM yet — observe and retry when they appear
      this.styleRetryObserver = new MutationObserver(() => this.applyStyles());
      this.styleRetryObserver.observe(doc.body, { childList: true, subtree: true });
    }
  }

  private onEvent(ev: MatrixEvent): void {
    this.mx.decryptEventIfNeeded(ev);
    this.feedEvent(ev);
  }

  private onEventDecrypted(ev: MatrixEvent): void {
    this.feedEvent(ev);
  }

  private onStateUpdate(ev: MatrixEvent): void {
    if (this.call === null) return;
    const raw = ev.getEffectiveEvent();
    this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
      console.error(
        'Error sending state update to widget:',
        e instanceof Error ? e.message : 'unknown error',
      );
    });
  }

  private async onToDeviceEvent(ev: MatrixEvent): Promise<void> {
    await this.mx.decryptEventIfNeeded(ev);
    if (ev.isDecryptionFailure()) return;
    await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
  }

  /**
   * Determines whether the event has a relation to an unknown parent.
   */
  private relatesToUnknown(ev: MatrixEvent): boolean {
    // Replies to unknown events don't count
    if (!ev.relationEventId || ev.replyEventId) return false;
    const room = this.mx.getRoom(ev.getRoomId());
    return room === null || !room.findEventById(ev.relationEventId);
  }

  /**
   * Advances the "read up to" marker for a room to a certain event. No-ops if
   * the event is before the marker.
   * @returns Whether the "read up to" marker was advanced.
   */
  private advanceReadUpToMarker(ev: MatrixEvent): boolean {
    const evId = ev.getId();
    if (evId === undefined) return false;
    const roomId = ev.getRoomId();
    if (roomId === undefined) return false;
    const room = this.mx.getRoom(roomId);
    if (room === null) return false;

    const upToEventId = this.readUpToMap[ev.getRoomId()!];
    if (!upToEventId) {
      // There's no marker yet; start it at this event
      this.readUpToMap[roomId] = evId;
      return true;
    }

    // Small optimization for exact match (skip the search)
    if (upToEventId === evId) return false;

    // Timelines are most recent last, so reverse the order and limit ourselves to 100 events
    // to avoid overusing the CPU.
    const timeline = room.getLiveTimeline();
    const events = [...timeline.getEvents()].reverse().slice(0, 100);
    function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
      return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
    }
    const possibleMarkerEv = events.find(isRelevantTimelineEvent);
    if (possibleMarkerEv?.getId() === upToEventId) {
      // The event must be somewhere before the "read up to" marker
      return false;
    }
    if (possibleMarkerEv?.getId() === ev.getId()) {
      // The event is after the marker; advance it
      this.readUpToMap[roomId] = evId;
      return true;
    }

    // We can't say for sure whether the widget has seen the event; let's
    // just assume that it has
    return false;
  }

  /**
   * Determines whether the event comes from a room that we've been invited to
   * (in which case we likely don't have the full timeline).
   */
  private isFromInvite(ev: MatrixEvent): boolean {
    const room = this.mx.getRoom(ev.getRoomId());
    return room?.getMyMembership() === KnownMembership.Invite;
  }

  private feedEvent(ev: MatrixEvent): void {
    if (this.call === null) return;
    if (
      // If we had decided earlier to feed this event to the widget, but
      // it just wasn't ready, give it another try
      this.eventsToFeed.delete(ev) ||
      // Skip marker timeline check for events with relations to unknown parent because these
      // events are not added to the timeline here and will be ignored otherwise:
      // https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
      this.relatesToUnknown(ev) ||
      // Skip marker timeline check for rooms where membership is
      // 'invite', otherwise the membership event from the invitation room
      // will advance the marker and new state events will not be
      // forwarded to the widget.
      this.isFromInvite(ev) ||
      // Check whether this event would be before or after our "read up to" marker. If it's
      // before, or we can't decide, then we assume the widget will have already seen the event.
      // If the event is after, or we don't have a marker for the room,
      // then the marker will advance and we'll send it through.
      // This approach of "read up to" prevents widgets receiving decryption spam from startup or
      // receiving ancient events from backfill and such.
      this.advanceReadUpToMarker(ev)
    ) {
      // If the event is still being decrypted, remember that we want to
      // feed it to the widget (even if not strictly in the order given by
      // the timeline) and get back to it later
      if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
        this.eventsToFeed.add(ev);
      } else {
        const raw = ev.getEffectiveEvent();
        this.call.feedEvent(raw as IRoomEvent).catch((e) => {
          console.error(
            'Error sending event to widget:',
            e instanceof Error ? e.message : 'unknown error',
          );
        });
      }
    }
  }

  /** [lotus #2] Latest io.lotus.call_state participants, or null if the fork
   * hasn't sent any yet (callers then fall back to DOM scraping). */
  public getLotusParticipants(): LotusCallParticipant[] | null {
    return this.lotusParticipants;
  }

  /** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
  public onLotusCallState(cb: () => void): () => void {
    this.lotusCallStateListeners.add(cb);
    return () => {
      this.lotusCallStateListeners.delete(cb);
    };
  }

  public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
    const wrapped = (ev: CustomEvent<T>) => {
      ev.preventDefault();
      // Reply with success so the widget transport doesn't time out waiting for a response
      this.call.transport.reply(ev.detail as any, {});
      callback(ev);
    };
    return this.listenEvent(`action:${type}`, wrapped);
  }

  public listenEvent<T>(type: string, callback: (event: T) => void) {
    this.call.on(type, callback);
    return () => {
      this.call.off(type, callback);
    };
  }
}
