'use client';

import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';

import { Textarea } from '@djangocfg/ui-core/components';
import { cn } from '@djangocfg/ui-core/lib';
import { attachComposer } from '@djangocfg/ui-tools/composer-registry';

import { useChatContextOptional } from '../context';
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
import { Attachments } from '../messages/Attachments';
import { AttachProvider } from './AttachContext';
import { ComposerActionBar } from './ComposerActionBar';
import { ComposerButton } from './ComposerButton';
import { ComposerFooter } from './ComposerFooter';
import { ComposerSizeProvider } from './size-context';
import { SlashHighlightTextarea } from './slash/SlashHighlightTextarea';
import { SlashMenu } from './slash/SlashMenu';
import { useSlashCommands } from './slash/useSlashCommands';
import { useComposerActions } from './useComposerActions';
import { useComposerAttach } from './useComposerAttach';
import type {
  ComposerAppearance,
  ComposerAttachConfig,
  ComposerFooterProps,
  ComposerLayout,
  ComposerSize,
  ComposerSlotComponents,
  ComposerSlotProps,
  ComposerSlots,
} from './types';

export type { ComposerSize, ComposerAppearance } from './types';

export interface ComposerProps {
  composer: UseChatComposerReturn;
  placeholder?: string;
  disabled?: boolean;
  /** Render the paperclip attach button. The composer ships a built-in
   *  file picker — paperclip, `+` menu, drag-drop and Ctrl+V paste all
   *  funnel through one validated pipeline. */
  showAttachmentButton?: boolean;
  /** Tune the built-in attach pipeline — accepted types, size / count
   *  caps, paste, and the optional upload transport. */
  attach?: ComposerAttachConfig;
  /** Override the built-in file picker. When set, the paperclip and the
   *  `+`-menu attach item call this instead of opening the native picker
   *  — for hosts that drive their own (e.g. a Wails native dialog). */
  onPickFiles?: () => void;
  className?: string;
  textareaClassName?: string;
  /** Visual size — controls textarea height + button slot size.
   *
   * - ``sm``  — dense compact composer (admin sidebars, etc); defaults
   *   to the `inline` single-row layout.
   * - ``md``  — default. Stacked layout (textarea + action bar).
   * - ``lg``  — generous textarea. Use when the chat is the page's
   *   primary surface (onboarding, support).
   */
  size?: ComposerSize;
  /** Spaciousness of the surface — orthogonal to `size`.
   *
   * - ``compact`` — default; embedded composer (FAB panel, dock).
   * - ``full``    — full-page chat; roomier surface, taller textarea,
   *   larger padding + radius. The host owns the centered max-width
   *   container; the composer just makes itself more spacious.
   */
  appearance?: ComposerAppearance;
  /** Show "Stop" button instead of "Send" while streaming. */
  isStreaming?: boolean;
  onCancel?: () => void;

  // ── Slot system ──────────────────────────────────────────────────────────
  /** Layout geometry. Default `stacked`; `size="sm"` falls back to `inline`. */
  layout?: ComposerLayout;
  /** Tier A — declarative action descriptors + raw-node escape hatches. */
  composerSlots?: ComposerSlots;
  /** Tier B — replace a primitive entirely. */
  slots?: ComposerSlotComponents;
  /** Per-slot prop overrides for the built-in primitives. */
  slotProps?: ComposerSlotProps;
  /** Telegram-style mic↔send swap. Default `true`. */
  micSendSwap?: boolean;
  /** Footer toolbar config below the input surface. `false` hides it. */
  footer?: ComposerFooterProps | false;
}

const SIZE_CLASSES: Record<ComposerSize, {
  surface: string;
  textarea: string;
  text: string;
  containerPadding: string;
  /** inline-layout slot height enforcement */
  inlineSlot: string;
}> = {
  sm: {
    // Symmetric corner inset — px == py so the trailing send button sits
    // equidistant from the surface's right and bottom edges.
    surface: 'rounded-xl px-2 py-2',
    textarea: 'min-h-7 max-h-44 px-1.5 py-1',
    text: 'text-sm',
    containerPadding: 'px-2 pt-1.5 pb-[max(0.375rem,env(safe-area-inset-bottom))]',
    inlineSlot: '[&>:not(textarea)]:h-7',
  },
  md: {
    // Symmetric corner inset — px == py so the trailing send button sits
    // equidistant from the surface's right and bottom edges (was py-1.5,
    // which left the button closer to the bottom than the right).
    surface: 'rounded-2xl px-2 py-2',
    // ~one-line tall (min-h-8) with tight, balanced vertical padding.
    // `py-1.5` (6px) stacked under the surface's own `py-1.5` read like a
    // phantom empty line under a single-line input — `py-1` keeps the
    // text vertically centred without the extra bottom gap.
    textarea: 'min-h-8 max-h-60 px-2 py-1',
    // Comfortable 15px chat body. `text-base sm:text-sm` collapsed to
    // 14px and read too small; 15px matches the rich-editor baseline.
    text: 'text-[15px]',
    containerPadding: 'px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]',
    inlineSlot: '[&>:not(textarea)]:h-9',
  },
  lg: {
    // Symmetric corner inset — px == py so the trailing send button sits
    // equidistant from the surface's right and bottom edges.
    surface: 'rounded-2xl px-3 py-3',
    textarea: 'min-h-12 max-h-72 px-2.5 py-2',
    text: 'text-base',
    containerPadding: 'px-3.5 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))]',
    inlineSlot: '[&>:not(textarea)]:h-12',
  },
};

/** Per-size cluster gap for the stacked grid layout. */
const GAP: Record<ComposerSize, string> = {
  sm: 'gap-0.5',
  md: 'gap-1',
  lg: 'gap-1.5',
};

/** Extra spaciousness layered on top of `size` when `appearance="full"`.
 *  Empty strings for `compact` keep the current geometry untouched. */
const APPEARANCE_CLASSES: Record<ComposerAppearance, {
  surface: string;
  textarea: string;
  text: string;
  containerPadding: string;
}> = {
  compact: { surface: '', textarea: '', text: '', containerPadding: '' },
  full: {
    // Symmetric corner inset — px == py so the trailing send button sits
    // equidistant from the surface's right and bottom edges.
    surface: 'rounded-3xl px-3 py-3',
    textarea: 'min-h-10 max-h-80 px-2.5 py-2',
    text: 'text-base',
    containerPadding: 'px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]',
  },
};

export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Composer(
  {
    composer,
    placeholder = 'Type a message...',
    disabled,
    showAttachmentButton = false,
    attach,
    onPickFiles,
    className,
    textareaClassName,
    size = 'md',
    appearance = 'compact',
    isStreaming: isStreamingProp,
    onCancel: onCancelProp,
    layout: layoutProp,
    composerSlots,
    slots,
    slotProps,
    micSendSwap = true,
    footer,
  },
  ref,
) {
  const ctx = useChatContextOptional();
  const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
  const onCancel = onCancelProp ?? ctx?.cancelStream;
  const isDisabled = disabled ?? isStreaming;
  const sz = SIZE_CLASSES[size];
  // `appearance` is orthogonal to `size` — `full` layers extra
  // spaciousness (radius, padding, textarea height) over the chosen size.
  const ap = APPEARANCE_CLASSES[appearance];
  // `size="sm"` defaults to the single-row `inline` layout unless the
  // host overrides it explicitly — see §3.1 of the redesign doc.
  const layout: ComposerLayout = layoutProp ?? (size === 'sm' ? 'inline' : 'stacked');

  // Publish the composer's imperative handle to the cross-tool
  // composer registry (`@djangocfg/ui-tools/composer-registry`).
  // Consumers (useAutoFocusOnStreamEnd, VoiceComposerSlot) read it
  // from there — works the same inside or outside a ChatProvider.
  const composerFocus = composer.focus;
  const composerSetValue = composer.setValue;
  const textareaRef = composer.textareaRef;
  const getValueRef = useRef<() => string>(() => composer.value);
  getValueRef.current = () => composer.value;
  // A custom `Textarea` slot (e.g. the TipTap `<ComposerRichTextarea>`)
  // has no `<textarea>` — the default `composer.focus` would be a no-op.
  // In that case the slot registers its own imperative handle; skip
  // ours so we don't overwrite it (child effects run before parent).
  const hasCustomTextarea = !!slots?.Textarea;
  useEffect(() => {
    if (hasCustomTextarea) return;
    return attachComposer({
      focus: composerFocus,
      moveCursorToEnd: () => {
        const el = textareaRef.current;
        if (!el) return;
        const end = el.value.length;
        el.setSelectionRange(end, end);
      },
      getValue: () => getValueRef.current(),
      setValue: composerSetValue,
    });
  }, [hasCustomTextarea, composerFocus, composerSetValue, textareaRef]);

  const inlineStart = composerSlots?.inlineStart;
  const inlineEnd = composerSlots?.inlineEnd;
  const blockStart = composerSlots?.blockStart;

  // ── Slash commands ─────────────────────────────────────────────────────
  // When the host wires `composerSlots.slashCommands`, an internal
  // `<SlashMenu>` is rendered as a floating popover above the input
  // surface and ↑/↓/Enter/Tab/Esc are routed to the slash hook while
  // the menu is open. The hook is always called (React rules) — it
  // is a no-op when `commands` is the empty array.
  const slashCommands = composerSlots?.slashCommands?.commands;
  const slashEnabled = !!slashCommands && slashCommands.length > 0;
  const slash = useSlashCommands({
    value: composer.value,
    commands: slashCommands ?? [],
    onApply: composer.setValue,
  });
  const slashOnKeyDown = slash.onKeyDown;
  const slashIsOpen = slashEnabled && slash.isOpen;
  // When slash is enabled and the current buffer is a `/verb` missing
  // a required argument (or an `autoExecute` action), submit is blocked:
  // the Send button reads disabled and Enter is a no-op. The host-facing
  // `composer` is wrapped into `gatedComposer` so every downstream
  // surface (built-in Send, custom SendSlot, TipTap `ComposerRichTextarea`,
  // plain `<Textarea>`) shares the same gate without each having to
  // know about slash state.
  const slashAllowsSubmit = slashEnabled ? slash.canSubmit : true;
  const gatedComposer = useMemo<UseChatComposerReturn>(() => {
    if (slashAllowsSubmit) return composer;
    const baseOnKeyDown = composer.textareaProps.onKeyDown;
    return {
      ...composer,
      canSubmit: false,
      submit: async () => {
        // Slash gate refuses — swallow programmatic submit attempts.
      },
      textareaProps: {
        ...composer.textareaProps,
        onKeyDown: (e) => {
          // Block Enter (without Shift) so the textarea's own submit
          // binding cannot fire while the slash gate is closed. Cmd /
          // Ctrl + Enter is treated the same. Other keys (history,
          // arrows, etc.) pass through to the original handler.
          if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            return;
          }
          baseOnKeyDown(e);
        },
      },
    };
  }, [composer, slashAllowsSubmit]);
  // Capture-phase handler — runs before the textarea / TipTap own
  // submit binding, so Enter / Tab pick a verb instead of sending.
  const handleSlashKeyDownCapture = !slashEnabled
    ? undefined
    : (e: React.KeyboardEvent<HTMLDivElement>) => {
        if (!slash.isOpen) return;
        slashOnKeyDown(e);
      };

  // Clicking the padding around the input (not a button/link) should focus
  // the editor — same affordance as ChatGPT/Gemini. Works for both the
  // plain `<textarea>` and the TipTap (`contenteditable`) backend by
  // querying the focusable node inside the surface itself, so it does not
  // depend on which textarea variant is mounted.
  const surfaceRef = useRef<HTMLDivElement>(null);
  const handleSurfaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
    if (isDisabled) return;
    const target = e.target as HTMLElement;
    // A real interactive element (or the editor itself) handles its own
    // focus — only act on clicks that land on the bare surface padding.
    if (
      target.closest(
        'textarea, input, button, a, [contenteditable="true"], [role="button"], [role="menu"]',
      )
    ) {
      return;
    }
    const editable = surfaceRef.current?.querySelector<HTMLElement>(
      'textarea, [contenteditable="true"]',
    );
    if (!editable) return;
    // Prevent the mousedown from blurring/again-selecting and move focus
    // (caret lands at the end via the textarea's own selection on focus).
    e.preventDefault();
    editable.focus();
  };

  // ── Attach pipeline ────────────────────────────────────────────────────
  // The built-in file picker is mounted whenever the host renders the
  // paperclip OR passes an `attach` config. `onPickFiles` (if given)
  // overrides it — a host driving its own picker (e.g. a Wails dialog).
  const attachEnabled = showAttachmentButton || attach != null;
  const attachHandle = useComposerAttach({
    composer,
    config: attach ?? {},
    disabled: isDisabled,
    pasteScopeRef: surfaceRef,
  });
  // The resolved click handler: host override wins, else the built-in
  // picker. `undefined` when attaching is not enabled at all.
  const pickFiles = !attachEnabled
    ? undefined
    : (onPickFiles ?? attachHandle.openPicker);

  // Drag-drop onto the input surface — same validated path as the picker.
  const [isDragging, setIsDragging] = useState(false);
  const dragDepth = useRef(0);
  const handleDragEnter = (e: React.DragEvent) => {
    if (!attachEnabled || isDisabled) return;
    if (!e.dataTransfer.types.includes('Files')) return;
    e.preventDefault();
    dragDepth.current += 1;
    setIsDragging(true);
  };
  const handleDragOver = (e: React.DragEvent) => {
    if (attachEnabled && !isDisabled && e.dataTransfer.types.includes('Files')) {
      e.preventDefault();
    }
  };
  const handleDragLeave = (e: React.DragEvent) => {
    if (!attachEnabled || isDisabled) return;
    e.preventDefault();
    dragDepth.current = Math.max(0, dragDepth.current - 1);
    if (dragDepth.current === 0) setIsDragging(false);
  };
  const handleDrop = (e: React.DragEvent) => {
    if (!attachEnabled || isDisabled) return;
    e.preventDefault();
    dragDepth.current = 0;
    setIsDragging(false);
    const dropped = Array.from(e.dataTransfer.files ?? []);
    // eslint-disable-next-line no-console
    console.log('[composer] handleDrop files=%d names=%o', dropped.length, dropped.map((f) => f.name));
    if (dropped.length > 0) attachHandle.attachFiles(dropped);
  };

  // Merge built-in send/stop/attach descriptors with host arrays.
  const { actionsStart, actionsEnd } = useComposerActions({
    composer: gatedComposer,
    isStreaming,
    isDisabled,
    showAttachmentButton,
    onPickFiles: pickFiles,
    actionsStart: composerSlots?.actionsStart,
    actionsEnd: composerSlots?.actionsEnd,
    onSend: () => void gatedComposer.submit(),
    onCancel,
    micSendSwap,
  });

  const SendSlot = slots?.SendButton;
  const AttachSlot = slots?.AttachButton;
  const TextareaSlot = slots?.Textarea;
  const ActionBarSlot = slots?.ActionBar;

  // ── Textarea ───────────────────────────────────────────────────────────
  // When slash commands are enabled and the host hasn't supplied its own
  // Textarea slot, swap in the overlay-mirror `<SlashHighlightTextarea>`
  // so a leading `/verb` reads as a styled chip while the user types.
  const sharedTextareaClass = cn(
    'flex-1 resize-none border-0 bg-transparent shadow-none',
    'focus-visible:ring-0 focus-visible:outline-none',
    sz.textarea,
    sz.text,
    ap.textarea,
    ap.text,
    slotProps?.textarea?.className,
    textareaClassName,
  );
  const textareaNode = TextareaSlot ? (
    <TextareaSlot
      composer={gatedComposer}
      placeholder={placeholder}
      disabled={isDisabled}
      size={size}
      className={slotProps?.textarea?.className ?? textareaClassName}
    />
  ) : slashEnabled ? (
    <SlashHighlightTextarea
      composer={gatedComposer}
      placeholder={placeholder}
      disabled={isDisabled}
      size={size}
      textareaClassName={sharedTextareaClass}
    />
  ) : (
    <Textarea
      {...gatedComposer.textareaProps}
      rows={1}
      placeholder={placeholder}
      aria-label={placeholder}
      aria-multiline="true"
      aria-keyshortcuts="Enter"
      disabled={isDisabled}
      className={sharedTextareaClass}
    />
  );

  // ── Action bar ─────────────────────────────────────────────────────────
  const actionBarNode = ActionBarSlot ? (
    <ActionBarSlot
      actionsStart={actionsStart}
      actionsEnd={actionsEnd}
      size={size}
      layout={layout}
      inlineStart={inlineStart}
      inlineEnd={inlineEnd}
    />
  ) : (
    <ComposerActionBar
      actionsStart={actionsStart}
      actionsEnd={actionsEnd}
      size={size}
      layout={layout}
      inlineStart={inlineStart}
      inlineEnd={inlineEnd}
      className={slotProps?.actionBar?.className}
    />
  );

  // Tier B fully-custom Send / Attach replacements override the
  // descriptor-driven action bar. They render alongside it for hosts
  // that need a branded button — the built-in send still lives in
  // `actionsEnd` unless the host also drops it from `composerSlots`.
  const customSend = SendSlot ? (
    <SendSlot
      streaming={isStreaming}
      disabled={!gatedComposer.canSubmit}
      size={size}
      onSend={() => void gatedComposer.submit()}
      onCancel={onCancel}
      {...slotProps?.send}
    />
  ) : null;
  const customAttach =
    AttachSlot && showAttachmentButton ? (
      <AttachSlot
        disabled={isDisabled}
        size={size}
        onClick={() => pickFiles?.()}
        {...slotProps?.attach}
      />
    ) : null;

  // Hidden native file input — the picker the paperclip + `+` menu open.
  // Rendered once per composer; only present when the pipeline is active
  // and no host override is in play (an override owns its own picker).
  const hiddenFileInput =
    attachEnabled && !onPickFiles ? (
      <input {...attachHandle.inputProps} />
    ) : null;

  // ── Footer ─────────────────────────────────────────────────────────────
  const footerNode =
    footer === false ? null : (
      <ComposerFooter
        {...footer}
        value={composer.value}
        size={footer?.size ?? size}
      />
    );

  const trayNode =
    blockStart ??
    (composer.attachments.length > 0 ? (
      <Attachments
        attachments={composer.attachments}
        // Route through the attach pipeline so a removed attachment's
        // object-URL is revoked; falls back to a plain remove when the
        // pipeline is not mounted (host-managed attachments).
        onRemove={(a) =>
          attachEnabled
            ? attachHandle.removeAttachment(a.id)
            : composer.removeAttachment(a.id)
        }
      />
    ) : null);

  // ── Inline (compact single-row) layout ─────────────────────────────────
  // Drag-drop handlers shared by both layouts. No-op when attaching is
  // disabled — the handlers themselves early-return.
  //
  // `data-wails-dropzone` is load-bearing inside the Wails desktop shell:
  // its runtime gates DOM file drops on a per-element basis. On `dragover`
  // it walks `elementFromPoint(...).closest('[data-wails-dropzone]')` and
  // sets `dataTransfer.dropEffect = 'copy'` ONLY when the cursor is over a
  // marked element — otherwise it forces `dropEffect = 'none'`, and the
  // browser then REJECTS the `drop` on that element (the React `onDrop`
  // never fires). Marking the composer surface makes the runtime allow the
  // drop here, exactly like the debug drop-test harness. Harmless no-op in
  // a plain browser (it's just an unknown data-attribute there).
  const dragProps: React.HTMLAttributes<HTMLDivElement> & {
    'data-wails-dropzone': string;
  } = {
    'data-wails-dropzone': '',
    onDragEnter: handleDragEnter,
    onDragOver: handleDragOver,
    onDragLeave: handleDragLeave,
    onDrop: handleDrop,
  };
  // A faint accent wash + ring while a file is dragged over the composer.
  const dragOverlay =
    isDragging ? (
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-primary/5 ring-2 ring-inset ring-primary/40"
      />
    ) : null;

  // Click outside the slash menu (and outside the composer surface)
  // dismisses it. Mounted only while the menu is open to keep the
  // listener footprint minimal.
  const slashMenuRef = useRef<HTMLDivElement>(null);
  const slashClear = slash.clear;
  useEffect(() => {
    if (!slashIsOpen) return;
    const onPointerDown = (e: MouseEvent) => {
      const target = e.target as Node | null;
      if (!target) return;
      if (slashMenuRef.current?.contains(target)) return;
      // Clicks inside the input surface (textarea, action buttons) are
      // fine — only outside-of-composer interactions should dismiss.
      // `surfaceRef` is only mounted in the stacked layout; fall back
      // to the active editable node so the inline layout works too.
      if (surfaceRef.current?.contains(target)) return;
      const editable = composer.textareaRef.current;
      if (editable && editable.contains(target)) return;
      slashClear();
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [slashIsOpen, slashClear, composer.textareaRef]);

  // Slash-command floating popover. Anchored to the top of the input
  // surface; appears just above it so it doesn't cover the textarea.
  const slashOverlay =
    slashIsOpen ? (
      <div
        className={cn(
          'pointer-events-auto absolute left-2.5 right-2.5 z-20',
          'bottom-full mb-1.5',
        )}
      >
        <SlashMenu
          ref={slashMenuRef}
          matches={slash.matches}
          highlight={slash.highlight}
          query={slash.query}
          onPick={slash.pick}
          onHighlight={slash.setHighlight}
        />
      </div>
    ) : null;

  // Start cluster sits left of the textarea, end cluster right of it —
  // `[📎][actionsStart] [textarea] [actionsEnd][🎙][▶]` (§3.6).
  if (layout === 'inline') {
    return (
      <ComposerSizeProvider value={size}>
       <AttachProvider value={attachHandle}>
        <div
          ref={ref}
          {...dragProps}
          onKeyDownCapture={handleSlashKeyDownCapture}
          className={cn(
            'relative border-t border-border bg-background/95',
            sz.containerPadding,
            ap.containerPadding,
            className,
          )}
        >
          {hiddenFileInput}
          {dragOverlay}
          {slashOverlay}
          {trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
          <div
            className={cn(
              'flex items-end gap-1.5 [&>:not(textarea)]:shrink-0',
              sz.inlineSlot,
            )}
          >
            {customAttach}
            {ActionBarSlot ? (
              actionBarNode
            ) : (
              <ComposerActionBar
                actionsStart={actionsStart}
                actionsEnd={[]}
                size={size}
                layout={layout}
                inlineStart={inlineStart}
                cluster="start"
              />
            )}
            {textareaNode}
            {ActionBarSlot ? null : (
              <ComposerActionBar
                actionsStart={[]}
                actionsEnd={actionsEnd}
                size={size}
                layout={layout}
                inlineEnd={inlineEnd}
                cluster="end"
              />
            )}
            {customSend}
          </div>
          {footerNode}
        </div>
       </AttachProvider>
      </ComposerSizeProvider>
    );
  }

  // ── Stacked (default) layout — two-tier surface ────────────────────────
  // The textarea always occupies the full top row; the action bar (attach /
  // tools on the left, mic / send on the right) sits in a fixed row below.
  // The draft never shares a baseline with the buttons — the text stays put
  // whether it is one line or many.
  const surfaceWrapper = (
    <div
      ref={surfaceRef}
      onMouseDown={handleSurfaceMouseDown}
      className={cn(
        // Raised input panel — a *gentle* elevation over the chat
        // background, not a hard box. `card` is the "slightly elevated
        // surface" token (one step up from `background`); `muted` is the
        // chips/fills token and sat far too light, reading as a
        // high-contrast block on the dark macOS theme. A softened
        // hairline ring (`border/60`) keeps the edge whisper-quiet
        // (Gemini/ChatGPT/Claude style). Focus firms the ring up.
        'flex flex-col rounded-2xl text-foreground',
        'bg-card ring-1 ring-border/60',
        'focus-within:ring-ring/50',
        'transition-colors',
        // The whole surface reads as a text field — the padding around the
        // editor shows a text caret and is click-to-focus.
        'cursor-text',
        sz.surface,
        ap.surface,
      )}
    >
      {/* Tier B: a fully-custom ActionBar renders below the textarea. */}
      {ActionBarSlot ? (
        <>
          {textareaNode}
          <div className="mt-1 flex items-center gap-1.5">
            {customAttach}
            {actionBarNode}
            {customSend}
          </div>
        </>
      ) : (
        <>
          {/* primary — textarea, full width on top */}
          <div className="flex">{textareaNode}</div>

          {/* action bar — leading cluster left, trailing cluster right */}
          <div className={cn('mt-1 flex items-center justify-between', GAP[size])}>
            <div className={cn('flex items-center', GAP[size])}>
              {customAttach}
              {inlineStart}
              {actionsStart.map((action) => (
                <ComposerButton key={action.id} action={action} size={size} />
              ))}
            </div>
            <div className={cn('flex items-center', GAP[size])}>
              {inlineEnd}
              {actionsEnd.map((action) => (
                <ComposerButton key={action.id} action={action} size={size} />
              ))}
              {customSend}
            </div>
          </div>
        </>
      )}
    </div>
  );

  return (
    <ComposerSizeProvider value={size}>
     <AttachProvider value={attachHandle}>
      <div
        ref={ref}
        {...dragProps}
        onKeyDownCapture={handleSlashKeyDownCapture}
        className={cn(
          'relative border-t border-border bg-background/95',
          sz.containerPadding,
          ap.containerPadding,
          className,
        )}
      >
        {hiddenFileInput}
        {dragOverlay}
        {slashOverlay}
        {trayNode ? <div className="mb-2">{trayNode}</div> : null}
        {surfaceWrapper}
        {footerNode}
      </div>
     </AttachProvider>
    </ComposerSizeProvider>
  );
});
