import { createContext, useCallback, useContext, useMemo, type ReactElement, type ReactNode } from 'react';
import { ArrowDown } from 'lucide-react';
import { useStickToBottom } from 'use-stick-to-bottom';
import { useMediaQuery } from '@/hooks/use-media-query';
import { Button, cn } from '@/components/ui';

/** Breathing room left between a revealed disclosure and the edge it is pulled from. */
const REVEAL_MARGIN_PX = 8;

/**
 * What the viewport exposes to the controls inside it. Deliberately these three
 * members rather than the library's whole instance: the scroll button has no
 * business holding the refs, and the animation choice is made once, here, where
 * the motion preference is read.
 */
interface ConversationScroll {
  /** False only while the reader has scrolled away from the newest message. */
  isAtBottom: boolean;
  scrollToLatest: () => void;
  /** See `usePiggyConversationReveal`. */
  revealOnExpand: (element: HTMLElement | null) => void;
}

const noReveal = () => {};

const ConversationScrollContext = createContext<ConversationScroll | null>(null);

/**
 * The transcript viewport.
 *
 * It replaces an effect that called `bottomRef.current?.scrollIntoView()` on
 * every change to the message array — which, during a stream, means once per
 * token. Two failures fell out of that, and both are the reason this component
 * exists rather than a tidier version of the same effect:
 *
 *  - There was no near-bottom check and no scroll listener anywhere, so
 *    scrolling up to re-read an earlier answer while a new one streamed was
 *    impossible: the next delta yanked the viewport back down, milliseconds
 *    later, for as long as the answer took to write.
 *  - `scrollIntoView` scrolls *every* scrollable ancestor. The dock is a sticky
 *    22rem column inside the page, so following Piggy also dragged the record
 *    the user was reading it against.
 *
 * `use-stick-to-bottom` fixes both. It follows new content only while the
 * reader is already at the bottom, lets go the instant they scroll or wheel
 * up, re-attaches when they come back down, and does it by writing one
 * element's `scrollTop` — so nothing outside this component moves.
 *
 * DOM shape, because it is load-bearing rather than incidental:
 *
 *     div  — positioned; the scroll button's containing block
 *       div  — the scrollport, the only thing that scrolls; takes `className`
 *         div — the measured content, whose growth drives the follow
 *
 * The button is absolutely positioned against the outermost element, which is
 * an ancestor of the scrollport rather than inside it, so it is neither
 * clipped by the overflow nor carried away by the scrolling.
 *
 * `className` lands on the scrollport rather than the outer element so that a
 * caller's gutters — the dock's `compact` px-3, the sheet's px-4 sm:px-5 —
 * scroll with the transcript exactly as they did before, instead of leaving a
 * dead band the text is sliced against. The outer element carries its own
 * `min-h-0 flex-1`, so a panel does not have to pass any layout at all.
 *
 * A child that should fill an otherwise-empty transcript — the suggestion
 * card — must use `flex-1`, not `h-full`: the content element is sized by its
 * children, so a percentage height there resolves to nothing.
 */
export function PiggyConversation({
  children,
  className,
  busy = false,
}: {
  children: ReactNode;
  className?: string;
  /** A turn is still writing, so the live region should hold its announcement. */
  busy?: boolean;
}) {
  // The library animates with JavaScript, so the global `scroll-behavior:
  // auto !important` under reduced motion does not reach it. Asked here and
  // passed down rather than read in two places.
  const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
  const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
    // A panel mounted against an existing transcript — the dock reopening, a
    // drawer coming back up — should already be at the newest message. An
    // animated first scroll would look like the answer arriving twice.
    initial: 'instant',
    resize: reducedMotion ? 'instant' : 'smooth',
  });

  /**
   * Keep a disclosure the reader has just opened on screen.
   *
   * `use-stick-to-bottom` follows *any* positive resize of the content while
   * the reader is at the bottom, and cannot tell content Piggy streamed in at
   * the tail from content the reader themselves unfolded halfway up. Opening a
   * tool step therefore scrolled the step away: its bottom edge measured 133px
   * above the scrollport in the dock, and on a phone the heading that says which
   * tool it is landed 273px above the top edge. That is the exact opposite of
   * what pressing it asked for, on the page whose whole claim is that the
   * records behind an answer can be inspected.
   *
   * Two moves, in this order:
   *
   *  - `stopScroll()` first, synchronously, while the click that will open the
   *    disclosure is still being dispatched. It clears the lock before the
   *    browser lays the expansion out, so the ResizeObserver's follow finds
   *    `isAtBottom` already false and abandons the scroll rather than racing it.
   *    Reading an unfolded step is scrolling away from the tail, and it releases
   *    the follow for the same reason wheeling up does.
   *  - Then, one frame later with the content in place, nudge the scrollport so
   *    the disclosure is actually visible — and only the scrollport. Never
   *    `scrollIntoView`, which walks every scrollable ancestor and would drag
   *    the record behind the dock along with it.
   *
   * v1.1.6 has no opt-out of the resize follow: its options are read live
   * through a ref, but the only ones there are the animation and a
   * `targetScrollTop` override, and lying about the target corrupts
   * `isNearBottom` — and with it the jump-to-latest button — for as long as the
   * lie is held.
   */
  const revealOnExpand = useCallback(
    (element: HTMLElement | null) => {
      const scrollport = scrollRef.current;
      if (!element || !scrollport) return;
      stopScroll();
      requestAnimationFrame(() => scrollIntoScrollport(scrollport, element));
    },
    [scrollRef, stopScroll],
  );

  const scroll = useMemo<ConversationScroll>(
    () => ({
      isAtBottom,
      scrollToLatest: () => {
        void scrollToBottom({ animation: reducedMotion ? 'instant' : 'smooth' });
      },
      revealOnExpand,
    }),
    [isAtBottom, scrollToBottom, reducedMotion, revealOnExpand],
  );

  return (
    <ConversationScrollContext.Provider value={scroll}>
      <div className="relative flex min-h-0 flex-1 flex-col">
        <div
          ref={scrollRef}
          // `scroll-pb-14` keeps the last 56px of the scrollport out of the
          // resting position of anything the browser scrolls to itself — a
          // focused follow-up chip, a revealed step, the tail of an answer. The
          // jump-to-latest pill floats in that band, and without the padding it
          // came to rest on top of the final line of the answer it had just
          // brought into view.
          className={cn('min-h-0 flex-1 scroll-pb-14 overflow-y-auto overscroll-contain', className)}
          role="log"
          aria-label="Piggy conversation"
          // Announce the finished answer rather than each token: a live region
          // fed deltas reads as an unbroken stutter.
          aria-live="polite"
          // On the live region ROOT, which is the element whose busy state a
          // screen reader consults before it decides to speak. It used to sit on
          // the message column inside this element instead, where an
          // implementation that only reads the root — the common case — went on
          // announcing every delta as it landed.
          aria-busy={busy}
          // The transcript is the one part of this panel a keyboard user
          // cannot otherwise reach: without a tab stop there is no way to
          // scroll back to an earlier answer without a pointer.
          tabIndex={0}
        >
          <div ref={contentRef} className="flex min-h-full flex-col">
            {children}
          </div>
        </div>
      </div>
    </ConversationScrollContext.Provider>
  );
}

/**
 * The way back down.
 *
 * Rendered anywhere inside `PiggyConversation` — its position is fixed by the
 * absolute placement below, not by where it sits in the children — and absent
 * entirely while the reader is at the bottom, because a control that jumps you
 * where you already are is noise floating over the answer.
 *
 * Rendered outside a `PiggyConversation` it is nothing at all. That is a
 * wiring mistake rather than a state, but a thrown error inside a streaming
 * transcript would take the whole panel down with it.
 */
export function PiggyConversationScrollButton(): ReactElement | null {
  const scroll = useContext(ConversationScrollContext);
  if (!scroll || scroll.isAtBottom) return null;
  return (
    <Button
      type="button"
      variant="secondary"
      size="icon"
      onClick={scroll.scrollToLatest}
      aria-label="Jump to the latest message"
      className={cn(
        // Right-aligned, not centred. Centred it sat over the middle of the
        // measure — which is where the sentence is — and a reader scrolled up
        // mid-answer had a disc parked on the words. The right gutter is empty
        // in every surface this panel is used in, from the 22rem dock to the
        // full page.
        'absolute bottom-3 right-3 z-10 rounded-full border border-border',
        // The `secondary` fill, left opaque. A translucent disc ghosted the
        // sentence it covered in light mode and disappeared into the panel
        // altogether in dark; `surface-2` reads against `surface` in both.
        'text-muted shadow-lg hover:text-fg',
        'animate-in fade-in zoom-in-95 duration-2 ease-enter',
      )}
    >
      <ArrowDown className="size-4" aria-hidden />
    </Button>
  );
}

/**
 * The callback a `<details>` inside the transcript must fire from its
 * summary's `onClick` when it is about to open, passing the element that will
 * grow.
 *
 * `onClick` rather than the `toggle` event because `toggle` is queued and fires
 * after the browser has already laid the expansion out and the follow has
 * already run; a click handler is dispatched before the default action opens
 * anything, which is the only moment early enough to get in front of it.
 *
 * Outside a `PiggyConversation` this does nothing, matching the scroll button:
 * a mis-wired transcript should render a slightly worse tool step, not throw
 * inside a stream and take the panel with it.
 */
export function usePiggyConversationReveal(): (element: HTMLElement | null) => void {
  const scroll = useContext(ConversationScrollContext);
  return scroll?.revealOnExpand ?? noReveal;
}

/**
 * Bring `element` inside `scrollport`, moving nothing else on the page.
 *
 * The downward correction is capped at the element's own top gap: chasing the
 * bottom of a disclosure taller than the viewport would scroll its heading —
 * the only part that says which tool this is — off the top of the scrollport.
 */
function scrollIntoScrollport(scrollport: HTMLElement, element: HTMLElement): void {
  const view = scrollport.getBoundingClientRect();
  const box = element.getBoundingClientRect();
  const topGap = box.top - (view.top + REVEAL_MARGIN_PX);
  if (topGap < 0) {
    scrollport.scrollTop += topGap;
    return;
  }
  const bottomOverflow = box.bottom - (view.bottom - REVEAL_MARGIN_PX);
  if (bottomOverflow > 0) scrollport.scrollTop += Math.min(bottomOverflow, topGap);
}
