import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
import {
  X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
  Square, Rows, Download, ArrowsLeftRight,
  ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
  FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
  ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { useStore, type FitMode } from "../../store";
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
import s from "./Reader.module.css";

// ── LRU image cache ───────────────────────────────────────────────────────────
// Keeps browser memory in check by revoking object-URLs for chapters that
// have scrolled far away.  We cache by chapterId (not URL) so that we can
// drop a whole chapter at once.
const MAX_CACHED_CHAPTERS = 6;

// Track insertion order so we can evict the oldest chapter.
const chapterCacheOrder: number[] = [];

function touchChapterOrder(chapterId: number) {
  const idx = chapterCacheOrder.indexOf(chapterId);
  if (idx !== -1) chapterCacheOrder.splice(idx, 1);
  chapterCacheOrder.push(chapterId);
}

function evictOldestChapter(
  pageCache: React.MutableRefObject<Map<number, string[]>>,
  keepIds: Set<number>,
): number | null {
  for (let i = 0; i < chapterCacheOrder.length; i++) {
    const id = chapterCacheOrder[i];
    if (!keepIds.has(id)) {
      chapterCacheOrder.splice(i, 1);
      pageCache.current.delete(id);
      return id;
    }
  }
  return null;
}

/** Fire-and-forget: create an Image and let the browser cache it. */
function preloadImage(url: string) {
  const img = new Image();
  img.src = url;
}

/**
 * Decode a single image fully before resolving.
 * Used to avoid showing a half-painted page.
 */
function decodeImage(url: string): Promise<void> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload  = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
    img.onerror = () => resolve(); // don't block on error
    img.src = url;
  });
}

function measureAspect(url: string): Promise<number> {
  return new Promise((res) => {
    const img = new Image();
    img.onload  = () => res(img.naturalWidth / img.naturalHeight);
    img.onerror = () => res(0.67);
    img.src = url;
  });
}

// ── Download modal ────────────────────────────────────────────────────────────
function DownloadModal({
  chapter,
  remaining,
  onClose,
}: {
  chapter: { id: number; name: string };
  remaining: { id: number }[];
  onClose: () => void;
}) {
  const [nextN, setNextN] = useState(5);
  const [busy, setBusy]   = useState(false);

  const run = async (fn: () => Promise<unknown>) => {
    setBusy(true);
    await fn().catch(console.error);
    setBusy(false);
    onClose();
  };

  return (
    <div className={s.dlBackdrop} onClick={onClose}>
      <div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
        <p className={s.dlTitle}>Download</p>
        <button className={s.dlOption} disabled={busy}
          onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
          This chapter
          <span className={s.dlSub}>{chapter.name}</span>
        </button>
        <div className={s.dlRow}>
          <button className={s.dlOption} disabled={busy || !remaining.length}
            onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
              chapterIds: remaining.slice(0, nextN).map((c) => c.id),
            }))}>
            Next chapters
            <span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
          </button>
          <div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
            <button className={s.dlStepBtn}
              onClick={() => setNextN((n) => Math.max(1, n - 1))}
              disabled={nextN <= 1}>−</button>
            <span className={s.dlStepVal}>{nextN}</span>
            <button className={s.dlStepBtn}
              onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
              disabled={nextN >= remaining.length}>+</button>
          </div>
        </div>
        <button className={s.dlOption} disabled={busy || !remaining.length}
          onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
            chapterIds: remaining.map((c) => c.id),
          }))}>
          All remaining
          <span className={s.dlSub}>{remaining.length} chapters</span>
        </button>
      </div>
    </div>
  );
}

// ── Zoom slider popover ───────────────────────────────────────────────────────
function ZoomPopover({
  value,
  onChange,
  onReset,
  onClose,
}: {
  value: number;
  onChange: (v: number) => void;
  onReset: () => void;
  onClose: () => void;
}) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) onClose();
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [onClose]);

  return (
    <div className={s.zoomPopover} ref={ref}>
      <input
        type="range"
        className={s.zoomSlider}
        min={200}
        max={2400}
        step={50}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
      />
      <button className={s.zoomResetBtn} onClick={onReset}>
        {Math.round((value / 900) * 100)}%
      </button>
    </div>
  );
}

// ── Reader ────────────────────────────────────────────────────────────────────

/** One chapter's worth of pages in the infinite strip */
interface StripChapter {
  chapterId: number;
  chapterName: string;
  urls: string[];
  /** Global page index offset for pages in this strip chunk */
  startGlobalIdx: number;
}

export default function Reader() {
  const containerRef    = useRef<HTMLDivElement>(null);
  const rafRef          = useRef(0);
  const pageNumRef      = useRef(1);
  const pageCache       = useRef<Map<number, string[]>>(new Map());
  const aspectCache     = useRef<Map<string, number>>(new Map());
  const hideTimerRef    = useRef<ReturnType<typeof setTimeout> | null>(null);
  const uiRef           = useRef<HTMLDivElement>(null);

  // Track which chapters are being fetched so we don't double-fire
  const fetchingRef       = useRef<Set<number>>(new Set());
  // Whether we've already appended the next chapter into the strip
  const appendedRef       = useRef<Set<number>>(new Set());
  // The chapter id whose pages are currently being loaded (prevents stale sets)
  const loadingChapterRef = useRef<number | null>(null);
  // Mirror of stripChapters in a ref so the scroll handler never closes over stale state
  const stripChaptersRef  = useRef<StripChapter[]>([]);
  // Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position
  const scrollAnchorRef   = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);

  const [loading, setLoading]       = useState(true);
  const [error, setError]           = useState<string | null>(null);
  const [dlOpen, setDlOpen]         = useState(false);
  const [zoomOpen, setZoomOpen]     = useState(false);
  const [uiVisible, setUiVisible]   = useState(true);
  const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
  const [pageGroups, setPageGroups] = useState<number[][]>([]);
  // True only after the first page of the new chapter has been decoded,
  // preventing any flash of the previous chapter's image.
  const [pageReady, setPageReady]   = useState(false);

  /**
   * The infinite strip: an ordered list of chapter chunks.
   * In non-longstrip modes this is unused — only pageUrls matters.
   */
  const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);

  /**
   * In longstrip autoNext mode, this tracks which chapter the user is
   * currently reading (for topbar display) without triggering a full reload.
   */
  const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);

  // Keep the ref mirror in sync so the scroll handler always sees current strip state
  useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);

  // Restore scroll position synchronously after a head-trim, before the browser paints
  useLayoutEffect(() => {
    const anchor = scrollAnchorRef.current;
    if (!anchor || !containerRef.current) return;
    scrollAnchorRef.current = null;
    const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
    // gained is negative when we removed nodes (scrollHeight shrank)
    // We want scrollTop to decrease by the same amount so the visible content stays put.
    // But since we removed nodes from the top, scrollHeight already shrank —
    // we just need to subtract the removed pixel height from scrollTop.
    if (gained < 0) {
      containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
    }
  }, [stripChapters]);

  const {
    activeManga, activeChapter, activeChapterList,
    pageUrls, pageNumber, settings,
    setPageUrls, setPageNumber, closeReader, openReader, openSettings,
    updateSettings, addHistory,
  } = useStore();

  const kb         = settings.keybinds;
  const rtl        = settings.readingDirection === "rtl";
  const fit        = settings.fitMode ?? "width";
  const style      = settings.pageStyle ?? "single";
  const maxW       = settings.maxPageWidth ?? 900;
  const autoNext   = settings.autoNextChapter ?? false;

  useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);

  // ── UI autohide ──────────────────────────────────────────────────────────────
  const scheduleHide = useCallback(() => {
    if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
    hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000);
  }, []);

  const showUi = useCallback(() => {
    setUiVisible(true);
    scheduleHide();
  }, [scheduleHide]);

  useEffect(() => {
    scheduleHide();
    return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); };
  }, []);



  // ── Auto-focus viewer so spacebar/arrows work ───────────────────────────────
  useEffect(() => {
    containerRef.current?.focus({ preventScroll: true });
  }, [activeChapter?.id]);

  // ── Fetch helpers ────────────────────────────────────────────────────────────
  const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
    const cached = pageCache.current.get(chapterId);
    if (cached) {
      touchChapterOrder(chapterId);
      return cached;
    }
    if (fetchingRef.current.has(chapterId)) {
      // Poll until another in-flight fetch resolves
      return new Promise((resolve) => {
        const interval = setInterval(() => {
          const c = pageCache.current.get(chapterId);
          if (c) { clearInterval(interval); resolve(c); }
        }, 50);
      });
    }
    fetchingRef.current.add(chapterId);
    const d = await gql<{ fetchChapterPages: { pages: string[] } }>(
      FETCH_CHAPTER_PAGES, { chapterId }
    );
    const urls = d.fetchChapterPages.pages.map(thumbUrl);
    pageCache.current.set(chapterId, urls);
    touchChapterOrder(chapterId);
    // Evict oldest chapters if we're over the limit, but always keep the
    // immediately adjacent chapters so navigation is instant.
    while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
      evictOldestChapter(pageCache, new Set([chapterId]));
    }
    fetchingRef.current.delete(chapterId);
    return urls;
  }, []);

  // ── Load pages ──────────────────────────────────────────────────────────────
  useEffect(() => {
    if (!activeChapter) return;
    setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
    // Reset strip state for new chapter navigation (non-scroll transitions)
    appendedRef.current = new Set();

    const targetId = activeChapter.id;
    loadingChapterRef.current = targetId;

    fetchPages(targetId)
      .then(async (urls) => {
        // Discard result if the user has already navigated to a different chapter
        if (loadingChapterRef.current !== targetId) return;

        // Decode the first page before committing so no previous chapter flashes
        await decodeImage(urls[0]);

        if (loadingChapterRef.current !== targetId) return;

        setPageUrls(urls);
        setPageReady(true);
        if (style === "longstrip" && autoNext) {
          setStripChapters([{
            chapterId: activeChapter.id,
            chapterName: activeChapter.name,
            urls,
            startGlobalIdx: 0,
          }]);
          setVisibleChapterId(activeChapter.id);
        } else {
          setStripChapters([]);
          setVisibleChapterId(null);
        }
      })
      .catch((e) => setError(e instanceof Error ? e.message : String(e)))
      .finally(() => {
        if (loadingChapterRef.current === targetId) setLoading(false);
      });
  }, [activeChapter?.id]);

  // ── Double-page grouping ─────────────────────────────────────────────────────
  // Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo.
  // Remaining portrait pages pair left-to-right: [2,3], [4,5], ...
  useEffect(() => {
    if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
    let cancelled = false;
    (async () => {
      const aspects: number[] = [];
      for (const url of pageUrls) {
        if (aspectCache.current.has(url)) {
          aspects.push(aspectCache.current.get(url)!);
        } else {
          const a = await measureAspect(url);
          aspectCache.current.set(url, a);
          aspects.push(a);
        }
      }
      if (cancelled) return;
      const groups: number[][] = [];
      groups.push([1]);
      let i = 2;
      while (i <= pageUrls.length) {
        const a = aspects[i - 1];
        if (a > 1.2) {
          groups.push([i]); i++;
        } else if (i === pageUrls.length) {
          groups.push([i]); i++;
        } else {
          const nextA = aspects[i];
          if (nextA !== undefined && nextA <= 1.2) {
            // Book order: left page is i, right page is i+1
            groups.push(rtl ? [i + 1, i] : [i, i + 1]);
            i += 2;
          } else {
            groups.push([i]); i++;
          }
        }
      }
      setPageGroups(groups);
    })();
    return () => { cancelled = true; };
  }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);

  // ── Preload ─────────────────────────────────────────────────────────────────
  // Eagerly decode pages ahead; fire-and-forget preload for pages behind.
  useEffect(() => {
    const ahead = settings.preloadPages ?? 3;
    for (let i = 1; i <= ahead; i++) {
      const url = pageUrls[pageNumber - 1 + i];
      if (url) decodeImage(url); // uses browser cache — no duplicate network request
    }
    // Also keep one page behind warm
    const behindUrl = pageUrls[pageNumber - 2];
    if (behindUrl) preloadImage(behindUrl);
  }, [pageNumber, pageUrls, settings.preloadPages]);

  // ── Adjacent chapters ────────────────────────────────────────────────────────
  const adjacent = useMemo(() => {
    if (!activeChapter || !activeChapterList.length)
      return { prev: null, next: null, remaining: [] };
    const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
    return {
      prev: idx > 0 ? activeChapterList[idx - 1] : null,
      next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
      remaining: activeChapterList.slice(idx + 1),
    };
  }, [activeChapter, activeChapterList]);

  useEffect(() => {
    const pinned = new Set<number>();
    if (activeChapter)  pinned.add(activeChapter.id);
    if (adjacent.next)  pinned.add(adjacent.next.id);
    if (adjacent.prev)  pinned.add(adjacent.prev.id);

    const preload = (id: number) => {
      fetchPages(id)
        .then((urls) => urls.slice(0, 3).forEach(preloadImage))
        .catch(() => {});
    };
    if (adjacent.next) preload(adjacent.next.id);
    if (adjacent.prev) preload(adjacent.prev.id);

    // After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS
    // that isn't pinned as adjacent or current.
    while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
      const evicted = evictOldestChapter(pageCache, pinned);
      if (evicted === null) break; // nothing left to evict
    }
  }, [adjacent.next?.id, adjacent.prev?.id]);

  const lastPage = pageUrls.length;

  /**
   * In infinite-strip mode, the topbar shows whichever chapter the user is
   * currently scrolled into rather than the "root" chapter we opened with.
   */
  const displayChapter = useMemo(() => {
    if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
    return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
  }, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);

  /**
   * In infinite-strip mode, the "last page" shown in the topbar is relative
   * to the currently visible chapter chunk.
   */
  const visibleChunkLastPage = useMemo(() => {
    if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage;
    const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
    return chunk ? chunk.urls.length : lastPage;
  }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);

  /** Page number within the currently visible chapter chunk (for topbar) */
  const visibleChunkPage = useMemo(() => {
    if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber;
    const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
    if (!chunk) return pageNumber;
    return Math.max(1, pageNumber - chunk.startGlobalIdx);
  }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);

  // ── Auto-mark read + history ─────────────────────────────────────────────────
  useEffect(() => {
    if (!activeChapter || !lastPage) return;
    if (activeManga) {
      addHistory({
        mangaId: activeManga.id, mangaTitle: activeManga.title,
        thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
        chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
      });
    }
    if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
      setMarkedRead((p) => new Set(p).add(activeChapter.id));
      gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
    }
  }, [pageNumber, lastPage, activeChapter?.id]);

  // ── Navigation ──────────────────────────────────────────────────────────────
  const advanceGroup = useCallback((forward: boolean) => {
    if (!pageGroups.length) return;
    const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
    if (forward) {
      if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
      else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
      else closeReader();
    } else {
      if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
      else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
    }
  }, [pageGroups, pageNumber, adjacent, activeChapterList]);

  const goForward = useCallback(() => {
    if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
    if (pageNumber < lastPage) {
      const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
      if (nextUrl) {
        decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1));
      } else {
        setPageNumber(pageNumber + 1);
      }
    } else if (adjacent.next) {
      setPageNumber(1);
      openReader(adjacent.next, activeChapterList);
    } else {
      closeReader();
    }
  }, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);

  const goBack = useCallback(() => {
    if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
    if (pageNumber > 1) {
      const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
      if (prevUrl) {
        decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1));
      } else {
        setPageNumber(pageNumber - 1);
      }
    } else if (adjacent.prev) {
      openReader(adjacent.prev, activeChapterList);
    }
  }, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);

  const goNext = rtl ? goBack  : goForward;
  const goPrev = rtl ? goForward : goBack;

  function cycleStyle() {
    const cycle = ["single", "longstrip"] as const;
    const cur = style === "double" ? "single" : style;
    const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length];
    updateSettings({ pageStyle: next });
  }

  function cycleFit() {
    const cycle: FitMode[] = ["width", "height", "screen", "original"];
    updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
  }

  // ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
  useEffect(() => {
    const onWheel = (e: WheelEvent) => {
      if (!e.ctrlKey) return;
      e.preventDefault();
      const delta = e.deltaY < 0 ? 50 : -50;
      updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) });
    };
    window.addEventListener("wheel", onWheel, { passive: false });
    return () => window.removeEventListener("wheel", onWheel);
  }, [maxW]);

  // ── Keybinds ─────────────────────────────────────────────────────────────────
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.target as HTMLElement).tagName === "INPUT") return;

      // Escape: close overlays in priority order, then exit reader
      if (e.key === "Escape") {
        e.preventDefault();
        if (zoomOpen) { setZoomOpen(false); return; }
        if (dlOpen)   { setDlOpen(false);   return; }
        closeReader();
        return;
      }

      // Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom
      if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
        e.preventDefault();
        updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) });
        return;
      }
      if (e.ctrlKey && e.key === "-") {
        e.preventDefault();
        updateSettings({ maxPageWidth: Math.max(200, maxW - 100) });
        return;
      }
      if (e.ctrlKey && e.key === "0") {
        e.preventDefault();
        updateSettings({ maxPageWidth: 900 });
        return;
      }

      if (matchesKeybind(e, kb.exitReader))             { e.preventDefault(); closeReader(); }
      else if (matchesKeybind(e, kb.pageRight))         { e.preventDefault(); goForward(); }
      else if (matchesKeybind(e, kb.pageLeft))          { e.preventDefault(); goBack(); }
      else if (matchesKeybind(e, kb.firstPage))         { e.preventDefault(); setPageNumber(1); }
      else if (matchesKeybind(e, kb.lastPage))          { e.preventDefault(); setPageNumber(lastPage); }
      else if (matchesKeybind(e, kb.chapterRight))      { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
      else if (matchesKeybind(e, kb.chapterLeft))       { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
      else if (matchesKeybind(e, kb.togglePageStyle))   { e.preventDefault(); cycleStyle(); }
      else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
      else if (matchesKeybind(e, kb.toggleFullscreen))  { e.preventDefault(); toggleFullscreen().catch(console.error); }
      else if (matchesKeybind(e, kb.openSettings))      { e.preventDefault(); openSettings(); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW]);

  // ── Longstrip scroll tracker ─────────────────────────────────────────────────
  // Tracks current page number. In autoNext mode, appends the next chapter's
  // pages directly into the strip (no re-render / scroll reset) so the flow
  // is one seamless ribbon of images.
  useEffect(() => {
    const el = containerRef.current;
    if (!el || style !== "longstrip") return;

    const onScroll = () => {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(() => {
        if (!el) return;
        const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[];

        // Find the image whose center is closest to the viewport center
        const viewMid = el.scrollTop + el.clientHeight * 0.5;
        let closest = 0;
        let closestDist = Infinity;
        for (let i = 0; i < imgs.length; i++) {
          const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5;
          const dist = Math.abs(imgMid - viewMid);
          if (dist < closestDist) { closestDist = dist; closest = i; }
        }
        const n = closest + 1;
        if (n !== pageNumRef.current) setPageNumber(n);

        // ── Infinite append ──────────────────────────────────────────────────
        if (!autoNext) {
          const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
          if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
          return;
        }

        const strip = stripChaptersRef.current;

        // Silently update visibleChapterId as we scroll into each chunk
        for (const chunk of strip) {
          const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
          if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
            if (chunk.chapterId !== visibleChapterId) {
              setVisibleChapterId(chunk.chapterId);
              if (settings.autoMarkRead) {
                const prevChunk = strip[strip.indexOf(chunk) - 1];
                if (prevChunk) {
                  setMarkedRead((r) => {
                    if (r.has(prevChunk.chapterId)) return r;
                    gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
                    return new Set(r).add(prevChunk.chapterId);
                  });
                }
              }
            }
            break;
          }
        }

        // Append next chapter when within 300px of the bottom
        const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
        if (!nearBottom) return;

        const lastChunk = strip[strip.length - 1];
        if (!lastChunk) return;

        const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
        if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return;

        const nextChEntry = activeChapterList[lastChunkIdx + 1];
        if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;

        appendedRef.current.add(nextChEntry.id);

        fetchPages(nextChEntry.id).then((urls) => {
          setStripChapters((prev) => {
            const lastInPrev = prev[prev.length - 1];
            const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0;
            const next = [
              ...prev,
              { chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
            ];

            const MAX_STRIP_CHAPTERS = 3;
            if (next.length > MAX_STRIP_CHAPTERS) {
              const toRemove = next.length - MAX_STRIP_CHAPTERS;
              // Snapshot scroll position now, inside the state updater, before React
              // removes the nodes. useLayoutEffect will restore it after the DOM mutation.
              scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight };
              return next.slice(toRemove);
            }
            return next;
          });
        }).catch(console.error);
      });
    };

    el.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      el.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(rafRef.current);
    };
  }, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);

  // Reset scroll position when switching chapters in non-longstrip modes
  useEffect(() => {
    if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
  }, [pageNumber, style]);

  // When switching to longstrip, reset scroll to top and rebuild strip from current chapter
  useEffect(() => {
    if (style === "longstrip" && containerRef.current) {
      containerRef.current.scrollTop = 0;
      if (activeChapter && pageUrls.length > 0) {
        appendedRef.current = new Set();
        if (autoNext) {
          setStripChapters([{
            chapterId: activeChapter.id,
            chapterName: activeChapter.name,
            urls: pageUrls,
            startGlobalIdx: 0,
          }]);
          setVisibleChapterId(activeChapter.id);
        } else {
          // Plain longstrip — no multi-chapter strip
          setStripChapters([]);
          setVisibleChapterId(null);
        }
      }
    } else if (style !== "longstrip") {
      setStripChapters([]);
      setVisibleChapterId(null);
    }
  }, [activeChapter?.id, style, autoNext]);

  function handleTap(e: React.MouseEvent) {
    if (style === "longstrip") return;
    const x = e.clientX / window.innerWidth;
    if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
    else       { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
  }

  // ── CSS vars ─────────────────────────────────────────────────────────────────
  const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;

  const imgCls = [
    s.img,
    fit === "width"    && s.fitWidth,
    fit === "height"   && s.fitHeight,
    fit === "screen"   && s.fitScreen,
    fit === "original" && s.fitOriginal,
    settings.optimizeContrast && s.optimizeContrast,
  ].filter(Boolean).join(" ");

  // ── Icons ────────────────────────────────────────────────────────────────────
  const fitIcon =
    fit === "width"    ? <ArrowsLeftRight size={14} weight="light" /> :
    fit === "height"   ? <ArrowsVertical size={14} weight="light" /> :
    fit === "screen"   ? <ArrowsIn size={14} weight="light" /> :
                         <ArrowsOut size={14} weight="light" />;

  const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];

  const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;

  if (loading) return (
    <div className={s.center}>
      <CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
    </div>
  );

  if (error) return (
    <div className={s.center}><p className={s.errorMsg}>{error}</p></div>
  );

  return (
    <div
      className={s.root}
      onMouseMove={(e) => {
        const fromTop    = e.clientY;
        const fromBottom = window.innerHeight - e.clientY;
        if (fromTop < 60 || fromBottom < 60) showUi();
      }}
    >
      {/* ── Topbar ── */}
      <div
        ref={uiRef}
        className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}
      >
        <button className={s.iconBtn} onClick={closeReader} title="Close reader">
          <X size={15} weight="light" />
        </button>
        <button
          className={s.iconBtn}
          onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
          disabled={!adjacent.prev}
          title="Previous chapter"
        >
          <CaretLeft size={14} weight="light" />
        </button>
        <span className={s.chLabel}>
          <span className={s.chTitle}>{activeManga?.title}</span>
          <span className={s.chSep}>/</span>
          <span>{displayChapter?.name}</span>
        </span>
        <span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
        <button
          className={s.iconBtn}
          onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
          disabled={!adjacent.next}
          title="Next chapter"
        >
          <CaretRight size={14} weight="light" />
        </button>

        <div className={s.topSep} />

        {/* Fit mode */}
        <button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
          {fitIcon}
          <span className={s.modeBtnLabel}>{fitLabel}</span>
        </button>

        {/* Zoom */}
        <div className={s.zoomWrap}>
          <button
            className={s.zoomBtn}
            onClick={() => setZoomOpen((o) => !o)}
            title="Zoom (click for slider, Ctrl+scroll)"
          >
            {Math.round((maxW / 900) * 100)}%
          </button>
          {zoomOpen && (
            <ZoomPopover
              value={maxW}
              onChange={(v) => updateSettings({ maxPageWidth: v })}
              onReset={() => updateSettings({ maxPageWidth: 900 })}
              onClose={() => setZoomOpen(false)}
            />
          )}
        </div>

        {/* RTL */}
        <button
          className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
          onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
          title={`Direction: ${rtl ? "RTL" : "LTR"}`}
        >
          <ArrowsLeftRight size={14} weight="light" />
          <span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
        </button>

        {/* Page style */}
        <button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
          {styleIcon}
          <span className={s.modeBtnLabel}>{style}</span>
        </button>

        {/* Page gap toggle */}
        {style !== "single" && (
          <button
            className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
            onClick={() => updateSettings({ pageGap: !settings.pageGap })}
            title="Toggle page gap"
          >
            <span className={s.modeBtnLabel}>Gap</span>
          </button>
        )}

        {/* Auto-next chapter */}
        {style === "longstrip" && (
          <button
            className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
            onClick={() => updateSettings({ autoNextChapter: !autoNext })}
            title="Auto-advance to next chapter"
          >
            <span className={s.modeBtnLabel}>Auto</span>
          </button>
        )}

        {/* Download */}
        <button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
          <Download size={14} weight="light" />
        </button>
      </div>

      {/* ── Viewer ── */}
      <div
        ref={containerRef}
        className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
        style={cssVars}
        tabIndex={-1}
        onClick={handleTap}
        onKeyDown={(e) => {
          if (e.key === " " && style === "longstrip") {
            e.preventDefault();
            containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
          }
        }}
      >
        {style === "longstrip" ? (
          <>
            {(autoNext && stripChapters.length > 0 ? stripChapters : [{
              chapterId: activeChapter?.id ?? 0,
              chapterName: activeChapter?.name ?? "",
              urls: pageUrls,
              startGlobalIdx: 0,
            }]).map((chunk) =>
              chunk.urls.map((url, i) => {
                const globalIdx = chunk.startGlobalIdx + i;
                return (
                  <img
                    key={`${chunk.chapterId}-${i}`}
                    src={url}
                    alt={`${chunk.chapterName} – Page ${i + 1}`}
                    data-page={globalIdx + 1}
                    data-chapter={chunk.chapterId}
                    className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
                    loading={globalIdx < 3 ? "eager" : "lazy"}
                    decoding="async"
                  />
                );
              })
            )}
          </>
        ) : (
          pageReady && (
            <img
              key={pageNumber}
              src={pageUrls[pageNumber - 1]}
              alt={`Page ${pageNumber}`}
              className={imgCls}
              decoding="async"
            />
          )
        )}
      </div>

      {/* ── Bottom nav ── */}
      <div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
        <button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
          <ArrowLeft size={13} weight="light" />
        </button>
        <button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
          <ArrowRight size={13} weight="light" />
        </button>
      </div>

      {dlOpen && activeChapter && (
        <DownloadModal
          chapter={activeChapter}
          remaining={adjacent.remaining}
          onClose={() => setDlOpen(false)}
        />
      )}
    </div>
  );
}