import {
  EventDirection,
  type Capability,
  type IOpenIDUpdate,
  type IRoomEvent,
  type ISendEventDetails,
  type SimpleObservable,
  MatrixCapabilities,
  WidgetDriver,
  WidgetEventCapability,
  OpenIDRequestState,
} from 'matrix-widget-api';
import {
  EventTimeline,
  EventType,
  type IContent,
  MatrixClient,
  MatrixError,
  MatrixEvent,
  MsgType,
  Room,
} from 'matrix-js-sdk';
import { Membership } from '../../../types/matrix/room';
import { isPortalRoomForOtherBridge } from './room';
import { BOT_CAP_OPENID, isWidgetExperience, type BotPreset } from './catalog';

const BOT_WIDGET_TIMELINE_LIMIT = 100;

const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null && !Array.isArray(value);

// `m.image` is allowed strictly so the widget can drive a Telegram QR-login
// flow: the bridge's `m.image` event carries the raw `tg://login?token=...`
// URL inside `content.body`, which is enough for client-side QR rendering
// without ever shipping the uploaded PNG bytes through the widget API.
// `sanitizeBotWidgetMessageEvent` strictly drops `url` / `file` / `info` /
// `thumbnail_url` etc. for `m.image`, so no media payload escapes the host.
const isAllowedMessageType = (msgtype: unknown): msgtype is 'm.text' | 'm.notice' | 'm.image' =>
  msgtype === 'm.text' || msgtype === 'm.notice' || msgtype === 'm.image';

export const isSafeBotWidgetRoom = (
  mx: MatrixClient,
  room: Room | null | undefined,
  preset: BotPreset
): room is Room => {
  if (!room) return false;
  if (isPortalRoomForOtherBridge(room, preset.mxid)) return false;

  const myUserId = mx.getSafeUserId();
  if (room.getMember(myUserId)?.membership !== Membership.Join) return false;
  if (room.getMember(preset.mxid)?.membership !== Membership.Join) return false;

  const activeMembers = room
    .getMembers()
    .filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite);
  const allowedMembers = new Set([myUserId, preset.mxid]);

  return (
    activeMembers.length === allowedMembers.size &&
    activeMembers.every((m) => allowedMembers.has(m.userId))
  );
};

// Pull the relate metadata from a content object. We only forward two narrow
// shapes the QR-login flow needs:
//   `m.relates_to` STRICTLY for `rel_type=m.replace` edit pointers;
//   `m.new_content` STRICTLY for `msgtype=m.image` replacement bodies.
// Anything else — reactions (`m.annotation`), threads (`m.thread`), references,
// custom relate types, or edits whose new_content claims a non-image
// msgtype — is dropped at the boundary. The widget never opted into reactions
// or threads, and forwarding those would be unexpected privilege creep.
type EditMetadata = {
  'm.relates_to'?: { rel_type: 'm.replace'; event_id: string };
  'm.new_content'?: { msgtype: 'm.image'; body: string };
};

const pickEditMetadata = (content: Record<string, unknown>): EditMetadata => {
  const out: EditMetadata = {};
  const relatesTo = content['m.relates_to'];
  if (
    isObject(relatesTo) &&
    relatesTo.rel_type === 'm.replace' &&
    typeof relatesTo.event_id === 'string'
  ) {
    out['m.relates_to'] = { rel_type: 'm.replace', event_id: relatesTo.event_id };
  }
  const newContent = content['m.new_content'];
  if (
    isObject(newContent) &&
    newContent.msgtype === 'm.image' &&
    typeof newContent.body === 'string'
  ) {
    out['m.new_content'] = { msgtype: 'm.image', body: newContent.body };
  }
  return out;
};

export const sanitizeBotWidgetMessageEvent = (rawEvent: IRoomEvent): IRoomEvent | undefined => {
  if (rawEvent.type !== EventType.RoomMessage) return undefined;
  if (rawEvent.state_key !== undefined) return undefined;
  if (!isObject(rawEvent.content)) return undefined;
  if (typeof rawEvent.event_id !== 'string' || rawEvent.event_id.length === 0) return undefined;
  if (typeof rawEvent.room_id !== 'string' || rawEvent.room_id.length === 0) return undefined;
  if (typeof rawEvent.sender !== 'string') return undefined;
  if (typeof rawEvent.origin_server_ts !== 'number') return undefined;

  const { msgtype, body } = rawEvent.content;
  if (!isAllowedMessageType(msgtype) || typeof body !== 'string') return undefined;

  // Rebuild the outgoing event from primitives instead of spreading rawEvent —
  // a future SDK upgrade could attach extra top-level keys (decryption hints,
  // local-echo flags, custom org.matrix.* fields) and we never want those to
  // ride out to a widget that only asked for {msgtype, body}.
  const baseContent: Record<string, unknown> = { msgtype, body };

  // For `m.image` the widget needs edit-relate metadata so the QR-rotation
  // event-replace stream is recognisable, but it must NEVER receive `url` /
  // `file` / `info` / `thumbnail_*` — those would either leak the bridge's
  // mxc:// authenticated media URL or open an authenticated-media fetch path
  // we have not security-reviewed. Allowlist body+relate-only at the
  // boundary; m.text / m.notice keep the strict {msgtype, body} shape.
  if (msgtype === 'm.image') {
    Object.assign(baseContent, pickEditMetadata(rawEvent.content));
  }

  return {
    type: EventType.RoomMessage,
    event_id: rawEvent.event_id,
    room_id: rawEvent.room_id,
    sender: rawEvent.sender,
    origin_server_ts: rawEvent.origin_server_ts,
    content: baseContent,
    unsigned: {},
  };
};

// QR-login flow: the bridge redacts the QR `m.image` event after a successful
// scan. The widget consumes the redaction as a signal that the QR is no
// longer valid and the flow has progressed (typically straight into 2FA or
// `Successfully logged in`). We forward only event identification + the
// `redacts` target — no `reason`, no other content fields.
export const sanitizeBotWidgetRedactionEvent = (rawEvent: IRoomEvent): IRoomEvent | undefined => {
  if (rawEvent.type !== EventType.RoomRedaction) return undefined;
  if (rawEvent.state_key !== undefined) return undefined;
  if (typeof rawEvent.event_id !== 'string' || rawEvent.event_id.length === 0) return undefined;
  if (typeof rawEvent.room_id !== 'string' || rawEvent.room_id.length === 0) return undefined;
  if (typeof rawEvent.sender !== 'string') return undefined;
  if (typeof rawEvent.origin_server_ts !== 'number') return undefined;

  // Pre-room-v11 servers carry `redacts` at the top level; v11+ moved it
  // into `content.redacts`. Tolerate both — the widget reads only one
  // resolved value and doesn't care which wire shape it arrived in.
  const topLevelRedacts = (rawEvent as IRoomEvent & { redacts?: unknown }).redacts;
  const contentRedacts = isObject(rawEvent.content)
    ? (rawEvent.content as { redacts?: unknown }).redacts
    : undefined;
  let redacts: string | undefined;
  if (typeof topLevelRedacts === 'string') redacts = topLevelRedacts;
  else if (typeof contentRedacts === 'string') redacts = contentRedacts;
  if (!redacts) return undefined;

  return {
    type: EventType.RoomRedaction,
    event_id: rawEvent.event_id,
    room_id: rawEvent.room_id,
    sender: rawEvent.sender,
    origin_server_ts: rawEvent.origin_server_ts,
    content: { redacts },
    // Mirror at the top level so widget-side parsers that read either spot
    // still pick it up regardless of room version.
    redacts,
    unsigned: {},
  } as IRoomEvent;
};

export const getBotWidgetCapabilities = (roomId: string): Set<Capability> =>
  new Set([
    `org.matrix.msc2762.timeline:${roomId}`,
    WidgetEventCapability.forRoomMessageEvent(EventDirection.Send, 'm.text').raw,
    WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.text').raw,
    WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.notice').raw,
    // Telegram QR login: the bridge ships QR as `m.image` with the raw
    // `tg://login?token=...` URL inside `content.body`. The driver
    // sanitizer strips `url` / `file` / `info` so the widget gets the
    // login-token string for client-side QR rendering and nothing else —
    // no mxc resolution, no PNG bytes through postMessage.
    WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.image').raw,
    // `m.room.redaction` lets the widget see the post-scan QR cleanup the
    // bridge fires after a successful login. Without this the widget would
    // keep re-rendering a stale QR until the 2FA prompt or success line
    // arrived (which is not guaranteed in the no-2FA branch).
    WidgetEventCapability.forRoomEvent(EventDirection.Receive, EventType.RoomRedaction).raw,
    WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
    // MSC4039 media download — the telegram widget renders contact/profile
    // avatars from bridge-ghost mxc URIs, and media on the homeserver is
    // authenticated (the widget itself holds no Matrix token). The driver's
    // downloadFile below serves thumbnails only.
    MatrixCapabilities.MSC4039DownloadFile,
  ]);

export class BotWidgetDriver extends WidgetDriver {
  private readonly allowedCapabilities: Set<Capability>;

  public constructor(
    private readonly mx: MatrixClient,
    private readonly roomId: string,
    private readonly preset: BotPreset
  ) {
    super();
    this.allowedCapabilities = getBotWidgetCapabilities(roomId);
  }

  private getSafeRoom(): Room | undefined {
    const room = this.mx.getRoom(this.roomId);
    return isSafeBotWidgetRoom(this.mx, room, this.preset) ? room : undefined;
  }

  private isAllowedTargetRoom(targetRoomId?: string | null): boolean {
    return !targetRoomId || targetRoomId === this.roomId;
  }

  public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
    if (!this.getSafeRoom()) return new Set();

    return new Set(Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)));
  }

  public async sendEvent(
    eventType: string,
    content: IContent,
    stateKey: string | null = null,
    targetRoomId: string | null = null
  ): Promise<ISendEventDetails> {
    const room = this.getSafeRoom();
    if (!room || !this.isAllowedTargetRoom(targetRoomId)) {
      throw new Error('Bot widget cannot send to this room');
    }
    if (stateKey !== null) throw new Error('Bot widget cannot send state events');
    if (eventType !== EventType.RoomMessage) throw new Error('Bot widget cannot send this event');
    if (!isObject(content) || content.msgtype !== 'm.text' || typeof content.body !== 'string') {
      throw new Error('Bot widget can only send m.text messages');
    }

    const result = await this.mx.sendEvent(room.roomId, EventType.RoomMessage, {
      msgtype: MsgType.Text,
      body: content.body,
    });

    return { roomId: room.roomId, eventId: result.event_id };
  }

  public async readRoomTimeline(
    targetRoomId: string,
    eventType: string,
    msgtype: string | undefined,
    stateKey: string | undefined,
    limit: number,
    since: string | undefined
  ): Promise<IRoomEvent[]> {
    const room = this.getSafeRoom();
    if (!room || !this.isAllowedTargetRoom(targetRoomId)) return [];
    if (eventType === EventType.RoomMember) {
      return this.readRoomState(targetRoomId, eventType, stateKey);
    }

    const safeLimit =
      limit > 0 ? Math.min(limit, BOT_WIDGET_TIMELINE_LIMIT) : BOT_WIDGET_TIMELINE_LIMIT;
    const collected: { ev: MatrixEvent; sanitized: IRoomEvent }[] = [];

    const timelineEvents = room.getLiveTimeline().getEvents();
    for (let i = timelineEvents.length - 1; i >= 0; i -= 1) {
      if (collected.length >= safeLimit) break;

      const ev = timelineEvents[i];
      if (since !== undefined && ev.getId() === since) break;

      if (!ev.isState() && ev.getType() === eventType) {
        if (eventType === EventType.RoomMessage) {
          const content = ev.getContent();
          if (
            isAllowedMessageType(content.msgtype) &&
            (msgtype === undefined || content.msgtype === msgtype)
          ) {
            const sanitized = sanitizeBotWidgetMessageEvent(ev.getEffectiveEvent() as IRoomEvent);
            if (sanitized) collected.push({ ev, sanitized });
          }
        } else if (eventType === EventType.RoomRedaction) {
          const sanitized = sanitizeBotWidgetRedactionEvent(ev.getEffectiveEvent() as IRoomEvent);
          if (sanitized) collected.push({ ev, sanitized });
        }
      }
    }

    return collected.map((c) => c.sanitized);
  }

  public async readRoomState(
    targetRoomId: string,
    eventType: string,
    stateKey: string | undefined
  ): Promise<IRoomEvent[]> {
    const room = this.getSafeRoom();
    if (!room || !this.isAllowedTargetRoom(targetRoomId)) return [];
    if (eventType !== EventType.RoomMember) return [];
    if (
      stateKey !== undefined &&
      stateKey !== this.mx.getSafeUserId() &&
      stateKey !== this.preset.mxid
    ) {
      return [];
    }

    const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
    if (!state) return [];

    const stateKeys = stateKey ? [stateKey] : [this.mx.getSafeUserId(), this.preset.mxid];
    return stateKeys.flatMap((key) => {
      const ev = state.getStateEvents(EventType.RoomMember, key);
      return ev ? [ev.getEffectiveEvent() as IRoomEvent] : [];
    });
  }

  public getKnownRooms(): string[] {
    return this.getSafeRoom() ? [this.roomId] : [];
  }

  // MSC1960 OpenID credentials for first-party bridge widgets. Gated on the
  // declarative `vojo.openid` opt-in in config.json (trusted root — the widget
  // cannot grant itself the capability) plus the same room-safety invariant as
  // every other driver surface. The OpenID token is identity-proof only
  // (federation `/openid/userinfo`), not a Matrix access token: the widget
  // exchanges it with the bridge provisioning API (`Bearer openid:<token>`),
  // which validates it against the homeserver. Auto-granted without a user
  // prompt because the widget origin is operator-allowlisted and the token
  // grants no homeserver read/write power.
  public askOpenID(observer: SimpleObservable<IOpenIDUpdate>): void {
    const { experience } = this.preset;
    if (
      !isWidgetExperience(experience) ||
      !experience.capabilities.includes(BOT_CAP_OPENID) ||
      !this.getSafeRoom()
    ) {
      observer.update({ state: OpenIDRequestState.Blocked });
      return;
    }
    this.mx.getOpenIdToken().then(
      (token) => observer.update({ state: OpenIDRequestState.Allowed, token }),
      (error) => {
        // eslint-disable-next-line no-console
        console.warn('[BotWidget] getOpenIdToken failed', error);
        observer.update({ state: OpenIDRequestState.Blocked });
      }
    );
  }

  // MSC4039 download, scoped to the bot-widget avatar use case: only mxc
  // URIs, and the host substitutes a server-side 96px crop thumbnail for the
  // original — the widget renders 38-48px avatars, so shipping full-size
  // profile photos through postMessage would be pure waste. Media is fetched
  // with the host's access token (authenticated media, MSC3916); the bytes
  // cross the postMessage boundary as a Blob, the token never does.
  public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
    if (!this.getSafeRoom()) throw new Error('Bot widget room is not available');
    if (typeof contentUri !== 'string' || !contentUri.startsWith('mxc://')) {
      throw new Error('Bot widget can only download mxc:// media');
    }
    const url = this.mx.mxcUrlToHttp(contentUri, 96, 96, 'crop', false, true, true);
    if (!url) throw new Error('Failed to resolve media URL');
    const token = this.mx.getAccessToken();
    const res = await fetch(url, {
      headers: token ? { Authorization: `Bearer ${token}` } : undefined,
    });
    if (!res.ok) throw new Error(`Media download failed: HTTP ${res.status}`);
    return { file: await res.blob() };
  }

  // eslint-disable-next-line class-methods-use-this
  public processError(error: unknown) {
    return error instanceof MatrixError
      ? { matrix_api_error: error.asWidgetApiErrorData() }
      : undefined;
  }
}
