'use client';

import {
  type ReactNode,
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} 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';
import type { ComposerAppearance } from '../composer/types';

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;
  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;
  /**
   * 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;
  /**
   * Pixel distance from the bottom that still counts as "at bottom".
   * Default 120 — generous enough that mid-message users keep getting
   * sticky-followed (matches ChatGPT / Slack feel) while still letting
   * a deliberate scroll-up break the lock. Plan11.
   */
  atBottomThreshold?: number;
  /**
   * Force-scroll to the bottom when this id changes. Wire to the id of
   * the most-recently-sent user message: every send re-anchors the
   * viewport so users always see their own bubble + the incoming
   * reply, even if they were scrolled up. Plan11.
   */
  scrollAnchorId?: string | number | null;
  /** Spaciousness of the default `<MessageBubble>`s — `full` scales the
   * whole bubble up for full-page chat. Default `compact` (no change).
   * Ignored when `renderItem` is set (the host owns bubble rendering). */
  appearance?: ComposerAppearance;
}

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

// Breathing room under the last bubble: a spacer rendered as the
// Virtuoso `Footer` keeps the final message from sitting flush against
// the composer, and the same value is fed as a scroll `offset` so every
// "land at bottom" path settles with this gap visible instead of pinning
// the last bubble to the very edge. Tasteful — one comfortable notch.
const BOTTOM_GAP_PX = 24;

// Stable Footer spacer — reserves `BOTTOM_GAP_PX` of scrollable space
// below the last bubble. Rendered inside the virtualized scroller so it
// counts toward content height (followOutput scrolls past it) without
// shifting the viewport when items mount/unmount.
const FooterSpacer = () => <div aria-hidden style={{ height: BOTTOM_GAP_PX }} />;

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

  const virtuosoRef = useRef<VirtuosoHandle | null>(null);
  // Virtuoso's `atBottomStateChange` only fires when the list is
  // scrollable AND the user actually scrolls. With ≤3 short bubbles the
  // total height stays below the viewport, virtuoso never fires "at
  // bottom", and the `<JumpToLatest>` pill stays stuck. Track the
  // scroller directly: when content fits, force `atBottom=true`.
  const scrollerRef = useRef<HTMLElement | Window | null>(null);
  const [isScrollable, setIsScrollable] = useState(false);
  const lastReportedAtBottomRef = useRef<boolean | null>(null);
  // Mirror of the latest "is the user following" state, readable from
  // the last-item ResizeObserver without re-subscribing it on every
  // sticky-state flip. This is the ONLY gate for sticky-bottom: we
  // follow because the user is at/near bottom, never because of message
  // length. The moment they scroll up, this flips false and we stop
  // re-pinning (the <JumpToLatest> pill takes over).
  const isFollowingRef = useRef(true);
  const reportAtBottom = useCallback(
    (value: boolean) => {
      isFollowingRef.current = value;
      if (lastReportedAtBottomRef.current === value) return;
      lastReportedAtBottomRef.current = value;
      onAtBottomChange?.(value);
    },
    [onAtBottomChange],
  );
  // Pin the viewport to the absolute bottom. Used both by the last-item
  // ResizeObserver (to correct virtuoso's `followOutput:'auto'`, which
  // settles a few px short on the final streamed chunk) and any other
  // "land exactly at end" path.
  const pinToBottom = useCallback((smooth = false) => {
    virtuosoRef.current?.scrollToIndex({
      index: 'LAST',
      align: 'end',
      // Scroll past the last bubble by the footer gap so it lands with
      // breathing room above the composer, not flush against the edge.
      offset: BOTTOM_GAP_PX,
      behavior: smooth ? 'smooth' : 'auto',
    });
  }, []);
  // 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) => {
        pinToBottom(smooth);
      },
      scrollToIndex: (index, smooth = false) => {
        virtuosoRef.current?.scrollToIndex({
          index,
          behavior: smooth ? 'smooth' : 'auto',
        });
      },
    }),
    [pinToBottom],
  );

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

  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',
        offset: BOTTOM_GAP_PX,
        behavior: 'auto',
      });
    });
    return () => cancelAnimationFrame(id);
  }, [messages.length]);

  // Force-scroll to bottom whenever the consumer bumps `scrollAnchorId`
  // (typically the id of the latest user-sent message). Two rAFs so
  // Virtuoso has measured the freshly-pushed bubble before we land,
  // otherwise the call lands on the previous height and clips the new
  // message under the composer. The initial-mount effect above handles
  // first paint, so we skip until it has run.
  useEffect(() => {
    if (scrollAnchorId == null) return;
    if (!didInitialScrollRef.current) return;
    let raf1 = 0;
    let raf2 = 0;
    raf1 = requestAnimationFrame(() => {
      raf2 = requestAnimationFrame(() => {
        virtuosoRef.current?.scrollToIndex({
          index: 'LAST',
          align: 'end',
          offset: BOTTOM_GAP_PX,
          behavior: 'smooth',
        });
      });
    });
    return () => {
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
    };
  }, [scrollAnchorId]);

  // Sticky-bottom corrector. Virtuoso's `followOutput:'auto'` keeps the
  // list near the bottom while content grows token-by-token, but on the
  // FINAL streamed chunk (and any late reflow — images loading, code
  // blocks measuring) it can settle a few px short: no further scroll
  // event arrives to nudge it the last stretch, so a short reply lands
  // slightly under-scrolled. We observe ONLY the last bubble's size and,
  // while the user is following (`isFollowingRef`), re-pin to the
  // absolute bottom on every size change. Cheap: one observer, last item
  // only, no work once the user scrolls up.
  const lastItemElRef = useRef<HTMLElement | null>(null);
  const lastItemRoRef = useRef<ResizeObserver | null>(null);
  const observeLastItem = useCallback(
    (el: HTMLElement | null) => {
      if (el === lastItemElRef.current) return;
      lastItemRoRef.current?.disconnect();
      lastItemElRef.current = el;
      if (!el) return;
      const ro = new ResizeObserver(() => {
        // Only stick when the user hasn't scrolled away. This is the
        // universal ChatGPT rule — follow = user at bottom, regardless
        // of how long the message is.
        if (isFollowingRef.current) pinToBottom(false);
      });
      ro.observe(el);
      lastItemRoRef.current = ro;
    },
    [pinToBottom],
  );
  useEffect(
    () => () => {
      lastItemRoRef.current?.disconnect();
      lastItemRoRef.current = null;
      lastItemElRef.current = null;
    },
    [],
  );

  // Watch the scroll container: when content fits (scrollHeight ≤ clientHeight)
  // there's nothing to scroll, the user is by definition "at bottom", and the
  // Jump-to-latest pill must stay hidden regardless of what virtuoso reports.
  useEffect(() => {
    const el = scrollerRef.current;
    if (!el || el === window || !(el instanceof HTMLElement)) return;

    const probe = () => {
      const scrollable = el.scrollHeight > el.clientHeight + 1;
      setIsScrollable(scrollable);
      if (!scrollable) reportAtBottom(true);
    };

    probe();
    const ro = new ResizeObserver(probe);
    ro.observe(el);
    return () => ro.disconnect();
  }, [reportAtBottom, 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>
        ))}
        {/* Mirror the virtualized Footer spacer so the last bubble keeps
            its breathing room above the composer in the non-virtual path. */}
        <FooterSpacer />
      </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) => {
        if (!m) return null;
        const node = itemRenderer(m, index);
        // Wrap only the last bubble in a size-observed shell so we can
        // re-pin to the absolute bottom as the streaming reply grows
        // (corrects virtuoso's slight final-chunk under-scroll). The
        // wrapper is `display:contents` so it adds no layout box.
        if (index === messages.length - 1) {
          return <div ref={observeLastItem}>{node}</div>;
        }
        return node;
      }}
      // 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}
      atBottomThreshold={atBottomThreshold}
      followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
      scrollerRef={(el) => {
        scrollerRef.current = el;
      }}
      atBottomStateChange={(atBottom) => {
        // Force `true` when the list isn't scrollable — virtuoso's signal
        // can stall in `false` on short transcripts (no scroll events fire).
        reportAtBottom(!isScrollable ? true : atBottom);
      }}
      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).
      // Footer always carries the bottom-gap spacer so the last bubble
      // never sits flush against the composer; Header only appears while
      // older history is loading.
      components={isLoadingMore ? COMPONENTS_WITH_HEADER : FOOTER_ONLY_COMPONENTS}
    />
  );
});

// Header spinner for the loading-older-history state.
const LoadingHeader = () => (
  <div className="flex justify-center py-2">
    <Spinner className="size-4 text-muted-foreground" />
  </div>
);

// Stable component maps — virtuoso indexes `components[name]` without a
// null-guard, so we always pass a concrete object. Both carry the
// FooterSpacer; the loading variant adds the history spinner Header.
const FOOTER_ONLY_COMPONENTS = { Footer: FooterSpacer } as const;
const COMPONENTS_WITH_HEADER = { Header: LoadingHeader, Footer: FooterSpacer } as const;
