// Minimal matrix-widget-api transport implemented inline. We don't pull
// the full SDK because the surface we use is tiny: the capability
// handshake (we request NO capabilities — the bridge is driven over its
// provisioning HTTP API, not over room events), theme_change pushes,
// MSC1960 OpenID credentials, and two `io.vojo.bot-widget` side-channel
// verbs (open-external-url / open-matrix-to).
//
// Protocol shapes match
//   node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// in the host repo.

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

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.
  response?: Record<string, unknown>;
};

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

export type OpenIdCredentials = {
  accessToken: string;
  /** Seconds until the token expires (Synapse default 3600). */
  expiresIn: number;
  matrixServerName: string;
};

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

const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;

// MSC1960 has a two-phase path: the host may reply `state: "request"`
// (user confirmation pending) and deliver the credentials later via a
// separate `openid_credentials` toWidget action. Vojo's host auto-grants
// for allowlisted first-party widgets, so phase 2 normally never happens —
// the generous timeout only matters if a future host adds a consent UI.
const OPENID_PHASE2_TIMEOUT_MS = 120_000;

const parseOpenIdCredentials = (raw: Record<string, unknown>): OpenIdCredentials | null => {
  const accessToken = raw.access_token;
  const matrixServerName = raw.matrix_server_name;
  if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
  if (typeof matrixServerName !== 'string' || matrixServerName.length === 0) return null;
  const expiresIn = typeof raw.expires_in === 'number' ? raw.expires_in : 3600;
  return { accessToken, expiresIn, matrixServerName };
};

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 }
  >();

  // Phase-2 OpenID waiters keyed by the ORIGINAL get_openid requestId —
  // the host's `openid_credentials` action carries it as
  // `original_request_id`.
  private readonly pendingOpenId = new Map<
    string,
    { resolve: (v: OpenIdCredentials) => void; reject: (e: Error) => void; timer: number }
  >();

  private requestSeq = 0;

  private isReady = false;

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

  public dispose(): void {
    window.removeEventListener('message', this.onMessage);
    this.pending.forEach(({ reject }) => reject(new Error('disposed')));
    this.pending.clear();
    this.pendingOpenId.forEach(({ reject, timer }) => {
      window.clearTimeout(timer);
      reject(new Error('disposed'));
    });
    this.pendingOpenId.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 Preact's useEffect runs *after*
    // that and attaches the `ready` listener), replay synchronously.
    if (event === 'ready' && this.isReady) {
      (listener as () => void)();
    }
  }

  // MSC1960: ask the host for OpenID credentials proving our Matrix
  // identity. The provisioning client exchanges them with the bridge
  // (`Authorization: Bearer openid:<token>`); they are NOT a Matrix access
  // token and grant no homeserver power. Rejects when the host driver
  // blocks the request (BotWidgetDriver gates on the `vojo.openid`
  // capability opt-in in config.json).
  public async getOpenIdCredentials(): Promise<OpenIdCredentials> {
    const requestId = this.nextRequestId();
    const reply = await this.fromWidget('get_openid', {}, requestId);
    const state = reply.state;
    if (state === 'allowed') {
      const creds = parseOpenIdCredentials(reply);
      if (!creds) throw new Error('host returned malformed OpenID credentials');
      return creds;
    }
    if (state === 'request') {
      // Phase 2: credentials arrive via the `openid_credentials` action.
      return new Promise<OpenIdCredentials>((resolve, reject) => {
        const timer = window.setTimeout(() => {
          this.pendingOpenId.delete(requestId);
          reject(new Error('OpenID confirmation timed out'));
        }, OPENID_PHASE2_TIMEOUT_MS);
        this.pendingOpenId.set(requestId, { resolve, reject, timer });
      });
    }
    throw new Error('OpenID request blocked by host');
  }

  // MSC4039 media download. The host driver only honours mxc URIs and
  // substitutes a 96px crop thumbnail — this exists for avatars, not for a
  // media viewer. Media on the homeserver is authenticated; the host fetches
  // with its token and ships the bytes here as a Blob (structured clone).
  public async downloadFile(mxcUri: string): Promise<Blob> {
    const reply = await this.fromWidget('org.matrix.msc4039.download_file', {
      content_uri: mxcUri,
    });
    const file = (reply as { file?: unknown }).file;
    if (file instanceof Blob) return file;
    if (file === undefined || file === null) throw new Error('host returned no file');
    // Other XMLHttpRequestBodyInit shapes (ArrayBuffer, string) — normalize.
    return new Blob([file as BlobPart]);
  }

  // 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. Needed because
  // cross-origin iframes inside Capacitor's Android WebView silently drop
  // `<a target="_blank">` clicks.
  public openExternalUrl(url: string): void {
    this.postSideChannel('open-external-url', { url });
  }

  // Ask the host to navigate to a Matrix room. The host validates the URL
  // through `parseMatrixToRoom` and picks the destination surface (bridge
  // space in Каналы / /direct/) — see BotWidgetMount.handleOpenMatrixToRoom.
  public openMatrixRoom(roomId: string): void {
    this.postSideChannel('open-matrix-to', {
      url: `https://matrix.to/#/${encodeURIComponent(roomId)}`,
    });
  }

  private postSideChannel(action: string, data: Record<string, unknown>): void {
    window.parent.postMessage(
      { api: 'io.vojo.bot-widget', action, data },
      this.bootstrap.parentOrigin
    );
  }

  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-tg-${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: every legit widget API message comes from the
    // host window that embedded our iframe — i.e. window.parent. A foreign
    // tab/frame on the same origin (browser extension content script,
    // popup, sibling iframe) could otherwise post a forged message that
    // passes the origin check. The `widgetId` check below is a soft
    // filter; this is the hard one.
    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': {
        // No MSC2762 capabilities — no timeline reads, no message sends. The
        // only capability the HTTP-transport widget needs is MSC4039 media
        // download, used for contact/profile avatar thumbnails (media on the
        // homeserver is authenticated and the widget holds no Matrix token).
        this.replyTo(msg, { capabilities: ['org.matrix.msc4039.download_file'] });
        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.msc1960'] });
        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 'openid_credentials': {
        // Phase-2 MSC1960 delivery after a `state: "request"` reply.
        const originalId = msg.data?.original_request_id;
        if (typeof originalId === 'string') {
          const waiter = this.pendingOpenId.get(originalId);
          if (waiter) {
            this.pendingOpenId.delete(originalId);
            window.clearTimeout(waiter.timer);
            const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null;
            if (creds) waiter.resolve(creds);
            else waiter.reject(new Error('OpenID request blocked by host'));
          }
        }
        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>,
    requestId = this.nextRequestId()
  ): Promise<Record<string, unknown>> {
    return new Promise((resolve, reject) => {
      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);
    });
  }
}
