"use client";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { ImageCard, type CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";
import { useSettings } from "@/components/settings/SettingsProvider";

/**
 * Windowed grid: renders only visible rows, mounting/unmounting cards
 * as the user scrolls. Backed by react-virtuoso's window-scroll mode
 * so the page itself scrolls (not an inner div).
 *
 * Items are bucketed into rows of `cols` cards each — Virtuoso treats
 * each row as one item and measures its height dynamically. This keeps
 * the masonry-style CSS grid (equal columns, variable card heights)
 * working without forcing fixed-aspect cards.
 *
 * Below a small threshold we render a plain CSS grid — virtualization
 * overhead isn't worth it for short lists, and the simpler DOM tree
 * cooperates better with browser-native fade-in / scroll animations.
 */
interface Props {
  images: CardImage[];
  view?: LibraryView;
  /** Highest page number currently appended. Used to decide whether
   *  to keep firing endReached. */
  loadedEnd?: number;
  /** Total pages for the current filter set. */
  totalPages?: number;
  /** When false, infinite-scroll appends are suppressed (filtered
   *  views, user toggle, etc.). */
  infiniteScrollEnabled?: boolean;
  /** Called when Virtuoso detects we're near the end and more pages
   *  exist. Parent fetches and grows `images`. */
  onEndReached?: () => void;
  /** True while the parent is fetching the next page — drives the
   *  small "Loading next page..." footer beneath the grid. */
  isFetching?: boolean;
  /** Per-batch fade controller. Each row looks up which batch its
   *  items belong to; the first row of that batch to intersect the
   *  viewport triggers the batch, fading every row of the batch in
   *  unison. */
  fadeController?: FadeController;
  /** Fade animation duration in ms; matches CSS `--fade-duration`. */
  fadeMs?: number;
  /** Items per logical "page" — used to derive the page currently
   *  scrolled into view from the first-visible row index. */
  pageSize?: number;
  /** The page the SSR rendered from. Item index 0 corresponds to this
   *  page, not page 1, when the user landed via ?page=N. */
  ssrAnchorPage?: number;
  /** Fires whenever the viewport's leading row changes pages. */
  onVisiblePageChange?: (page: number) => void;
  /** Receives Virtuoso's imperative handle so the parent can call
   *  `scrollToIndex` on it (e.g. for Prev/Next navigation jumps). */
  virtuosoHandleRef?: React.MutableRefObject<VirtuosoHandle | null>;
}

interface FadeController {
  batchIdOf(itemId: number): number | null;
  isTriggered(batchId: number): boolean;
  trigger(batchId: number): void;
  subscribe(batchId: number, cb: () => void): () => void;
  expire(batchId: number): void;
}

function MasonryRow({
  rowIdx, row, cols, view, fadeController, fadeMs,
}: {
  rowIdx: number;
  row: CardImage[];
  cols: number;
  view: LibraryView;
  fadeController?: FadeController;
  fadeMs?: number;
}) {
  // Resolve this row's batch once on mount. If null, the row was part
  // of the SSR initial page (or its batch already expired) — no fade.
  const [batchId] = useState<number | null>(
    () => fadeController?.batchIdOf(row[0]?.id ?? -1) ?? null,
  );
  const isFresh = batchId != null;
  // `animated` flips when the batch's trigger fires. Initial value
  // honors a batch that was already triggered (e.g. row remounted via
  // Virtuoso unmount/remount mid-fade).
  const [animated, setAnimated] = useState<boolean>(
    () => batchId != null && !!fadeController?.isTriggered(batchId),
  );
  const ref = useRef<HTMLDivElement | null>(null);

  // Subscribe to batch trigger so every row flips at the same time.
  useEffect(() => {
    if (!isFresh || !fadeController || batchId == null) return;
    return fadeController.subscribe(batchId, () => setAnimated(true));
  }, [isFresh, fadeController, batchId]);

  // First row to intersect triggers the batch. Virtuoso pre-renders
  // rows in its overscan window (well below the viewport), so without
  // this gate the animation would finish off-screen.
  useEffect(() => {
    if (!isFresh || animated || !fadeController || batchId == null) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) {
          fadeController.trigger(batchId);
          io.disconnect();
          return;
        }
      }
    });
    io.observe(el);
    return () => io.disconnect();
  }, [isFresh, animated, fadeController, batchId]);

  // Once the animation has played, drop the batch so that any future
  // remount of these rows doesn't replay the keyframe.
  useEffect(() => {
    if (!animated || !fadeController || batchId == null) return;
    const t = setTimeout(() => fadeController.expire(batchId), (fadeMs ?? 0) + 50);
    return () => clearTimeout(t);
  }, [animated, fadeController, batchId, fadeMs]);

  const showFade = isFresh && animated;
  const hiddenUntilSeen = isFresh && !animated;

  return (
    <div
      ref={ref}
      data-masonry-row={rowIdx}
      className={showFade ? "grid gap-5 pb-5 fade-in" : "grid gap-5 pb-5"}
      style={{
        gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
        ...(hiddenUntilSeen ? { opacity: 0 } : null),
      }}
    >
      {row.map((img) => (
        <ImageCard key={img.id} image={img} view={view} />
      ))}
    </div>
  );
}

export function MasonryGrid({
  images,
  view = "landscape",
  loadedEnd = 1,
  totalPages = 1,
  infiniteScrollEnabled = true,
  onEndReached,
  isFetching = false,
  fadeController,
  fadeMs,
  pageSize = 100,
  ssrAnchorPage = 1,
  onVisiblePageChange,
  virtuosoHandleRef,
}: Props) {
  const { settings } = useSettings();
  const cols = view === "portrait"
    ? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
    : Math.max(2, Math.min(4, settings.gridColumns || 3));

  // CSS var is used by the simple-grid path so the column count is
  // correct on first paint regardless of what `settings.gridColumns`
  // resolves to client-side. Virtuoso path uses the JS number because
  // it bucket-rows items + only renders post-hydration anyway.
  const cssCols = view === "portrait" ? "var(--grid-cols-portrait, 6)" : "var(--grid-cols, 3)";

  const rows = useMemo(() => {
    const out: CardImage[][] = [];
    for (let i = 0; i < images.length; i += cols) {
      out.push(images.slice(i, i + cols));
    }
    return out;
  }, [images, cols]);

  if (images.length === 0) return null;

  // Threshold for short lists: skip virtualization and render plain
  // grid. ImageCard's lazy-loaded thumbnails handle the bytes side.
  // Above the threshold we go through Virtuoso *from first render* —
  // initialItemCount lets SSR emit a few rows so the simple-grid →
  // virtuoso swap (and its flash) is gone.
  const SIMPLE_THRESHOLD = 24;
  if (images.length <= SIMPLE_THRESHOLD) {
    return (
      <div
        className="grid gap-5"
        style={{ gridTemplateColumns: `repeat(${cssCols}, minmax(0, 1fr))` }}
      >
        {images.map((img) => (
          <ImageCard key={img.id} image={img} view={view} />
        ))}
      </div>
    );
  }

  const canLoadMore = infiniteScrollEnabled && loadedEnd < totalPages;

  // Track which "page" sits at the top of the viewport. We scan
  // rendered rows on each scroll tick and pick the first one whose
  // bottom edge is past the viewport top — that's the row currently
  // crossing/just-below the top. Virtuoso's own rangeChanged reports
  // the *rendered* range (includes overscan above viewport) so it
  // lags by a row or two.
  const lastReportedPage = useRef<number>(ssrAnchorPage);
  const totalItemsRef = useRef<number>(images.length);
  useEffect(() => { totalItemsRef.current = images.length; }, [images.length]);
  useEffect(() => {
    if (!onVisiblePageChange) return;
    const update = () => {
      // Find the *bottom-most* row currently visible — i.e. the row
      // furthest along that has any pixel in the viewport. Combined
      // with last-item page derivation, the label flips the moment
      // the first card of the next page peeks in from the bottom of
      // the viewport.
      const vh = window.innerHeight;
      const els = document.querySelectorAll<HTMLElement>("[data-masonry-row]");
      let chosen: HTMLElement | null = null;
      for (const el of els) {
        const r = el.getBoundingClientRect();
        if (r.top < vh && r.bottom > 0) chosen = el; // keep updating
      }
      if (!chosen && els.length > 0) chosen = els[0];
      if (!chosen) return;
      const idx = Number(chosen.dataset.masonryRow ?? 0);
      // Last item of the bottom-most visible row drives the page
      // label. As soon as a row containing the first card of the
      // next page enters from the bottom, the label flips. Clamp to
      // the actual item count so a partially-filled trailing row
      // (e.g. only 1 of 3 slots populated) doesn't claim a page that
      // has no real content yet.
      const total = totalItemsRef.current;
      const itemIdx = Math.min(
        idx * cols + Math.max(0, cols - 1),
        Math.max(0, total - 1),
      );
      const page = ssrAnchorPage + Math.floor(itemIdx / pageSize);
      if (page !== lastReportedPage.current) {
        lastReportedPage.current = page;
        onVisiblePageChange(page);
      }
    };
    let raf: number | null = null;
    const onScroll = () => {
      if (raf != null) return;
      raf = requestAnimationFrame(() => { raf = null; update(); });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    update();
    return () => {
      window.removeEventListener("scroll", onScroll);
      if (raf != null) cancelAnimationFrame(raf);
    };
  }, [cols, pageSize, ssrAnchorPage, onVisiblePageChange]);

  return (
    <>
      <Virtuoso
        ref={(h) => { if (virtuosoHandleRef) virtuosoHandleRef.current = h; }}
        useWindowScroll
        // `data` makes Virtuoso re-render rows whenever the array
        // reference changes (e.g. on append). Without it, item
        // closures freeze on the first render and updates to
        // fadeFromIndex / loadedEnd never propagate into rows.
        data={rows}
        initialItemCount={Math.min(rows.length, 8)}
        endReached={canLoadMore ? onEndReached : undefined}
        increaseViewportBy={600}
        itemContent={(rowIdx, row) => {
          if (!row) return null;
          return (
            <MasonryRow
              rowIdx={rowIdx}
              row={row}
              cols={cols}
              view={view}
              fadeController={fadeController}
              fadeMs={fadeMs}
            />
          );
        }}
        overscan={600}
        // Key by the first card's id, falling back to rowIdx for empty
        // rows. Joining every id in the row meant a partial trailing
        // row (e.g. 2 of 3 columns filled) re-keyed to a brand-new
        // identity once infinite-scroll filled the missing slots,
        // unmounting the row mid-scroll and blanking the in-flight
        // fade-in batch. The first id is stable across that fill.
        computeItemKey={(rowIdx, row) => (row && row[0] ? row[0].id : rowIdx)}
      />
      {isFetching && (
        <div className="flex items-center justify-center gap-2 py-4 text-xs font-mono text-[var(--color-fg-muted)]">
          <Loader2 className="w-4 h-4 animate-spin" /> Loading next page...
        </div>
      )}
    </>
  );
}
