export type RichNode =
  | { kind: 'text'; text: string }
  | { kind: 'element'; tag: string; attrs: Record<string, string>; children: RichNode[] }
  | { kind: 'mention'; user_id: string; children: RichNode[] }
  | { kind: 'spoiler'; children: RichNode[] }
  | { kind: 'emote'; mxc: string; alt: string };

// The tag allowlist from the Matrix spec's m.room.message HTML section, minus font colours.
const ALLOWED = new Set([
  'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i',
  'u', 'strong', 'em', 's', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
  'caption', 'pre', 'span', 'details', 'summary', 'font'
]);
const DROPPED = new Set(['mx-reply', 'script', 'style', 'head', 'title', 'iframe', 'object', 'template']);
const MATRIX_TO_USER = /^https:\/\/matrix\.to\/#\/(@[^/?]+)/;
const SAFE_HREF = /^(https?:|mailto:)/i;

function attrsFor(el: Element, tag: string): Record<string, string> | null {
  const attrs: Record<string, string> = {};
  if (tag === 'a') {
    const href = el.getAttribute('href') ?? '';
    if (!SAFE_HREF.test(href)) return null;
    attrs['href'] = href;
  } else if (tag === 'ol') {
    const start = Number(el.getAttribute('start'));
    if (Number.isInteger(start) && start > 0) attrs['start'] = String(start);
  } else if (tag === 'code') {
    const lang = /(?:^|\s)(language-[\w+#-]+)/.exec(el.getAttribute('class') ?? '');
    if (lang) attrs['class'] = lang[1];
  }
  return attrs;
}

function convert(node: Node): RichNode[] {
  if (node.nodeType === Node.TEXT_NODE) return node.textContent ? [{ kind: 'text', text: node.textContent }] : [];
  if (node.nodeType !== Node.ELEMENT_NODE) return [];
  const el = node as Element;
  const tag = el.tagName.toLowerCase();
  if (DROPPED.has(tag)) return [];
  const children = () => [...el.childNodes].flatMap(convert);
  if (tag === 'img') {
    const src = el.getAttribute('src') ?? '';
    const alt = el.getAttribute('alt') ?? el.getAttribute('title') ?? '';
    if (src.startsWith('mxc://')) return [{ kind: 'emote', mxc: src, alt }];
    return alt ? [{ kind: 'text', text: alt }] : [];
  }
  if (tag === 'span' && el.hasAttribute('data-mx-spoiler')) return [{ kind: 'spoiler', children: children() }];
  if (!ALLOWED.has(tag)) return children();
  if (tag === 'a') {
    const user = MATRIX_TO_USER.exec(el.getAttribute('href') ?? '');
    if (user) {
      try {
        return [{ kind: 'mention', user_id: decodeURIComponent(user[1]), children: children() }];
      } catch {
        return children();
      }
    }
  }
  const attrs = attrsFor(el, tag);
  if (!attrs) return children();
  return [{ kind: 'element', tag: tag === 'font' ? 'span' : tag, attrs, children: children() }];
}

let scratch: HTMLElement | null = null;

/** Parses into one shared inert body, so no Document is allocated per message. */
function withParsedBody<T>(html: string, read: (body: HTMLElement) => T): T {
  scratch ??= document.implementation.createHTMLDocument('').body;
  scratch.innerHTML = html;
  try {
    return read(scratch);
  } finally {
    scratch.replaceChildren();
  }
}

/** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */
export function parseRich(html: string): RichNode[] {
  return withParsedBody(html, (body) => [...body.childNodes].flatMap(convert));
}
