/**
 * Chat Message Component
 * Renders user / assistant / system / toolresult messages
 * with markdown, images, and tool cards. Thinking output is
 * surfaced via ExecutionGraphCard, not inside message bubbles.
 */
import { useState, useCallback, useEffect, memo } from 'react';
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle, Clock3 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { invokeIpc, statFile } from '@/lib/api-client';
import type { RawMessage, AttachedFileMeta } from '@/types/chat';
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
import { getToolVisibilityId } from './chat-transcript';

interface ChatMessageProps {
  message: RawMessage;
  messageKey?: string;
  showTimestamps?: boolean;
  expandedToolIds?: ReadonlySet<string>;
  onToolExpandedChange?: (toolId: string, expanded: boolean) => void;
  assistantAvatarSrc?: string;
  assistantAvatarAlt?: string;
  /**
   * Keep the assistant column aligned without painting a second avatar when
   * the message belongs to an execution trace that already owns the avatar.
   */
  assistantAvatarMode?: 'visible' | 'reserved' | 'reserved-trace';
  /** Reduce the top whitespace when the reply follows its own execution trace. */
  compactAssistantReply?: boolean;
  textOverride?: string;
  suppressToolCards?: boolean;
  suppressProcessAttachments?: boolean;
  /**
   * When true, hides the assistant text bubble (and any thinking block that
   * would be shown above it). Used when the message's text is being folded
   * into an ExecutionGraphCard as a narration step, to prevent the same text
   * from appearing both inside the graph and as an orphan bubble in the chat
   * stream.
   */
  suppressAssistantText?: boolean;
  isStreaming?: boolean;
  streamingTools?: Array<{
    id?: string;
    toolCallId?: string;
    name: string;
    status: 'running' | 'completed' | 'error';
    durationMs?: number;
    summary?: string;
  }>;
  /**
   * Optional callback invoked when a non-image file card is clicked.
   * When provided, the file opens in the in-app preview panel instead of
   * the system default editor.
   */
  onOpenFile?: (file: AttachedFileMeta) => void;
}

interface ExtractedImage { url?: string; data?: string; mimeType: string; }

const DIRECTORY_MIME_TYPE = 'application/x-directory';
const EMPTY_TOOL_IDS: ReadonlySet<string> = new Set<string>();

function isChatPreviewDocument(file: AttachedFileMeta): boolean {
  const name = file.fileName.toLowerCase();
  const mime = file.mimeType.toLowerCase();
  return (
    mime === 'application/pdf'
    || mime === 'application/vnd.ms-excel'
    || mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    || name.endsWith('.pdf')
    || name.endsWith('.xls')
    || name.endsWith('.xlsx')
  );
}

function isDirectoryAttachment(file: AttachedFileMeta): boolean {
  return file.mimeType === DIRECTORY_MIME_TYPE;
}

function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null {
  if (!file.filePath) return null;
  // User-selected uploads and already enriched attachments are trusted enough
  // for immediate display. Regex-derived message refs start at size 0/null and
  // are validated through main-process stat before becoming clickable cards.
  if (file.source !== 'message-ref' && file.source !== 'tool-result') return null;
  if (file.fileSize > 0 || file.preview) return null;
  return isDirectoryAttachment(file) ? 'dir' : 'file';
}

function attachmentIdentity(file: AttachedFileMeta): string {
  return file.filePath?.trim()
    || file.runtimeUrl?.trim()
    || `${file.mimeType}:${file.fileName}:${file.fileSize}`;
}

function normalizeRenderableAttachments(files: AttachedFileMeta[]): AttachedFileMeta[] {
  const normalized: AttachedFileMeta[] = [];
  const indexes = new Map<string, number>();

  for (const file of files) {
    const key = attachmentIdentity(file);
    const existingIndex = indexes.get(key);
    if (existingIndex === undefined) {
      indexes.set(key, normalized.length);
      normalized.push(file);
      continue;
    }

    const existing = normalized[existingIndex];
    if (!existing.preview && file.preview) {
      normalized[existingIndex] = file;
    }
  }

  // Images need actual preview bytes/URLs to be meaningful in the transcript.
  // A metadata-only image record otherwise becomes a misleading blank file tile.
  return normalized.filter((file) => !file.mimeType.startsWith('image/') || Boolean(file.preview));
}

function previewMimeFromPath(filePath: string): string | null {
  const lower = filePath.toLowerCase();
  if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
  if (lower.endsWith('.pdf')) return 'application/pdf';
  if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
  if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  return null;
}

function fileNameFromPath(filePath: string): string {
  return filePath.split(/[\\/]/).pop() || 'file';
}

function trimPathTerminators(filePath: string): string {
  return filePath.replace(/[，。；;,.!?]+$/u, '');
}

function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] {
  if (!text) return [];
  const refs: AttachedFileMeta[] = [];
  const seen = new Set<string>();
  const pushRef = (filePath: string, mimeType: string) => {
    const normalizedPath = trimPathTerminators(filePath);
    if (!normalizedPath || seen.has(normalizedPath)) return;
    seen.add(normalizedPath);
    refs.push({
      fileName: fileNameFromPath(normalizedPath),
      mimeType,
      fileSize: 0,
      preview: null,
      filePath: normalizedPath,
      source: 'message-ref',
    });
  };
  // Deliberately narrow this render-layer fallback to user-facing artifacts.
  const exts = 'pdf|xlsx?|PDF|XLSX?';
  const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g');
  const unixRegex = new RegExp('(?<![\\w./:])((?:\\/|~\\/)[^\\s\\n"\'`()\\[\\],<>]*?\\.(?:' + exts + '))', 'g');
  const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>，。；;,.!?])';
  const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+';
  const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?';
  const skillDirRegex = new RegExp(
    `(?<![\\w./:])((?:~[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathPart})|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathPart}))${skillPathBoundary}`,
    'gi',
  );
  const skillMarkdownRegex = new RegExp(
    `(?<![\\w./:])((?:~[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathTail}\\.md)|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathTail}\\.md))${skillPathBoundary}`,
    'gi',
  );

  let workingText = text;
  let taggedMatch: RegExpExecArray | null;
  while ((taggedMatch = taggedRegex.exec(text)) !== null) {
    const filePath = taggedMatch[1];
    const mimeType = previewMimeFromPath(filePath);
    if (mimeType) pushRef(filePath, mimeType);
    const start = taggedMatch.index;
    const end = start + taggedMatch[0].length;
    workingText = workingText.slice(0, start) + ' '.repeat(end - start) + workingText.slice(end);
  }

  for (const regex of [unixRegex, skillMarkdownRegex, skillDirRegex]) {
    let match: RegExpExecArray | null;
    while ((match = regex.exec(workingText)) !== null) {
      const filePath = match[1];
      const mimeType = regex === skillDirRegex ? DIRECTORY_MIME_TYPE : previewMimeFromPath(filePath);
      if (mimeType) pushRef(filePath, mimeType);
    }
  }

  return refs;
}

/**
 * Normalize LaTeX delimiters so `remark-math` can detect them.
 *
 * Many LLMs emit LaTeX using `\(` / `\)` for inline math and `\[` / `\]`
 * for block math (OpenAI style), which are NOT recognized by remark-math.
 * remark-math only parses `$...$` and `$$...$$`.
 *
 * We convert the backslash-paren/bracket forms to dollar-sign forms so the
 * math is rendered regardless of which convention the model uses.
 *
 * Transformations are skipped inside fenced/inline code spans to avoid
 * clobbering code samples that legitimately contain `\(` etc.
 */
function normalizeLatexDelimiters(input: string): string {
  if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) {
    return input;
  }

  const parts = input.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (!part) continue;
    if (part.startsWith('```') || part.startsWith('`')) continue;
    let next = part.replace(/\\\[([\s\S]+?)\\\]/g, (_m, body: string) => `\n$$\n${body.trim()}\n$$\n`);
    next = next.replace(/\\\(([\s\S]+?)\\\)/g, (_m, body: string) => `$${body}$`);
    parts[i] = next;
  }
  return parts.join('');
}

/** Resolve an ExtractedImage to a displayable src string, or null if not possible. */
function imageSrc(img: ExtractedImage): string | null {
  if (img.url) return img.url;
  if (img.data) return `data:${img.mimeType};base64,${img.data}`;
  return null;
}

export const ChatMessage = memo(function ChatMessage({
  message,
  messageKey,
  showTimestamps = true,
  expandedToolIds = EMPTY_TOOL_IDS,
  onToolExpandedChange,
  assistantAvatarSrc,
  assistantAvatarAlt = '',
  assistantAvatarMode = 'visible',
  compactAssistantReply = false,
  textOverride,
  suppressToolCards = false,
  suppressProcessAttachments = false,
  suppressAssistantText = false,
  isStreaming = false,
  streamingTools = [],
  onOpenFile,
}: ChatMessageProps) {
  const isUser = message.role === 'user';
  const deliveryStatus = isUser ? message._deliveryStatus : undefined;
  const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
  const isToolResult = role === 'toolresult' || role === 'tool_result';
  const shouldRenderAssistantAvatar = !isUser && assistantAvatarMode === 'visible';
  const shouldReserveAssistantAvatar = !isUser
    && (assistantAvatarMode === 'reserved' || assistantAvatarMode === 'reserved-trace');
  const shouldAlignTraceContent = !isUser && assistantAvatarMode === 'reserved-trace';
  const text = textOverride ?? extractText(message);
  // When text is folded into an ExecutionGraphCard, treat the message as
  // having no text for rendering purposes. Keeping this behind a flag (vs
  // blanking `text` outright) lets future hover affordances still read the
  // original content without surfacing the bubble.
  const hideAssistantText = suppressAssistantText && !isUser;
  const hasText = !hideAssistantText && text.trim().length > 0;
  const images = extractImages(message);
  const tools = extractToolUse(message);
  const visibleTools = suppressToolCards ? [] : tools;
  const stableMessageKey = messageKey?.trim()
    ? messageKey
    : (message.id?.trim() ? message.id : undefined);
  const [validatedPaths, setValidatedPaths] = useState<Record<string, boolean>>({});
  const rawAttachedFiles = message._attachedFiles || [];
  const textPreviewFiles = isUser ? [] : extractPreviewDocumentPaths(text);
  const rawAttachedPaths = new Set(rawAttachedFiles.map((file) => file.filePath).filter(Boolean));
  const derivedAttachedFiles = [
    ...rawAttachedFiles,
    ...textPreviewFiles.filter((file) => !file.filePath || !rawAttachedPaths.has(file.filePath)),
  ];
  const validationTargets = derivedAttachedFiles
    .map((file) => {
      const kind = validationKindForAttachment(file);
      return kind && file.filePath ? { filePath: file.filePath, kind } : null;
    })
    .filter((target): target is { filePath: string; kind: 'file' | 'dir' } => !!target);
  const validationKey = validationTargets
    .map((target) => `${target.kind}:${target.filePath}`)
    .sort()
    .join('\n');
  useEffect(() => {
    if (!validationKey) return;
    const pendingTargets = validationTargets.filter((target) => validatedPaths[target.filePath] === undefined);
    if (pendingTargets.length === 0) return;

    let cancelled = false;
    void Promise.all(
      pendingTargets.map(async (target) => {
        try {
          const stat = await statFile(target.filePath);
          return {
            filePath: target.filePath,
            exists: !!stat.ok && (target.kind === 'dir' ? !!stat.isDir : !!stat.isFile),
          };
        } catch {
          return { filePath: target.filePath, exists: false };
        }
      }),
    ).then((results) => {
      if (cancelled) return;
      setValidatedPaths((current) => {
        const next = { ...current };
        for (const result of results) next[result.filePath] = result.exists;
        return next;
      });
    });

    return () => {
      cancelled = true;
    };
  }, [validationKey, validationTargets, validatedPaths]);
  const existingDerivedAttachedFiles = derivedAttachedFiles.filter((file) => {
    const kind = validationKindForAttachment(file);
    if (!kind || !file.filePath) return true;
    return validatedPaths[file.filePath] === true;
  });
  const filteredProcessAttachments = derivedAttachedFiles.filter((file) => {
    if (file.source !== 'tool-result' && file.source !== 'message-ref') return true;
    // Runtime-produced user-facing artifacts (images, PDFs, spreadsheets,
    // skill directories, ...) must remain visible in the reply bubble even
    // when generic process attachments are folded into the execution graph.
    // The graph card itself does not render `_attachedFiles`, so dropping
    // images here would leave the user with no way to see them at all.
    if (file.mimeType.startsWith('image/')) return true;
    return isChatPreviewDocument(file) || isDirectoryAttachment(file);
  });
  // When a message is attachment-only, keep those attachments visible even if
  // process attachments are generally suppressed for this run segment —
  // otherwise the reply disappears entirely.
  const processVisibleAttachments = filteredProcessAttachments.filter((file) => {
    const kind = validationKindForAttachment(file);
    if (!kind || !file.filePath) return true;
    return validatedPaths[file.filePath] === true;
  });
  const attachedFiles = normalizeRenderableAttachments(suppressProcessAttachments && (hasText || images.length > 0 || visibleTools.length > 0)
    ? processVisibleAttachments
    : existingDerivedAttachedFiles);
  const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);

  // Never render tool result messages in chat UI
  if (isToolResult) return null;

  const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
  if (!hasText && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;

  return (
    <div
      className={cn(
        'flex group',
        isUser ? 'flex-row-reverse gap-0' : 'flex-row gap-3',
      )}
    >
      {/* Avatar */}
      {(shouldRenderAssistantAvatar || shouldReserveAssistantAvatar) && (
        <div
          data-testid={shouldRenderAssistantAvatar ? 'chat-message-avatar-assistant' : 'chat-message-avatar-reserved'}
          aria-hidden={shouldReserveAssistantAvatar || undefined}
          className={cn(
            'mt-1 h-8 w-8 shrink-0',
            shouldRenderAssistantAvatar
              && 'flex items-center justify-center overflow-hidden rounded-md border border-foreground/15 bg-surface-tertiary text-foreground shadow-soft',
          )}
        >
          {shouldRenderAssistantAvatar && (
            assistantAvatarSrc ? (
              <img src={assistantAvatarSrc} alt={assistantAvatarAlt} className="h-full w-full object-cover [image-rendering:pixelated]" />
            ) : (
              <Sparkles className="h-4 w-4" />
            )
          )}
        </div>
      )}

      {/* Content */}
      <div
        className={cn(
          'flex w-full min-w-0 flex-col space-y-2',
          isUser ? 'max-w-[78%] items-end' : 'max-w-none flex-1 items-start',
          shouldAlignTraceContent && 'pl-9',
        )}
      >
        {isStreaming && !isUser && streamingTools.length > 0 && (
          <ToolStatusBar tools={streamingTools} />
        )}

        {/* Tool use cards */}
        {visibleTools.length > 0 && (
          <div className="space-y-1">
            {visibleTools.map((tool, i) => (
              <ToolCard
                key={getToolVisibilityId(stableMessageKey, tool.id, i)}
                id={getToolVisibilityId(stableMessageKey, tool.id, i)}
                name={tool.name}
                input={tool.input}
                expanded={expandedToolIds.has(
                  getToolVisibilityId(stableMessageKey, tool.id, i),
                )}
                onExpandedChange={onToolExpandedChange}
              />
            ))}
          </div>
        )}

        {/* Images — rendered ABOVE text bubble for user messages */}
        {/* Images from content blocks */}
        {isUser && images.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {images.map((img, i) => {
              const src = imageSrc(img);
              if (!src) return null;
              return (
                <ImageThumbnail
                  key={`content-${i}`}
                  src={src}
                  fileName="image"
                  base64={img.data}
                  mimeType={img.mimeType}
                  onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
                />
              );
            })}
          </div>
        )}

        {/* File attachments — images above text for user, file cards below */}
        {isUser && attachedFiles.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {attachedFiles.map((file, i) => {
              const isImage = file.mimeType.startsWith('image/');
              // Skip image attachments if we already have images from content blocks
              if (isImage && images.length > 0) return null;
              if (isImage && file.preview) {
                return (
                  <ImageThumbnail
                    key={`local-${i}`}
                    src={file.preview}
                    fileName={file.fileName}
                    filePath={file.filePath}
                    mimeType={file.mimeType}
                    onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
                  />
                );
              }
              // Non-image files → file card
              return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
            })}
          </div>
        )}

        {/* Main text bubble */}
        {hasText && (
          <MessageBubble
            text={text}
            isUser={isUser}
            isStreaming={isStreaming}
            deliveryStatus={deliveryStatus}
            compactAssistantReply={compactAssistantReply}
          />
        )}

        {/* Images from content blocks — assistant messages (below text) */}
        {!isUser && images.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {images.map((img, i) => {
              const src = imageSrc(img);
              if (!src) return null;
              return (
                <ImagePreviewCard
                  key={`content-${i}`}
                  src={src}
                  fileName="image"
                  base64={img.data}
                  mimeType={img.mimeType}
                  onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
                />
              );
            })}
          </div>
        )}

        {/* File attachments — assistant messages (below text) */}
        {!isUser && attachedFiles.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {attachedFiles.map((file, i) => {
              const isImage = file.mimeType.startsWith('image/');
              if (isImage && images.length > 0) return null;
              if (isImage && file.preview) {
                return (
                  <ImagePreviewCard
                    key={`local-${i}`}
                    src={file.preview}
                    fileName={file.fileName}
                    filePath={file.filePath}
                    mimeType={file.mimeType}
                    onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
                  />
                );
              }
              return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
            })}
          </div>
        )}

        {/* Hover row for user messages — timestamp only */}
        {isUser && showTimestamps && message.timestamp && (
          <span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none">
            {formatTimestamp(message.timestamp)}
          </span>
        )}

        {/* Hover row for assistant messages — only when there is real text content */}
        {!isUser && hasText && (
          <AssistantHoverBar
            text={text}
            timestamp={showTimestamps ? message.timestamp : undefined}
          />
        )}
      </div>

      {/* Image lightbox portal */}
      {lightboxImg && (
        <ImageLightbox
          src={lightboxImg.src}
          fileName={lightboxImg.fileName}
          filePath={lightboxImg.filePath}
          base64={lightboxImg.base64}
          mimeType={lightboxImg.mimeType}
          onClose={() => setLightboxImg(null)}
        />
      )}
    </div>
  );
});

function formatDuration(durationMs?: number): string | null {
  if (!durationMs || !Number.isFinite(durationMs)) return null;
  if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
  return `${(durationMs / 1000).toFixed(1)}s`;
}

function ToolStatusBar({
  tools,
}: {
  tools: Array<{
    id?: string;
    toolCallId?: string;
    name: string;
    status: 'running' | 'completed' | 'error';
    durationMs?: number;
    summary?: string;
  }>;
}) {
  return (
    <div className="w-full space-y-1">
      {tools.map((tool) => {
        const duration = formatDuration(tool.durationMs);
        const isRunning = tool.status === 'running';
        const isError = tool.status === 'error';
        return (
          <div
            key={tool.toolCallId || tool.id || tool.name}
            className={cn(
              'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
              isRunning && 'border-primary/30 bg-primary/5 text-foreground',
              !isRunning && !isError && 'border-border/50 bg-muted/20 text-muted-foreground',
              isError && 'border-destructive/30 bg-destructive/5 text-destructive',
            )}
          >
            {isRunning && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />}
            {!isRunning && !isError && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
            {isError && <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
            <Wrench className="h-3 w-3 shrink-0 opacity-60" />
            <span className="font-mono text-xs font-medium">{tool.name}</span>
            {duration && <span className="text-tiny opacity-60">{tool.summary ? `(${duration})` : duration}</span>}
            {tool.summary && (
              <span className="truncate text-tiny opacity-70">{tool.summary}</span>
            )}
          </div>
        );
      })}
    </div>
  );
}

// ── Assistant hover bar (timestamp + copy, shown on group hover) ─

function AssistantHoverBar({ text, timestamp }: { text: string; timestamp?: number }) {
  const [copied, setCopied] = useState(false);

  const copyContent = useCallback(() => {
    navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, [text]);

  return (
    <div className="flex items-center justify-between w-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none px-1">
      <span className="text-xs text-muted-foreground">
        {timestamp ? formatTimestamp(timestamp) : ''}
      </span>
      <Button
        variant="ghost"
        size="icon"
        className="h-6 w-6"
        onClick={copyContent}
      >
        {copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
      </Button>
    </div>
  );
}

// ── Message Bubble ──────────────────────────────────────────────

function MessageBubble({
  text,
  isUser,
  isStreaming,
  deliveryStatus,
  compactAssistantReply,
}: {
  text: string;
  isUser: boolean;
  isStreaming: boolean;
  deliveryStatus?: RawMessage['_deliveryStatus'];
  compactAssistantReply: boolean;
}) {
  const deliveryLabel = deliveryStatus === 'queued'
    ? 'Queued'
    : deliveryStatus === 'sending'
      ? 'Sending'
      : null;

  return (
    <div
      data-testid={isUser ? 'chat-message-bubble-user' : 'chat-message-bubble-assistant'}
      className={cn(
        'relative rounded-2xl border border-foreground/15 px-4 pb-3 shadow-soft',
        !isUser && 'w-full',
        isUser || !compactAssistantReply ? 'pt-3' : 'pt-1',
        isUser
          ? 'chat-user-message-surface bg-brand-soft text-foreground'
          : 'chat-assistant-message-surface bg-background text-foreground',
      )}
    >
      {isUser ? (
        <>
          {deliveryLabel && (
            <div
              className="mb-2 flex items-center justify-end gap-1.5 text-[11px] font-medium leading-none text-foreground/70"
              data-testid={`message-delivery-${deliveryStatus}`}
            >
              {deliveryStatus === 'sending' ? (
                <Loader2 className="h-3 w-3 animate-spin" />
              ) : (
                <Clock3 className="h-3 w-3" />
              )}
              <span>{deliveryLabel}</span>
            </div>
          )}
          <p className="whitespace-pre-wrap break-words break-all text-sm">{text}</p>
        </>
      ) : (
        <div className="prose prose-sm max-w-none break-words break-all text-foreground">
          <ReactMarkdown
            remarkPlugins={[remarkGfm, remarkMath]}
            rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
            components={{
              code({ className, children, ...props }) {
                const match = /language-(\w+)/.exec(className || '');
                const isInline = !match && !className;
                if (isInline) {
                  return (
                    <code className="bg-background/50 px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
                      {children}
                    </code>
                  );
                }
                return (
                  <pre className="bg-background/50 rounded-lg p-4 overflow-x-auto">
                    <code className={cn('text-sm font-mono', className)} {...props}>
                      {children}
                    </code>
                  </pre>
                );
              },
              a({ href, children }) {
                return (
                  <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-words break-all">
                    {children}
                  </a>
                );
              },
            }}
          >
            {normalizeLatexDelimiters(text)}
          </ReactMarkdown>
          {isStreaming && (
            <span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
          )}
        </div>
      )}

    </div>
  );
}

// ── File Card (for user-uploaded non-image files) ───────────────

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

function FileIcon({ mimeType, className }: { mimeType: string; className?: string }) {
  if (mimeType === DIRECTORY_MIME_TYPE) return <FolderOpen className={className} />;
  if (mimeType.startsWith('audio/')) return <Music className={className} />;
  if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') return <FileText className={className} />;
  if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return <FileArchive className={className} />;
  if (mimeType === 'application/pdf') return <FileText className={className} />;
  return <File className={className} />;
}

function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) {
  const handleOpen = useCallback(() => {
    if (!file.filePath) return;
    if (onOpen) {
      onOpen(file);
    } else {
      invokeIpc('shell:openPath', file.filePath);
    }
  }, [file, onOpen]);

  return (
    <div
      className={cn(
        "flex items-center gap-3 rounded-xl border border-border/70 px-3 py-2.5 bg-surface-subtle max-w-[220px]",
        file.filePath && "cursor-pointer hover:bg-surface-tertiary transition-colors"
      )}
      onClick={handleOpen}
      title={file.filePath ? "Open file" : undefined}
    >
      <FileIcon mimeType={file.mimeType} className="h-5 w-5 shrink-0 text-muted-foreground" />
      <div className="min-w-0 overflow-hidden">
        <p className="text-xs font-medium truncate">{file.fileName}</p>
        <p className="text-2xs text-muted-foreground">
          {file.mimeType === DIRECTORY_MIME_TYPE ? 'Folder' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
        </p>
      </div>
    </div>
  );
}

// ── Image Thumbnail (user bubble — square crop with zoom hint) ──

function ImageThumbnail({
  src,
  fileName,
  filePath,
  base64,
  mimeType,
  onPreview,
}: {
  src: string;
  fileName: string;
  filePath?: string;
  base64?: string;
  mimeType?: string;
  onPreview: () => void;
}) {
  void filePath; void base64; void mimeType;
  return (
    <div
      className="relative w-36 h-36 rounded-xl border overflow-hidden border-border/70 bg-surface-subtle group/img cursor-zoom-in"
      onClick={onPreview}
    >
      <img src={src} alt={fileName} className="w-full h-full object-cover" />
      <div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/25 transition-colors flex items-center justify-center">
        <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
      </div>
    </div>
  );
}

// ── Image Preview Card (assistant bubble — natural size with overlay actions) ──

function ImagePreviewCard({
  src,
  fileName,
  filePath,
  base64,
  mimeType,
  onPreview,
}: {
  src: string;
  fileName: string;
  filePath?: string;
  base64?: string;
  mimeType?: string;
  onPreview: () => void;
}) {
  void filePath; void base64; void mimeType;
  return (
    <div
      className="relative max-w-xs rounded-xl border overflow-hidden border-border/70 bg-surface-subtle group/img cursor-zoom-in"
      onClick={onPreview}
    >
      <img src={src} alt={fileName} className="block w-full" />
      <div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors flex items-center justify-center">
        <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
      </div>
    </div>
  );
}

// ── Image Lightbox ───────────────────────────────────────────────

function ImageLightbox({
  src,
  fileName,
  filePath,
  base64,
  mimeType,
  onClose,
}: {
  src: string;
  fileName: string;
  filePath?: string;
  base64?: string;
  mimeType?: string;
  onClose: () => void;
}) {
  void src; void base64; void mimeType; void fileName;

  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [onClose]);

  const handleShowInFolder = useCallback(() => {
    if (filePath) {
      invokeIpc('shell:showItemInFolder', filePath);
    }
  }, [filePath]);

  return createPortal(
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/65 backdrop-blur-sm"
      onClick={onClose}
    >
      {/* Image + buttons stacked */}
      <div
        className="flex flex-col items-center gap-3"
        onClick={(e) => e.stopPropagation()}
      >
        <img
          src={src}
          alt={fileName}
          className="max-w-[90vw] max-h-[85vh] rounded-lg shadow-2xl object-contain"
        />

        {/* Action buttons below image */}
        <div className="flex items-center gap-2">
          {filePath && (
            <Button
              variant="ghost"
              size="icon"
              className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
              onClick={handleShowInFolder}
              title="在文件夹中显示"
            >
              <FolderOpen className="h-4 w-4" />
            </Button>
          )}
          <Button
            variant="ghost"
            size="icon"
            className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
            onClick={onClose}
            title="关闭"
          >
            <X className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>,
    document.body,
  );
}

// ── Tool Card ───────────────────────────────────────────────────

interface ToolCardProps {
  id: string;
  name: string;
  input: unknown;
  expanded: boolean;
  onExpandedChange?: (id: string, expanded: boolean) => void;
}

function ToolCard({
  id,
  name,
  input,
  expanded,
  onExpandedChange,
}: ToolCardProps) {
  return (
    <div className="rounded-xl border border-border/70 bg-surface-subtle text-sm">
      <button
        type="button"
        aria-expanded={expanded}
        aria-label={`工具 ${name}`}
        className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
        onClick={() => onExpandedChange?.(id, !expanded)}
      >
        <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
        <Wrench className="h-3 w-3 shrink-0 opacity-60" />
        <span className="font-mono text-xs">{name}</span>
        {expanded
          ? <ChevronDown className="h-3 w-3 ml-auto" />
          : <ChevronRight className="h-3 w-3 ml-auto" />}
      </button>
      {expanded && input != null && (
        <pre className="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto">
          {typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
        </pre>
      )}
    </div>
  );
}
