'use client';

import {
  type ReactNode,
  type RefObject,
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
} from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';

import { cn } from '@djangocfg/ui-core/lib';
import { Spinner } from '@djangocfg/ui-core/components';
import { useCopy } from '@djangocfg/ui-core/hooks';

import type { ChatMessage } from '../types';
import { useChatContextOptional } from '../context';
import { MessageBubble } from './MessageBubble';

export interface MessageListProps {
  messages?: ChatMessage[];
  renderItem?: (m: ChatMessage, i: number) => ReactNode;
  renderEmpty?: () => ReactNode;
  isLoadingMore?: boolean;
  /**
   * Fires when the user scrolls within `topThresholdPx` of the top of
   * the list — wire to your `loadMore()` here. Replaces the previous
   * `topSentinelRef` API. The callback is gated by Virtuoso, so it
   * won't fire repeatedly while a load is in flight (Virtuoso pauses
   * `startReached` until `data` length grows).
   */
  onStartReached?: () => void;
  /**
   * @deprecated Kept as a no-op for backwards compatibility — wire
   * `onStartReached` instead. Virtuoso owns the scroll viewport now,
   * external sentinels never see scroll events.
   */
  topSentinelRef?: RefObject<HTMLDivElement | null>;
  /**
   * @deprecated Kept as a no-op for backwards compatibility — Virtuoso
   * exposes `scrollToIndex` via the imperative handle instead. See
   * `useChatScroll` for the chat-friendly wrapper.
   */
  bottomRef?: RefObject<HTMLDivElement | null>;
  className?: string;
  itemClassName?: string;
  /**
   * Skip virtualization and render through plain `.map()`. Use for
   * stories / debugging where DevTools needs to see every node, or
   * when `messages` is guaranteed-tiny. Default: `false` — virtualize
   * always. Plan64.
   */
  noVirtualize?: boolean;
  /**
   * @deprecated No-op as of 2.1.373. We intentionally let Virtuoso run
   * its first-item probe pass: cmdop bubbles vary from 40px (one-line
   * user message) to 800px (markdown with code blocks), so any single
   * estimate fed here forces a measure/reshape loop on every render.
   * The kept prop avoids a breaking-change bump for hosts that still
   * pass it explicitly.
   */
  defaultItemHeight?: number;
  /**
   * Fires when the viewport sticky state changes — `true` when the
   * user is pinned to the bottom (streaming token deltas keep them
   * there), `false` once they scroll up. Wire to your "Jump to
   * latest" affordance via the inverse: render the pill when
   * `!isAtBottom`. Plan64.
   */
  onAtBottomChange?: (isAtBottom: boolean) => void;
}

export interface MessageListHandle {
  scrollToBottom: (smooth?: boolean) => void;
  scrollToIndex: (index: number, smooth?: boolean) => void;
}

export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
  {
    messages: messagesProp,
    renderItem,
    renderEmpty,
    isLoadingMore: isLoadingMoreProp,
    onStartReached,
    className,
    itemClassName,
    noVirtualize = false,
    defaultItemHeight: _deprecatedDefaultItemHeight,
    onAtBottomChange,
  },
  ref,
) {
  void _deprecatedDefaultItemHeight;
  const ctx = useChatContextOptional();
  const messages = messagesProp ?? ctx?.messages ?? [];
  const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false;
  const { copyToClipboard } = useCopy();

  const virtuosoRef = useRef<VirtuosoHandle | null>(null);
  // Track whether we've already landed on the bottom for the initial
  // history. Virtuoso's `initialTopMostItemIndex` only fires on first
  // mount and uses the `messages` length at that moment. Chats almost
  // always mount with `messages=[]` and then receive history one
  // dispatch later — so we ALSO fire an imperative scroll when the
  // first non-empty batch arrives. Subsequent updates fall through to
  // `followOutput` (sticky-bottom while the user is at bottom).
  const didInitialScrollRef = useRef(false);

  useImperativeHandle(
    ref,
    () => ({
      scrollToBottom: (smooth = false) => {
        virtuosoRef.current?.scrollToIndex({
          index: 'LAST',
          behavior: smooth ? 'smooth' : 'auto',
          align: 'end',
        });
      },
      scrollToIndex: (index, smooth = false) => {
        virtuosoRef.current?.scrollToIndex({
          index,
          behavior: smooth ? 'smooth' : 'auto',
        });
      },
    }),
    [],
  );

  const defaultRenderItem = useCallback(
    (m: ChatMessage) => (
      <div className={itemClassName}>
        <MessageBubble
          message={m}
          onCopy={() => void copyToClipboard(m.content)}
          onRegenerate={ctx ? () => void ctx.regenerate(m.id) : undefined}
          onDelete={ctx ? () => ctx.deleteMessage(m.id) : undefined}
        />
      </div>
    ),
    [itemClassName, ctx, copyToClipboard],
  );

  const itemRenderer = renderItem ?? defaultRenderItem;
  // Jump to bottom on the first non-empty messages batch. Wrapped in
  // rAF so virtuoso has measured the new items before we ask it to
  // scroll — otherwise the call happens while the list is still
  // mid-layout and lands on the wrong offset.
  useEffect(() => {
    if (didInitialScrollRef.current) return;
    if (messages.length === 0) return;
    didInitialScrollRef.current = true;
    const id = requestAnimationFrame(() => {
      virtuosoRef.current?.scrollToIndex({
        index: 'LAST',
        align: 'end',
        behavior: 'auto',
      });
    });
    return () => cancelAnimationFrame(id);
  }, [messages.length]);

  // Virtuoso may invoke `computeItemKey` for an index briefly out of
  // sync with `data` during fast state churn (streaming chunks +
  // Strict Mode double-mount). `m` arrives undefined in that window.
  // Falling back to the index keeps the lookup stable instead of
  // crashing with `undefined.id` — the next pass with the real item
  // re-keys it correctly. See react-virtuoso issue #532 / #1045.
  const computeItemKey = useCallback(
    (index: number, m: ChatMessage | undefined) => m?.id ?? index,
    [],
  );

  // Wrap user-supplied onStartReached so we don't double-fire while a
  // load is already in flight. Virtuoso pauses startReached until
  // `data` grows, but our consumers pass `onStartReached={loadMore}`
  // directly — `loadMore` sets `isLoadingMore=true` synchronously, so
  // we can suppress further calls until that flag drops back to false.
  const startReachedHandler = useMemo(() => {
    if (!onStartReached) return undefined;
    let inFlight = false;
    return () => {
      if (inFlight || isLoadingMore) return;
      inFlight = true;
      try {
        onStartReached();
      } finally {
        // Release on the next tick — Virtuoso re-fires startReached
        // only after data length changes, so the inFlight guard is
        // belt+suspenders against same-frame double calls.
        queueMicrotask(() => {
          inFlight = false;
        });
      }
    };
  }, [onStartReached, isLoadingMore]);

  // Empty path — render renderEmpty instead of the virtualizer to avoid
  // a blank Virtuoso frame and the cost of mounting it for nothing.
  if (messages.length === 0) {
    return (
      <div
        role="log"
        aria-live="polite"
        aria-atomic="false"
        className={cn('flex-1 overflow-y-auto', className)}
      >
        {renderEmpty?.() ?? null}
      </div>
    );
  }

  if (noVirtualize) {
    return (
      <div
        role="log"
        aria-live="polite"
        aria-atomic="false"
        className={cn('flex-1 overflow-y-auto', className)}
      >
        {isLoadingMore ? (
          <div className="flex justify-center py-2">
            <Spinner className="size-4 text-muted-foreground" />
          </div>
        ) : null}
        {messages.map((m, i) => (
          <div key={m.id ?? i}>{itemRenderer(m, i)}</div>
        ))}
      </div>
    );
  }

  return (
    <Virtuoso
      ref={virtuosoRef}
      role="log"
      aria-live="polite"
      aria-atomic="false"
      className={cn('flex-1', className)}
      data={messages}
      computeItemKey={computeItemKey}
      itemContent={(index, m) => (m ? itemRenderer(m, index) : null)}
      // No `defaultItemHeight` — Virtuoso uses its first-item probe
      // pass. We previously fed it `120` which was wildly low for
      // chat bubbles (markdown + code blocks reach 500px+) and forced
      // virtuoso into a measure/reshape loop on every render: predicted
      // height << real height → page recompute → measure → repeat,
      // visible as jittery scrolling and bubbles "blinking" in and out.
      // The probe pass costs one extra render on mount; that's cheaper
      // than perpetual reshape.
      //
      // No `alignToBottom` — it only matters when the list is shorter
      // than the viewport (centers items near the bottom). Combined
      // with dynamic bubble heights it triggered the same measure loop:
      // virtuoso recomputes top padding on every size change to keep
      // the cluster bottom-aligned. `initialTopMostItemIndex` + the
      // mount-time imperative scroll already land us at the bottom on
      // open, which is what users actually want.
      //
      // No `increaseViewportBy` overscan — virtuoso's default (~0px)
      // is the right call for chat: every overscanned bubble re-renders
      // on every streaming token delta, so a 400px buffer means 3–4
      // extra bubbles re-rendering at 60Hz during a stream. Default
      // keeps the working set tight.
      initialTopMostItemIndex={messages.length > 0 ? messages.length - 1 : 0}
      followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
      atBottomStateChange={onAtBottomChange}
      startReached={startReachedHandler}
      // Spinner while older history is loading. Rendering it as the
      // Header keeps it inside the virtualized scroll, so it doesn't
      // shift the viewport when it appears/disappears.
      //
      // Always pass an object — virtuoso indexes `components[name]`
      // internally without a null-guard, so passing `undefined` here
      // crashes with `d[l]` on any render where the empty-defaults
      // path runs (regression: ui-tools 2.1.369 first ship).
      components={
        isLoadingMore
          ? {
              Header: () => (
                <div className="flex justify-center py-2">
                  <Spinner className="size-4 text-muted-foreground" />
                </div>
              ),
            }
          : EMPTY_COMPONENTS
      }
    />
  );
});

// Stable empty-components reference — passed when `isLoadingMore` is
// false so virtuoso's `components[name]` lookups never see undefined.
const EMPTY_COMPONENTS = {} as const;
