import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";

// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// the same view returns. Keyed by the shell's `scrollKey`.
const savedScroll = new Map<string, number>();

// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
function restoreScroll(el: HTMLElement, top: number): () => void {
  if (top <= 0) {
    el.scrollTop = 0;
    return () => {};
  }
  let raf = 0;
  let frames = 0;
  let cancelled = false;
  const tick = () => {
    if (cancelled) return;
    if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
    if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => {
    cancelled = true;
    cancelAnimationFrame(raf);
  };
}

// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
// which is why the edge-fade and the scroll restore/reset policy live here once instead of three
// times.
//
// Two invariants this element carries, both learned the hard way:
//
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
//   from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
//   lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
//   appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
//   taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
//   loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
//   switch from the tall Videos tab to the short About tab.
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
//   the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
//   fade next to its scrollbar, not instead of it.
//
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
//   restores where you were (bug 1). The channel page is a different scroller, so the feed's
//   scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
// unmount, which would otherwise persist a 0 too).
//
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
// `children` arrives as an already-built element, so React skips the page subtree underneath.
export default function PageScroller({
  children,
  scrollKey,
  fadeTop = true,
}: {
  children: ReactNode;
  scrollKey?: string;
  // Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade.
  fadeTop?: boolean;
}) {
  const fade = useScrollFade(20, fadeTop);
  const fadeRef = fade.ref;
  const elRef = useRef<HTMLElement | null>(null);
  // Stable identity: an inline ref callback changes every render, which makes React detach and
  // re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
  // into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
  // once here is safe.
  const setRef = useCallback(
    (node: HTMLElement | null) => {
      elRef.current = node;
      fadeRef(node);
    },
    [fadeRef],
  );

  useLayoutEffect(() => {
    const el = elRef.current;
    if (!el || scrollKey == null) return;
    const key = scrollKey;
    const want = savedScroll.get(key) ?? 0;
    const cancelRestore = restoreScroll(el, want);
    // Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
    // time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
    const onScroll = () => {
      savedScroll.set(key, el.scrollTop);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      cancelRestore();
      el.removeEventListener("scroll", onScroll);
    };
  }, [scrollKey]);

  return (
    <main
      ref={setRef}
      style={fade.style}
      className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
    >
      {children}
    </main>
  );
}
