import {
  createContext,
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type ButtonHTMLAttributes,
  type CSSProperties,
  type ReactNode,
} from "react";
import {
  useFloating,
  autoUpdate,
  offset as offsetMiddleware,
  flip,
  shift,
  size,
  hide,
  useClick,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  useListNavigation,
  useTypeahead,
  useInteractions,
  useFloatingNodeId,
  useListItem,
  safePolygon,
  FloatingNode,
  FloatingList,
  FloatingPortal,
  FloatingFocusManager,
  type Placement,
  type UseRoleProps,
} from "@floating-ui/react";
import { useLayer } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";

// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
//   - flip()  → measured two-way flip (top/bottom) when a side runs out of room
//   - shift() → slides back into view horizontally (the old MEASURED-width clamp)
//   - hide()  → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we
//               hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back)
//   - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
//   - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
//   - useTypeahead      → type-to-focus a matching item by its label
//   - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
//     (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
//     Escape strands focus on <body>). Non-modal so the rest of the page stays interactive.
// Call sites render their options as <PopoverItem> — it self-registers (useListItem) and carries the
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.

// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";

export interface AnchoredPopoverOptions {
  open: boolean;
  onOpenChange: (open: boolean, event?: Event) => void;
  /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
  mode?: PopoverMode;
  placement?: Placement;
  /** Gap between trigger and panel, px. Default 4. */
  offset?: number;
  /** Hover mode only: open/close intent delays, ms. */
  hoverDelay?: number | { open?: number; close?: number };
  /** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
  role?: UseRoleProps["role"];
  /** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
   *  caller's own max-height class wins; turn on for long lists that should shrink to fit. */
  fitHeight?: boolean;
  /** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
   *  contain a free-text input, where character keys belong to the text field. */
  typeahead?: boolean;
  /** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
   *  focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
   *  focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
  hostsInput?: boolean;
  /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
   *  lands the roving focus on it instead of the first item — the behaviour a "pick one of these
   *  modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
  selectedIndex?: number | null;
}

// Internal: call sites use the inferred hook return, they don't name the type.
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;

export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
  const mode = opts.mode ?? "menu";
  const isHover = mode === "hover";
  // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
  // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
  const isNav = mode === "menu" || mode === "listbox";
  // null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
  const nodeId = useFloatingNodeId();

  // Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
  const elementsRef = useRef<Array<HTMLElement | null>>([]);
  const labelsRef = useRef<Array<string | null>>([]);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
    nodeId,
    open: opts.open,
    onOpenChange: opts.onOpenChange,
    placement: opts.placement ?? "bottom-start",
    whileElementsMounted: autoUpdate,
    middleware: [
      offsetMiddleware(opts.offset ?? 4),
      flip({ padding: 8 }),
      shift({ padding: 8 }),
      ...(opts.fitHeight
        ? [
            size({
              padding: 8,
              apply({ availableHeight, elements }) {
                elements.floating.style.maxHeight = `${availableHeight}px`;
              },
            }),
          ]
        : []),
      hide({ padding: 8 }),
    ],
  });

  // A stale active item would highlight (and steal the next open's initial focus) the second time a
  // menu opens; clear it whenever the surface closes.
  useEffect(() => {
    if (!opts.open) setActiveIndex(null);
  }, [opts.open]);

  const click = useClick(context, { enabled: !isHover });
  const hover = useHover(context, {
    enabled: isHover,
    move: false,
    delay: opts.hoverDelay ?? { open: 120, close: 140 },
    handleClose: safePolygon(),
  });
  const focus = useFocus(context, { enabled: isHover });
  // Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
  // is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
  // off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
  // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
  // press stays with floating-ui (tree-aware).
  const dismiss = useDismiss(context, { escapeKey: false });
  const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
  const listNav = useListNavigation(context, {
    enabled: isNav,
    listRef: elementsRef,
    activeIndex,
    onNavigate: setActiveIndex,
    selectedIndex: opts.selectedIndex ?? null,
    loop: true,
    // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
    // which strands focus on the menu container — arrows then can't enter the roving list at all).
    focusItemOnOpen: true,
    // A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
    // passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
    // Enter to the hovered item. Turned off together with typeahead for such menus.
    focusItemOnHover: !opts.hostsInput,
  });
  const typeahead = useTypeahead(context, {
    enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
    listRef: labelsRef,
    activeIndex,
    onMatch: setActiveIndex,
  });

  const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);

  // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
  // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
  const referenceHidden = middlewareData.hide?.referenceHidden ?? false;

  // Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
  // underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
  // Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
  // player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
  // an Escape the user aimed at whatever is actually on screen.
  useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));

  return {
    nodeId,
    mode,
    isNav,
    open: opts.open,
    selectedIndex: opts.selectedIndex ?? null,
    refs,
    floatingStyles,
    context,
    isPositioned,
    referenceHidden,
    elementsRef,
    labelsRef,
    activeIndex,
    ...interactions,
  };
}

// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
interface PopoverListContextValue {
  activeIndex: number | null;
  getItemProps: AnchoredPopover["getItemProps"];
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);

type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";

/** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving
 *  focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are
 *  one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once,
 *  `option`+`selected` for a listbox choice, plain `menuitem` for an action.
 *
 *  The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`:
 *  a click-opened menu focuses the first item programmatically, which does NOT reliably trip the
 *  focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
 *  key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
 *  theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
 *  arbitrary value like the app's own glass idiom:
 *  `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
export function PopoverItem({
  children,
  onSelect,
  role = "menuitem",
  checked,
  selected,
  disabled,
  className,
  /** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
  label,
  ...rest
}: {
  children: ReactNode;
  onSelect?: () => void;
  role?: ItemRole;
  checked?: boolean;
  selected?: boolean;
  disabled?: boolean;
  className?: string;
  label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
  const ctx = useContext(PopoverListContext);
  const textLabel = label ?? (typeof children === "string" ? children : undefined);
  const { ref, index } = useListItem({ label: textLabel });
  const active = ctx?.activeIndex === index;

  const ariaProps =
    role === "menuitemradio" || role === "menuitemcheckbox"
      ? { role, "aria-checked": !!checked }
      : role === "option"
        ? { role, "aria-selected": !!selected }
        : { role };

  // Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
  // composes handlers rather than dropping them — spreading `{...rest}` separately would let the
  // getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the
  // caller's first (e.g. a stopPropagation), then the select.
  const mergedClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    rest.onClick?.(e);
    if (!disabled) onSelect?.();
  };
  const handlers = ctx
    ? ctx.getItemProps({ ...rest, onClick: mergedClick })
    : { ...rest, onClick: mergedClick };

  return (
    <button
      ref={ref}
      type="button"
      {...ariaProps}
      {...handlers}
      // These win over anything in `handlers` — the roving tabindex must not be a caller override,
      // and disabled/className/data-active are the platform's to set.
      tabIndex={active ? 0 : -1}
      data-active={active ? "true" : undefined}
      disabled={disabled}
      className={className}
    >
      {children}
    </button>
  );
}

interface PopoverSurfaceProps {
  popover: AnchoredPopover;
  className?: string;
  style?: CSSProperties;
  children: ReactNode;
  /** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
   *  take its name from. */
  ariaLabel?: string;
  focusManaged?: boolean;
  initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
  /** The surface never receives pointer events — for a passive caption that must not swallow a click
   *  aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
   *  content, links) must NOT set this. */
  passthrough?: boolean;
}

/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
 *  library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
 *  focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
 *  surface out (e.g. a hover card).
 *
 *  A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10
 *  per table row) must not subscribe fullscreen listeners or build a portal container. */
export function PopoverSurface(props: PopoverSurfaceProps) {
  if (!props.popover.open) return null;
  return <PopoverSurfaceInner {...props} />;
}

function PopoverSurfaceInner({
  popover,
  className,
  style,
  children,
  focusManaged,
  initialFocus,
  passthrough,
  ariaLabel,
}: PopoverSurfaceProps) {
  const {
    context,
    refs,
    floatingStyles,
    getFloatingProps,
    getItemProps,
    nodeId,
    referenceHidden,
    isPositioned,
    mode,
    isNav,
    selectedIndex,
    activeIndex,
    elementsRef,
    labelsRef,
  } = popover;

  // A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
  // opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
  // fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
  // element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
  // focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
  // SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
  // remount instead would rebuild the subtree and lose all of that.)
  const fsRoot = useFullscreenElement();
  const [container] = useState<HTMLDivElement | null>(() =>
    typeof document === "undefined" ? null : document.createElement("div"),
  );
  useLayoutEffect(() => {
    if (container) (fsRoot ?? document.body).appendChild(container);
  }, [container, fsRoot]);
  useEffect(() => () => container?.remove(), [container]);

  const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
    style?: CSSProperties;
  } & Record<string, unknown>;
  // useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label
  // in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
  if (ariaLabel) delete floatingProps["aria-labelledby"];

  const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
  // Everything but a passive hover card manages focus by default (trap into the surface, return to
  // the trigger on close).
  const managed = focusManaged ?? mode !== "hover";

  const surface = (
    <div
      ref={refs.setFloating}
      {...floatingProps}
      aria-label={ariaLabel}
      className={zClass}
      style={{
        ...floatingPropStyle,
        ...floatingStyles,
        // Hide until the first position is measured (else it flashes at 0,0 before the transform
        // lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
        ...(!isPositioned || referenceHidden
          ? { visibility: "hidden" as const, pointerEvents: "none" as const }
          : null),
        ...(passthrough ? { pointerEvents: "none" as const } : null),
      }}
    >
      {/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
          positioned element is essential: the popIn keyframe animates `transform`, which would
          otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
          for the animation's duration — the "flash in the top-left corner" bug. */}
      <div className={className} style={style}>
        {children}
      </div>
    </div>
  );

  // Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
  // <PopoverItem>s can self-register and rove.
  const positioned = isNav ? (
    <PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
      <FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
        {surface}
      </FloatingList>
    </PopoverListContext.Provider>
  ) : (
    surface
  );

  return (
    <FloatingNode id={nodeId}>
      <FloatingPortal root={container ?? undefined}>
        {managed ? (
          <FloatingFocusManager
            context={context}
            modal={false}
            // Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
            // (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
            // suppress the manager's initial focus with -1. For a plain nav menu we let the manager
            // focus normally: it lands on the first tabbable child, which is the first roving item, or
            // — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
            // input, so such a menu still has a keyboard entry point instead of stranding focus.
            initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
            returnFocus
          >
            {positioned}
          </FloatingFocusManager>
        ) : (
          positioned
        )}
      </FloatingPortal>
    </FloatingNode>
  );
}
