'use client';

import { memo, useMemo, type ReactNode } from 'react';

import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components';
import { cn } from '@djangocfg/ui-core/lib';

import { MarkdownMessage, urlChipRule, type LinkRule } from '../../dev/code/MarkdownMessage';
import type {
  ChatAssistantContext,
  ChatAttachment,
  ChatMessage,
  ChatSource,
  ChatToolCall,
  ChatUserContext,
} from '../types';
import { resolvePersona, deriveInitials } from '../core/persona';
import { useChatContextOptional } from '../context';
import { useChatBubbleStyles } from '../styles';
import { StreamingIndicator } from './StreamingIndicator';
import { Sources } from './Sources';
import { ToolCalls } from './ToolCalls';
import {
  AttachmentsGrid,
  AttachmentsList,
  type AttachmentRendererMap,
} from './Attachments';
import { MessageActions } from './MessageActions';
import {
  BubbleContextMenu,
  buildDefaultBubbleMenuItems,
  useResolvedBubbleMenuItems,
  type BubbleMenuItem,
} from './BubbleContextMenu';
import { MessageBlocks } from './blocks';
import { AutoLinkPreview } from './AutoLinkPreview';
import type { BlockRegistry } from './blocks';
import type { ToolCallsProps } from './ToolCalls';
import type { ComposerAppearance } from '../composer/types';

export interface MessageBubbleProps {
  message: ChatMessage;
  isUser?: boolean;
  showAvatar?: boolean;
  /** Override avatar URL (skips persona resolution). */
  avatarSrc?: string;
  /** Override avatar fallback (skips persona resolution). */
  avatarFallback?: ReactNode;
  /** Personas — when provided, take precedence over context. */
  user?: ChatUserContext;
  assistant?: ChatAssistantContext;
  showTimestamp?: boolean;
  /**
   * Render the legacy under-bubble action button row (copy / regenerate /
   * edit / delete). Default `false` now — those actions live in the
   * right-click / long-press context menu (see `bubbleMenuItems`). Set
   * `true` to bring the hover button row back alongside the menu.
   */
  showActions?: boolean;
  /**
   * Disable the right-click / long-press context menu entirely (e.g. a
   * read-only transcript). Default `false` — the menu is on.
   */
  disableContextMenu?: boolean;
  /**
   * Host-injected EXTRA context-menu items, merged after the built-in
   * Copy / Regenerate / Delete defaults (a divider is inserted between).
   * Each carries its own typed `onSelect(message)` callback — e.g.
   * "Reveal in Finder", "Copy as markdown", "Inspect". Ignored when
   * `getBubbleMenuItems` is set (that gives the host full control).
   */
  bubbleMenuItems?: BubbleMenuItem[];
  /**
   * Full control over the bubble's context menu. Receives the message and
   * the built-in default items so the host can keep / reorder / drop /
   * extend them and return the final ordered list. Beats `bubbleMenuItems`.
   */
  getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[];
  isCompact?: boolean;
  /** Spaciousness of the bubble — `full` scales padding, text, avatar up
   * for full-page chat. `compact` (default) keeps the current geometry. */
  appearance?: ComposerAppearance;
  className?: string;
  beforeContent?: ReactNode;
  afterContent?: ReactNode;
  toolCallsRenderer?: (calls: ChatToolCall[]) => ReactNode;
  /** Forwarded to the default `<ToolCalls>` when `toolCallsRenderer` is not set. */
  toolCallsProps?: Omit<ToolCallsProps, 'calls'>;
  sourcesRenderer?: (sources: ChatSource[]) => ReactNode;
  attachmentsRenderer?: (atts: ChatAttachment[]) => ReactNode;
  /** Per-type attachment renderers forwarded to default `<Attachments>`. */
  attachmentRenderers?: AttachmentRendererMap;
  /**
   * Registry of `kind` → renderer for `message.blocks`. Prop beats
   * context; both fall back to `BUILTIN_BLOCK_REGISTRY`.
   */
  blockRegistry?: BlockRegistry;
  /** Click handler for attachment tiles (e.g. open lightbox). */
  onAttachmentOpen?: (a: ChatAttachment) => void;
  onCopy?: () => void;
  onRegenerate?: () => void;
  onEdit?: () => void;
  onDelete?: () => void;
  /**
   * Extra content rendered alongside the default copy/regenerate/edit/
   * delete actions (after them, on the same row). Hosts pass app-
   * specific affordances here — e.g. "Send to plan", "Add note",
   * "Open in inspector" — without having to fork `<MessageActions>`.
   * Receives the message so renderers can branch by id / role / etc.
   * Plan64.
   */
  messageActionsExtra?: (m: ChatMessage) => ReactNode;
  /**
   * Override the default streaming indicator (the dots / pulse + tool
   * activity label). Receives the message so the host can read
   * `toolActivity`, the running tool call name, etc., and render a
   * richer affordance ("Running cmd_execute on vps-audi…"). Plan64.
   *
   * Renders in two slots: as the in-bubble pre-token affordance, and
   * as the inline label above the bubble when `toolActivity` is set.
   * The render-prop is called for both — branch on `m.content` if
   * you need different behaviour per slot.
   */
  streamingIndicator?: (m: ChatMessage) => ReactNode;
  /**
   * Render arbitrary content beneath the message body, regardless of
   * whether the message carries tool calls. Used for product widgets
   * that ride a side channel (e.g. `ui_payload` SSE frames driving
   * vehicle cards, tax tables, charts) and therefore can't piggy-back
   * on the `toolCallsRenderer` slot — the latter is gated on the
   * message having a non-empty `toolCalls` array, which doesn't hold
   * when raw tool events are hidden from public clients.
   *
   * Receives the message so the host can scope the widget by id /
   * role / timing. Renders even on streaming messages, so progressive
   * UI hints (skeletons that fill in as payloads arrive) work as
   * expected.
   */
  renderAfterMessage?: (m: ChatMessage) => ReactNode;
}

/** Extra spaciousness layered on top of the default bubble geometry when
 *  `appearance="full"`. Empty strings for `compact` keep every pixel of the
 *  current geometry untouched (backward-compat). */
const APPEARANCE_CLASSES: Record<ComposerAppearance, {
  row: string;
  avatar: string;
  avatarFallback: string;
  /** Max-width cap on the whole message column (text + blocks + media),
   *  so a single message never spans the full row — ChatGPT/Claude style. */
  content: string;
  bubble: string;
  meta: string;
}> = {
  compact: { row: '', avatar: '', avatarFallback: '', content: 'max-w-[34rem]', bubble: '', meta: '' },
  full: {
    row: 'gap-4',
    avatar: 'size-10',
    avatarFallback: 'text-sm',
    content: 'max-w-[44rem]',
    // Roomy ChatGPT-style bubble: larger text with generous line-height
    // and padding so a full-page chat reads spacious, not just bigger.
    bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed',
    meta: 'text-xs',
  },
};

const MessageBubbleInner = ({
  message,
  isUser: isUserProp,
  showAvatar = true,
  avatarSrc,
  avatarFallback,
  user,
  assistant,
  showTimestamp = false,
  showActions = false,
  disableContextMenu = false,
  bubbleMenuItems,
  getBubbleMenuItems,
  isCompact = false,
  appearance = 'compact',
  className,
  beforeContent,
  afterContent,
  toolCallsRenderer,
  toolCallsProps,
  sourcesRenderer,
  attachmentsRenderer,
  attachmentRenderers,
  blockRegistry,
  onAttachmentOpen,
  onCopy,
  onRegenerate,
  onEdit,
  onDelete,
  messageActionsExtra,
  streamingIndicator,
  renderAfterMessage,
}: MessageBubbleProps) => {
  const isUser = isUserProp ?? message.role === 'user';
  const isStreaming = !!message.isStreaming;
  const isErr = !!message.isError;
  const { surface: bubbleSurface } = useChatBubbleStyles(
    isUser ? 'user' : 'assistant',
    isErr,
  );

  const ctx = useChatContextOptional();
  // Prop beats context; `<MessageBlocks>` falls back to the built-in
  // registry when both are absent.
  const resolvedBlockRegistry = blockRegistry ?? ctx?.blockRegistry;

  // Auto link-preview (Telegram-style) is rendered below the content via the
  // shared <AutoLinkPreview> slot (so a forked bubble shares the exact same
  // detect+render and can't drift). See AutoLinkPreview.tsx.

  // URL chips (plan22): when the host opts in via `config.linkChips`, render
  // bare web URLs in the bubble as compact favicon chips (matching the
  // composer). The chip rule only claims bare `http(s)` autolinks; labeled
  // links keep their text. Appended last so any host rules would win.
  const linkRules = useMemo<readonly LinkRule[] | undefined>(
    () => (ctx?.config.linkChips ? [urlChipRule] : undefined),
    [ctx?.config.linkChips],
  );

  const persona = resolvePersona(
    message,
    user ?? ctx?.config.user,
    assistant ?? ctx?.config.assistant,
  );
  const initials = deriveInitials(persona, message.role);
  const personaName = persona.name ?? (isUser ? 'You' : 'Assistant');
  // `appearance` scales the whole bubble (padding, text, avatar) so a
  // full-page chat reads like ChatGPT/Gemini. `compact` = no changes.
  const ap = APPEARANCE_CLASSES[appearance];

  // Right-click / long-press menu. The built-in Copy / Regenerate /
  // Delete items wire to the same handlers the old button row used;
  // hosts merge extras via `bubbleMenuItems` or take full control via
  // `getBubbleMenuItems`. Suppressed while streaming (no actions yet) and
  // when the host opts out. Memoised so the menu doesn't rebuild every
  // token delta.
  const defaultMenuItems = useMemo(
    () =>
      buildDefaultBubbleMenuItems({
        role: message.role,
        onCopy,
        onRegenerate,
        // Edit — user bubbles only (gated inside the builder). Just fires
        // the host trigger; the host loads the text into the composer.
        onEdit,
        onDelete,
      }),
    [message.role, onCopy, onRegenerate, onEdit, onDelete],
  );
  const menuItems = useResolvedBubbleMenuItems(
    message,
    defaultMenuItems,
    bubbleMenuItems,
    getBubbleMenuItems,
  );
  const menuEnabled = !disableContextMenu && !isStreaming && menuItems.length > 0;

  return (
    <div
      role="article"
      aria-label={`${personaName} said: ${message.content.slice(0, 80)}`}
      aria-busy={isStreaming || undefined}
      data-role={message.role}
      className={cn(
        // Selection containment (plan138 follow-up). The whole row —
        // and EVERY wrapper down to the bubble — is `select-none`; only
        // the innermost prose element (`<MarkdownMessage>`) re-enables
        // `select-text`. Putting `select-text` on the bubble DIV (the
        // old approach) made the bubble's own padding selectable, so a
        // drag could ride out of one bubble's text and grab the next
        // bubble's top-padding strip — the two bubbles were one
        // contiguous selectable region. With the text island as the
        // ONLY selectable node, the range can't bridge the
        // non-selectable padding/gap between two messages.
        'group/msg flex select-none gap-2.5 px-2.5 py-2',
        ap.row,
        isUser ? 'flex-row-reverse' : 'flex-row',
        className,
      )}
    >
      {showAvatar ? (
        <Avatar
          className={cn('size-7 shrink-0', ap.avatar)}
          title={persona.description ?? personaName}
        >
          {avatarSrc || persona.avatarUrl ? (
            <AvatarImage src={avatarSrc ?? persona.avatarUrl} alt={personaName} />
          ) : null}
          <AvatarFallback className={cn('text-[10px]', ap.avatarFallback)}>
            {avatarFallback ?? initials}
          </AvatarFallback>
        </Avatar>
      ) : null}

      <div
        className={cn(
          'min-w-0 flex-1',
          ap.content,
          isUser && 'flex flex-col items-end',
        )}
      >
        {beforeContent}
        {message.attachments?.length
          ? attachmentsRenderer
            ? attachmentsRenderer(message.attachments)
            : (
                <div className="mb-1.5 w-full">
                  {attachmentRenderers ? (
                    <AttachmentsList
                      attachments={message.attachments}
                      renderers={attachmentRenderers}
                      onClick={onAttachmentOpen}
                      className={isUser ? 'items-end' : undefined}
                    />
                  ) : (
                    <AttachmentsGrid
                      attachments={message.attachments}
                      onClick={onAttachmentOpen}
                      className={isUser ? 'justify-end' : undefined}
                    />
                  )}
                </div>
              )
          : null}

        <BubbleContextMenu
          message={message}
          items={menuEnabled ? menuItems : []}
          // `inline-block` so the trigger hugs the bubble (right-click +
          // long-press land on the bubble, not the full-width column).
          className={cn('inline-block max-w-full', isUser && 'self-end')}
        >
          <div
            className={cn(
              // Bubble surface stays `select-none` (inherited from the
              // row) — its padding is NOT a selectable region, so a drag
              // can't extend from this bubble's text into the next
              // bubble's padding. `<MarkdownMessage>` inside re-enables
              // `select-text` on the prose itself: the text is the only
              // selection island, bounded to this one message.
              'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
              ap.bubble,
              bubbleSurface,
            )}
          >
            {isStreaming && message.toolActivity ? (
              <div className="mb-1.5">
                {streamingIndicator
                  ? streamingIndicator(message)
                  : <StreamingIndicator label={message.toolActivity} />}
              </div>
            ) : null}

            {message.content || !isStreaming ? (
              <MarkdownMessage
                content={message.content || (isErr ? '*Failed to generate a response.*' : '')}
                isUser={isUser}
                size={appearance === 'full' ? 'base' : isCompact ? 'xs' : 'sm'}
                // Render markdown LIVE during streaming (formatted as
                // tokens arrive) instead of forcing the raw plain-text
                // branch. `streaming` enables block-split + per-block
                // memoization (O(n), not O(n²)) and boundary-buffers the
                // in-flight trailing block so half-written syntax doesn't
                // flash. The same sanitized pipeline runs in both phases.
                streaming={isStreaming}
                linkRules={linkRules}
              />
            ) : (
              streamingIndicator ? streamingIndicator(message) : <StreamingIndicator />
            )}
          </div>
        </BubbleContextMenu>

        {message.blocks?.length ? (
          <MessageBlocks
            blocks={message.blocks}
            registry={resolvedBlockRegistry ?? undefined}
            appearance={appearance}
            isUser={isUser}
          />
        ) : null}

        <AutoLinkPreview
          message={message}
          isStreaming={isStreaming}
          isError={isErr}
          isUser={isUser}
          appearance={appearance}
        />

        {message.toolCalls?.length
          ? toolCallsRenderer
            ? toolCallsRenderer(message.toolCalls)
            : <ToolCalls calls={message.toolCalls} {...toolCallsProps} />
          : null}

        {renderAfterMessage ? renderAfterMessage(message) : null}

        {message.sources?.length && !isStreaming
          ? sourcesRenderer
            ? sourcesRenderer(message.sources)
            : <Sources sources={message.sources} />
          : null}

        {showActions && !isStreaming ? (
          <div className="flex items-center gap-0.5">
            <MessageActions
              role={message.role}
              onCopy={onCopy}
              onRegenerate={onRegenerate}
              onEdit={onEdit}
              onDelete={onDelete}
            />
            {messageActionsExtra ? messageActionsExtra(message) : null}
          </div>
        ) : null}

        {showTimestamp ? (
          <div className={cn('mt-1 text-[10px] text-muted-foreground', ap.meta)}>
            {new Date(message.createdAt).toLocaleTimeString()}
          </div>
        ) : null}

        {afterContent}
      </div>
    </div>
  );
};

export const MessageBubble = memo(MessageBubbleInner, (prev, next) => {
  const a = prev.message;
  const b = next.message;
  return (
    a.id === b.id &&
    a.content === b.content &&
    a.isStreaming === b.isStreaming &&
    a.isError === b.isError &&
    (a.version ?? 0) === (b.version ?? 0) &&
    a.toolActivity === b.toolActivity &&
    a.toolCalls === b.toolCalls &&
    a.sources === b.sources &&
    a.attachments === b.attachments &&
    a.blocks === b.blocks &&
    // Context-menu config — handlers/extras are expected to be stable
    // (useCallback / module const), so a reference change means the host
    // wants a different menu and we must re-render.
    prev.bubbleMenuItems === next.bubbleMenuItems &&
    prev.getBubbleMenuItems === next.getBubbleMenuItems &&
    prev.disableContextMenu === next.disableContextMenu &&
    prev.showActions === next.showActions
  );
});
MessageBubble.displayName = 'MessageBubble';
