import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { shortenModelName } from '@/lib/modelName';
import { CapHitSentinel } from './CapHitSentinel';
import { DoomLoopSentinel } from './DoomLoopSentinel';
import { MarkdownRenderer } from './MarkdownRenderer';
import { Button } from '@/components/ui/button';
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';

// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
function useTerminals(): TerminalRegistration[] {
  const [list, setList] = useState(() => terminalsRegistry.list());
  useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
  return list;
}

// Wrap a message body with a right-click context menu offering Copy and
// "Send to terminal → <pane name>". Send is disabled when nothing is
// selected or no terminal panes are open; clicking a target emits a
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
function SendToTerminalMenu({ children }: { children: ReactNode }) {
  const [selection, setSelection] = useState('');
  const terminals = useTerminals();
  const hasSelection = selection.length > 0;
  const canSend = hasSelection && terminals.length > 0;

  return (
    <ContextMenu
      onOpenChange={(open) => {
        if (open) {
          const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
          setSelection(sel);
        }
      }}
    >
      <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem
          disabled={!hasSelection}
          onSelect={() => {
            void navigator.clipboard.writeText(selection).catch((err) => {
              toast.error(err instanceof Error ? err.message : 'copy failed');
            });
          }}
        >
          Copy
        </ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuSub>
          <ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
          <ContextMenuSubContent>
            {terminals.length === 0 ? (
              <ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
            ) : (
              terminals.map((t) => (
                <ContextMenuItem
                  key={t.paneId}
                  onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
                >
                  {t.label}
                </ContextMenuItem>
              ))
            )}
          </ContextMenuSubContent>
        </ContextMenuSub>
      </ContextMenuContent>
    </ContextMenu>
  );
}

// v1.8.2: human labels for the machine-readable error reasons that ride on
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
  llm_provider_error: 'LLM provider error',
  tool_execution_failed: 'Tool execution failed',
  summary_after_cap_failed: 'Summary after tool budget hit failed',
};

// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
// panes can render assistant content with the same Shiki + remark-gfm setup.

export interface MessageActions {
  onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
  onResend?: (chatId: string, content: string) => Promise<void>;
  onFork?: (chatId: string, messageId: string) => Promise<void>;
  onDelete?: (chatId: string, messageId: string) => Promise<void>;
  // write-edit-robustness #4 (BooCoder only): reset the worktree to this
  // message's pre-turn checkpoint and trim the transcript past it. BooChat
  // passes no such callback → the "Restore to here" control never renders.
  onRestoreCheckpoint?: (chatId: string, messageId: string) => Promise<void>;
}

interface Props {
  message: Message;
  sessionChats?: Chat[];
  capHitInfo?: { position: number; isLatest: boolean };
  actions?: MessageActions;
  /** Hide actions that don't apply (fork, delete). */
  hideActions?: ('fork' | 'delete')[];
  /**
   * write-edit-robustness #4: this assistant message has a worktree checkpoint
   * → render "Restore to here" (only when `actions.onRestoreCheckpoint` is also
   * provided). CoderMessageList sets this from the checkpoint set.
   */
  hasCheckpoint?: boolean;
  /**
   * write-edit-robustness #4: suppress the restore control during an active
   * turn (mirrors composer gating). Defaults to enabled.
   */
  restoreDisabled?: boolean;
}

function StatsLine({ message }: { message: Message }) {
  const tokens = message.tokens_used;
  if (typeof tokens !== 'number' || tokens <= 0) return null;
  const started = message.started_at ? Date.parse(message.started_at) : NaN;
  const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
  let tps: number | null = null;
  if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
    const seconds = (finished - started) / 1000;
    if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
  }
  const ctxUsed = message.ctx_used;
  const ctxMax = message.ctx_max;
  const ctxPart =
    typeof ctxUsed === 'number'
      ? typeof ctxMax === 'number' && ctxMax > 0
        ? `${ctxUsed} / ${ctxMax} ctx`
        : `${ctxUsed} ctx`
      : null;

  const parts: string[] = [`${tokens} tokens`];
  if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
  if (ctxPart) parts.push(ctxPart);

  return (
    <div className="text-[10px] font-mono text-muted-foreground">
      {parts.join(' · ')}
    </div>
  );
}

function ActionRow({
  message,
  actions,
  hiddenSet,
  hasCheckpoint = false,
  restoreDisabled = false,
}: {
  message: Message;
  actions?: MessageActions;
  hiddenSet: Set<string>;
  hasCheckpoint?: boolean;
  restoreDisabled?: boolean;
}) {
  const [justCopied, setJustCopied] = useState(false);
  const [regenerating, setRegenerating] = useState(false);
  const [forking, setForking] = useState(false);
  const [deleteOpen, setDeleteOpen] = useState(false);
  const [deleting, setDeleting] = useState(false);
  const [restoreOpen, setRestoreOpen] = useState(false);
  const [restoring, setRestoring] = useState(false);

  async function copy() {
    try {
      await navigator.clipboard.writeText(message.content);
      setJustCopied(true);
      setTimeout(() => setJustCopied(false), 1200);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'copy failed');
    }
  }

  async function regenerate() {
    if (regenerating || message.status === 'streaming') return;
    setRegenerating(true);
    try {
      if (actions?.onRegenerate) {
        await actions.onRegenerate(message.chat_id, message.id);
      } else {
        await api.messages.regenerate(message.chat_id, message.id);
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'regenerate failed');
    } finally {
      setRegenerating(false);
    }
  }

  async function resend() {
    if (!canResend) return;
    try {
      if (actions?.onResend) {
        await actions.onResend(message.chat_id, message.content!);
      } else {
        await api.messages.send(message.chat_id, message.content!);
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'resend failed');
    }
  }

  async function fork() {
    if (forking || message.status !== 'complete') return;
    setForking(true);
    try {
      if (actions?.onFork) {
        await actions.onFork(message.chat_id, message.id);
      } else {
        const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
        sessionEvents.emit({ type: 'refetch_messages' });
        sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'fork failed');
    } finally {
      setForking(false);
    }
  }

  async function confirmDelete() {
    if (deleting) return;
    setDeleting(true);
    try {
      if (actions?.onDelete) {
        await actions.onDelete(message.chat_id, message.id);
      } else {
        await api.messages.remove(message.chat_id, message.id);
      }
      setDeleteOpen(false);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'delete failed');
    } finally {
      setDeleting(false);
    }
  }

  async function confirmRestore() {
    if (restoring || !actions?.onRestoreCheckpoint) return;
    setRestoring(true);
    try {
      await actions.onRestoreCheckpoint(message.chat_id, message.id);
      setRestoreOpen(false);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'restore failed');
    } finally {
      setRestoring(false);
    }
  }

  const isAssistant = message.role === 'assistant';
  const isUser = message.role === 'user';
  const canRegen = isAssistant && message.status !== 'streaming';
  const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
  const canFork = message.status === 'complete';
  const canDelete = message.status !== 'streaming';
  // write-edit-robustness #4: show "Restore to here" only for a completed
  // assistant message that has a checkpoint AND when the coder wired the
  // callback. Disabled (but visible) during an active turn.
  const canRestore =
    isAssistant &&
    hasCheckpoint &&
    message.status === 'complete' &&
    !!actions?.onRestoreCheckpoint;

  return (
    <>
      <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
        <button
          type="button"
          onClick={() => void copy()}
          className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
          aria-label="Copy message"
          title="Copy"
        >
          {justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
        </button>
        {canResend && (
          <button
            type="button"
            onClick={() => void resend()}
            className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
            aria-label="Resend message"
            title="Resend"
          >
            <RefreshCw className="size-3" />
          </button>
        )}
        {isAssistant && (
          <button
            type="button"
            onClick={() => void regenerate()}
            disabled={!canRegen || regenerating}
            className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
            aria-label="Regenerate message"
            title="Regenerate"
          >
            <RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
          </button>
        )}
        {!hiddenSet.has('fork') && (
          <button
            type="button"
            onClick={() => void fork()}
            disabled={!canFork || forking}
            className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
            aria-label="Fork from here"
            title="Fork from here"
          >
            <GitFork className="size-3" />
          </button>
        )}
        {!hiddenSet.has('delete') && (
          <button
            type="button"
            onClick={() => setDeleteOpen(true)}
            disabled={!canDelete}
          className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
          aria-label="Delete message"
          title="Delete message"
        >
          <Trash2 className="size-3" />
        </button>
        )}
        {canRestore && (
          <button
            type="button"
            onClick={() => setRestoreOpen(true)}
            disabled={restoreDisabled || restoring}
            className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
            aria-label="Restore to here"
            title="Restore worktree to this point"
          >
            <History className="size-3" />
          </button>
        )}
      </div>
      <Dialog
        open={deleteOpen}
        onOpenChange={(open) => {
          if (!deleting) setDeleteOpen(open);
        }}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Delete this message and all messages after it?</DialogTitle>
            <DialogDescription>
              This removes the selected message and every later message in this chat. This cannot be undone.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <Button
              variant="outline"
              onClick={() => setDeleteOpen(false)}
              disabled={deleting}
            >
              Cancel
            </Button>
            <Button
              variant="destructive"
              onClick={() => void confirmDelete()}
              disabled={deleting}
            >
              {deleting ? 'Deleting…' : 'Delete'}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
      <Dialog
        open={restoreOpen}
        onOpenChange={(open) => {
          if (!restoring) setRestoreOpen(open);
        }}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Restore to this point?</DialogTitle>
            <DialogDescription>
              This resets the worktree to before this turn, removes every later
              message in this chat, and resets the agent's session. This cannot
              be undone.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <Button
              variant="outline"
              onClick={() => setRestoreOpen(false)}
              disabled={restoring}
            >
              Cancel
            </Button>
            <Button
              variant="destructive"
              onClick={() => void confirmRestore()}
              disabled={restoring}
            >
              {restoring ? 'Restoring…' : 'Restore'}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
  const [expanded, setExpanded] = useState(false);
  const [copied, setCopied] = useState(false);
  const [shareOpen, setShareOpen] = useState(false);
  const [rerunning, setRerunning] = useState(false);

  const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
  const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
  const summaryText = headerMatch
    ? message.content.slice(headerMatch[0].length).trim()
    : message.content;

  async function handleCopy() {
    try {
      await navigator.clipboard.writeText(summaryText);
      setCopied(true);
      setTimeout(() => setCopied(false), 1200);
      toast.success('Summary copied to clipboard');
    } catch {
      toast.error('Copy failed');
    }
  }

  async function handleShareToChat(chat: Chat) {
    try {
      await api.messages.send(chat.id, summaryText);
      toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
      setShareOpen(false);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to share');
    }
  }

  async function handleRerun() {
    if (rerunning) return;
    setRerunning(true);
    try {
      await api.chats.compact(message.chat_id);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Re-run failed');
    } finally {
      setRerunning(false);
    }
  }

  const otherChats = (sessionChats ?? []).filter(
    (c) => c.id !== message.chat_id && c.status === 'open'
  );

  return (
    <div className="rounded-lg border bg-muted/30 text-sm">
      <div className="flex items-center gap-2 px-3 py-2">
        <button
          type="button"
          onClick={() => setExpanded(!expanded)}
          className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
        >
          {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
          <span className="text-xs font-medium truncate">{headerText}</span>
        </button>
        <button
          type="button"
          onClick={() => void handleCopy()}
          className="p-1 rounded hover:bg-muted text-muted-foreground"
          aria-label="Copy summary"
          title="Copy summary"
        >
          {copied ? <Check size={12} /> : <Copy size={12} />}
        </button>
        <div className="relative">
          <button
            type="button"
            onClick={() => setShareOpen(!shareOpen)}
            className="p-1 rounded hover:bg-muted text-muted-foreground"
            aria-label="Send to chat"
            title="Send to chat"
          >
            <Share2 size={12} />
          </button>
          {shareOpen && (
            <div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
              {otherChats.length === 0 ? (
                <div className="px-3 py-1.5 text-xs text-muted-foreground">
                  No other chats in this session
                </div>
              ) : (
                otherChats.map((c) => (
                  <button
                    key={c.id}
                    type="button"
                    onClick={() => void handleShareToChat(c)}
                    className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
                  >
                    {c.name ?? 'New chat'}
                  </button>
                ))
              )}
            </div>
          )}
        </div>
        <button
          type="button"
          onClick={() => void handleRerun()}
          disabled={rerunning}
          className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
          aria-label="Re-run compact"
          title="Re-run compact"
        >
          <RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
        </button>
      </div>
      {expanded && (
        <div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
          {summaryText}
        </div>
      )}
    </div>
  );
}

// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
// role='assistant', summary=true row. Distinct from legacy CompactCard
// (which renders the kind='compact' system rows produced by v1.10 /compact).
// Collapsed by default; header shows the timestamp; body renders the
// summary markdown when expanded. Copy button matches CompactCard's affordance.
function SummaryCard({ message }: { message: Message }) {
  const [expanded, setExpanded] = useState(false);
  const [copied, setCopied] = useState(false);

  // Use finished_at when available (that's when the summary actually landed);
  // fall back to created_at for any row missing it. Both are ISO strings.
  const ts = message.finished_at ?? message.created_at;
  const headerTs = ts ? new Date(ts).toLocaleString() : '';

  async function handleCopy() {
    try {
      await navigator.clipboard.writeText(message.content);
      setCopied(true);
      setTimeout(() => setCopied(false), 1200);
      toast.success('Summary copied to clipboard');
    } catch {
      toast.error('Copy failed');
    }
  }

  return (
    <div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
      <div className="flex items-center gap-2 px-3 py-2">
        <button
          type="button"
          onClick={() => setExpanded(!expanded)}
          className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
        >
          {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
          <span className="text-xs font-medium truncate">
            Compacted summary — {headerTs}
          </span>
        </button>
        <button
          type="button"
          onClick={() => void handleCopy()}
          className="p-1 rounded hover:bg-muted text-muted-foreground"
          aria-label="Copy summary"
          title="Copy summary"
        >
          {copied ? <Check size={12} /> : <Copy size={12} />}
        </button>
      </div>
      {expanded && (
        <div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
          <MarkdownRenderer content={message.content} />
        </div>
      )}
    </div>
  );
}

// Collapsible "Thinking" block for assistant reasoning. Fed by either
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
// (native inference, persisted from message_parts). Starts COLLAPSED to start
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
// claude SDK) alike — so the transcript stays tidy; click to expand. The
// `streaming` pulse still animates while the turn runs.
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
      >
        {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
        <Brain size={13} />
        <span className="text-xs font-medium">Thinking</span>
        {streaming && (
          <span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
        )}
      </button>
      {expanded && (
        <div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
          {text}
        </div>
      )}
    </div>
  );
}

// feature #12: mistake-recovery sentinel. Inserted by the backend as a
// role='system', metadata.kind='mistake_recovery' row when the model hit
// repeated *different* errors (distinct from doom_loop, which is the same
// call repeated). Visual treatment mirrors CapHitSentinel / DoomLoopSentinel
// (amber card + alert icon). Non-escalated → recovery guidance was injected
// and the turn continues. Escalated → the turn was stopped; if can_continue
// is set, offer the same Continue affordance as the cap-hit sentinel.
// Loose `!= null` guards per the CLAUDE.md coder-message note (coder rows pass
// metadata as undefined, not null).
function MistakeRecoverySentinel({ message }: { message: Message }) {
  const meta = message.metadata;
  const isMistakeRecovery =
    meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery';
  const failureKinds = isMistakeRecovery ? meta.failure_kinds : [];
  const escalated = isMistakeRecovery ? meta.escalated : false;
  const canContinue = isMistakeRecovery ? meta.can_continue === true : false;

  const [continuing, setContinuing] = useState(false);

  async function handleContinue() {
    if (continuing || !canContinue) return;
    setContinuing(true);
    try {
      await api.chats.continue(message.chat_id, message.id);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'continue failed');
    } finally {
      setContinuing(false);
    }
  }

  const kindsLabel =
    Array.isArray(failureKinds) && failureKinds.length > 0
      ? failureKinds.join(', ')
      : null;

  return (
    <div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
      <div className="px-3 py-2 flex items-start gap-2">
        <AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
        <div className="flex-1 min-w-0 space-y-1">
          <div className="text-xs font-medium text-amber-700 dark:text-amber-300">
            {escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'}
          </div>
          <div className="text-xs text-muted-foreground">
            {escalated
              ? 'Repeated errors persisted — stopped the turn.'
              : kindsLabel
                ? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.`
                : 'Hit repeated different errors — recovery guidance injected, continuing.'}
          </div>
          {escalated && canContinue && (
            <div className="pt-1">
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void handleContinue()}
                disabled={continuing}
              >
                {continuing ? 'Continuing…' : 'Continue'}
              </Button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export function MessageBubble({
  message,
  sessionChats,
  capHitInfo,
  actions,
  hideActions,
  hasCheckpoint,
  restoreDisabled,
}: Props) {
  const hiddenSet = new Set(hideActions ?? []);
  // v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
  // branch because summary=true never coexists with kind='compact' (new
  // compactions emit role='assistant' rows with kind='message'+summary=true).
  if (message.summary) {
    return <SummaryCard message={message} />;
  }
  if (message.kind === 'compact') {
    return <CompactCard message={message} sessionChats={sessionChats} />;
  }

  // v1.8.2: cap-hit sentinels render as a distinct system bubble with a
  // Continue button. MessageList's pre-render pass tags each sentinel with
  // its position; only the latest gets the actionable button.
  if (
    message.role === 'system' &&
    message.metadata?.kind === 'cap_hit' &&
    capHitInfo
  ) {
    return (
      <CapHitSentinel
        message={message}
        capHitPosition={capHitInfo.position}
        isLatest={capHitInfo.isLatest}
      />
    );
  }

  // v1.11.6: doom-loop sentinel. No Continue affordance — retrying with the
  // same tools would just re-loop. The card explains what tripped and
  // suggests next steps (new message angle / switch agents).
  if (message.role === 'system' && message.metadata?.kind === 'doom_loop') {
    return <DoomLoopSentinel message={message} />;
  }

  // feature #12: mistake-recovery sentinel. Non-escalated rows narrate that
  // recovery guidance was injected mid-turn; escalated rows report the turn
  // was stopped and (when can_continue) offer the cap-hit-style Continue.
  if (message.role === 'system' && message.metadata?.kind === 'mistake_recovery') {
    return <MistakeRecoverySentinel message={message} />;
  }

  // v1.8.2: tool messages and assistant tool_calls are now rendered by
  // MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
  // this point only if MessageList didn't consume them (shouldn't happen,
  // but guard against it by rendering nothing rather than a stale card).
  if (message.role === 'tool') return null;

  if (message.role === 'user') {
    return (
      <div className="group flex flex-col items-end gap-1">
        <SendToTerminalMenu>
          <div className="boo-user-bubble max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
            {message.content}
          </div>
        </SendToTerminalMenu>
        <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
      </div>
    );
  }

  const isStreaming = message.status === 'streaming';
  const failed = message.status === 'failed';
  // v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
  // assistant turn doesn't render an empty bubble + dangling ActionRow.
  const hasContent = message.content.trim().length > 0;
  // model-attribution chip: short label for the model that produced this turn.
  const modelLabel = shortenModelName(message.model);
  // Reasoning arrives as a pre-joined string (coder wire) or as parts (native
  // inference). Read whichever is present; loose ?? chain tolerates the coder
  // shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
  const reasoningText = (
    message.reasoning_text ??
    message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
    ''
  ).trim();
  const hasReasoning = reasoningText.length > 0;
  // v1.8.2: if metadata stamps an error reason, surface it inline under the
  // generic "message failed" line. Keeps the user's eye where it already is
  // rather than introducing a separate banner.
  const errorMeta =
    message.metadata != null && message.metadata.kind === 'error'
      ? message.metadata
      : null;

  return (
    <div className="group flex flex-col gap-2">
      {hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
      {(hasContent || isStreaming) && (
        <SendToTerminalMenu>
          <div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
            {hasContent ? <MarkdownRenderer content={message.content} /> : null}
            {isStreaming && (
              <span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
            )}
          </div>
        </SendToTerminalMenu>
      )}
      {failed && (
        <div className="text-xs text-destructive">
          message failed
          {errorMeta && (
            <span className="block text-muted-foreground mt-0.5">
              {ERROR_REASON_LABELS[errorMeta.error_reason]}
              {errorMeta.error_text ? ` — ${errorMeta.error_text}` : ''}
            </span>
          )}
        </div>
      )}
      {!isStreaming && (modelLabel || null) && (
        <span
          className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
          title={message.model ?? undefined}
        >
          {modelLabel}
        </span>
      )}
      {!isStreaming && <StatsLine message={message} />}
      {!isStreaming && hasContent && (
        <ActionRow
          message={message}
          actions={actions}
          hiddenSet={hiddenSet}
          hasCheckpoint={hasCheckpoint}
          restoreDisabled={restoreDisabled}
        />
      )}
    </div>
  );
}
