import {
  MatrixClient,
  MatrixEvent,
  MatrixEventEvent,
  EventType,
  Room,
  RoomEvent,
  RoomStateEvent,
  type RoomEventHandlerMap,
} from 'matrix-js-sdk';
import {
  ClientWidgetApi,
  type IRoomEvent,
  MatrixWidgetType,
  Widget,
  WidgetApiToWidgetAction,
  type WidgetDriver,
} from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import { BOT_CAP_ADD_TO_CHAT, isWidgetExperience, type BotPreset } from './catalog';
import {
  BotWidgetDriver,
  sanitizeBotWidgetMessageEvent,
  sanitizeBotWidgetRedactionEvent,
} from './BotWidgetDriver';

export type BotWidgetEmbedOptions = {
  mx: MatrixClient;
  room: Room;
  preset: BotPreset;
  container: HTMLElement;
  theme: Theme;
  language: string;
  onError: (error: Error) => void;
  onReady?: () => void;
  // Optional generic «navigate cinny to a matrix.to room/alias» callback.
  // Plumbed from `BotWidgetMount` where react-router's `useNavigate` is
  // available. The embed validates the URL via `parseMatrixToRoom` BEFORE
  // calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}`
  // and is free to assume the inputs are well-formed Matrix references. Not
  // bot-aware: any widget that delivers a matrix.to URL via the side-channel
  // (`open-matrix-to` action) reaches the same handler.
  onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
  // Elevated `add-to-chat` verb (gated on the `vojo.add_to_chat` capability
  // opt-in in config.json). The widget supplies NO room and NO mxid — the host
  // substitutes the invitee from `preset.mxid` and the target room comes from
  // the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
  // `onOpenMatrixToRoom`) where `mx` + navigation are available.
  onAddToChat?: () => void;
};

const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;

const getBotWidgetUrl = (
  preset: BotPreset,
  room: Room,
  mx: MatrixClient,
  theme: Theme,
  language: string,
  widgetId: string
): string => {
  if (!isWidgetExperience(preset.experience))
    throw new Error('Bot widget experience is not configured');

  const url = new URL(preset.experience.url, window.location.origin);
  url.searchParams.set('widgetId', widgetId);
  url.searchParams.set('parentUrl', window.location.origin);
  url.searchParams.set('roomId', room.roomId);
  url.searchParams.set('userId', mx.getSafeUserId());
  url.searchParams.set('botId', preset.id);
  url.searchParams.set('botMxid', preset.mxid);
  url.searchParams.set('commandPrefix', preset.experience.commandPrefix);
  // UI-only render hint: tells the widget whether to draw the «Add to chat»
  // button. NOT an authorization input — the real gate is host-side in
  // `onWidgetMessage` (the host re-reads `preset.experience.capabilities` from
  // the trusted config). Empty CSV when no caps; the widget reads ''→[] (F19).
  url.searchParams.set('capabilities', preset.experience.capabilities.join(','));
  // Bridge provisioning API base URL (validated in catalog.ts). The widget
  // uses it as its fetch target, authenticating with MSC1960 OpenID creds it
  // requests through the widget API — see BotWidgetDriver.askOpenID.
  if (preset.experience.provisioningUrl) {
    url.searchParams.set('provisioningUrl', preset.experience.provisioningUrl);
  }
  url.searchParams.set('theme', theme.kind);
  url.searchParams.set('clientLanguage', language);
  url.searchParams.set('baseUrl', mx.baseUrl);
  const deviceId = mx.getDeviceId();
  if (deviceId) url.searchParams.set('deviceId', deviceId);

  return url.toString();
};

const createBotWidget = (
  preset: BotPreset,
  room: Room,
  mx: MatrixClient,
  theme: Theme,
  language: string
): Widget => {
  const widgetId = getBotWidgetId(preset);
  return new Widget({
    id: widgetId,
    creatorUserId: mx.getSafeUserId(),
    type: MatrixWidgetType.Custom,
    name: preset.name,
    url: getBotWidgetUrl(preset, room, mx, theme, language, widgetId),
    waitForIframeLoad: true,
    data: {
      title: `${preset.name} Bot`,
    },
  });
};

// Build the iframe with all attributes EXCEPT `src`. The caller must assign
// `iframe.src` only AFTER `new ClientWidgetApi(widget, iframe, driver)` has
// attached its internal `load` listener — same-origin static widget HTML can
// finish loading before the listener is wired, and `beginCapabilities()` then
// never fires. This is the canonical Element-Web ordering.
const createBotIframe = (preset: BotPreset): HTMLIFrameElement => {
  const iframe = document.createElement('iframe');

  iframe.title = `${preset.name} Bot`;
  // Sandbox baseline: scripts + forms + same-origin (per docs/plans/
  // bots_tab.md M8). Plus `allow-popups` + `allow-popups-to-escape-
  // sandbox` so widget-side `<a target="_blank">` actually opens —
  // every shipped widget surfaces external links (mautrix bridge
  // GitHub repos in About modals, Meta ToS in the WhatsApp warning
  // modal) and without popups those clicks are silently dropped.
  // `allow-popups-to-escape-sandbox` lets the opened tab run without
  // inheriting our sandbox, which is what users expect when leaving
  // the iframe for an external site.
  //
  // `allow-downloads` stays off — no current widget needs to deliver
  // downloadable content. Add it as a per-preset opt-in if a future
  // widget genuinely needs it.
  //
  // Threat-model honesty: the sandbox here is STRUCTURAL, not
  // adversarial.
  // (a) The widget is served cross-origin (widgets.vojo.chat in prod,
  //     localhost:8081 in dev) so the documented `allow-scripts` +
  //     `allow-same-origin` same-origin-escape doesn't apply — same-
  //     origin refers to the iframe's OWN origin, not the host's. The
  //     widget can't read host (vojo.chat) localStorage / cookies
  //     because it's a different origin entirely.
  // (b) The actual security boundary against a compromised widget
  //     bundle is BotWidgetDriver — capability allowlist, sanitizer
  //     (only `m.text` / `m.notice` / `m.image` fields the bridge
  //     needs, no mxc / file / info), strict 1:1 room invariant in
  //     `isSafeBotWidgetRoom`. A hostile bundle would still see only
  //     the events the driver hands it.
  // (c) Popups require user interaction (a click) to open under modern
  //     browser pop-up blockers, so a bundle can't spam-open windows
  //     in the background.
  // If we ever serve the widget same-origin (e.g. inlined as a static
  // bundle under /widgets/ on vojo.chat), drop `allow-same-origin` here
  // — the postMessage transport doesn't need it, and the same-origin
  // sandbox-escape becomes real once the iframe shares the host's
  // origin.
  iframe.setAttribute(
    'sandbox',
    'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
  );
  iframe.allow = 'clipboard-write';
  iframe.referrerPolicy = 'no-referrer';
  iframe.style.width = '100%';
  iframe.style.height = '100%';
  iframe.style.border = 'none';

  return iframe;
};

export class BotWidgetEmbed {
  public readonly api: ClientWidgetApi;

  public readonly iframe: HTMLIFrameElement;

  private disposed = false;

  // Gate live event/state feeding on the widget completing its capability
  // handshake. Without this, an initial-sync burst from matrix-js-sdk can
  // call api.feedEvent / api.feedStateUpdate while the postMessage transport
  // has no widget on the other end yet — pending sends can timeout/reject and
  // demote the widget into the chat fallback even though the widget is fine.
  private isReady = false;

  // Two halves of «widget is fully booted». `iframeLoaded` flips on the
  // iframe's `load` event (document complete + same-origin assets ready);
  // `handshakeReady` flips on the ClientWidgetApi `ready` event (host
  // capability handshake complete). The external `onReady` callback only
  // fires once BOTH have happened — defensive coupling so a same-origin
  // synchronous handshake (cached bundle) can't beat the document-complete
  // signal and trigger a premature loading-bar fade. In practice load
  // tends to fire first, but the order is not guaranteed by the spec.
  private iframeLoaded = false;

  private handshakeReady = false;

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

  // Expected origin of the widget iframe — captured once at construction
  // from `iframe.src`. Used by `onWidgetMessage` to validate inbound
  // postMessages: a source-window check alone is NOT sufficient because
  // a compromised widget bundle could `window.location.href = '<attacker>'`
  // and the browser keeps the same WindowProxy across same-frame
  // navigation, so `iframe.contentWindow` would still match. Pinning
  // `ev.origin` to the original widget origin closes that gap (see
  // PortSwigger: «Controlling the web message source»).
  private widgetOrigin = '';

  // Dedup events that have already been forwarded to the widget. Encrypted DMs
  // hit feedEvent twice in succession (once from RoomEvent.Timeline with the
  // ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure
  // chat rooms can also re-emit Timeline on store-rehydrate. Track by event_id
  // so the widget never receives the same message twice.
  private readonly fedEventIds = new Set<string>();

  private readonly onTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
    ev,
    timelineRoom,
    toStartOfTimeline,
    _removed,
    data
  ) => {
    if (timelineRoom?.roomId !== this.room.roomId) return;
    // Back-paginated history must not replay into a fresh widget session —
    // the widget asks for history explicitly via readRoomTimeline.
    if (toStartOfTimeline) return;
    if (data?.liveEvent === false) return;
    this.mx.decryptEventIfNeeded(ev);
    this.feedEvent(ev);
  };

  private readonly onEventDecrypted = (ev: MatrixEvent) => {
    if (ev.getRoomId() === this.room.roomId) this.feedEvent(ev);
  };

  private readonly onStateUpdate = (ev: MatrixEvent) => {
    if (ev.getRoomId() !== this.room.roomId) return;
    this.feedStateUpdate(ev);
  };

  // Side-channel postMessage handler for the widget's Vojo-extension
  // actions. Distinct from matrix-widget-api's `fromWidget` channel
  // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
  // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
  // of our extension and avoids the «unknown action» reply path.
  //
  // Three actions today:
  //
  //   * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
  //     capability opt-in in config.json. Carries NO url and NO room/mxid:
  //     the host substitutes the invitee (`preset.mxid`) and the target room
  //     comes from the host's own picker (BotWidgetMount). A compromised
  //     bundle on a preset that didn't declare the cap is silently ignored —
  //     it cannot grant itself the capability (config.json is the trusted
  //     root). See the gate in the handler below.
  //
  //   * `open-external-url` — forwards an https:// URL to the host's
  //     `openExternalUrl` (utils/capacitor.ts), which routes through
  //     Capacitor's Browser plugin on native and `window.open` on web.
  //     Exists because cross-origin iframes don't bubble click events
  //     to the host document, so the global `setupExternalLinkHandler`
  //     never sees widget-side `<a target="_blank">` clicks — on
  //     Capacitor's Android WebView those would silently disappear.
  //
  //   * `open-matrix-to` — generic «navigate cinny to a matrix.to room
  //     or alias». Validates the URL through the same `parseMatrixToRoom`
  //     cinny uses for in-app mention rendering, then hands the parsed
  //     `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by
  //     BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
  //     widget never sees a route — it only knows matrix.to URLs.
  //
  // Security gates (defence in depth, apply to BOTH actions):
  //   1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
  //      check, a compromised widget bundle could `window.location.href
  //      = 'https://attacker.example/'` — the browser keeps the same
  //      WindowProxy across same-frame navigation, so `iframe.contentWindow`
  //      stays equal even after the iframe is hijacked. With it, only
  //      messages from the original widget origin are honoured. See
  //      PortSwigger «Controlling the web message source»:
  //      https://portswigger.net/web-security/dom-based/controlling-the-web-message-source
  //   2. Source must also be our iframe's contentWindow (an unrelated
  //      iframe of the SAME origin — e.g. an ad embed loaded into a
  //      sibling frame on the same origin in a future deployment —
  //      could otherwise pass the origin check).
  //
  // Per-action URL validation (NOT shared — each url-branch extracts and
  // checks `msg.data.url` itself; `add-to-chat` carries no url at all, which is
  // why the string-typeof gate must NOT be hoisted above the dispatch — see F1):
  //   * `open-external-url` — requires `https:` protocol, rejecting plain
  //     http, javascript:, data:, file:, etc. We tightened from http+https
  //     to https-only because no shipped widget content links over plain
  //     http; rejecting http closes a cleartext-redirect vector via
  //     Capacitor `Browser.open` on Android.
  //   * `open-matrix-to` — requires the URL to parse as a matrix.to room
  //     or alias via `parseMatrixToRoom`. Anything else (matrix.to user
  //     links, event links, arbitrary https URLs, javascript:/data:/file:
  //     pseudo-schemes) returns undefined and silently no-ops.
  private readonly onWidgetMessage = (ev: MessageEvent) => {
    if (ev.origin !== this.widgetOrigin) return;
    if (ev.source !== this.iframe.contentWindow) return;
    const msg = ev.data as
      | { api?: unknown; action?: unknown; data?: { url?: unknown } }
      | undefined;
    if (!msg || typeof msg !== 'object') return;
    if (msg.api !== 'io.vojo.bot-widget') return;

    // Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
    // is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
    // early-return and the verb would never fire (F1). The host gate reads the
    // capability from the trusted config (`preset.experience.capabilities`),
    // never from the widget message; the invitee is `preset.mxid`, host-set.
    if (msg.action === 'add-to-chat') {
      const { experience } = this.options.preset;
      if (!isWidgetExperience(experience) || !experience.capabilities.includes(BOT_CAP_ADD_TO_CHAT))
        return;
      this.options.onAddToChat?.();
      return;
    }

    if (msg.action === 'open-external-url') {
      const url = msg.data?.url;
      if (typeof url !== 'string') return;
      try {
        const parsed = new URL(url);
        if (parsed.protocol !== 'https:') return;
      } catch {
        return;
      }
      openExternalUrl(url).catch(() => {
        /* fire-and-forget: log handled inside openExternalUrl */
      });
      return;
    }

    if (msg.action === 'open-matrix-to') {
      // Generic «navigate cinny to a matrix.to room/alias». Not bot-aware —
      // the widget hands over a matrix.to URL it obtained however (parsed
      // from a bridge sentinel, scraped from chat, whatever), and we
      // validate via the same `parseMatrixToRoom` cinny uses for in-app
      // mention rendering (plugins/react-custom-html-parser.tsx). Only the
      // matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user
      // links, event links, non-matrix.to URLs, javascript:/data:/etc. all
      // return undefined and silently no-op here. The host-side router
      // hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
      // paths that don't provide a callback (e.g. future test harness) get
      // a silent drop, not a crash.
      const url = msg.data?.url;
      if (typeof url !== 'string') return;
      const parsed = parseMatrixToRoom(url);
      if (!parsed) return;
      this.options.onOpenMatrixToRoom?.(parsed);
    }
  };

  public constructor(private readonly options: BotWidgetEmbedOptions) {
    const { mx, room, preset, container, theme, language } = options;
    const widget = createBotWidget(preset, room, mx, theme, language);
    const widgetUrl = widget.getCompleteUrl({
      currentUserId: mx.getSafeUserId(),
      clientTheme: theme.kind,
      clientLanguage: language,
      widgetRoomId: room.roomId,
    });
    // Pin the expected origin BEFORE the iframe loads — `onWidgetMessage`
    // will reject anything whose `ev.origin` doesn't match. `new URL`
    // resolves a relative `widgetUrl` (e.g. dev `/widgets/...`) against
    // the host's origin, so this works whether the widget is served
    // cross-origin (prod widgets.vojo.chat) or same-origin (dev local
    // bundle / future inlined deployment).
    this.widgetOrigin = new URL(widgetUrl, window.location.origin).origin;

    // Strict ordering — DO NOT reorder:
    //   1. Build iframe with NO src.
    //   2. Append iframe to DOM (creates contentWindow).
    //   3. Construct ClientWidgetApi (attaches the internal `load` listener
    //      that triggers beginCapabilities()).
    //   4. Only now assign iframe.src.
    // If we set src in step 1 or 2, a same-origin static page can finish
    // loading before step 3 wires the listener — beginCapabilities() never
    // fires, no Capabilities request is sent to-widget, no `ready` event,
    // and the widget hangs forever on a blank handshake.
    const iframe = createBotIframe(preset);
    container.append(iframe);

    // External onReady fires only when BOTH the iframe document is fully
    // loaded AND the ClientWidgetApi handshake is complete — see
    // `iframeLoaded` / `handshakeReady` field comments. The host gate this
    // serves is the loading-bar overlay in BotWidgetMount.
    const fireExternalReady = () => {
      if (this.iframeLoaded && this.handshakeReady) {
        this.options.onReady?.();
      }
    };

    // Attach the iframe load listener BEFORE the src assignment for the
    // same reason ClientWidgetApi attaches its internal listener early —
    // a cached same-origin document can finish loading before src returns
    // control here, and a late listener would miss the load event.
    const onIframeLoad = () => {
      this.iframeLoaded = true;
      fireExternalReady();
    };
    iframe.addEventListener('load', onIframeLoad);

    const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset);
    const api = new ClientWidgetApi(widget, iframe, driver);

    this.api = api;
    this.iframe = iframe;

    api.setViewedRoomId(room.roomId);

    iframe.src = widgetUrl;

    const onReady = () => {
      this.isReady = true;
      this.handshakeReady = true;
      // eslint-disable-next-line no-console
      console.info('[BotWidget] handshake complete');
      fireExternalReady();
    };
    // Element-Web treats `error:preparing` as informational — it never tears
    // down the widget. Vojo follows the same rule: log the upstream cause and
    // leave the iframe mounted. The user can fall back to the chat manually
    // via the «Show chat» toolbar; transient handshake hiccups (slow widget
    // boot, dev hot-reload, mid-launch theme race) no longer demote a working
    // session into chat. Real teardown still happens for: iframe network
    // failure, room becomes unsafe, and host unmount.
    const onPreparingError = (error: unknown) => {
      // eslint-disable-next-line no-console
      console.warn('[BotWidget] error:preparing (left mounted)', error);
    };
    const onIframeError = (event: Event) => {
      // eslint-disable-next-line no-console
      console.error('[BotWidget] iframe error', event);
      this.fail(new Error('Bot widget iframe failed to load'));
    };

    api.on('ready', onReady);
    api.on('error:preparing', onPreparingError);
    iframe.addEventListener('error', onIframeError);
    room.on(RoomEvent.Timeline, this.onTimelineEvent);
    mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
    mx.on(RoomStateEvent.Events, this.onStateUpdate);
    window.addEventListener('message', this.onWidgetMessage);

    this.disposables.push(
      () => iframe.removeEventListener('load', onIframeLoad),
      () => api.off('ready', onReady),
      () => api.off('error:preparing', onPreparingError),
      () => iframe.removeEventListener('error', onIframeError),
      () => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent),
      () => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted),
      () => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate),
      () => window.removeEventListener('message', this.onWidgetMessage)
    );
  }

  private get mx(): MatrixClient {
    return this.options.mx;
  }

  private get room(): Room {
    return this.options.room;
  }

  private fail(error: Error): void {
    if (this.disposed) return;
    // eslint-disable-next-line no-console
    console.error('[BotWidget] fail:', error.message, error);
    this.options.onError(error);
    this.destroy();
  }

  // No per-event isSafeBotWidgetRoom re-check on the feed paths. Initial sync
  // can transiently report "unsafe" before all members hydrate, and the per-
  // event fail() that used to live here would tear the widget down on flap.
  // Membership-driven teardown is the responsibility of `useBotRoom` upstream:
  // when the room transitions out of `ready`, BotExperienceHost dispatches to
  // a different state branch and unmounts the widget through React. The
  // driver still applies the safety gate on every capability/sendEvent/read*
  // call (BotWidgetDriver.getSafeRoom) so a hostile widget can't escalate.
  private feedEvent(ev: MatrixEvent): void {
    if (this.disposed || !this.isReady) return;
    if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;

    const raw = ev.getEffectiveEvent() as Partial<IRoomEvent>;
    if (raw.room_id !== this.room.roomId) return;
    if (typeof raw.event_id !== 'string' || raw.event_id.length === 0) return;
    if (this.fedEventIds.has(raw.event_id)) return;

    // Dispatch by event type — m.room.message and m.room.redaction take
    // different sanitizer paths (the QR-login flow needs both: edits arrive
    // as m.image with `m.relates_to`, the post-scan cleanup arrives as a
    // separate m.room.redaction targeting the QR event id).
    const sanitized =
      raw.type === EventType.RoomRedaction
        ? sanitizeBotWidgetRedactionEvent(raw as IRoomEvent)
        : sanitizeBotWidgetMessageEvent(raw as IRoomEvent);
    if (!sanitized) return;

    this.fedEventIds.add(raw.event_id);
    this.api.feedEvent(sanitized).catch((error) => {
      // eslint-disable-next-line no-console
      console.warn('[BotWidget] feedEvent rejected', error);
    });
  }

  private feedStateUpdate(ev: MatrixEvent): void {
    if (this.disposed || !this.isReady) return;
    if (ev.getType() !== EventType.RoomMember) return;
    const stateKey = ev.getStateKey();
    if (stateKey !== this.mx.getSafeUserId() && stateKey !== this.options.preset.mxid) return;

    this.api.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch((error) => {
      // eslint-disable-next-line no-console
      console.warn('[BotWidget] feedStateUpdate rejected', error);
    });
  }

  // Push a runtime theme change. No-op until the widget completes the
  // capability handshake — the initial theme is already in the URL params, so
  // sending it pre-handshake would just queue a postMessage request that times
  // out after 10 s in the transport (matrix-widget-api default).
  public setTheme(theme: Theme): Promise<void> {
    if (this.disposed || !this.isReady) return Promise.resolve();
    return this.api.transport
      .send(WidgetApiToWidgetAction.ThemeChange, { name: theme.kind })
      .then(() => undefined);
  }

  public destroy(): void {
    if (this.disposed) return;

    this.disposed = true;
    this.disposables.forEach((dispose) => dispose());
    this.api.stop();
    this.iframe.remove();
    this.fedEventIds.clear();
  }
}
