import type { ReactNode } from 'react';
import { highlightMentions } from './mentions';

/**
 * WhatsApp-style lightweight markup for message bodies.
 *
 *   *bold*   _italic_   ~strike~   `code`   ```code block```   plus bare URLs
 *
 * Messages stay PLAIN TEXT on the wire — this only affects rendering, so nothing already stored
 * breaks and a client without this build just shows the marker characters.
 *
 * Security: this returns a ReactNode tree and never touches dangerouslySetInnerHTML, so message
 * text can never inject markup. The one genuinely dangerous surface is links, where an attacker
 * could otherwise smuggle `javascript:` — {@link safeHref} allows only http/https/mailto.
 *
 * Precedence (deliberate): code is tokenized first and its contents are left completely alone, so
 * `*not bold*` inside backticks stays literal. Everything else may nest (*bold with _italic_*).
 */

/** Only protocols that cannot execute script. Anything else renders as plain text, not a link. */
export function safeHref(raw: string): string | null {
  try {
    const url = new URL(raw);
    return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
  } catch {
    return null;
  }
}

// A bare URL: stops before trailing punctuation so "see https://x.com." doesn't swallow the period.
const URL_RE = /\bhttps?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|\bwww\.[^\s<>()]+[^\s<>().,;:!?'"]/g;

interface Rule {
  /** The wrapping marker, e.g. '*' for bold. */
  marker: string;
  tag: 'strong' | 'em' | 'del';
}

const RULES: Rule[] = [
  { marker: '*', tag: 'strong' },
  { marker: '_', tag: 'em' },
  { marker: '~', tag: 'del' },
];

/** Escape a marker so it is literal inside a RegExp. */
function esc(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Find the first inline span (`*bold*`, `_italic_`, `~strike~`) in `text`.
 *
 * Two guards keep everyday prose from being mangled:
 *   - the content must not start or end with whitespace, so "5 * 3 = 15" and a lone `*` are inert;
 *   - the markers must sit on word boundaries, so `snake_case_name` is NOT italicised (the bug
 *     every naive markdown renderer ships with).
 */
function firstSpan(text: string): { start: number; end: number; rule: Rule; inner: string } | null {
  let best: { start: number; end: number; rule: Rule; inner: string } | null = null;
  for (const rule of RULES) {
    const m = esc(rule.marker);
    // (lead)(marker)(inner)(marker) — `lead` keeps the boundary char out of the match itself.
    const re = new RegExp(`(^|[^\\w${m}])${m}(?=\\S)([^${m}]*[^\\s${m}])${m}(?![\\w${m}])`);
    const found = re.exec(text);
    if (!found) continue;
    const start = found.index + (found[1]?.length ?? 0);
    if (best === null || start < best.start) {
      best = { start, end: found.index + found[0].length, rule, inner: found[2] ?? '' };
    }
  }
  return best;
}

/**
 * The same text with its formatting markers removed, for places that show a one-line plain-text
 * preview (conversation list, notifications) where `~crazy~` must read as "crazy" — you cannot
 * render nodes into those, so the markers have to come off rather than be styled.
 */
export function stripMarkup(text: string): string {
  const noCode = text
    .replace(/```([\s\S]*?)```/g, (_m, code: string) => code.trim())
    .replace(/`([^`\n]+)`/g, '$1');
  return stripSpans(noCode);
}

/** Recursively drop *bold* / _italic_ / ~strike~ markers, honouring the same word-boundary rule. */
function stripSpans(text: string): string {
  const span = firstSpan(text);
  if (!span) return text;
  return text.slice(0, span.start) + stripSpans(span.inner) + stripSpans(text.slice(span.end));
}

/** Linkify + mention-highlight a run of text that carries no other markup. */
function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
  const out: ReactNode[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  URL_RE.lastIndex = 0;
  while ((m = URL_RE.exec(text)) !== null) {
    if (m.index > last) out.push(...highlightMentions(text.slice(last, m.index), memberNames));
    const raw = m[0];
    const href = safeHref(raw.startsWith('www.') ? `https://${raw}` : raw);
    out.push(
      href ? (
        <a key={keyed()} className="miu-link" href={href} target="_blank" rel="noopener noreferrer nofollow">
          {raw}
        </a>
      ) : (
        raw
      ),
    );
    last = m.index + raw.length;
  }
  if (last < text.length) out.push(...highlightMentions(text.slice(last), memberNames));
  return out;
}

/** Tokenize one segment that is known to contain no code, recursing so styles can nest. */
function inline(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
  const span = firstSpan(text);
  if (!span) return plain(text, memberNames, keyed);
  const { start, end, rule, inner } = span;
  const Tag = rule.tag;
  return [
    ...(start > 0 ? inline(text.slice(0, start), memberNames, keyed) : []),
    <Tag key={keyed()}>{inline(inner, memberNames, keyed)}</Tag>,
    ...(end < text.length ? inline(text.slice(end), memberNames, keyed) : []),
  ];
}

/**
 * Render message text as a safe ReactNode tree: code first (its contents stay literal), then
 * nested bold/italic/strike, then links and @mentions.
 */
export function renderRichText(text: string, memberNames: string[] = []): ReactNode[] {
  let n = 0;
  const keyed = (): string => `rt${n++}`;
  const out: ReactNode[] = [];
  // ```block``` or `inline` — matched together so the longer fence wins.
  const CODE_RE = /```([\s\S]+?)```|`([^`\n]+)`/g;
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = CODE_RE.exec(text)) !== null) {
    if (m.index > last) out.push(...inline(text.slice(last, m.index), memberNames, keyed));
    if (m[1] !== undefined) {
      out.push(
        <pre key={keyed()} className="miu-code-block">
          <code>{m[1].replace(/^\n/, '')}</code>
        </pre>,
      );
    } else {
      out.push(
        <code key={keyed()} className="miu-code">
          {m[2]}
        </code>,
      );
    }
    last = m.index + m[0].length;
  }
  if (last < text.length) out.push(...inline(text.slice(last), memberNames, keyed));
  return out.length > 0 ? out : [text];
}
