// Minimal matrix-widget-api transport implemented inline. Mirrors the
// Telegram widget's transport (apps/widget-telegram/src/widget-api.ts);
// the postMessage protocol is bot-agnostic and the host-side
// BotWidgetDriver / BotWidgetEmbed treat every bot identically.
//
// Protocol shapes match
//   node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// (in the host repo). Default request timeout on the host transport is
// 10 s — keep that in mind for bridge-bot replies that take time.

import type { WidgetBootstrap } from './bootstrap';

export type RoomEvent = {
  type: string;
  event_id: string;
  room_id: string;
  sender: string;
  origin_server_ts: number;
  content: { msgtype?: string; body?: string; [k: string]: unknown };
  unsigned: Record<string, unknown>;
  // `m.room.redaction` events carry `redacts` at the top level (room v < 11)
  // and/or inside `content.redacts` (v11+). The host driver mirrors at both
  // for forward-compat; the widget-side parser reads either.
  redacts?: string;
};

type ToWidgetMessage = {
  api: 'toWidget';
  widgetId: string;
  requestId: string;
  action: string;
  data: Record<string, unknown>;
  // Present when this message IS a reply to a prior toWidget request.
  // Per matrix-widget-api PostmessageTransport: replies preserve the original
  // `api` field and add `response`. Both directions follow the same shape.
  response?: Record<string, unknown>;
};

type FromWidgetMessage = {
  api: 'fromWidget';
  widgetId: string;
  requestId: string;
  action: string;
  data: Record<string, unknown>;
  response?: Record<string, unknown>;
};

export type Capability = string;

export type WidgetApiEvents = {
  ready: () => void;
  liveEvent: (ev: RoomEvent) => void;
  themeChange: (name: 'light' | 'dark') => void;
};

const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;

export class WidgetApi {
  private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};

  private readonly pending = new Map<
    string,
    { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
  >();

  private requestSeq = 0;

  private isReady = false;

  public constructor(
    private readonly bootstrap: WidgetBootstrap,
    private readonly capabilities: Capability[]
  ) {
    window.addEventListener('message', this.onMessage);
  }

  public dispose(): void {
    window.removeEventListener('message', this.onMessage);
    this.pending.forEach(({ reject }) => reject(new Error('disposed')));
    this.pending.clear();
  }

  public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
    const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
    list.push(listener);
    // `ready` is a one-shot lifecycle signal. If the handshake completed
    // before this listener attached (cached-bundle race: host fires the
    // capabilities request on iframe `load`, the WidgetApi catches and
    // resolves it during script init, then React's useEffect runs *after*
    // that and attaches the `ready` listener), replay synchronously so
    // App.tsx still flips `handshakeOk` and fires the initial probe.
    if (event === 'ready' && this.isReady) {
      (listener as () => void)();
    }
  }

  public sendText(body: string): Promise<{ event_id: string }> {
    return this.fromWidget('send_event', {
      type: 'm.room.message',
      content: { msgtype: 'm.text', body },
    }) as Promise<{ event_id: string }>;
  }

  // Open an external URL via the host. The host receives this on a
  // SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
  // matrix-widget-api's `fromWidget` so it doesn't route through
  // ClientWidgetApi's request/response machinery.
  //
  // Why this exists: cross-origin iframes inside Capacitor's Android
  // WebView silently drop `<a target="_blank">` clicks — the WebView
  // doesn't have a multi-window concept, and the host's global
  // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
  // inside the host document, not inside the iframe (cross-origin
  // events don't bubble across the frame boundary). The widget posts
  // this message instead; the host calls `openExternalUrl(url)` which
  // routes to `Browser.open` on native and `window.open` on web.
  public openExternalUrl(url: string): void {
    window.parent.postMessage(
      {
        api: 'io.vojo.bot-widget',
        action: 'open-external-url',
        data: { url },
      },
      this.bootstrap.parentOrigin
    );
  }

  // Ask the host to navigate to a matrix.to URL inside the cinny app
  // (room or space). Same side-channel pattern as `openExternalUrl` —
  // distinct from matrix-widget-api's `fromWidget` so the SDK stays
  // ignorant of this Vojo extension. The host validates the URL via
  // `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
  // BEFORE routing into the react-router; sending anything that isn't a
  // matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
  // host side. The widget is responsible for only invoking this when it
  // genuinely has a matrix.to room URL (e.g. parsed from a bridge
  // sentinel).
  public openMatrixToUrl(url: string): void {
    window.parent.postMessage(
      {
        api: 'io.vojo.bot-widget',
        action: 'open-matrix-to',
        data: { url },
      },
      this.bootstrap.parentOrigin
    );
  }

  // Always prefix outbound commands with `<commandPrefix> ` (trailing space).
  // Legacy mautrix-discord routes management-room commands through the
  // bridge.commands.Processor in mautrix/go bridge/commands; outside the
  // management room the prefix is required, inside it's optional but stays
  // unambiguous when other text is present. We always send the prefix —
  // works in both cases, never wrong.
  public sendCommand(rawBody: string): Promise<{ event_id: string }> {
    const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
    return this.sendText(body);
  }

  // Timeline-resume probe. Action name is MSC2876 (`read_events`); the
  // capability is MSC2762 timeline (already requested at construction). We
  // pass `room_ids: [bootstrap.roomId]` explicitly so the host's
  // ClientWidgetApi takes the modern code path that calls our driver's
  // `readRoomTimeline` (single-room cap-checked) rather than the deprecated
  // `readRoomEvents` fallback. Driver returns events newest-first; reversing
  // to chronological order is the caller's job.
  //
  // `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
  // post-scan cleanup events. `msgtype` is honoured only for m.room.message.
  public async readTimeline(opts: {
    limit: number;
    type?: 'm.room.message' | 'm.room.redaction';
    msgtype?: 'm.text' | 'm.notice' | 'm.image';
  }): Promise<RoomEvent[]> {
    const data: Record<string, unknown> = {
      type: opts.type ?? 'm.room.message',
      limit: opts.limit,
      room_ids: [this.bootstrap.roomId],
    };
    if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
    const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
    return (res.events as RoomEvent[] | undefined) ?? [];
  }

  private emit<K extends keyof WidgetApiEvents>(
    event: K,
    ...args: Parameters<WidgetApiEvents[K]>
  ): void {
    const list = this.listeners[event] as
      | Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
      | undefined;
    list?.forEach((fn) => fn(...args));
  }

  private nextRequestId(): string {
    this.requestSeq += 1;
    return `widget-discord-${Date.now()}-${this.requestSeq}`;
  }

  private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
    window.parent.postMessage(msg, this.bootstrap.parentOrigin);
  }

  private onMessage = (ev: MessageEvent): void => {
    if (ev.origin !== this.bootstrap.parentOrigin) return;
    // Source-window guard — see telegram widget for full rationale.
    if (ev.source !== window.parent) return;
    const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
    if (!msg || typeof msg !== 'object') return;
    if (msg.widgetId !== this.bootstrap.widgetId) return;

    if (msg.api === 'toWidget') {
      this.handleToWidget(msg);
      return;
    }

    if (msg.api === 'fromWidget' && msg.response) {
      const pending = this.pending.get(msg.requestId);
      if (!pending) return;
      this.pending.delete(msg.requestId);
      const err = (msg.response as { error?: { message?: string } }).error;
      if (err) pending.reject(new Error(err.message ?? 'request failed'));
      else pending.resolve(msg.response);
    }
  };

  private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
    this.postToHost({
      api: msg.api,
      widgetId: msg.widgetId,
      requestId: msg.requestId,
      action: msg.action,
      data: msg.data,
      response,
    });
  }

  private handleToWidget(msg: ToWidgetMessage): void {
    if (!msg.requestId || !msg.action) return;
    switch (msg.action) {
      case 'capabilities': {
        this.replyTo(msg, { capabilities: this.capabilities });
        return;
      }
      case 'notify_capabilities': {
        this.replyTo(msg, {});
        if (!this.isReady) {
          this.isReady = true;
          this.emit('ready');
        }
        return;
      }
      case 'supported_api_versions': {
        this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
        return;
      }
      case 'theme_change': {
        const name = (msg.data?.name as string | undefined) ?? '';
        const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
        this.emit('themeChange', themed);
        this.replyTo(msg, {});
        return;
      }
      case 'send_event': {
        // Live event push from host. Forward `m.room.message` (carries the
        // bot's notices / errors / `m.image` QR-login broadcasts) AND
        // `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver
        // `sanitizeBotWidgetRedactionEvent`).
        const data = msg.data as Partial<RoomEvent> | undefined;
        if (
          data &&
          data.event_id &&
          (data.type === 'm.room.message' || data.type === 'm.room.redaction')
        ) {
          this.emit('liveEvent', data as RoomEvent);
        }
        this.replyTo(msg, {});
        return;
      }
      case 'update_state': {
        // Initial room state push from host (m.room.member members) — ignored.
        this.replyTo(msg, {});
        return;
      }
      default: {
        // Be liberal — reply empty so the host's request promise resolves.
        this.replyTo(msg, {});
      }
    }
  }

  private fromWidget(
    action: string,
    data: Record<string, unknown>
  ): Promise<Record<string, unknown>> {
    return new Promise((resolve, reject) => {
      const requestId = this.nextRequestId();
      this.pending.set(requestId, { resolve, reject });
      this.postToHost({
        api: 'fromWidget',
        widgetId: this.bootstrap.widgetId,
        requestId,
        action,
        data,
      });
      window.setTimeout(() => {
        if (this.pending.has(requestId)) {
          this.pending.delete(requestId);
          reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
        }
      }, FROM_WIDGET_REQUEST_TIMEOUT_MS);
    });
  }
}

// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
// The driver is bot-agnostic — the same allowlist is applied for telegram
// and discord. Discord-specific additions would have to land in
// BotWidgetDriver first.
//
// `m.image` carries the QR login URL in `content.body` (the host sanitizer
// strips `url` / `file` / `info`, so only the URL string survives); we
// render the QR client-side from that URL via `qrcode-generator`.
// `m.room.redaction` is how the bridge signals «QR consumed by a successful
// scan» — see mautrix-discord/commands.go::fnLoginQR which redacts the QR
// event after the remoteauth websocket completes.
export const buildCapabilities = (roomId: string): Capability[] => [
  `org.matrix.msc2762.timeline:${roomId}`,
  'org.matrix.msc2762.send.event:m.room.message#m.text',
  'org.matrix.msc2762.receive.event:m.room.message#m.text',
  'org.matrix.msc2762.receive.event:m.room.message#m.notice',
  'org.matrix.msc2762.receive.event:m.room.message#m.image',
  'org.matrix.msc2762.receive.event:m.room.redaction',
  'org.matrix.msc2762.receive.state_event:m.room.member',
];
