import "foliate-js/view.js";
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
import { Alpine } from "../alpine";
import { loadSettings, saveSettings } from "./settings-manager";
import { getToken } from "../storage";
import {
  extractPdfPages,
  searchPdfPages,
  type PdfPageText,
} from "./pdf-search";

const HIGHLIGHT_COLORS = [
  "#ffd54f",
  "#a5d6a7",
  "#90caf9",
  "#f48fb1",
  "#ce93d8",
];

foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;

const FONT_MAP: Record<string, string> = {
  literata: '"Literata"',
  crimson: '"Crimson Pro"',
  "source-serif": '"Source Serif 4"',
  "eb-garamond": '"EB Garamond"',
  libertinus: '"Libertinus Serif"',
  "noto-serif": '"Noto Serif"',
  "charis-sil": '"Charis SIL"',
  "ibm-plex": '"IBM Plex Serif"',
};

interface FontFile {
  family: string;
  url: string;
  weight: string;
  style: string;
}

const FONT_FILES: FontFile[] = [
  {
    family: '"Literata"',
    url: "/static/fonts/literata/Literata-Variable.woff2",
    weight: "100 900",
    style: "normal",
  },
  {
    family: '"Literata"',
    url: "/static/fonts/literata/Literata-Italic-Variable.woff2",
    weight: "100 900",
    style: "italic",
  },
  {
    family: '"Crimson Pro"',
    url: "/static/fonts/crimson/CrimsonPro-Variable.woff2",
    weight: "100 900",
    style: "normal",
  },
  {
    family: '"Crimson Pro"',
    url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2",
    weight: "100 900",
    style: "italic",
  },
  {
    family: '"Source Serif 4"',
    url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2",
    weight: "100 900",
    style: "normal",
  },
  {
    family: '"Source Serif 4"',
    url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2",
    weight: "100 900",
    style: "italic",
  },
  {
    family: '"EB Garamond"',
    url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2",
    weight: "100 900",
    style: "normal",
  },
  {
    family: '"EB Garamond"',
    url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2",
    weight: "100 900",
    style: "italic",
  },
  {
    family: '"Libertinus Serif"',
    url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2",
    weight: "400",
    style: "normal",
  },
  {
    family: '"Libertinus Serif"',
    url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2",
    weight: "700",
    style: "normal",
  },
  {
    family: '"Libertinus Serif"',
    url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2",
    weight: "400",
    style: "italic",
  },
  {
    family: '"Libertinus Serif"',
    url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2",
    weight: "700",
    style: "italic",
  },
  {
    family: '"Noto Serif"',
    url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2",
    weight: "100 900",
    style: "normal",
  },
  {
    family: '"Noto Serif"',
    url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2",
    weight: "100 900",
    style: "italic",
  },
  {
    family: '"Charis SIL"',
    url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2",
    weight: "400",
    style: "normal",
  },
  {
    family: '"Charis SIL"',
    url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2",
    weight: "700",
    style: "normal",
  },
  {
    family: '"Charis SIL"',
    url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2",
    weight: "400",
    style: "italic",
  },
  {
    family: '"Charis SIL"',
    url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2",
    weight: "700",
    style: "italic",
  },
  {
    family: '"IBM Plex Serif"',
    url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2",
    weight: "400",
    style: "normal",
  },
  {
    family: '"IBM Plex Serif"',
    url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2",
    weight: "700",
    style: "normal",
  },
  {
    family: '"IBM Plex Serif"',
    url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2",
    weight: "400",
    style: "italic",
  },
  {
    family: '"IBM Plex Serif"',
    url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2",
    weight: "700",
    style: "italic",
  },
];

let fontBlobUrls: Record<string, string> = {};
let fontsLoaded = false;

async function loadFontBlobUrls(): Promise<void> {
  if (fontsLoaded) return;
  const entries = await Promise.all(
    FONT_FILES.map(async (f) => {
      try {
        const resp = await fetch(f.url);
        const blob = await resp.blob();
        const blobUrl = URL.createObjectURL(blob);
        return { key: f.url, blobUrl };
      } catch (_e) {
        return { key: f.url, blobUrl: f.url };
      }
    }),
  );
  for (const { key, blobUrl } of entries) {
    fontBlobUrls[key] = blobUrl;
  }
  fontsLoaded = true;
}

function buildFontFaceCSS(): string {
  return FONT_FILES.map((f) => {
    const src = fontBlobUrls[f.url] ?? f.url;
    return `@font-face {
  font-family: ${f.family};
  src: url("${src}") format("woff2");
  font-weight: ${f.weight};
  font-style: ${f.style};
}`;
  }).join("\n");
}

interface ThemeColors {
  fg: string;
  bg: string;
  link: string;
}

const THEME_COLORS: Record<string, { light: ThemeColors; dark: ThemeColors }> =
  {
    light: {
      light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" },
      dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" },
    },
    paper: {
      light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" },
      dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" },
    },
    sepia: {
      light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" },
      dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" },
    },
    parchment: {
      light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" },
      dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" },
    },
    warm: {
      light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" },
      dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" },
    },
    candlelight: {
      light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" },
      dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" },
    },
    azure: {
      light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" },
      dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" },
    },
    sky: {
      light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" },
      dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" },
    },
    arctic: {
      light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" },
      dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" },
    },
    frost: {
      light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" },
      dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" },
    },
    dusk: {
      light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" },
      dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" },
    },
    sunset: {
      light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" },
      dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" },
    },
    twilight: {
      light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" },
      dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" },
    },
    forest: {
      light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" },
      dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" },
    },
    moss: {
      light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" },
      dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" },
    },
    slate: {
      light: { fg: "#334155", bg: "#f8fafc", link: "#475569" },
      dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" },
    },
    oled: {
      light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" },
      dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" },
    },
    solarized: {
      light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" },
      dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" },
    },
  };

const getCSS = ({
  fontFamily,
  fontSize,
  lineHeight,
  justify,
  hyphenate,
  themeName,
  themeMode,
}: {
  fontFamily: string;
  fontSize: number;
  lineHeight: number;
  justify: boolean;
  hyphenate: boolean;
  themeName: string;
  themeMode: string;
}) => {
  const themeSet = THEME_COLORS[themeName] ?? THEME_COLORS.light;
  const theme = themeMode === "dark" ? themeSet.dark : themeSet.light;
  const font = FONT_MAP[fontFamily] ?? '"Literata"';
  const fontFamilyRule = fontFamily
    ? `
        body {
            font-family: ${font}, serif !important;
        }
        body * {
            font-family: inherit !important;
        }`
    : "";

  return `
    ${buildFontFaceCSS()}
    @namespace epub "http://www.idpf.org/2007/ops";
    html {
        color-scheme: light dark;
        font-size: ${fontSize}px;
        line-height: ${lineHeight};
    }
    ${fontFamilyRule}
    html, body {
        color: ${theme.fg} !important;
        background-color: ${theme.bg} !important;
    }
    body * {
        color: inherit !important;
        border-color: currentColor !important;
    }
    a:any-link {
        color: ${theme.link} !important;
        text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
        text-underline-offset: .1em;
    }
    svg, img {
        background-color: transparent !important;
    }
    aside[epub|type~="footnote"] {
        display: none;
    }
    p, li, blockquote, dd {
        line-height: ${lineHeight} !important;
        text-align: ${justify ? "justify" : "start"} !important;
        hyphens: ${hyphenate ? "auto" : "none"};
    }
    [align="left"] { text-align: left; }
    [align="right"] { text-align: right; }
    [align="center"] { text-align: center; }
    [align="justify"] { text-align: justify; }
    pre {
        white-space: pre-wrap !important;
        tab-size: 2;
    }
    ::selection {
        background-color: rgba(128, 128, 128, 0.3);
    }
    img, svg, video {
        max-width: 100%;
        height: auto;
    }
    html {
        hanging-punctuation: allow-end last;
        orphans: 2;
        widows: 2;
    }
`;
};
document.addEventListener("alpine:init", () => {
  Alpine.data("readerShell", () => ({
    view: null as any,
    renderer: null as any,
    book: null as any,
    zoomPercent: 100,
    isFixedLayout: false,
    isPDF: false,
    interactionMode: "select" as string,
    magnifierEnabled: false,
    doublePageSpread: true as boolean,
    chromeVisible: true,
    chromeBehavior: "auto-hide" as string,
    hideTimer: null as ReturnType<typeof setTimeout> | null,
    tapZonesEnabled: true as boolean,
    tapZoneSize: 30 as number,
    tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
    highlightItems: [] as {
      id: string;
      text: string;
      note: string;
      color: string;
      cfi: string;
      percentage: number;
      pdfPage: number;
      pdfRects: number[][];
    }[],
    noteItems: [] as { id: string; content: string; positionLabel: string }[],
    annotationsTab: "highlights" as string,
    newNoteText: "",
    highlightColors: HIGHLIGHT_COLORS,
    searchOpen: false,
    searchQuery: "",
    searchGroups: [] as {
      label: string;
      items: { cfi: string; pre: string; match: string; post: string }[];
    }[],
    searchProgress: 0,
    searching: false,
    searchError: "",
    searchGen: 0,
    pdfPagesCache: null as PdfPageText[] | null,
    pdfSearchKeys: [] as string[],
    backStack: [] as { cfi?: string; page?: number }[],
    selectionPopover: {
      open: false,
      mode: "create" as "create" | "edit",
      x: 0,
      y: 0,
      text: "",
      cfi: "",
      id: "",
      color: "#ffd54f",
      note: "",
      noteOpen: false,
      // PDF/fixed-layout anchor: page index + page-fraction rects
      // (empty for CFI-anchored reflowable highlights).
      pdfPage: -1,
      pdfRects: [] as number[][],
    },
    pdfSelDoc: null as any,
    progressText: "",
    progressLabel: "",
    progressMain: "",
    sliderValue: 0,
    settings: null as ReaderSettings | null,
    justify: true,
    hyphenate: true,
    tocOpen: false,
    settingsOpen: false,
    bookmarksOpen: false,
    bookmarkItems: [] as {
      id: string;
      title: string;
      positionLabel: string;
      cfi: string;
      page: number | null;
    }[],
    tocItems: [] as any[],
    mediaItemId: "" as string,
    saveTimeout: null as ReturnType<typeof setTimeout> | null,
    initTime: 0 as number,
    contextText: "" as string,
    readingTheme: "light" as string,
    readingMode: "light" as string,
    readingFont: "literata" as string,
    fontSize: 16 as number,
    lineHeight: 1.6 as number,
    progressMode: "pages" as string,
    readingSpeedPpm: 0 as number,
    sectionFractionsArr: [] as number[],
    lastRelocateDetail: null as {
      fraction: number;
      location: { current: number; next: number; total: number };
      pageItem: { id: number; label: string; href: string } | null;
      tocItem: FoliateTocItem | null;
      section: { current: number; total: number };
    } | null,
    chapterBoundaries: [] as {
      id: number;
      label: string;
      startPage: number;
    }[],
    get themeSwatches(): {
      value: string;
      label: string;
      bg: string;
      fg: string;
    }[] {
      const dark = this.readingMode === "dark";
      return Object.entries(THEME_COLORS).map(([value, set]) => {
        const colors = dark ? set.dark : set.light;
        return {
          value,
          label: value.charAt(0).toUpperCase() + value.slice(1),
          bg: colors.bg,
          fg: colors.fg,
        };
      });
    },
    get searchMatchCount(): number {
      return this.searchGroups.reduce(
        (n: number, g: { items: unknown[] }) => n + g.items.length,
        0,
      );
    },
    init() {
      const link = document.getElementById("reader-back");
      if (!link) return;
      const storageKey = "reader_back";
      if (document.referrer) {
        try {
          const ref = new URL(document.referrer);
          if (
            ref.origin === window.location.origin &&
            !ref.pathname.startsWith("/readers/") &&
            ref.pathname !== window.location.pathname
          ) {
            sessionStorage.setItem(storageKey, ref.pathname + ref.search);
          }
        } catch {}
      } else {
        sessionStorage.removeItem(storageKey);
      }
      const backUrl =
        sessionStorage.getItem(storageKey) ||
        link.getAttribute("href") ||
        "/dashboard";
      link.addEventListener("click", (e: Event) => {
        e.preventDefault();
        sessionStorage.removeItem(storageKey);
        window.location.href = backUrl;
      });
    },
    async initReader(config: {
      mediaItemId: string;
      fileUrl: string;
      formatGroup: string;
      readingDirection: string;
      mangaType: string;
      savedPercentage?: number;
      savedCfi?: string;
      savedPage?: number;
      savedTotalPages?: number;
      bookmarks?: {
        id: string;
        title: string;
        positionLabel: string;
        cfi: string;
        page: number | null;
      }[];
    }) {
      this.mediaItemId = config.mediaItemId;
      this.bookmarkItems = config.bookmarks ?? [];
      this.settings = await loadSettings();
      if (this.settings) {
        this.progressMode = this.settings.progress_mode || "pages";
        this.readingTheme = this.settings.reading_theme || "light";
        this.readingFont = this.settings.reading_font || "literata";
        this.fontSize = this.settings.font_size || 18;
        this.lineHeight = this.settings.line_height || 1.6;
        this.doublePageSpread = this.settings.double_page_spread ?? true;
        const behavior = this.settings.chrome_behavior || "auto-hide";
        this.chromeBehavior =
          behavior === "always-visible" ? "always-visible" : "auto-hide";
        this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
        this.tapZoneSize = this.settings.tap_zone_size || 30;
        const mode = this.settings.pdf_interaction_mode;
        if (mode === "pan" || mode === "text" || mode === "select") {
          this.interactionMode = mode;
        }
        if (this.settings.reading_mode) {
          this.readingMode = this.settings.reading_mode;
        } else {
          this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
        }
      }
      const viewport = document.getElementById("reader-viewport")!;
      const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
      const themeColors =
        this.readingMode === "dark" ? themeSet.dark : themeSet.light;
      viewport.style.backgroundColor = themeColors.bg;
      this.view = document.getElementById("reader-view") as any;
      await loadFontBlobUrls();
      const resp = await fetch(config.fileUrl, {
        headers: { Authorization: `Bearer ${getToken()}` },
      });
      const blob = await resp.blob();
      const fileName = new URL(config.fileUrl, window.location.origin).pathname;
      const file = new File([blob], fileName, { type: blob.type });
      await this.view.open(file, {
        pdf: {},
      });
      this.renderer = this.view.renderer;
      this.book = this.view.book;
      this.isFixedLayout = this.view.isFixedLayout;
      if (this.isFixedLayout) {
        // Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
        // but makeComicBook never sets dir. Apply it from metadata so RTL page
        // turns (and spread ordering) read right-to-left. Reflowable EPUBs keep
        // whatever direction foliate read from the OPF.
        if (config.readingDirection === "rtl" && this.book.dir !== "rtl") {
          this.book.dir = "rtl";
        }
        this.isPDF = (this.renderer as any).isPDF;
        // Click on a PDF/fixed-layout highlight rect → edit popover.
        this.renderer.addEventListener(
          "show-rect-annotation",
          (e: any) => {
            const { key, clientX, clientY } = e.detail;
            const h = this.highlightItems.find((x) => x.id === key);
            if (!h) return;
            this.openSelectionPopover({
              mode: "edit",
              x: clientX,
              y: clientY,
              text: h.text,
              cfi: "",
              id: h.id,
              color: h.color,
              note: h.note,
              pdfPage: h.pdfPage,
              pdfRects: h.pdfRects,
            });
          },
        );
        this.renderer.addEventListener("zoom", () => {
          this.zoomPercent = this.renderer.zoomPercent;
        });
        this.computeFixedLayoutChapterBoundaries();
        this.applyDoublePageSpread();
      } else {
        this.renderer.setStyles?.(this.buildCSS());
      }
      this.view.addEventListener("load", (e: any) => {
        const { doc, index } = e.detail;
        const link = doc.createElement("link");
        link.rel = "stylesheet";
        link.href = "/static/reader-fonts.css";
        doc.head.append(link);
        doc.addEventListener("keydown", (ev: KeyboardEvent) =>
          this.handleKeydown(ev),
        );
        // Pointer activity inside page iframes should keep the chrome awake
        // (fixed-layout pages and EPUB sections are separate documents).
        doc.addEventListener("pointermove", () => this.pokeChrome(), {
          passive: true,
        });
        // Tap zones inside the page document (iframe events never bubble
        // out to the host document, so the viewport listeners miss them).
        if (window.matchMedia("(pointer: coarse)").matches) {
          this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
        }
        // Text selection → highlight popover (reflowable EPUB only;
        // fixed-layout highlight overlays are a later milestone).
        if (!this.isFixedLayout) {
          const checkSelection = () => {
            const sel = doc.getSelection();
            if (!sel || sel.isCollapsed || !sel.rangeCount) {
              if (this.selectionPopover.mode === "create")
                this.hideSelectionPopover();
              return;
            }
            const range = sel.getRangeAt(0);
            const text = sel.toString().replace(/\s+/g, " ").trim();
            if (!text) return;
            let cfi: string;
            try {
              cfi = this.view.getCFI(index, range);
            } catch {
              return;
            }
            const frame = doc.defaultView?.frameElement as HTMLElement | null;
            const iframeRect = frame?.getBoundingClientRect();
            const rect = range.getBoundingClientRect();
            this.openSelectionPopover({
              mode: "create",
              x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
              y: (iframeRect?.top ?? 0) + rect.top,
              text,
              cfi,
            });
          };
          doc.addEventListener(
            "pointerup",
            () => setTimeout(checkSelection, 0),
            { passive: true },
          );
          doc.addEventListener(
            "keyup",
            (ev: KeyboardEvent) => {
              if (ev.shiftKey) setTimeout(checkSelection, 0);
            },
            { passive: true },
          );
        }
        // PDF textLayer selection → rect-fraction highlight popover.
        // Detected structurally: renderer.isPDF isn't set until the first
        // spread renders (during view.init), which is after this listener
        // attaches — the stale copy here would always be falsy.
        if (this.isFixedLayout && doc.querySelector(".textLayer")) {
          const checkPDFSelection = () => {
            const sel = doc.getSelection();
            if (!sel || sel.isCollapsed || !sel.rangeCount) return;
            const range = sel.getRangeAt(0);
            const text = sel.toString().replace(/\s+/g, " ").trim();
            if (!text) return;
            // Denominator: the element whose post-transform screen rect IS
            // the visible page. For PDFs that's the rendered canvas — pdf.js
            // scales <html> by 1/devicePixelRatio, so documentElement's rect
            // is dpr× too small and would inflate every fraction (highlight
            // shifted right/oversized on any dpr != 1 display). The canvas's
            // rect is in the same transform-inclusive space as the textLayer
            // span rects, so the dpr scaling cancels exactly.
            const denom =
              (doc.querySelector("#canvas canvas") as HTMLElement) ||
              (doc.querySelector("img") as HTMLElement);
            let dr = denom?.getBoundingClientRect();
            if (!dr || !dr.width || !dr.height) {
              const vw = doc.defaultView;
              dr = {
                left: 0,
                top: 0,
                width: vw?.innerWidth || 1,
                height: vw?.innerHeight || 1,
              } as DOMRect;
            }
            const rects: number[][] = [];
            for (const r of range.getClientRects()) {
              const x = (r.left - dr.left) / dr.width;
              const y = (r.top - dr.top) / dr.height;
              const w = r.width / dr.width;
              const h = r.height / dr.height;
              if (w > 0 && h > 0) rects.push([x, y, w, h]);
            }
            if (!rects.length) return;
            // Map the first rect to host-space for popover placement.
            // The canvas's screen rect maps 1:1 onto the host iframe box
            // (the visible page fills the iframe), so sx/sy are 1 for PDFs;
            // kept general for the comic img fallback.
            const frame = doc.defaultView?.frameElement as HTMLElement | null;
            if (!frame) return;
            const fr = frame.getBoundingClientRect();
            const first = range.getBoundingClientRect();
            const sx = fr.width / dr.width;
            const sy = fr.height / dr.height;
            this.pdfSelDoc = doc;
            this.openSelectionPopover({
              mode: "create",
              x: fr.left + first.left * sx + (first.width * sx) / 2,
              y: fr.top + first.top * sy,
              text,
              cfi: "",
              pdfPage: index,
              pdfRects: rects,
            });
          };
          doc.addEventListener(
            "pointerup",
            () => setTimeout(checkPDFSelection, 0),
            { passive: true },
          );
        }
        if (!this.isFixedLayout) {
          this.computeChapterPageBoundaries(doc);
          doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
        }
      });
      // ----- highlight rendering (foliate overlayer pipeline) -----
      // Internal link clicks (footnotes, cross-references): record where we
      // came from so the back-to-location stack can return; foliate then
      // navigates on its own.
      this.view.addEventListener("link", () => this.pushBackStack());
      this.view.addEventListener("draw-annotation", (e: any) => {
        const { draw, annotation } = e.detail;
        draw(Overlayer.highlight, { color: annotation.color || "#ffd54f" });
      });
      this.view.addEventListener("show-annotation", (e: any) => {
        const { value, index, range } = e.detail;
        const h = this.highlightItems.find((x) => x.cfi === value);
        if (!h) return;
        const doc = this.renderer
          ?.getContents?.()
          ?.find((c: any) => c.index === index)?.doc;
        const frame = doc?.defaultView?.frameElement as HTMLElement | null;
        const iframeRect = frame?.getBoundingClientRect();
        const rect = range.getBoundingClientRect();
        this.openSelectionPopover({
          mode: "edit",
          x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
          y: (iframeRect?.top ?? 0) + rect.top,
          text: h.text,
          cfi: h.cfi,
          id: h.id,
          color: h.color,
          note: h.note,
        });
      });
      this.view.addEventListener("create-overlay", () => {
        this.renderAllHighlights();
      });
      this.view.addEventListener("relocate", (e: any) => {
        const { fraction, location, pageItem, cfi, tocItem, section } =
          e.detail;
        this.hideSelectionPopover();
        this.lastRelocateDetail = {
          fraction,
          location,
          pageItem,
          tocItem,
          section,
        };
        const progressParts = this.formatProgressParts(
          fraction,
          location,
          pageItem,
          tocItem,
          section,
        );
        this.setProgress(progressParts);
        this.sliderValue = fraction;
        for (const slider of this.progressSliders()) {
          slider.value = fraction;
        }
        const range = e.detail.range as Range | undefined;
        if (range) {
          const text = range.toString().replace(/\s+/g, " ").trim();
          this.contextText = text ? text.slice(0, 100) : "";
        }
        this.debouncedSaveProgress(fraction, location, cfi);
      });
      if (this.book.dir) {
        for (const slider of this.progressSliders()) {
          slider.dir = this.book.dir;
        }
      }
      if (this.view.getSectionFractions) {
        this.sectionFractionsArr = this.view.getSectionFractions();
        const tickMarks = document.getElementById("tick-marks");
        if (tickMarks) {
          for (const fraction of this.sectionFractionsArr) {
            const option = document.createElement("option");
            option.value = fraction;
            tickMarks.append(option);
          }
        }
      }
      document.addEventListener("keydown", (ev: KeyboardEvent) =>
        this.handleKeydown(ev),
      );
      if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) {
        // Fixed-layout & comics: a page index is the exact, universal locator.
        // A bare number navigates directly to the section index in foliate.
        await this.view.init({ lastLocation: config.savedPage - 1 })
      } else if (config.savedCfi) {
        await this.view.init({ lastLocation: config.savedCfi })
      } else if (config.savedPercentage && config.savedPercentage > 0) {
        await this.view.init({
          lastLocation: { fraction: config.savedPercentage },
        })
      } else {
        await this.view.init({})
      }
      // The renderer only knows it's a PDF once frames exist (they carry
      // pdf.js onZoom), i.e. after init has rendered the first spread.
      // Read it now and apply the saved pointer mode — this also makes the
      // Smart|Pan|Text control appear for PDFs.
      this.isPDF = !!this.renderer?.isPDF;
      if (this.isPDF) {
        this.renderer.setAttribute("interaction-mode", this.interactionMode);
      }
      this.initTime = Date.now();
      this.fetchReadingSpeed();
      this.refreshAnnotations();
      this.setupChrome();
      this.setupTapZones();
    },
    // Chrome visibility: bars overlay the edge-to-edge reading surface.
    // 'auto-hide' keeps them up while the pointer is active and fades them
    // out after a quiet period; 'always-visible' pins them.
    setupChrome() {
      document.addEventListener("pointermove", () => this.pokeChrome(), {
        passive: true,
      });
      // Dismiss the selection popover on clicks outside it (iframe clicks
      // are covered by the selection tracker's collapsed check).
      document.addEventListener("pointerdown", (e: PointerEvent) => {
        if (!this.selectionPopover.open) return;
        if ((e.target as HTMLElement)?.closest?.("#selection-popover")) return;
        this.hideSelectionPopover();
      }, { passive: true });
      if (this.chromeBehavior === "always-visible") {
        this.chromeVisible = true;
        return;
      }
      this.pokeChrome();
    },
    pokeChrome() {
      this.chromeVisible = true;
      if (this.chromeBehavior !== "auto-hide") return;
      if (this.hideTimer) clearTimeout(this.hideTimer);
      this.hideTimer = setTimeout(() => {
        if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
        this.chromeVisible = false;
      }, 2500);
    },
    chromeHasFocus(): boolean {
      const el = document.activeElement;
      if (!el) return false;
      const tag = el.tagName;
      return (
        (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") &&
        !!el.closest("#reader-topbar, #reader-bottombar")
      );
    },
    applyChromeBehavior() {
      if (this.chromeBehavior !== "auto-hide") this.chromeVisible = true;
      else this.pokeChrome();
      saveSettings({ chrome_behavior: this.chromeBehavior });
    },
    // ----- tap zones (touch devices) -----
    // Kindle-style: tap left/right margins to page, center to toggle chrome.
    // Pointer-based and passive, so drags (pan/selection/swipe) never
    // trigger. Attached to the viewport and inside every page document
    // (iframe contents don't bubble to the host document).
    setupTapZones() {
      if (!window.matchMedia("(pointer: coarse)").matches) return;
      const vp = document.getElementById("reader-viewport");
      if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
    },
    attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
      let downX = 0;
      let downY = 0;
      let downT = 0;
      let downId = -1;
      let moved = false;
      surface.addEventListener(
        "pointerdown",
        (e: PointerEvent) => {
          if (!e.isPrimary || e.pointerType === "mouse") return;
          downX = e.clientX;
          downY = e.clientY;
          downT = Date.now();
          downId = e.pointerId;
          moved = false;
        },
        { passive: true },
      );
      surface.addEventListener(
        "pointermove",
        (e: PointerEvent) => {
          if (e.pointerId !== downId) return;
          if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12)
            moved = true;
        },
        { passive: true },
      );
      surface.addEventListener(
        "pointerup",
        (e: PointerEvent) => {
          if (e.pointerId !== downId) return;
          downId = -1;
          if (moved || Date.now() - downT > 500) return;
          if (!this.tapZonesEnabled) return;
          const target = e.target as HTMLElement | null;
          if (
            target?.closest?.(
              "a, button, input, textarea, select, [contenteditable]",
            )
          )
            return;
          const sel = isDoc ? (surface as any).getSelection?.() : null;
          if (sel?.toString?.()) return;
          // No tap actions while a fixed-layout page is zoomed — taps then
          // belong to the content (and double-tap zoom).
          if (this.isFixedLayout && this.renderer?.zoom != null) return;
          const width =
            (isDoc
              ? (surface as any).documentElement.clientWidth
              : (surface as HTMLElement).getBoundingClientRect().width) || 1;
          const relX =
            (e.clientX -
              (isDoc
                ? 0
                : (surface as HTMLElement).getBoundingClientRect().left)) /
            width;
          this.routeTapDebounced(relX);
        },
        { passive: true },
      );
    },
    // A short debounce lets a second tap (double-tap zoom in the fixed-layout
    // engine) cancel the zone action instead of also paging.
    routeTapDebounced(relX: number) {
      if (this.tapZoneTimer) {
        clearTimeout(this.tapZoneTimer);
        this.tapZoneTimer = null;
        return;
      }
      this.tapZoneTimer = setTimeout(() => {
        this.tapZoneTimer = null;
        const zone = this.tapZoneSize / 100;
        if (relX < zone) this.goLeft();
        else if (relX > 1 - zone) this.goRight();
        else this.toggleChromeManually();
      }, 280);
    },
    toggleChromeManually() {
      if (this.chromeVisible) {
        this.chromeVisible = false;
        if (this.hideTimer) clearTimeout(this.hideTimer);
      } else {
        this.pokeChrome();
      }
    },
    applyTapZoneSettings() {
      saveSettings({
        tap_zones_enabled: this.tapZonesEnabled,
        tap_zone_size: this.tapZoneSize,
      });
    },
    // ----- annotations (highlights + notes) -----
    openSelectionPopover(opts: {
      mode: "create" | "edit";
      x: number;
      y: number;
      text: string;
      cfi: string;
      id?: string;
      color?: string;
      note?: string;
      pdfPage?: number;
      pdfRects?: number[][];
    }) {
      const p = this.selectionPopover;
      p.mode = opts.mode;
      p.text = opts.text;
      p.cfi = opts.cfi;
      p.id = opts.id ?? "";
      p.color = opts.color || "#ffd54f";
      p.note = opts.note ?? "";
      p.noteOpen = !!p.note && opts.mode === "edit";
      p.pdfPage = opts.pdfPage ?? -1;
      p.pdfRects = opts.pdfRects ?? [];
      // Clamp so the popover stays on screen (it anchors bottom-center).
      const w = window.innerWidth;
      const h = window.innerHeight;
      p.x = Math.min(Math.max(opts.x, 90), w - 90);
      p.y = Math.min(Math.max(opts.y, 60), h - 60);
      p.open = true;
    },
    hideSelectionPopover() {
      this.selectionPopover.open = false;
      this.selectionPopover.noteOpen = false;
    },
    renderAllHighlights() {
      for (const hl of this.highlightItems) {
        if (hl.pdfPage >= 0) {
          this.renderer?.addRectAnnotation?.({
            key: hl.id,
            index: hl.pdfPage,
            rects: hl.pdfRects,
            color: hl.color,
          });
        } else {
          this.view
            ?.addAnnotation({
              value: hl.cfi,
              color: hl.color,
              note: hl.note,
              id: hl.id,
            })
            ?.catch?.(() => {});
        }
      }
    },
    mapHighlightRow(r: any) {
      let cfi = r.epubcfi_start ?? "";
      let pdfPage = -1;
      let pdfRects: number[][] = [];
      // Fixed-layout anchors are stored as a JSON descriptor in the CFI
      // column (page index + page-fraction rects).
      if (cfi.startsWith("{")) {
        try {
          const a = JSON.parse(cfi);
          if (
            a &&
            typeof a.page === "number" &&
            Array.isArray(a.rects) &&
            a.rects.length
          ) {
            pdfPage = a.page;
            pdfRects = a.rects;
            cfi = "";
          }
        } catch {
          /* not ours; leave as-is */
        }
      }
      return {
        id: r.id,
        text: r.selection_text ?? "",
        note: r.note_text ?? "",
        color: r.color ?? "#ffff00",
        cfi,
        percentage: r.percentage_start ?? 0,
        pdfPage,
        pdfRects,
      };
    },
    async refreshAnnotations() {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      try {
        const [hlResp, noteResp] = await Promise.all([
          fetch(`/api/media-items/${this.mediaItemId}/highlights`, {
            headers: { Authorization: `Bearer ${token}` },
          }),
          fetch(`/api/media-items/${this.mediaItemId}/notes`, {
            headers: { Authorization: `Bearer ${token}` },
          }),
        ]);
        if (hlResp.ok) {
          const rows = await hlResp.json();
          this.highlightItems = (rows as any[])
            .map((r) => this.mapHighlightRow(r))
            .filter((hl: any) => hl.cfi || hl.pdfPage >= 0);
          this.renderAllHighlights();
        }
        if (noteResp.ok) {
          const rows = await noteResp.json();
          this.noteItems = (rows as any[]).map((r) => ({
            id: r.id,
            content: r.content ?? "",
            positionLabel: r.position ?? "",
          }));
        }
      } catch (_e) {
        /* annotations are non-critical; leave lists as-is */
      }
    },
    async createHighlight(color: string) {
      const p = this.selectionPopover;
      const token = getToken();
      if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
      const pdfAnchor =
        p.pdfPage >= 0
          ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
          : "";
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/highlights`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              selection_text: p.text,
              start_position: "",
              end_position: "",
              epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
              color,
              note_text: "",
              percentage_start: this.lastRelocateDetail?.fraction ?? 0,
            }),
          },
        );
        if (!resp.ok) return;
        const row = await resp.json();
        const hl = this.mapHighlightRow(row);
        this.highlightItems.push(hl);
        if (hl.pdfPage >= 0) {
          this.renderer?.addRectAnnotation?.({
            key: hl.id,
            index: hl.pdfPage,
            rects: hl.pdfRects,
            color,
          });
          // Clear the textLayer selection so the highlight reads cleanly.
          try {
            this.pdfSelDoc?.getSelection()?.removeAllRanges();
          } catch {
            /* doc may be gone */
          }
        } else {
          this.view?.addAnnotation({
            value: p.cfi,
            color,
            note: "",
            id: row.id,
          });
        }
        this.hideSelectionPopover();
      } catch (_e) {
        /* ignore highlight errors */
      }
    },
    async saveHighlightChanges() {
      const p = this.selectionPopover;
      const token = getToken();
      if (!token || !this.mediaItemId || !p.id) return;
      const anchor =
        p.pdfPage >= 0
          ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
          : p.cfi;
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/highlights/${p.id}`,
          {
            method: "PUT",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              selection_text: p.text,
              start_position: "",
              end_position: "",
              epubcfi_start: anchor,
              color: p.color,
              note_text: p.note,
            }),
          },
        );
        if (!resp.ok) return;
        const row = await resp.json();
        const idx = this.highlightItems.findIndex((h) => h.id === p.id);
        if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
        // Re-add so the overlay redraws with the new color.
        if (p.pdfPage >= 0) {
          this.renderer?.addRectAnnotation?.({
            key: p.id,
            index: p.pdfPage,
            rects: p.pdfRects,
            color: p.color,
          });
        } else {
          this.view?.addAnnotation({
            value: p.cfi,
            color: p.color,
            note: p.note,
            id: p.id,
          });
        }
        p.noteOpen = false;
      } catch (_e) {
        /* ignore highlight errors */
      }
    },
    async deleteHighlightById(id: string) {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      const hl = this.highlightItems.find((h) => h.id === id);
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/highlights/${id}`,
          { method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
        );
        if (!resp.ok && resp.status !== 204) return;
        this.highlightItems = this.highlightItems.filter((h) => h.id !== id);
        if (hl?.pdfPage >= 0) {
          this.renderer?.removeRectAnnotation?.(id);
        } else if (hl?.cfi) {
          this.view?.deleteAnnotation({ value: hl.cfi });
        }
        this.hideSelectionPopover();
      } catch (_e) {
        /* ignore highlight errors */
      }
    },
    async copySelectionText() {
      try {
        await navigator.clipboard.writeText(this.selectionPopover.text);
        this.hideSelectionPopover();
      } catch (_e) {
        /* clipboard unavailable */
      }
    },
    goToHighlight(hl: {
      cfi: string;
      pdfPage: number;
    }) {
      if (hl.pdfPage >= 0) {
        // Fixed-layout: a bare number navigates to the section (page) index.
        this.pushBackStack();
        this.view?.goTo?.(hl.pdfPage);
        this.closeDrawers();
      } else if (hl.cfi) {
        this.pushBackStack();
        this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
        this.closeDrawers();
      }
    },
    async addNote(content: string) {
      const token = getToken();
      if (!token || !this.mediaItemId || !content.trim()) return;
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/notes`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              content,
              position: !this.isFixedLayout
                ? `cfi:${this.view?.lastLocation?.cfi ?? ""}`
                : `page:${(this.renderer?.index ?? 0) + 1}`,
            }),
          },
        );
        if (!resp.ok) return;
        await this.refreshAnnotations();
      } catch (_e) {
        /* ignore note errors */
      }
    },
    async deleteNoteById(id: string) {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/notes/${id}`,
          { method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
        );
        if (resp.ok || resp.status === 204) {
          this.noteItems = this.noteItems.filter((n) => n.id !== id);
        }
      } catch (_e) {
        /* ignore note errors */
      }
    },
    debouncedSaveProgress(fraction: number, location: any, cfi: string) {
      if (Date.now() - this.initTime < 5000) return;
      if (this.saveTimeout) clearTimeout(this.saveTimeout);
      this.saveTimeout = setTimeout(() => {
        this.saveProgress(fraction, location, cfi);
      }, 2000);
    },
    async saveProgress(fraction: number, location: any, cfi: string) {
      const token = getToken();
      if (!token || !this.mediaItemId) return;

      const body: Record<string, any> = {
        percentage: fraction,
        current_page: this.isFixedLayout
          ? (this.renderer?.index ?? 0) + 1
          : location?.current ?? 0,
        total_pages: this.isFixedLayout
          ? this.book?.sections?.filter((s: any) => s.linear !== "no")
              ?.length ?? 0
          : location?.total ?? 0,
        reading_mode: this.readingMode || undefined,
      };
      if (!this.isFixedLayout) {
        body.epubcfi = cfi || "";
        body.context_text = this.contextText || "";
      }

      const tocItem = this.lastRelocateDetail?.tocItem;
      if (tocItem) {
        if (tocItem.label) {
          const boundaryIdx = this.chapterBoundaries.findIndex(
            (b: any) => b.label === tocItem.label,
          );
          if (boundaryIdx !== -1) {
            body.chapter = boundaryIdx;
          }
        }
      }

      if (this.isFixedLayout && this.renderer?.zoomPercent) {
        body.zoom_level = this.renderer.zoomPercent / 100;
      }

      try {
        await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
          method: "PUT",
          headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify(body),
        });
      } catch (_e) {
        // silent fail — progress save is non-critical
      }
    },
    zoomIn() {
      if (!this.isFixedLayout) return;
      const newScale = Math.min(10, this.renderer.currentScale * 1.2);
      this.renderer.setAttribute("zoom", newScale);
      this.zoomPercent = this.renderer.zoomPercent;
    },
    zoomOut() {
      if (!this.isFixedLayout) return;
      const newScale = Math.max(0.1, this.renderer.currentScale / 1.2);
      this.renderer.setAttribute("zoom", newScale);
      this.zoomPercent = this.renderer.zoomPercent;
    },
    resetZoom() {
      if (!this.isFixedLayout) return;
      this.renderer.resetZoom();
      this.renderer.dragOffset = { x: 0, y: 0 };
      this.zoomPercent = 100;
    },
    toggleMagnifier() {
      if (!this.isFixedLayout) return;
      this.renderer.toggleMagnifier();
      this.magnifierEnabled = this.renderer.zoomMagnifierEnabled;
    },
    setInteractionMode(mode: string) {
      if (!this.isFixedLayout || !this.isPDF) return;
      if (mode !== "select" && mode !== "pan" && mode !== "text") return;
      this.renderer.setAttribute("interaction-mode", mode);
      this.interactionMode = mode;
      saveSettings({ pdf_interaction_mode: mode });
    },
    applyFitMode(mode: string) {
      if (!this.isFixedLayout || !this.renderer) return;
      if (mode === "fit-width" || mode === "fit-page") {
        this.renderer.setAttribute("zoom", mode);
        this.renderer.dragOffset = { x: 0, y: 0 };
      }
    },
    toggleSpread() {
      this.doublePageSpread = !this.doublePageSpread;
      this.applyDoublePageSpread();
    },
    applyDoublePageSpread() {
      if (!this.isFixedLayout || !this.renderer) return;
      this.renderer.setAttribute(
        "spread",
        this.doublePageSpread ? "auto" : "none",
      );
      saveSettings({ double_page_spread: this.doublePageSpread });
    },
    goLeft() {
      this.view?.goLeft?.();
    },
    goRight() {
      this.view?.goRight?.();
    },
    nextPage() {
      this.view?.next?.();
    },
    previousPage() {
      this.view?.prev?.();
    },
    goToFraction(value: string) {
      this.view?.goToFraction?.(parseFloat(value));
    },
    toggleTOC() {
      const opening = !this.tocOpen;
      this.closeDrawers();
      this.tocOpen = opening;
      if (opening && this.tocItems.length === 0 && this.book?.toc) {
        this.tocItems = this.flattenTOC(this.book.toc);
      }
    },
    toggleSettings() {
      const opening = !this.settingsOpen;
      this.closeDrawers();
      this.settingsOpen = opening;
    },
    toggleBookmarks() {
      const opening = !this.bookmarksOpen;
      this.closeDrawers();
      this.bookmarksOpen = opening;
    },
    toggleSearch() {
      // Reflowable books use foliate's DOM search; PDFs use extracted text.
      // Comics have no text at all.
      if (this.isFixedLayout && !this.isPDF) return;
      const opening = !this.searchOpen;
      this.closeDrawers();
      this.searchOpen = opening;
      if (opening) {
        (this as any).$nextTick(() =>
          (this as any).$refs.searchInput?.focus(),
        );
      }
    },
    anyDrawerOpen(): boolean {
      return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
    },
    closeDrawers() {
      this.tocOpen = false;
      this.settingsOpen = false;
      this.bookmarksOpen = false;
      this.searchOpen = false;
    },
    // ----- in-book search (reflowable) -----
    // view.search() is an async generator: it progressively scans sections,
    // yields {progress} per section and {label, subitems} per section with
    // hits, and draws outline highlights through the overlayer pipeline.
    // A generation counter discards results from superseded searches.
    async runSearch() {
      const q = this.searchQuery.trim();
      if (!q) {
        this.clearSearchResults();
        return;
      }
      this.searchGen++;
      const gen = this.searchGen;
      this.searching = true;
      this.searchProgress = 0;
      this.searchGroups = [];
      this.searchError = "";
      try {
        if (this.isFixedLayout && this.isPDF) {
          await this.runPdfSearch(q, gen);
        } else {
          await this.runEpubSearch(q, gen);
        }
      } catch (e) {
        // Swallowing this silently made a wiring bug look like "no matches".
        console.warn("search failed:", e);
        this.searchError = "Search failed (see browser console)";
      }
      if (gen === this.searchGen) this.searching = false;
    },
    async runEpubSearch(q: string, gen: number) {
      for await (const r of this.view.search({ query: q })) {
        if (gen !== this.searchGen) return;
        if (r === "done") break;
        if (r && typeof r === "object") {
          if (typeof r.progress === "number") {
            this.searchProgress = r.progress;
            continue;
          }
          if (Array.isArray(r.subitems) && r.subitems.length) {
            this.searchGroups.push({
              label: r.label || `Section ${this.searchGroups.length + 1}`,
              items: r.subitems.map((s: any) => ({
                cfi: s.cfi,
                page: null,
                pre: s.excerpt?.pre ?? "",
                match: s.excerpt?.match ?? "",
                post: s.excerpt?.post ?? "",
              })),
            });
          }
        }
      }
    },
    async runPdfSearch(q: string, gen: number) {
      // CRITICAL: unwrap Alpine's reactive proxy. `this.book` is a plain
      // object, so reactivity wraps it — and reading `.pdf` through the
      // proxy wraps the PDFDocumentProxy too. pdf.js uses #private fields,
      // so calling getPage() on the proxy throws "cannot read private
      // member", which used to be swallowed as an empty result set.
      const rawBook = (Alpine as any).raw
        ? (Alpine as any).raw(this.book)
        : this.book;
      const pdf = rawBook?.pdf;
      if (!pdf) {
        this.searchError = "PDF text engine unavailable";
        return;
      }
      if (!this.pdfPagesCache) {
        // Extraction reports progress; it's cached so re-searches are instant.
        this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => {
          if (gen === this.searchGen) this.searchProgress = fraction;
        });
      }
      if (gen !== this.searchGen) return;
      const hits = searchPdfPages(this.pdfPagesCache, q);
      const byPage = new Map<number, typeof hits>();
      for (const hit of hits) {
        const list = byPage.get(hit.page) ?? [];
        list.push(hit);
        byPage.set(hit.page, list);
      }
      const groups: typeof this.searchGroups = [];
      let i = 0;
      for (const [page, list] of byPage) {
        groups.push({
          label: `Page ${page + 1}`,
          items: list.map((hit) => {
            const key = `pdfsearch:${i++}`;
            this.pdfSearchKeys.push(key);
            this.renderer?.addRectAnnotation?.({
              key,
              index: page,
              rects: hit.rects,
              color: "#ffd54f",
            });
            return {
              cfi: "",
              page,
              pre: hit.pre,
              match: hit.match,
              post: hit.post,
            };
          }),
        });
      }
      this.searchGroups = groups;
    },
    clearSearchResults() {
      this.searchGen++;
      this.searching = false;
      this.searchError = "";
      this.searchGroups = [];
      this.searchProgress = 0;
      for (const key of this.pdfSearchKeys) {
        this.renderer?.removeRectAnnotation?.(key);
      }
      this.pdfSearchKeys = [];
      this.view?.clearSearch?.();
    },
    goToSearchResult(item: { cfi?: string; page?: number | null }) {
      if (item.cfi) {
        this.pushBackStack();
        this.view?.goTo?.(item.cfi);
      } else if (item.page != null) {
        this.pushBackStack();
        this.view?.goTo?.(item.page);
      } else return;
      this.closeDrawers();
    },
    // ----- back-to-location stack -----
    // Research pattern: jump somewhere (search hit, footnote link, TOC
    // entry), read, then return. Recorded before every programmatic jump
    // and on every internal link click (footnotes). Ordinary paging never
    // touches the stack.
    pushBackStack() {
      let loc: { cfi?: string; page?: number } | null = null;
      if (this.isFixedLayout) {
        const page = this.renderer?.index;
        if (typeof page === "number" && page >= 0) loc = { page };
      } else {
        const cfi = this.view?.lastLocation?.cfi;
        if (cfi) loc = { cfi };
      }
      if (!loc) return;
      const top = this.backStack[this.backStack.length - 1];
      if (top && top.cfi === loc.cfi && top.page === loc.page) return;
      this.backStack.push(loc);
      if (this.backStack.length > 50) this.backStack.shift();
    },
    goBackToLocation() {
      const loc = this.backStack.pop();
      if (!loc) return;
      if (loc.cfi) this.view?.goTo?.(loc.cfi);
      else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
    },
    flattenTOC(items: any[], depth = 0): any[] {
      const result: any[] = [];
      for (const item of items) {
        result.push({ ...item, depth });
        if (item.subitems?.length) {
          result.push(...this.flattenTOC(item.subitems, depth + 1));
        }
      }
      return result;
    },
    goToTOCItem(item: any) {
      if (this.view && item.href) {
        this.pushBackStack();
        this.view.goTo(item.href);
        this.tocOpen = false;
      }
    },
    goToBookmark(item: { cfi: string; page: number | null }) {
      if (!this.view) return;
      if (item.cfi) {
        this.pushBackStack();
        this.view.goTo(item.cfi);
      } else if (item.page != null && item.page > 0) {
        this.pushBackStack();
        // Fixed-layout/comic: sections are pages; foliate takes an index.
        this.view.goTo(item.page - 1);
      } else {
        return;
      }
      this.bookmarksOpen = false;
    },
    applyTheme() {
      const viewport = document.getElementById("reader-viewport")!;
      const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
      const themeColors =
        this.readingMode === "dark" ? themeSet.dark : themeSet.light;
      viewport.style.backgroundColor = themeColors.bg;
      this.applyStyles();
      saveSettings({
        reading_theme: this.readingTheme,
        reading_mode: this.readingMode,
      });
    },
    toggleReadingMode() {
      this.readingMode = this.readingMode === "dark" ? "light" : "dark";
      this.applyTheme();
    },
    detectChromeDarkMode(): boolean {
      const darkChromeThemes = [
        "theme-tokyo-night",
        "theme-dracula",
        "theme-nord",
        "theme-monokai",
        "theme-one-dark",
        "theme-material",
        "theme-catppuccin",
      ];
      return darkChromeThemes.some((t) => document.body.classList.contains(t));
    },
    applyFont() {
      this.applyStyles();
      saveSettings({
        reading_font: this.readingFont,
        font_size: this.fontSize,
        line_height: this.lineHeight,
      });
    },
    applyStyles() {
      if (!this.renderer?.setStyles) return;
      this.renderer.setStyles(this.buildCSS());
    },
    buildCSS() {
      return getCSS({
        fontFamily: this.readingFont,
        fontSize: this.fontSize,
        lineHeight: this.lineHeight,
        justify: this.justify,
        hyphenate: this.hyphenate,
        themeName: this.readingTheme,
        themeMode: this.readingMode,
      });
    },
    restoreDefaults() {
      this.progressMode = "pages";
      this.readingTheme = "light";
      this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
      this.readingFont = "literata";
      this.fontSize = 18;
      this.lineHeight = 1.6;
      this.justify = true;
      this.hyphenate = true;
      this.chromeBehavior = "auto-hide";
      this.applyChromeBehavior();
      this.interactionMode = "select";
      this.setInteractionMode("select");
      this.applyTheme();
      this.applyFont();
    },
    async refreshBookmarks() {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/bookmarks`,
          { headers: { Authorization: `Bearer ${token}` } },
        );
        if (!resp.ok) return;
        const rows = await resp.json();
        this.bookmarkItems = (rows as any[]).map((r) => ({
          id: r.id,
          title: r.title ?? "",
          positionLabel: r.position?.String ?? r.position ?? "",
          cfi: r.cfi_position?.String ?? r.cfi_position ?? "",
          page:
            r.page_number != null
              ? (r.page_number?.Int32 ?? r.page_number)
              : null,
        }));
      } catch (_e) {
        /* leave the existing list on fetch failure */
      }
    },
    async addBookmark() {
      const token = getToken();
      if (!token || !this.view || !this.mediaItemId) return;
      const location = this.view.lastLocation;
      if (!location) return;

      const cfi = (!this.isFixedLayout && location.cfi) || "";
      const page = this.isFixedLayout
        ? (this.renderer?.index ?? 0) + 1
        : 0;

      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/bookmarks`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              title: `Bookmark at ${this.progressText || "current position"}`,
              position: this.isFixedLayout
                ? `page:${page}`
                : cfi
                  ? `cfi:${cfi}`
                  : "",
              cfi_position: cfi,
              page_number: page,
              chapter_number: this.chapterNumberForProgress() || 0,
              percentage: location.fraction ?? 0,
            }),
          },
        );
        if (resp.ok) await this.refreshBookmarks();
      } catch (_e) {
        /* ignore bookmark errors for now */
      }
    },
    async deleteBookmark(id: string) {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      try {
        const resp = await fetch(
          `/api/media-items/${this.mediaItemId}/bookmarks/${id}`,
          { method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
        );
        if (resp.ok || resp.status === 204) {
          this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id);
        }
      } catch (_e) {
        /* ignore bookmark errors for now */
      }
    },
    chapterNumberForProgress(): number {
      const tocItem = this.lastRelocateDetail?.tocItem;
      if (!tocItem?.label) return 0;
      const idx = this.chapterBoundaries.findIndex(
        (b: any) => b.label === tocItem.label,
      );
      return idx === -1 ? 0 : idx;
    },
    formatProgressParts(
      fraction: number,
      location: { current: number; next: number; total: number },
      pageItem: { id: number; label: string; href: string },
      tocItem: FoliateTocItem | null,
      section: { current: number; total: number },
    ): { label: string; main: string } {
      const percent = new Intl.NumberFormat("en", { style: "percent" }).format(
        fraction,
      );
      const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : "";
      switch (this.progressMode) {
        case "percentage":
          return { label: "", main: percent };
        case "chapter": {
          if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
            const totalSections = this.book?.sections?.filter(
              (s: any) => s.linear !== "no",
            )?.length ?? 0;
            const currentPage = this.renderer?.index ?? 0;
            const boundaryIdx = this.chapterBoundaries.findIndex(
              (b) => b.id === tocItem.id,
            );
            if (boundaryIdx !== -1 && totalSections > 0) {
              const chapterStart =
                this.chapterBoundaries[boundaryIdx].startPage;
              const nextBoundary =
                this.chapterBoundaries[boundaryIdx + 1];
              const chapterEnd = nextBoundary
                ? nextBoundary.startPage
                : totalSections;
              const chapterPages = Math.max(1, chapterEnd - chapterStart);
              const currentInChapter = Math.max(
                1,
                Math.min(currentPage - chapterStart + 1, chapterPages),
              );
              return {
                label: chapterLabel,
                main: `${currentInChapter} / ${chapterPages}`,
              };
            }
          }
          if (tocItem && this.chapterBoundaries.length > 0) {
            const boundaryIdx = this.chapterBoundaries.findIndex(
              (b) => b.id === tocItem.id,
            );
            if (boundaryIdx !== -1) {
              const currentPage = this.renderer?.page ?? 1;
              const chapterStart =
                this.chapterBoundaries[boundaryIdx].startPage;
              const nextBoundary =
                this.chapterBoundaries[boundaryIdx + 1];
              const totalPages = this.renderer?.pages ?? 0;
              const chapterEnd = nextBoundary
                ? nextBoundary.startPage
                : totalPages > 2
                  ? totalPages - 1
                  : chapterStart + 1;
              const chapterPages = Math.max(1, chapterEnd - chapterStart);
              const currentInChapter = Math.max(
                1,
                Math.min(currentPage - chapterStart + 1, chapterPages),
              );
              return {
                label: chapterLabel,
                main: `${currentInChapter} / ${chapterPages}`,
              };
            }
          }
          if (section && this.sectionFractionsArr.length > 1) {
            const pageInfo = this.getRenderedPageInfo();
            const pageBase = pageInfo?.total ?? location.total;
            const idx = section.current;
            const startFrac = this.sectionFractionsArr[idx] ?? 0;
            const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
            const sectionFrac = endFrac - startFrac;
            if (sectionFrac > 0 && pageBase > 0) {
              const totalInSec = Math.max(
                1,
                Math.round(sectionFrac * pageBase),
              );
              const currentInSec = Math.max(
                1,
                Math.round((fraction - startFrac) * pageBase),
              );
              const clamped = Math.min(currentInSec, totalInSec);
              return {
                label: chapterLabel,
                main: `${clamped} / ${totalInSec}`,
              };
            }
          }
          if (location.total > 0) {
            return {
              label: "",
              main: `${percent} · ${location.current} / ${location.total}`,
            };
          }
          return { label: "", main: percent };
        }
        case "time-left": {
          if (this.readingSpeedPpm > 0 && location.total > 0) {
            const remaining = location.total - location.current;
            const mins = Math.ceil(remaining / this.readingSpeedPpm);
            if (mins >= 60) {
              const hrs = Math.floor(mins / 60);
              const m = mins % 60;
              return { label: "", main: `${percent} · ~${hrs}h ${m}m left` };
            }
            return { label: "", main: `${percent} · ~${mins} min left` };
          }
          return { label: "", main: `${percent} · ~-- min left` };
        }
        default: {
          if (this.isFixedLayout) {
            const pageInfo = this.getRenderedPageInfo();
            if (pageInfo) {
              return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` };
            }
          }
          if (pageItem) {
            return { label: "", main: `${percent} · Page ${pageItem.label}` };
          }
          const pageInfoReflow = this.getRenderedPageInfo();
          if (pageInfoReflow) {
            return {
              label: "",
              main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`,
            };
          }
          return {
            label: "",
            main: `${percent} · ${location.current} / ${location.total}`,
          };
        }
      }
    },
    // The reflowable and fixed-layout bottom rows each have their own slider
    // (only one is displayed); helpers below target whichever exists.
    progressSliders(): HTMLInputElement[] {
      const ids = ["progress-slider", "progress-slider-fx"];
      return ids
        .map((id) => document.getElementById(id) as HTMLInputElement | null)
        .filter((el): el is HTMLInputElement => !!el);
    },
    setProgress(parts: { label: string; main: string }, sliderTitle?: string) {
      this.progressLabel = parts.label;
      this.progressMain = parts.main;
      this.progressText = parts.label + parts.main;
      for (const slider of this.progressSliders()) {
        slider.title = sliderTitle ?? this.progressText;
      }
    },
    cycleProgressMode() {
      const modes = ["pages", "chapter", "percentage", "time-left"];
      const idx = modes.indexOf(this.progressMode);
      this.progressMode = modes[(idx + 1) % modes.length];
      this.applyProgressMode();
      if (this.lastRelocateDetail) {
        const { fraction, location, pageItem, tocItem, section } =
          this.lastRelocateDetail;
        this.setProgress(
          this.formatProgressParts(
            fraction,
            location,
            pageItem,
            tocItem,
            section,
          ),
        );
      }
    },
    async fetchReadingSpeed() {
      const token = getToken();
      if (!token || !this.mediaItemId) return;
      try {
        const resp = await fetch(`/readers/${this.mediaItemId}/reading-speed`, {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (resp.ok) {
          const data = await resp.json();
          this.readingSpeedPpm = data.pages_per_minute || 0;
        }
      } catch (_e) {
        this.readingSpeedPpm = 0;
      }
    },
    applyProgressMode() {
      saveSettings({ progress_mode: this.progressMode as any });
      if (this.lastRelocateDetail) {
        const { fraction, location, pageItem, tocItem, section } =
          this.lastRelocateDetail;
        this.setProgress(
          this.formatProgressParts(
            fraction,
            location,
            pageItem,
            tocItem,
            section,
          ),
        );
      }
    },
    computeFixedLayoutChapterBoundaries() {
      if (!this.book?.toc || !this.view) return;
      const flat = this.flattenTOC(this.book.toc);
      for (const item of flat) {
        try {
          const resolved = this.view.resolveNavigation(item.href);
          if (resolved?.index != null) {
            this.chapterBoundaries.push({
              id: item.id,
              label: item.label,
              startPage: resolved.index,
            });
          }
        } catch {
          // skip unresolvable anchors
        }
      }
      this.chapterBoundaries.sort(
        (a, b) => a.startPage - b.startPage,
      );
    },
    computeChapterPageBoundaries(doc: Document) {
      this.chapterBoundaries = [];
      if (!this.book?.toc) return;
      const size = this.renderer?.size;
      if (!size) return;
      const flat = this.flattenTOC(this.book.toc);
      for (const item of flat) {
        const fragment = item.href?.split("#")[1];
        if (!fragment) continue;
        try {
          const el = this.book.getTOCFragment?.(doc, fragment);
          if (!el) continue;
          const rect = el.getBoundingClientRect();
          const page = Math.floor(rect.left / size) + 1;
          this.chapterBoundaries.push({
            id: item.id,
            label: item.label,
            startPage: page,
          });
        } catch {
          // skip unresolvable anchors
        }
      }
      this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage);
    },
    getRenderedPageInfo(): { total: number; current: number } | null {
      if (this.isFixedLayout) {
        const total =
          this.book?.sections?.filter(
            (s: any) => s.linear !== "no",
          )?.length ?? 0;
        if (total === 0) return null;
        const index = this.renderer?.index ?? 0;
        return { total, current: index + 1 };
      }
      const pages = this.renderer?.pages;
      if (pages != null && pages > 2) {
        return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 };
      }
      return null;
    },
    progressTooltip(): string {
      const labels: Record<string, string> = {
        pages: "Pages",
        chapter: "Chapter",
        percentage: "Percentage",
        "time-left": "Time Left",
      };
      const label = labels[this.progressMode] ?? "Pages";
      if (
        this.progressMode === "chapter" &&
        this.chapterBoundaries.length === 0
      ) {
        return `${label} · no chapter data · Click to switch`;
      }
      return `${label} · Click to switch`;
    },
    handleKeydown(event: KeyboardEvent) {
      const k = event.key;
      // Never hijack keys while the user is typing in a form control.
      const tag = (event.target as HTMLElement)?.tagName;
      const typing =
        tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
      this.pokeChrome();
      if (k === "ArrowLeft" || k === "h") {
        if (event.altKey) {
          event.preventDefault();
          this.goBackToLocation();
        } else this.goLeft();
      } else if (k === "ArrowRight" || k === "l") this.goRight();
      else if (k === "+" || k === "=") this.zoomIn();
      else if (k === "-" || k === "_") this.zoomOut();
      else if (k === "0") this.resetZoom();
      else if (k === "Escape") {
        if (this.selectionPopover.open) {
          this.hideSelectionPopover();
        } else if (this.anyDrawerOpen()) {
          this.closeDrawers();
        } else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
          this.toggleMagnifier();
        } else {
          // Toggle chrome without a fresh auto-hide timer.
          this.chromeVisible = !this.chromeVisible;
          if (this.hideTimer) clearTimeout(this.hideTimer);
        }
      } else if (!typing) {
        if (k === "t") this.toggleTOC();
        else if (k === "s") this.toggleSettings();
        else if (k === "b") this.addBookmark();
        else if (k === "/") {
          event.preventDefault();
          this.toggleSearch();
        }
      }
    },
  }));
});

Alpine.start();
