import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import type {
  AriaRole,
  CSSProperties,
  KeyboardEvent as ReactKeyboardEvent,
  ReactNode,
  RefObject,
} from "react";
import { createPortal } from "react-dom";
import { computeOverlayPosition } from "./overlayPlacement";
import type { Placement as OverlayPlacement } from "./overlayPlacement";

export interface AnchorRect {
  left: number;
  top: number;
  right: number;
  bottom: number;
  width: number;
  height: number;
}

type Anchor = HTMLElement | AnchorRect;
/**
 * Wave-9 I14 adds `right-start`: the panel sits BESIDE the anchor (top edges aligned), not
 * under it — the owner's "flyout to the RIGHT". It is a real placement rather than a margin
 * hack because the views rail is a 188px column at the left edge of an iframe, and a panel
 * absolutely-positioned inside `.cg-views` would be clipped by the rail it escapes; every
 * other overlay here is already body-level and fixed for the same reason.
 *
 * The placement ARITHMETIC moved to overlayPlacement.ts with that change, so the collision
 * maths is gated rather than eyeballed (verify_overlay.py).
 */
type Placement = OverlayPlacement;

interface OverlayStack {
  register: (id: string) => () => void;
  isTop: (id: string) => boolean;
}

const OverlayStackContext = createContext<OverlayStack | null>(null);

const FOCUSABLE = [
  "[data-overlay-autofocus]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "[href]",
  '[tabindex]:not([tabindex="-1"])',
].join(",");

export function OverlayProvider({ children }: { children: ReactNode }) {
  const stack = useRef<string[]>([]);
  const api = useMemo<OverlayStack>(
    () => ({
      register: (id) => {
        stack.current = [...stack.current.filter((item) => item !== id), id];
        return () => {
          stack.current = stack.current.filter((item) => item !== id);
        };
      },
      isTop: (id) => stack.current.at(-1) === id,
    }),
    []
  );
  return (
    <OverlayStackContext.Provider value={api}>
      {children}
    </OverlayStackContext.Provider>
  );
}

/**
 * ⚠ NULL IS A REACHABLE VALUE HERE, and it must not be fatal.
 *
 * Half the call sites pass `someRef.current`, which is legitimately null on the render
 * before the ref attaches, and `Anchor` does not include null — so every one of them was
 * one ordering accident away from `"getBoundingClientRect" in null`, a TypeError thrown
 * during RENDER, which unmounts the whole tree. That is not a hypothetical: it is what
 * `_qa_live_rail.py` reproduced on the shipped build when the saved-view menu's anchor
 * came back null (2026-08-04).
 *
 * A missing anchor is a positioning problem, not a reason to lose the application. The
 * panel degrades to a zero-rect at the viewport origin — visible, dismissible, obviously
 * wrong — while the root causes stay fixable at their own call sites.
 */
const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };

function anchorRect(anchor: Anchor | null | undefined): AnchorRect {
  if (!anchor) return NO_RECT;
  if ("getBoundingClientRect" in anchor) {
    const rect = anchor.getBoundingClientRect();
    return {
      left: rect.left,
      top: rect.top,
      right: rect.right,
      bottom: rect.bottom,
      width: rect.width,
      height: rect.height,
    };
  }
  return anchor;
}

function samePosition(a: CSSProperties, b: CSSProperties): boolean {
  return (
    a.left === b.left &&
    a.top === b.top &&
    a.maxWidth === b.maxWidth &&
    a.maxHeight === b.maxHeight &&
    a.visibility === b.visibility
  );
}

interface OverlayLayerOptions {
  panelRef: RefObject<HTMLElement | null>;
  onDismiss: () => void;
  dismissOnOutside?: boolean;
  initialFocus?: "first" | "none" | string;
  restoreFocus?: boolean;
  trapFocus?: boolean;
  outsideElements?: Array<HTMLElement | null>;
}

/** Shared dismissal/focus contract for anchored menus and fixed drawers. */
// oxlint-disable-next-line react/only-export-components -- shares the private overlay stack context.
export function useOverlayLayer({
  panelRef,
  onDismiss,
  dismissOnOutside = true,
  initialFocus = "first",
  restoreFocus = true,
  trapFocus = false,
  outsideElements = [],
}: OverlayLayerOptions): void {
  const id = useId();
  const stack = useContext(OverlayStackContext);
  const dismissRef = useRef(onDismiss);
  const outsideRef = useRef(outsideElements);
  dismissRef.current = onDismiss;
  outsideRef.current = outsideElements;

  useEffect(() => stack?.register(id), [id, stack]);

  useEffect(() => {
    const onPointerDown = (event: PointerEvent) => {
      if (!dismissOnOutside || (stack && !stack.isTop(id))) return;
      const target = event.target as Node | null;
      if (!target || panelRef.current?.contains(target)) return;
      if (outsideRef.current.some((element) => element?.contains(target))) return;
      if (
        document.activeElement instanceof HTMLElement &&
        panelRef.current?.contains(document.activeElement)
      )
        document.activeElement.blur();
      dismissRef.current();
    };
    const onKeyDown = (event: KeyboardEvent) => {
      if (stack && !stack.isTop(id)) return;
      if (event.key === "Escape") {
        event.preventDefault();
        event.stopPropagation();
        if (
          document.activeElement instanceof HTMLElement &&
          panelRef.current?.contains(document.activeElement)
        )
          document.activeElement.blur();
        dismissRef.current();
        return;
      }
      if (event.key !== "Tab" || !trapFocus) return;
      const focusable = Array.from(
        panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
      ).filter((element) => element.getClientRects().length > 0);
      if (!focusable.length) {
        event.preventDefault();
        panelRef.current?.focus();
        return;
      }
      const first = focusable[0];
      const last = focusable.at(-1)!;
      const active = document.activeElement;
      if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && active === last) {
        event.preventDefault();
        first.focus();
      }
    };
    document.addEventListener("pointerdown", onPointerDown, true);
    document.addEventListener("keydown", onKeyDown, true);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown, true);
      document.removeEventListener("keydown", onKeyDown, true);
    };
  }, [dismissOnOutside, id, panelRef, stack, trapFocus]);

  useLayoutEffect(() => {
    const previous =
      document.activeElement instanceof HTMLElement ? document.activeElement : null;
    const frame = window.requestAnimationFrame(() => {
      if (initialFocus === "none") return;
      const selector = initialFocus === "first" ? FOCUSABLE : initialFocus;
      const target = panelRef.current?.querySelector<HTMLElement>(selector);
      target?.focus({ preventScroll: true });
    });
    return () => {
      window.cancelAnimationFrame(frame);
      if (restoreFocus && previous?.isConnected) previous.focus({ preventScroll: true });
    };
  }, [initialFocus, panelRef, restoreFocus]);
}

export function BodyPortal({ children }: { children: ReactNode }) {
  return createPortal(children, document.body);
}

interface AnchoredOverlayProps {
  /** ⚠ Nullable BY DECLARATION as of 2026-08-04. Several call sites pass `ref.current`,
   *  which is null before the ref attaches, and the old non-null type made that a
   *  render-time TypeError instead of a type error. See `anchorRect`. */
  anchor: Anchor | null | undefined;
  className: string;
  children: ReactNode;
  onDismiss: () => void;
  placement?: Placement;
  role?: AriaRole;
  ariaLabel?: string;
  id?: string;
  initialFocus?: "first" | "none" | string;
  restoreFocus?: boolean;
  dismissOnOutside?: boolean;
  onKeyDown?: (event: ReactKeyboardEvent<HTMLElement>) => void;
  dataKind?: string;
}

/** Body-level fixed overlay with iframe-viewport collision handling. */
export function AnchoredOverlay({
  anchor,
  className,
  children,
  onDismiss,
  placement = "bottom-start",
  role,
  ariaLabel,
  id,
  initialFocus = "first",
  restoreFocus = true,
  dismissOnOutside = true,
  onKeyDown,
  dataKind,
}: AnchoredOverlayProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const [style, setStyle] = useState<CSSProperties>({
    position: "fixed",
    left: 0,
    top: 0,
    visibility: "hidden",
  });
  const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null;

  useOverlayLayer({
    panelRef,
    onDismiss,
    dismissOnOutside,
    initialFocus,
    restoreFocus,
    outsideElements: [anchorElement],
  });

  const updatePosition = useCallback(() => {
    const panel = panelRef.current;
    if (!panel) return;
    const target = anchorRect(anchor);
    const viewport = window.visualViewport;
    const viewportLeft = viewport?.offsetLeft ?? 0;
    const viewportTop = viewport?.offsetTop ?? 0;
    const viewportWidth = viewport?.width ?? window.innerWidth;
    const viewportHeight = viewport?.height ?? window.innerHeight;
    const measured = panel.getBoundingClientRect();
    // The arithmetic lives in overlayPlacement.ts so a gate can run it under node. This
    // reads the DOM, decides nothing.
    const placed = computeOverlayPosition({
      placement,
      target,
      panel: {
        width: Math.max(measured.width, panel.scrollWidth),
        height: Math.max(measured.height, panel.scrollHeight),
      },
      viewport: {
        left: viewportLeft,
        top: viewportTop,
        width: viewportWidth,
        height: viewportHeight,
      },
    });
    const next: CSSProperties = {
      position: "fixed",
      left: placed.left,
      top: placed.top,
      maxWidth: placed.maxWidth,
      maxHeight: placed.maxHeight,
      visibility: "visible",
    };
    setStyle((current) => (samePosition(current, next) ? current : next));
  }, [anchor, placement]);

  useLayoutEffect(() => {
    updatePosition();
    const frame = window.requestAnimationFrame(updatePosition);
    const observer = new ResizeObserver(updatePosition);
    if (panelRef.current) observer.observe(panelRef.current);
    if (anchorElement) observer.observe(anchorElement);
    window.addEventListener("resize", updatePosition);
    window.addEventListener("scroll", updatePosition, true);
    window.visualViewport?.addEventListener("resize", updatePosition);
    window.visualViewport?.addEventListener("scroll", updatePosition);
    return () => {
      window.cancelAnimationFrame(frame);
      observer.disconnect();
      window.removeEventListener("resize", updatePosition);
      window.removeEventListener("scroll", updatePosition, true);
      window.visualViewport?.removeEventListener("resize", updatePosition);
      window.visualViewport?.removeEventListener("scroll", updatePosition);
    };
  }, [anchorElement, updatePosition]);

  return (
    <BodyPortal>
      <div
        ref={panelRef}
        id={id}
        className={className}
        style={style}
        role={role}
        aria-label={ariaLabel}
        data-overlay-kind={dataKind}
        onKeyDown={onKeyDown}
      >
        {children}
      </div>
    </BodyPortal>
  );
}
