"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname, useSearchParams } from "next/navigation";
import type { VirtuosoHandle } from "react-virtuoso";
import { MasonryGrid } from "./MasonryGrid";
import { PaginationBar } from "./PaginationBar";
import { useInfiniteScrollEnabled } from "./InfiniteScrollToggle";
import { useSettings } from "@/components/settings/SettingsProvider";
import type { CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";

interface Props {
  initialItems: CardImage[];
  initialPage: number;
  totalPages: number;
  totalCount: number;
  view: LibraryView;
  infiniteScrollEnabled?: boolean;
}

/**
 * Wrapper that owns the "loaded pages" state for the cover grid. The
 * top + bottom pagination bars and the grid itself all read from here
 * so the bottom bar can show "Pages 1–7 of 7" once the user has
 * scroll-appended through the whole result set.
 *
 * Top bar stays anchored at `initialPage` — that's where the user
 * landed via URL. Bottom bar reflects `loadedEnd`, the highest page
 * currently appended.
 */
export function LibraryGrid({
  initialItems,
  initialPage,
  totalPages,
  totalCount,
  view,
  infiniteScrollEnabled: infiniteFromProp = true,
}: Props) {
  const sp = useSearchParams();
  const pathname = usePathname();
  // The toggle in the FilterBar persists per-user-tab via localStorage.
  // The prop is the page-level "is this surface ever allowed to
  // infinite-scroll?" gate; AND with the user's preference.
  const userInfinite = useInfiniteScrollEnabled();
  const infiniteScrollEnabled = infiniteFromProp && userInfinite;
  const { settings } = useSettings();
  const fadeMs = settings.fadeTransitions ? Math.max(0, settings.fadeDurationMs ?? 400) : 0;

  // Loaded items split into two buckets: the SSR-rendered initial
  // page (kept stable for hydration) and any appended-by-fetch pages.
  const [extra, setExtra] = useState<CardImage[]>([]);
  const [loadedEnd, setLoadedEnd] = useState<number>(initialPage);
  // The page currently in the viewport, derived from Virtuoso's
  // first-visible-row index. Used solely for the "Page X of Y" label
  // — navigation still keys off `initialPage` (URL anchor) and
  // `loadedEnd`. Defaults to initialPage so SSR matches.
  const [visiblePage, setVisiblePage] = useState<number>(initialPage);
  const pageSize = Math.max(25, Math.min(500, settings.coverPageSize || 100));
  // Per-batch fade controller. Each appended page is a "batch"; when
  // any row of that batch first intersects the viewport, every row in
  // the same batch fades in together (rather than one-row-at-a-time
  // as the user scrolls past). Rows subscribe to their batch's trigger
  // so they all flip to "animated" at once.
  const fadeController = useMemo(() => {
    let seq = 0;
    const itemBatch = new Map<number, number>();
    const batchIds = new Map<number, number[]>();
    const triggered = new Set<number>();
    const subs = new Map<number, Set<() => void>>();
    return {
      addBatch(ids: number[]): number {
        seq += 1;
        const id = seq;
        batchIds.set(id, ids);
        for (const it of ids) itemBatch.set(it, id);
        return id;
      },
      batchIdOf(itemId: number): number | null {
        return itemBatch.get(itemId) ?? null;
      },
      isTriggered(batchId: number): boolean {
        return triggered.has(batchId);
      },
      trigger(batchId: number) {
        if (triggered.has(batchId)) return;
        triggered.add(batchId);
        const set = subs.get(batchId);
        if (set) for (const cb of set) cb();
      },
      subscribe(batchId: number, cb: () => void): () => void {
        let set = subs.get(batchId);
        if (!set) { set = new Set(); subs.set(batchId, set); }
        set.add(cb);
        return () => { set?.delete(cb); };
      },
      // Drop a batch entirely — items no longer count as pending so a
      // future remount won't replay the keyframe.
      expire(batchId: number) {
        const ids = batchIds.get(batchId);
        if (ids) for (const it of ids) itemBatch.delete(it);
        batchIds.delete(batchId);
        triggered.delete(batchId);
        subs.delete(batchId);
      },
      reset() {
        seq = 0;
        itemBatch.clear();
        batchIds.clear();
        triggered.clear();
        subs.clear();
      },
    };
  }, []);
  const fetchInFlightRef = useRef<boolean>(false);
  // Page number currently being fetched (or just resolved). Combined
  // with fetchInFlightRef it dedupes simultaneous requests for the same
  // page — strict-mode double-invoke and Virtuoso's onEndReached firing
  // twice in rapid succession both hit this.
  const lastFetchTargetRef = useRef<number>(0);
  // Auto-fetch suppression: after appendNextPage resolves we set this
  // true. Virtuoso's onEndReached path checks it and bails. The user
  // scrolling at least one full viewport flips it back to false so the
  // next bottom-trigger can append. Without this, mounting on a page
  // whose SSR rows are shorter than the viewport causes onEndReached
  // to fire repeatedly, chaining 3-4 page appends instantly and
  // dragging visiblePage way past the URL anchor.
  const autoFetchPausedRef = useRef<boolean>(false);
  const [isFetching, setIsFetching] = useState(false);

  // Reset when the SSR-anchor changes (filter/sort/page nav). Page
  // remount via key in app/page.tsx already handles most of this.
  useEffect(() => {
    setExtra([]);
    setLoadedEnd(initialPage);
    fadeController.reset();
  }, [initialPage, initialItems, fadeController]);

  const allItems = useMemo(() => [...initialItems, ...extra], [initialItems, extra]);

  // Mirror loadedEnd into a ref so the save closure (registered once
  // on mount) always reads the current value, without rebinding.
  const loadedEndRef = useRef(loadedEnd);
  useEffect(() => { loadedEndRef.current = loadedEnd; }, [loadedEnd]);

  // Reflect the visible page in the URL with `replaceState` (no
  // history push, so the back button still goes to the previous
  // route, not through every scroll position). Debounced via
  // visiblePage state which only updates when the bottom-most-visible
  // row changes pages — already throttled at the source.
  useEffect(() => {
    if (typeof window === "undefined") return;
    const usp = new URLSearchParams(sp.toString());
    if (visiblePage > 1) usp.set("page", String(visiblePage));
    else usp.delete("page");
    const qs = usp.toString();
    const target = qs ? `${pathname}?${qs}` : pathname;
    if (window.location.pathname + window.location.search !== target) {
      window.history.replaceState(window.history.state, "", target);
    }
  }, [visiblePage, sp, pathname]);

  // Track when we're mounted on the client so the portal can target
  // document.body without breaking SSR.
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);

  // Imperative handle on Virtuoso so we can call scrollToIndex even
  // when the target row has been unmounted from DOM (it's outside the
  // overscan window).
  const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);


  // Scroll + appended-page restoration. The page is `force-dynamic` so
  // Next.js re-renders fresh on back-nav (Router Cache doesn't apply),
  // which means scroll is *not* preserved automatically.
  //
  // Strategy: save scrollY + loadedEnd to sessionStorage on every
  // scroll (debounced) and on cleanup. On mount, if a snapshot exists,
  // re-fetch any pages the user had appended, then scrollTo with a
  // retry loop because Virtuoso lazily measures rows and the page may
  // briefly be too short for the saved scrollY to be valid.
  useEffect(() => {
    if (typeof window === "undefined") return;
    const key = `pinkudex:scroll:${pathname}?${sp.toString()}`;
    // Skip restore when arriving via an internal Prev/Next click
    // (PaginationBar sets this marker before pushing). Otherwise the
    // user clicking Prev all the way back to / would replay a snapshot
    // saved during their previous scroll session, re-fetching pages
    // 2–N and re-creating the visiblePage drift loop.
    const internalMarker = sessionStorage.getItem("pinkudex:nav-internal");
    if (internalMarker) {
      sessionStorage.removeItem("pinkudex:nav-internal");
      // Also clear the snapshot for this URL so subsequent scrolls
      // capture fresh state instead of compounding on the old one.
      try { sessionStorage.removeItem(key); } catch { /* ignore */ }
      return;
    }

    let cancelled = false;
    const restore = async () => {
      let snap: { scrollY: number; loadedEnd: number } | null = null;
      try {
        const raw = sessionStorage.getItem(key);
        if (raw) snap = JSON.parse(raw);
      } catch { /* corrupt — ignore */ }
      if (!snap || snap.scrollY <= 0) return;

      // Refetch missing appended pages so the document is tall enough
      // for the saved scrollY to land somewhere meaningful.
      if (snap.loadedEnd > initialPage && infiniteScrollEnabled) {
        const collected: CardImage[] = [];
        for (let p = initialPage + 1; p <= snap.loadedEnd && p <= totalPages; p++) {
          if (cancelled) return;
          const usp = new URLSearchParams(sp.toString());
          usp.set("page", String(p));
          try {
            const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
            if (!r.ok) break;
            const data = (await r.json()) as { items: CardImage[]; page: number };
            if (!Array.isArray(data.items)) break;
            collected.push(...data.items);
          } catch { break; }
        }
        if (cancelled) return;
        if (collected.length > 0) {
          setExtra(collected);
          setLoadedEnd(snap.loadedEnd);
        }
      }

      // Retry scrollTo for up to ~1s. Stops once position settles
      // within a couple of pixels of target. Necessary because Next.js
      // and Virtuoso both touch scroll/layout shortly after mount.
      const target = snap.scrollY;
      let attempts = 0;
      const maxAttempts = 60;
      const tryScroll = () => {
        if (cancelled) return;
        window.scrollTo(0, target);
        attempts += 1;
        if (Math.abs(window.scrollY - target) <= 2 || attempts >= maxAttempts) return;
        requestAnimationFrame(tryScroll);
      };
      requestAnimationFrame(tryScroll);
    };
    restore();

    // Save scroll + loadedEnd. No restoredRef gate — we want every
    // scroll captured, and re-saving a stale value before restore is
    // harmless (restore reads once, before it loops).
    let t: ReturnType<typeof setTimeout> | null = null;
    const save = () => {
      try {
        const payload = JSON.stringify({ scrollY: window.scrollY, loadedEnd: loadedEndRef.current });
        sessionStorage.setItem(key, payload);
      } catch { /* quota / private mode */ }
    };
    const onScroll = () => {
      if (t) clearTimeout(t);
      t = setTimeout(save, 100);
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("pagehide", save);

    return () => {
      cancelled = true;
      // Intentionally NOT calling save() here. By the time cleanup
      // runs on back-nav, Next.js has already reset window.scrollY=0,
      // and saving that would clobber the snapshot.
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("pagehide", save);
      if (t) clearTimeout(t);
    };
    // Run once per LibraryGrid mount. Filter changes already remount
    // via the page-level key in app/page.tsx.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Scroll to the first row of `targetPage` once it's in the buffer.
  // Pure DOM operation; caller is responsible for ensuring the target
  // page has been loaded.
  const scrollToLoadedPage = useCallback((targetPage: number): boolean => {
    const cols = view === "portrait"
      ? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
      : Math.max(2, Math.min(4, settings.gridColumns || 3));
    const itemIdx = (targetPage - initialPage) * pageSize;
    const rowIdx = Math.floor(itemIdx / cols);
    const handle = virtuosoHandleRef.current;
    if (!handle) return false;
    handle.scrollToIndex({ index: rowIdx, align: "start", behavior: "smooth" });
    return true;
  }, [initialPage, pageSize, view, settings.gridColumns, settings.gridColumnsPortrait]);

  // Append the page just past loadedEnd. Returns true on success, false
  // if there's nothing more to load or the request fails. Shared between
  // the infinite-scroll auto-fetch path, the explicit Load-More button,
  // and the scroll-mode prefetch loop in scrollToPage.
  const appendNextPage = useCallback(async (): Promise<boolean> => {
    if (loadedEnd >= totalPages) return false;
    const next = loadedEnd + 1;
    // Dedupe: a second invocation for the same target while the first
    // is in flight is a no-op. This catches strict-mode double-invoke
    // in dev and Virtuoso firing onEndReached twice for one bottom hit.
    if (fetchInFlightRef.current && lastFetchTargetRef.current === next) {
      return false;
    }
    if (fetchInFlightRef.current) return false;
    fetchInFlightRef.current = true;
    lastFetchTargetRef.current = next;
    setIsFetching(true);
    try {
      const usp = new URLSearchParams(sp.toString());
      usp.set("page", String(next));
      const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
      if (!r.ok) return false;
      const data = (await r.json()) as { items: CardImage[]; page: number; hasMore: boolean };
      if (!Array.isArray(data.items) || data.items.length === 0) return false;
      if (fadeMs > 0) {
        fadeController.addBatch(data.items.map((it) => it.id));
      }
      setExtra((cur) => [...cur, ...data.items]);
      setLoadedEnd(data.page);
      return true;
    } catch {
      return false;
    } finally {
      fetchInFlightRef.current = false;
      setIsFetching(false);
    }
  }, [loadedEnd, sp, totalPages, fadeMs, fadeController]);

  // Auto-fetch path used by Virtuoso's onEndReached. Gated on the
  // infinite-scroll preference. After each successful append we pause
  // until the user scrolls — that breaks the chain where a freshly
  // mounted SSR page has the bottom row near the viewport, causing
  // onEndReached to fire repeatedly and append 3-4 pages back-to-back.
  // The explicit Load-More / prefetch paths bypass this guard via
  // appendNextPage directly.
  const fetchNextPage = useCallback(async () => {
    if (!infiniteScrollEnabled) return;
    if (autoFetchPausedRef.current) return;
    const ok = await appendNextPage();
    if (ok) autoFetchPausedRef.current = true;
  }, [infiniteScrollEnabled, appendNextPage]);

  // Release the auto-fetch pause only on a real user gesture — wheel,
  // touchmove, or a scroll-direction key. The earlier window-scroll
  // listener was too sensitive: programmatic scroll-restoration and
  // browser overflow-anchor adjustments fire scroll events and were
  // bypassing the pause, which let the auto-fetch chain still run
  // 3-4 pages deep on initial mount and after URL nav back to /.
  useEffect(() => {
    if (typeof window === "undefined") return;
    const release = () => { autoFetchPausedRef.current = false; };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "PageDown" || e.key === "ArrowDown" || e.key === "End" || e.key === " " || e.key === "Spacebar") {
        autoFetchPausedRef.current = false;
      }
    };
    window.addEventListener("wheel", release, { passive: true });
    window.addEventListener("touchmove", release, { passive: true });
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("wheel", release);
      window.removeEventListener("touchmove", release);
      window.removeEventListener("keydown", onKey);
    };
  }, []);

  // Build the scroll-mode entry point. Backward across the SSR anchor
  // returns false → URL nav. Forward past loadedEnd prefetches one
  // page at a time until the target lands inside the buffer, then
  // scrolls to it. Each loop iteration awaits a real network round-trip
  // — this is the "always scroll, prefetch when needed" behavior.
  const scrollToPageScrollMode = useCallback(async (targetPage: number): Promise<boolean> => {
    if (targetPage < initialPage) return false;
    if (targetPage > totalPages) return false;
    while (targetPage > loadedEndRef.current) {
      const ok = await appendNextPage();
      if (!ok) return false;
    }
    return scrollToLoadedPage(targetPage);
  }, [initialPage, totalPages, appendNextPage, scrollToLoadedPage]);

  // Pick the right Prev/Next handler based on the user's preference.
  // - "url"    → no callback; bar always URL-navs.
  // - "scroll" → prefetch + smooth scroll, URL fallback only on backward.
  const onScrollToPageProp = settings.paginationMode === "scroll" ? scrollToPageScrollMode : undefined;

  // Same-URL nav handler — fires when the bar would push to the page
  // we're already at (e.g. clicking Prev at "Page 5 (scrolled from
  // URL=/)" wants to land on page 1). Resets the appended buffer +
  // scrolls to top so the user sees a real change instead of nothing.
  const handleSamePageNav = useCallback(() => {
    setExtra([]);
    setLoadedEnd(initialPage);
    fadeController.reset();
    autoFetchPausedRef.current = true;
    if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
  }, [initialPage, fadeController]);

  return (
    <>
      <MasonryGrid
        images={allItems}
        view={view}
        infiniteScrollEnabled={infiniteScrollEnabled}
        loadedEnd={loadedEnd}
        totalPages={totalPages}
        onEndReached={fetchNextPage}
        isFetching={isFetching}
        fadeController={fadeController}
        fadeMs={fadeMs}
        pageSize={pageSize}
        ssrAnchorPage={initialPage}
        onVisiblePageChange={setVisiblePage}
        virtuosoHandleRef={virtuosoHandleRef}
      />

      {/* Floating bar — portaled to <body> to escape any ancestor's
          backdrop-filter / transform, which would otherwise trap a
          `position: fixed` child to that ancestor's box. */}
      {mounted && createPortal(
        <div className="fixed inset-x-0 bottom-[12px] z-30 flex justify-center pointer-events-none">
          <div
            className="pointer-events-auto rounded-2xl shadow-2xl px-4 py-2.5 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
            style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
          >
            <PaginationBar
              currentPage={visiblePage}
              totalPages={totalPages}
              totalCount={totalCount}
              onScrollToPage={onScrollToPageProp}
              onSamePageNav={handleSamePageNav}
            />
          </div>
        </div>,
        document.body,
      )}
    </>
  );
}
