import { useState, useRef, useMemo, useEffect, memo, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import { useTheme } from '../lib/ThemeContext';
import { MatrixText } from './MatrixText';
import { DocPreview } from './DocPreview';
import { DocDataExport } from './DocDataExport';
import { MermaidDiagram } from './MermaidDiagram';
import remarkGfm from 'remark-gfm';
import remarkEmoji from 'remark-emoji';
import '../pages/DiscussionsPage.css';
import type { DiscussionMessage, AgentType } from '../types/generated';
import { agentColor } from '../lib/constants';
import { gravatarUrl } from '../lib/gravatar';
import {
  Cpu, AlertTriangle, Zap, Loader2, Pause, Play,
  Key, Settings, Send, Pencil, RotateCcw, Check, Copy, Clock, ShieldCheck,
} from 'lucide-react';

// Hoisted regexes (avoid creating new RegExp objects per message per render)
const RE_AUTH_ERROR = /api.?key|invalid.*key|key.*not.*config|authenticat|unauthori|login|sign.?in/i;
const RE_PARTIAL_RESPONSE = /Réponse partielle.*interrompu|Timeout d'inactivité/i;

// 0.8.5 (#qp-improver UX follow-up) — Kronn-emitted "seed" envelope.
// When the QP AI Improver / Workflow Architect / Bootstrap Architect
// spawn a discussion, the User message they post carries:
//   - a short visible status line ("✨ Audit en cours…")
//   - THEN the full technical seed wrapped in this marker pair.
// The agent runtime reads the entire message verbatim, so the seed
// reaches the agent. The UI parses the marker out and renders the
// seed inside a collapsed `<details>`-style toggle so the user isn't
// forced to scroll past hundreds of lines of QP JSON + catalog.
const RE_SEED = /<!--KRONN_SEED_START-->\s*([\s\S]*?)\s*<!--KRONN_SEED_END-->/;

/**
 * Split a message body into a visible prefix + an optional collapsed
 * "seed" payload. Pure helper extracted for unit testing. When no
 * marker pair is found, the entire content is returned as `visible`
 * with `seed = null` — legacy messages stay untouched.
 */
export function splitMessageSeed(content: string): { visible: string; seed: string | null } {
  const m = content.match(RE_SEED);
  if (!m) return { visible: content, seed: null };
  // index is set when match succeeds; ts-narrow it for the slice.
  const idx = m.index ?? 0;
  const visible = content.slice(0, idx).trim();
  return { visible, seed: m[1].trim() };
}

// 0.8.5 — collapsed disclosure for Kronn-internal seed payloads.
// Mirrors `<details>` semantics with kronn-styled controls. Hidden
// by default; opens on click. Pre-renders the seed inside a `<pre>`
// with whitespace-preserved formatting so JSON stays readable.
const KronnSeedToggle = memo(({ seed }: { seed: string }) => {
  const [open, setOpen] = useState(false);
  return (
    <div className="kronn-seed-toggle" data-testid="kronn-seed-toggle" style={{ marginTop: 8, fontSize: 12, color: 'var(--kr-text-dim)' }}>
      <button
        type="button"
        className="disc-icon-btn"
        style={{ padding: '3px 8px', fontSize: 11, color: 'var(--kr-text-dim)' }}
        onClick={() => setOpen(o => !o)}
        aria-expanded={open}
      >
        {open ? '▾' : '▸'} Contexte technique envoyé à l'agent
      </button>
      {open && (
        <pre
          data-testid="kronn-seed-body"
          style={{
            marginTop: 6,
            padding: 8,
            borderRadius: 4,
            background: 'rgba(255, 255, 255, 0.04)',
            fontSize: 11,
            whiteSpace: 'pre-wrap',
            wordBreak: 'break-word',
            maxHeight: 320,
            overflow: 'auto',
          }}
        >
          {seed}
        </pre>
      )}
    </div>
  );
});

// ─── MessageBubble component (memo'd to avoid re-rendering all messages) ─────

export interface MessageBubbleProps {
  msg: DiscussionMessage;
  idx: number;
  isLastUser: boolean;
  isLastAgent: boolean;
  isEditing: boolean;
  isCopied: boolean;
  isTtsActive: boolean;
  ttsState: string;
  isExpandedSummary: boolean;
  prevUserTs: string | null;
  defaultAgent: AgentType;
  summaryCache: string | null;
  language: string;
  sending: boolean;
  editingText: string;
  hasFullAccess: boolean;
  onCopy: (msgId: string, content: string) => void;
  onTts: (msgId: string, content: string, lang: string) => void;
  onEditStart: (msgId: string, content: string) => void;
  onEditCancel: () => void;
  onEditSubmit: () => void;
  onEditTextChange: (text: string) => void;
  onRetry: () => void;
  onExpandSummary: (msgId: string) => void;
  onNavigate: (page: string, opts?: { scrollTo?: string }) => void;
  /** Discussion id, threaded through to MarkdownContent so the
   *  `kronn-doc-preview` fence handler knows which generated-files
   *  directory to target when the user clicks "Export PDF". */
  discussionId?: string;
  /** 0.8.3 — project id of the active discussion (when bound). When a
   * message contains `KRONN:VALIDATION_COMPLETE` AND we have a
   * projectId, render a "View Tech Debts" CTA under the bubble that
   * jumps the user to the project card with the docs/tech-debt
   * section pre-expanded. Saves N clicks at end-of-validation. */
  projectId?: string | null;
  t: (key: string, ...args: (string | number)[]) => string;
}

export const MessageBubble = memo(function MessageBubble(props: MessageBubbleProps) {
  const { msg, isLastUser, isLastAgent, isEditing, isCopied, isTtsActive, ttsState: tts, isExpandedSummary,
    prevUserTs, defaultAgent, summaryCache, language, sending, editingText, hasFullAccess,
    onCopy, onTts, onEditStart, onEditCancel, onEditSubmit, onEditTextChange, onRetry, onExpandSummary, onNavigate, discussionId, projectId, t } = props;
  const isUser = msg.role === 'User';
  const agentType = msg.agent_type ?? defaultAgent;

  // Matrix theme — when the discussion first loads, decode the user's
  // LAST message as plain scrambled text before swapping to the normal
  // Markdown rendering. Gives the "you are in the system" feel without
  // disturbing agent answers or older history. Only runs once per
  // MessageBubble mount, under the matrix theme, for the last user msg.
  const { theme } = useTheme();
  const [decodeDone, setDecodeDone] = useState(false);
  const isMatrixLastUser = theme === 'matrix' && isUser && isLastUser;
  useEffect(() => {
    if (!isMatrixLastUser) { setDecodeDone(true); return; }
    setDecodeDone(false);
    // Slightly longer than the title decode (28 frames) because
    // message content is longer — gives every char time to settle.
    const timer = setTimeout(() => setDecodeDone(true), 700);
    return () => clearTimeout(timer);
  }, [isMatrixLastUser, msg.id]);

  const copyBtn = (size: number, showLabel: boolean) => (
    <button
      className="disc-copy-btn"
      data-copied={isCopied}
      onClick={() => onCopy(msg.id, msg.content)}
      title={t('disc.copyMessage')}
    >
      {isCopied ? <><Check size={size} /> {t('disc.copied')}</> : <><Copy size={size} /> {showLabel && t('disc.copy')}</>}
    </button>
  );

  // Duration calculation (O(1) — prevUserTs is pre-computed)
  const durationLabel = useMemo(() => {
    if (msg.role !== 'Agent' || !prevUserTs) return null;
    const ms = new Date(msg.timestamp).getTime() - new Date(prevUserTs).getTime();
    if (ms <= 0) return null;
    const s = Math.round(ms / 1000);
    return s >= 60 ? `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ''}` : `${s}s`;
  }, [msg.role, msg.timestamp, prevUserTs]);

  // Memoize formatted time
  const formattedTime = useMemo(() =>
    new Date(msg.timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),
    [msg.timestamp]
  );

  // kronn-internal tool-call trace — System messages emitted by the
  // backend (post-stream MCP capture or slash-marker resolver) so the
  // user sees "the agent looked at message #4" inline in the
  // transcript. Distinct visual treatment from regular System messages
  // (which carry summary-cache notices or errors) so the user
  // recognises tool activity at a glance.
  const isKronnTool = msg.role === 'System' && msg.content.startsWith('[kronn-internal:');
  const kronnToolMatch = isKronnTool
    ? /^\[kronn-internal: ([a-z_]+)(?:\(([^)]*)\))?(?: → (.*))?\]$/s.exec(msg.content.trim())
    : null;

  return (
    <div className="disc-msg-row" data-role={isUser ? 'user' : msg.role === 'System' ? 'system' : 'agent'}>
      <div
        className="disc-msg-bubble"
        data-role={isUser ? 'user' : msg.role === 'System' ? 'system' : 'agent'}
        data-variant={msg.role === 'System' ? (
          isKronnTool ? 'kronn-tool'
          : msg.content.startsWith('summary cached') ? 'summary'
          : 'error'
        ) : undefined}
      >
        {isUser && (msg.author_pseudo || msg.author_avatar_email) && (
          <div className="disc-msg-author">
            {msg.author_avatar_email ? (
              <img src={gravatarUrl(msg.author_avatar_email, 20)} alt="" className="disc-msg-author-avatar" />
            ) : msg.author_pseudo ? (
              <span className="disc-msg-author-initials">
                {msg.author_pseudo.slice(0, 2).toUpperCase()}
              </span>
            ) : null}
            <span className="disc-msg-author-name">{msg.author_pseudo}</span>
          </div>
        )}
        {msg.role === 'Agent' && (
          <div className="disc-msg-agent-label" style={{ color: agentColor(agentType), justifyContent: 'space-between' }}>
            <span className="flex-row gap-2">
              <Cpu size={10} /> {agentType}
            </span>
            {copyBtn(9, false)}
          </div>
        )}
        {msg.role === 'System' && isKronnTool && (
          <div className="disc-msg-kronn-tool" data-testid="kronn-tool-badge">
            {/* Wrench icon mirrors the `🔧 N` pill in ChatHeader so the
             *  user makes the visual connection: pill counts these
             *  badges. The tool name + args render compactly; if the
             *  resolver returned a payload (slash-marker path) we
             *  render it as a collapsed snippet on hover. */}
            <span className="disc-msg-kronn-tool-icon" aria-hidden="true">🔧</span>
            <span className="disc-msg-kronn-tool-label">
              {kronnToolMatch ? (
                <>
                  <code className="disc-msg-kronn-tool-name">{kronnToolMatch[1]}</code>
                  {kronnToolMatch[2] && (
                    <span className="disc-msg-kronn-tool-args">({kronnToolMatch[2]})</span>
                  )}
                </>
              ) : (
                <span>{msg.content}</span>
              )}
            </span>
            {kronnToolMatch && kronnToolMatch[3] && (
              <details className="disc-msg-kronn-tool-result">
                <summary>{t('disc.kronnToolResult')}</summary>
                <pre>{kronnToolMatch[3]}</pre>
              </details>
            )}
          </div>
        )}
        {msg.role === 'System' && !isKronnTool && (
          <div className="disc-msg-agent-label" style={{ color: msg.content.startsWith('summary cached') ? 'var(--kr-success)' : 'var(--kr-error)' }}>
            {msg.content.startsWith('summary cached') ? <Zap size={10} /> : <AlertTriangle size={10} />}
            {' '}{msg.content.startsWith('summary cached') ? t('disc.summaryCached') : t('disc.system')}
            {msg.content.startsWith('summary cached') && summaryCache && (
              <button
                className="disc-summary-toggle"
                aria-label={t('disc.viewSummary')}
                onClick={() => onExpandSummary(msg.id)}
              >
                {isExpandedSummary ? t('disc.hideSummary') : t('disc.viewSummary')}
              </button>
            )}
          </div>
        )}
        {msg.role === 'System' && msg.content.startsWith('summary cached') && isExpandedSummary && summaryCache && (
          <div className="disc-summary-expanded">
            {summaryCache}
          </div>
        )}
        {isEditing ? (
          <div className="flex-col gap-4">
            <textarea
              value={editingText}
              onChange={e => onEditTextChange(e.target.value)}
              onKeyDown={e => {
                // IME guard: pressing Enter during an active composition
                // confirms the candidate, never re-submits the edit.
                if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.nativeEvent.isComposing) {
                  e.preventDefault();
                  onEditSubmit();
                }
              }}
              className="disc-edit-textarea"
              autoFocus
            />
            <div className="disc-edit-actions">
              <button className="disc-icon-btn" style={{ fontSize: 11, padding: '4px 10px', color: 'var(--kr-text-faint)' }} onClick={onEditCancel}>{t('disc.cancel')}</button>
              <button className="disc-scan-btn" style={{ fontSize: 11, padding: '4px 10px' }} onClick={onEditSubmit} disabled={!editingText.trim()}>
                <Send size={10} /> {t('disc.resend')}
                <span className="text-2xs opacity-50" style={{ marginLeft: 4 }}>Ctrl+Enter</span>
              </button>
            </div>
          </div>
        ) : isKronnTool ? (
          // The kronn-tool badge above is the entire content. Skip
          // the markdown render — we don't want a second copy of
          // `[kronn-internal: ...]` rendered as raw text below.
          null
        ) : isMatrixLastUser && !decodeDone ? (
          // Matrix: render as plain scrambled text for ~700ms before
          // flipping to Markdown. Markdown parse is skipped during
          // decode — fewer re-renders through ReactMarkdown.
          <div className="matrix-text" data-decoding="true" style={{ whiteSpace: 'pre-wrap' }}>
            <MatrixText text={msg.content} />
          </div>
        ) : (
          (() => {
            // 0.8.5 — split off the optional Kronn-internal seed payload
            // BEFORE we strip the KRONN:* signals. The seed itself may
            // contain a signal name inside instructions ("emit KRONN:QP_IMPROVED"),
            // and stripping those would corrupt the agent prompt. Splitting first
            // keeps the agent-bound full content intact in the DB while the UI
            // only renders the visible prefix.
            const { visible, seed } = splitMessageSeed(msg.content);
            const cleaned = visible.replace(/KRONN:(BRIEFING_COMPLETE|VALIDATION_COMPLETE|BOOTSTRAP_COMPLETE|WORKFLOW_READY|REPO_READY|ARCHITECTURE_READY|PLAN_READY|STRUCTURE_READY|ISSUES_READY|ISSUES_CREATED|QP_IMPROVED|BUNDLE_READY)/gi, '').trim();
            return (
              <>
                <MarkdownContent content={cleaned} discussionId={discussionId} />
                {seed && <KronnSeedToggle seed={seed} />}
              </>
            );
          })()
        )}
        {msg.role === 'Agent' && (
          <button
            className="disc-tts-btn"
            onClick={() => onTts(msg.id, msg.content, language)}
            title={isTtsActive ? (tts === 'loading' ? 'Chargement...' : tts === 'playing' ? 'Pause' : tts === 'paused' ? 'Reprendre' : 'Lire') : 'Lire \u00e0 voix haute'}
          >
            {isTtsActive && tts === 'loading' ? <><Loader2 size={9} style={{ animation: 'spin 1s linear infinite' }} /> TTS</>
              : isTtsActive && tts === 'playing' ? <><Pause size={9} /> Pause</>
              : isTtsActive && tts === 'paused' ? <><Play size={9} /> Reprendre</>
              : <><Play size={9} /> TTS</>}
          </button>
        )}
        {RE_AUTH_ERROR.test(msg.content) && (
          <div className="disc-auth-error-cta">
            <button className="disc-scan-btn" style={{ fontSize: 11, padding: '5px 12px' }} onClick={() => onNavigate('settings')}>
              <Key size={11} /> {t('disc.overrideKey')}
            </button>
            <span className="disc-auth-error-hint">{t('disc.orCheckAgent')}</span>
          </div>
        )}
        {RE_PARTIAL_RESPONSE.test(msg.content) && (
          <div className="disc-auth-error-cta">
            <button className="disc-scan-btn" style={{ fontSize: 11, padding: '5px 12px', borderColor: 'rgba(var(--kr-warning-amber-rgb), 0.3)', background: 'rgba(var(--kr-warning-amber-rgb), 0.08)', color: 'var(--kr-warning-amber)' }} onClick={() => onNavigate('settings', { scrollTo: 'settings-server' })}>
              <Settings size={11} /> {t('disc.editTimeout')}
            </button>
          </div>
        )}
        {/* 0.8.3 — End-of-validation CTA. When the agent emits
            KRONN:VALIDATION_COMPLETE in a project-bound discussion,
            surface a one-click jump to the TD index. Pattern: same
            as the auth-error / timeout CTAs above (button below the
            bubble, before the footer). The hash + onNavigate combo
            re-uses Dashboard's existing `#project-<id>` deep-link so
            we don't need new plumbing in the Dashboard state machine. */}
        {projectId && /KRONN:VALIDATION_COMPLETE/i.test(msg.content) && (
          <div className="disc-auth-error-cta">
            <button
              className="disc-scan-btn"
              style={{ fontSize: 11, padding: '5px 12px' }}
              onClick={() => {
                // 0.8.3 (#314) — deep-link directly to the tech-debt folder.
                // Pre-fix the CTA only navigated to the project (via hash
                // #project-<id>) and the user landed on the AI Context
                // tab but had to manually expand + click into docs/tech-debt/.
                // The sessionStorage flag is read by ProjectCard on mount
                // and triggers `setExpandedTab('docAi') + setDocDeepLink`
                // automatically, so a single click takes the user from
                // "validation finished" to "looking at the TDs".
                try {
                  sessionStorage.setItem(`kronn:postValidation:${projectId}`, 'docs/tech-debt');
                } catch { /* private-mode / quota — fall through */ }
                window.location.hash = `#project-${projectId}`;
                onNavigate('projects');
              }}
            >
              <ShieldCheck size={11} /> {t('audit.viewTechDebtsAfterValidation')}
            </button>
          </div>
        )}
        <div className="disc-msg-footer">
          <div className="disc-msg-time-row">
            <span className="disc-msg-time">{formattedTime}</span>
            {msg.tokens_used > 0 && <span className="disc-msg-token-count">{msg.tokens_used.toLocaleString()} tok</span>}
            {msg.auth_mode && <span className="disc-msg-auth-mode" data-mode={msg.auth_mode === 'override' ? 'override' : 'local'}>{msg.auth_mode === 'override' ? 'API key' : 'auth locale'}</span>}
            {durationLabel && <span className="disc-msg-duration"><Clock size={8} /> {durationLabel}</span>}
          </div>
          <div className="disc-msg-footer-right">
            {msg.role === 'Agent' && copyBtn(9, true)}
            {msg.role === 'Agent' && hasFullAccess && (
              <span className="disc-full-access-badge">
                <AlertTriangle size={8} /> {t('config.fullAccessBadge')}
              </span>
            )}
            {msg.role === 'Agent' && msg.model_tier && (
              <span className="disc-model-tier-badge" data-tier={msg.model_tier}>
                {msg.model_tier === 'economy' ? '⚡' : '\ud83e\udde0'} {t(`disc.tier.${msg.model_tier}`)}
              </span>
            )}
            {!sending && !isEditing && (isLastUser || isLastAgent) && (
              <div className="flex-row gap-2">
                {isLastUser && (
                  <button className="disc-icon-btn" style={{ padding: '2px 6px', fontSize: 10, color: 'var(--kr-text-dim)' }} onClick={() => onEditStart(msg.id, msg.content)} title={t('disc.editResend')} aria-label={t('disc.editResend')}>
                    <Pencil size={10} />
                  </button>
                )}
                {isLastAgent && (
                  <button className="disc-icon-btn" style={{ padding: '2px 6px', fontSize: 10, color: 'var(--kr-text-dim)' }} onClick={onRetry} title={t('disc.retryResponse')} aria-label={t('disc.retryResponse')}>
                    <RotateCcw size={10} />
                  </button>
                )}
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
});

// ─── MarkdownContent component ───────────────────────────────────────────────

/** Extract plain text from a DOM node tree (for copy-to-clipboard). */
function extractText(node: HTMLElement): string {
  if (node.tagName === 'TABLE') {
    const rows = Array.from(node.querySelectorAll('tr'));
    return rows.map(row => {
      const cells = Array.from(row.querySelectorAll('th, td'));
      return cells.map(c => c.textContent?.trim() ?? '').join('\t');
    }).join('\n');
  }
  return node.textContent ?? '';
}

/** Tiny copy button overlaid on a block (table or code). */
function CopyableBlock({ children, className, tag }: { children: ReactNode; className?: string; tag: 'table' | 'pre' }) {
  const ref = useRef<HTMLDivElement>(null);
  const [copied, setCopied] = useState(false);
  const handleCopy = () => {
    const el = ref.current?.querySelector(tag);
    if (el) {
      navigator.clipboard.writeText(extractText(el as HTMLElement));
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    }
  };
  return (
    <div ref={ref} className={`relative ${className || ''}`}>
      {children}
      <button
        onClick={handleCopy}
        className="disc-copyable-block-btn"
        data-copied={copied}
        onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
        onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
      >
        {copied ? <>{'\u2713'}</> : <>{'\u2398'}</>}
      </button>
    </div>
  );
}

// react-markdown's component override prop type is essentially
// `{ children?: ReactNode; ...HTMLAttributes }` with a few extras the
// remark plugins inject (node, index, etc.). We don't need them — only
// `children`, `href` and `className` — so a narrow shape is enough and
// avoids 16 `any`s.
type MdProps = {
  children?: ReactNode;
  className?: string;
  href?: string;
};

const mdComponents = {
  p: ({ children }: MdProps) => <p>{children}</p>,
  h1: ({ children }: MdProps) => <h1>{children}</h1>,
  h2: ({ children }: MdProps) => <h2>{children}</h2>,
  h3: ({ children }: MdProps) => <h3>{children}</h3>,
  ul: ({ children }: MdProps) => <ul>{children}</ul>,
  ol: ({ children }: MdProps) => <ol>{children}</ol>,
  li: ({ children }: MdProps) => <li>{children}</li>,
  code: ({ className, children }: MdProps) => {
    const isBlock = className?.includes('language-');
    return isBlock
      ? <code className="disc-md-pre-code">{children}</code>
      : <code>{children}</code>;
  },
  pre: ({ children }: MdProps) => (
    <CopyableBlock tag="pre">
      <pre>{children}</pre>
    </CopyableBlock>
  ),
  table: ({ children }: MdProps) => (
    <CopyableBlock tag="table" className="overflow-hidden">
      <table>{children}</table>
    </CopyableBlock>
  ),
  th: ({ children }: MdProps) => <th>{children}</th>,
  td: ({ children }: MdProps) => <td>{children}</td>,
  blockquote: ({ children }: MdProps) => <blockquote>{children}</blockquote>,
  hr: () => <hr />,
  a: ({ href, children }: MdProps) => <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>,
  strong: ({ children }: MdProps) => <strong>{children}</strong>,
};

// `remark-emoji` transforms GitHub-style shortcodes (`:tada:`) into the
// actual Unicode character during markdown parsing. Messages are stored
// with the shortcode intact — the conversion is purely visual, so agents
// and full-text search still see `tada` in the DB. Keeps the agent prompt
// portable (some CLIs choke on raw multi-byte emoji sequences).
const remarkPluginsList = [remarkGfm, remarkEmoji];

export const MarkdownContent = memo(({ content, discussionId }: { content: string; discussionId?: string }) => {
  // Override the `pre` handler when we have a discussion id: fenced
  // blocks tagged `kronn-doc-preview` get replaced with the DocPreview
  // component (sandboxed iframe + export buttons). Everything else
  // renders through the shared mdComponents table above.
  const components = useMemo(() => {
    if (!discussionId) return mdComponents;
    return {
      ...mdComponents,
      pre: ({ children }: { children?: ReactNode }) => {
        // ReactMarkdown renders <pre><code>…</code></pre>; the outer pre
        // gets a child element whose props carry the language class.
        // We need access to that child's `props`, which ReactNode
        // doesn't expose by default — narrow to a React element.
        const childEl = Array.isArray(children) ? children[0] : children;
        const codeEl = (childEl as { props?: { className?: string; children?: ReactNode } } | undefined);
        const className: string = codeEl?.props?.className ?? '';
        // 0.8.3 (#289) — visual Mermaid render in chat. Same pattern
        // as kronn-doc-preview below: intercept the fence, dynamic
        // import the renderer. Out-of-band Mermaid blocks (agent
        // emits a diagram mid-conversation) get the same treatment
        // as docs files for consistency.
        if (className.includes('language-mermaid')) {
          const raw = codeEl?.props?.children;
          const source = Array.isArray(raw) ? raw.join('') : String(raw ?? '');
          return <MermaidDiagram source={source.trim()} />;
        }
        if (className.includes('language-kronn-doc-preview')) {
          // `children` of a fenced block is typically a string (or an
          // array with one string); coerce safely.
          const raw = codeEl?.props?.children;
          const html = Array.isArray(raw) ? raw.join('') : String(raw ?? '');
          return <DocPreview html={html.trim()} discussionId={discussionId} />;
        }
        if (className.includes('language-kronn-doc-data')) {
          // Parsed payload must carry a `format` discriminator ∈ {csv,xlsx,pptx}.
          // Malformed JSON or unknown format → fall through to a regular
          // code block so the chat keeps rendering instead of blowing up.
          const raw = codeEl?.props?.children;
          const text = Array.isArray(raw) ? raw.join('') : String(raw ?? '');
          try {
            const parsed = JSON.parse(text);
            const { format, ...payload } = parsed;
            if (format === 'csv' || format === 'xlsx' || format === 'pptx') {
              return <DocDataExport payload={payload} format={format} discussionId={discussionId} />;
            }
          } catch {
            // fall through to raw code render
          }
        }
        return (
          <CopyableBlock tag="pre">
            <pre>{children}</pre>
          </CopyableBlock>
        );
      },
    };
  }, [discussionId]);

  return (
    <div className="disc-md">
      <ReactMarkdown remarkPlugins={remarkPluginsList} components={components}>
        {content}
      </ReactMarkdown>
    </div>
  );
});

// ─── Inline style constants removed — all styles now in DiscussionsPage.css ──
