import { useCoreServices } from '@learnitive/context';
import type { SyncTexFileMapping, SyncTexForwardResult, SyncTexRect } from '@learnitive/latex-synctex';
import type { ProjectCompileState, ProjectLivePdfInstantPatch, ProjectLivePdfInstantStatus } from '@learnitive/service-core';
import { Button } from '@learnitive/ui-components';
import { WsFilePath } from '@learnitive/ws-path';
import { useAtomValue } from 'jotai';
import {
  ArrowDown,
  ArrowUp,
  Copy,
  Download,
  ChevronLeft,
  ChevronRight,
  Link2,
  LocateFixed,
  MapPinned,
  Maximize2,
  Minimize2,
  Search as SearchIcon,
  X,
  ZoomIn,
  ZoomOut,
} from 'lucide-react';
import * as pdfjs from 'pdfjs-dist';
import 'pdfjs-dist/web/pdf_viewer.css';
import React from 'react';
import { CompilerDiagnosticsList } from './compiler-diagnostics-list';

const TEX_PT_TO_PDF_PT = 72 / 72.27;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.15;
const MAX_PDF_SELECTION = 8_000;
const PAGE_GAP = 18;
const VIRTUAL_ROOT_MARGIN = '1100px 0px';
const PDF_SCROLL_SYNC_DEBOUNCE_MS = 180;
const SCROLL_OWNERSHIP_KEYS = new Set([
  'ArrowUp',
  'ArrowDown',
  'PageUp',
  'PageDown',
  'Home',
  'End',
  ' ',
]);

type PdfTextLayerController = {
  render: () => Promise<void>;
  cancel?: () => void;
};

type PdfTextLayerConstructor = new (options: {
  textContentSource: Awaited<ReturnType<pdfjs.PDFPageProxy['getTextContent']>>;
  container: HTMLElement;
  viewport: ReturnType<pdfjs.PDFPageProxy['getViewport']>;
}) => PdfTextLayerController;

type PdfSearchResult = {
  page: number;
  pageOrdinal: number;
};

type PageSize = {
  width: number;
  height: number;
};

type PdfPageCanvasStatus = 'idle' | 'rendering' | 'ready' | 'error';
type PdfPageTextLayerStatus = 'idle' | 'native' | 'fallback' | 'unavailable';

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function focusedSyncTexRect(rects: readonly SyncTexRect[]): SyncTexRect | undefined {
  const meaningful = rects.filter((rect) => rect.width > 0 && rect.height > 0);
  if (!meaningful.length) return undefined;
  // SyncTeX can return a page/container box plus every wrapped line for one
  // source line. Painting all of them obscures the PDF. Prefer the smallest
  // real box as a short-lived location cue while still using the mapping for
  // scroll positioning.
  return [...meaningful].sort((a, b) => (a.width * a.height) - (b.width * b.height))[0];
}

function downloadBytes(bytes: Uint8Array, fileName: string, mimeType: string): void {
  const buffer = bytes.buffer.slice(
    bytes.byteOffset,
    bytes.byteOffset + bytes.byteLength,
  ) as ArrayBuffer;
  const blob = new Blob([buffer], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement('a');
  anchor.href = url;
  anchor.download = fileName;
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  queueMicrotask(() => URL.revokeObjectURL(url));
}


async function canvasJpegBytes(canvas: HTMLCanvasElement): Promise<Uint8Array> {
  const blob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob((value) => value ? resolve(value) : reject(new Error('Unable to encode PDF export page.')), 'image/jpeg', 0.985);
  });
  return new Uint8Array(await blob.arrayBuffer());
}

type RasterPdfPage = { width: number; height: number; pixelWidth: number; pixelHeight: number; jpeg: Uint8Array };

function asciiBytes(value: string): Uint8Array {
  return new TextEncoder().encode(value);
}

function buildRasterPdf(pages: readonly RasterPdfPage[]): Uint8Array {
  const chunks: Uint8Array[] = [];
  const offsets: number[] = [0];
  let length = 0;
  const push = (chunk: Uint8Array) => { chunks.push(chunk); length += chunk.byteLength; };
  const pushText = (value: string) => push(asciiBytes(value));
  pushText('%PDF-1.4\n% Learnitive Free export\n');
  const objectCount = 2 + pages.length * 3;
  const pageRefs = pages.map((_, index) => `${3 + index * 3} 0 R`).join(' ');
  const writeObject = (id: number, body: () => void) => {
    offsets[id] = length;
    pushText(`${id} 0 obj\n`);
    body();
    pushText('\nendobj\n');
  };
  writeObject(1, () => pushText('<< /Type /Catalog /Pages 2 0 R >>'));
  writeObject(2, () => pushText(`<< /Type /Pages /Kids [${pageRefs}] /Count ${pages.length} >>`));
  pages.forEach((page, index) => {
    const pageId = 3 + index * 3;
    const imageId = pageId + 1;
    const contentId = pageId + 2;
    const width = Number(page.width.toFixed(3));
    const height = Number(page.height.toFixed(3));
    writeObject(pageId, () => pushText(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] /Resources << /XObject << /Im0 ${imageId} 0 R >> >> /Contents ${contentId} 0 R >>`));
    writeObject(imageId, () => {
      pushText(`<< /Type /XObject /Subtype /Image /Width ${page.pixelWidth} /Height ${page.pixelHeight} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.byteLength} >>\nstream\n`);
      push(page.jpeg);
      pushText('\nendstream');
    });
    const commands = `q\n${width} 0 0 ${height} 0 0 cm\n/Im0 Do\nQ\n`;
    const commandBytes = asciiBytes(commands);
    writeObject(contentId, () => {
      pushText(`<< /Length ${commandBytes.byteLength} >>\nstream\n`);
      push(commandBytes);
      pushText('endstream');
    });
  });
  const xrefOffset = length;
  pushText(`xref\n0 ${objectCount + 1}\n`);
  pushText('0000000000 65535 f \n');
  for (let id = 1; id <= objectCount; id += 1) {
    pushText(`${String(offsets[id] ?? 0).padStart(10, '0')} 00000 n \n`);
  }
  pushText(`trailer\n<< /Size ${objectCount + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`);
  const output = new Uint8Array(length);
  let cursor = 0;
  for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.byteLength; }
  return output;
}

async function rasterizeFreeWatermarkedPdf(documentProxy: pdfjs.PDFDocumentProxy): Promise<Uint8Array> {
  const pages: RasterPdfPage[] = [];
  for (let pageNumber = 1; pageNumber <= documentProxy.numPages; pageNumber += 1) {
    const page = await documentProxy.getPage(pageNumber);
    const baseViewport = page.getViewport({ scale: 1 });
    // Free export adds the watermark after PDF.js rendering. Keep the raster
    // fallback print-quality instead of the previous 108-DPI output, which
    // visibly softened small scientific text and line art. 300 DPI is the
    // target; the pixel cap bounds memory for unusually large pages.
    const targetScale = 300 / 72;
    const exportScale = Math.min(targetScale, 4096 / Math.max(baseViewport.width, baseViewport.height));
    const viewport = page.getViewport({ scale: Math.max(1, exportScale) });
    const canvas = document.createElement('canvas');
    canvas.width = Math.max(1, Math.ceil(viewport.width));
    canvas.height = Math.max(1, Math.ceil(viewport.height));
    const context = canvas.getContext('2d', { alpha: false });
    if (!context) throw new Error('Canvas is unavailable for PDF export.');
    await page.render({ canvasContext: context, viewport, canvas }).promise;
    context.save();
    context.translate(canvas.width / 2, canvas.height / 2);
    context.rotate(-Math.PI / 6);
    context.globalAlpha = 0.16;
    context.fillStyle = '#6d5a00';
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    context.font = `600 ${Math.max(34, Math.round(canvas.width / 12))}px sans-serif`;
    context.fillText('Learnitive · Free', 0, 0);
    context.restore();
    pages.push({
      width: baseViewport.width,
      height: baseViewport.height,
      pixelWidth: canvas.width,
      pixelHeight: canvas.height,
      jpeg: await canvasJpegBytes(canvas),
    });
    page.cleanup();
  }
  return buildRasterPdf(pages);
}

async function createFreeWatermarkedPdf(
  pdfBytes: Uint8Array,
  documentProxy?: pdfjs.PDFDocumentProxy | null,
): Promise<Uint8Array> {
  if (documentProxy) return rasterizeFreeWatermarkedPdf(documentProxy);

  // The toolbar can become interactive before PDF.js has committed the preview
  // proxy. Free export must still apply the entitlement policy in that window,
  // so load an isolated copy instead of ever falling back to the clean bytes.
  const loadingTask = pdfjs.getDocument({ data: pdfBytes.slice() });
  try {
    const temporaryDocument = await loadingTask.promise;
    return await rasterizeFreeWatermarkedPdf(temporaryDocument);
  } finally {
    await loadingTask.destroy();
  }
}

function downloadText(value: string, fileName: string): void {
  const blob = new Blob([value], { type: 'text/plain;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement('a');
  anchor.href = url;
  anchor.download = fileName;
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  queueMicrotask(() => URL.revokeObjectURL(url));
}

function compiledOutputName(rootFile: string, extension: 'pdf' | 'log'): string {
  return `${rootFile.replace(/\.[^.]+$/, '') || 'document'}.${extension}`;
}

function formatPages(pages: number[]): string {
  if (pages.length === 0) return '—';
  if (pages.length <= 6) return pages.join(', ');
  return `${pages.slice(0, 5).join(', ')}, …`;
}

function textItemsFromContent(
  content: Awaited<ReturnType<pdfjs.PDFPageProxy['getTextContent']>>,
): string[] {
  return content.items.flatMap((item) => ('str' in item ? [item.str] : []));
}

function countOccurrences(value: string, query: string): number {
  if (!query) return 0;
  let count = 0;
  let cursor = 0;
  const normalized = value.toLocaleLowerCase();
  while (cursor <= normalized.length - query.length) {
    const found = normalized.indexOf(query, cursor);
    if (found < 0) break;
    count += 1;
    cursor = found + Math.max(1, query.length);
  }
  return count;
}

function configureSelectableTextLayer(
  container: HTMLElement,
  viewport: ReturnType<pdfjs.PDFPageProxy['getViewport']>,
): void {
  // PDF.js 5/6 uses --total-scale-factor for text-layer geometry. Keep the
  // older --scale-factor too because Learnitive still supports cached/fallback
  // viewer assets across upgrades. Explicit selection styles prevent parent
  // workspace/resizer CSS from accidentally turning the transparent text
  // overlay into a non-interactive canvas-only surface.
  container.style.setProperty('--total-scale-factor', String(viewport.scale));
  container.style.setProperty('--scale-factor', String(viewport.scale));
  container.style.setProperty('--user-unit', '1');
  container.style.width = `${viewport.width}px`;
  container.style.height = `${viewport.height}px`;
  container.style.pointerEvents = 'auto';
  container.style.userSelect = 'text';
  container.style.webkitUserSelect = 'text';
  container.style.cursor = 'text';
}

function finalizeTextLayer(container: HTMLElement): void {
  const spans = Array.from(container.querySelectorAll<HTMLElement>('span'));
  spans.forEach((span, index) => {
    span.dataset.learnitiveTextIndex = String(index);
    span.style.userSelect = 'text';
    span.style.webkitUserSelect = 'text';
    span.style.cursor = 'text';
  });
}

function renderFallbackTextLayer(
  container: HTMLElement,
  content: Awaited<ReturnType<pdfjs.PDFPageProxy['getTextContent']>>,
  viewport: ReturnType<pdfjs.PDFPageProxy['getViewport']>,
): void {
  container.replaceChildren();
  for (const rawItem of content.items) {
    if (!('str' in rawItem) || !rawItem.str) continue;
    const item = rawItem as typeof rawItem & {
      transform: number[];
      width: number;
      fontName: string;
    };
    const transformed = pdfjs.Util.transform(viewport.transform, item.transform);
    const angle = Math.atan2(transformed[1] ?? 0, transformed[0] ?? 1);
    const fontHeight = Math.max(
      1,
      Math.hypot(transformed[2] ?? 0, transformed[3] ?? 0),
    );
    const style = content.styles[item.fontName] as
      | { ascent?: number; descent?: number; fontFamily?: string }
      | undefined;
    const fontAscent = style?.ascent
      ? style.ascent * fontHeight
      : style?.descent
        ? (1 + style.descent) * fontHeight
        : fontHeight;
    const span = document.createElement('span');
    span.textContent = item.str;
    // The compatibility layer must carry its own geometry as well as the
    // shared CSS. `left`/`top` do nothing on statically positioned spans,
    // which was the root cause of visible PDF text not being mouse-selectable
    // when the native PDF.js TextLayer fell back.
    span.style.position = 'absolute';
    span.style.whiteSpace = 'pre';
    span.style.transformOrigin = '0 0';
    span.style.left = `${transformed[4] ?? 0}px`;
    span.style.top = `${(transformed[5] ?? 0) - fontAscent}px`;
    span.style.fontSize = `${fontHeight}px`;
    span.style.lineHeight = '1';
    span.style.fontFamily = style?.fontFamily ?? 'sans-serif';
    span.style.minWidth = `${Math.max(1, item.width * viewport.scale)}px`;
    span.style.transform = angle ? `rotate(${angle}rad)` : '';
    container.appendChild(span);
  }
  finalizeTextLayer(container);
}

export async function renderSelectableTextLayer(options: {
  container: HTMLElement;
  content: Awaited<ReturnType<pdfjs.PDFPageProxy['getTextContent']>>;
  viewport: ReturnType<pdfjs.PDFPageProxy['getViewport']>;
  textLayerConstructor?: PdfTextLayerConstructor | null;
}): Promise<{ controller?: PdfTextLayerController; mode: 'native' | 'fallback' }> {
  const textLayerConstructor =
    options.textLayerConstructor === undefined
      ? (pdfjs as unknown as { TextLayer?: PdfTextLayerConstructor }).TextLayer
      : options.textLayerConstructor ?? undefined;
  options.container.replaceChildren();
  configureSelectableTextLayer(options.container, options.viewport);
  if (!textLayerConstructor) {
    renderFallbackTextLayer(options.container, options.content, options.viewport);
    return { mode: 'fallback' };
  }

  let layer: PdfTextLayerController | undefined;
  try {
    layer = new textLayerConstructor({
      textContentSource: options.content,
      container: options.container,
      viewport: options.viewport,
    });
    await layer.render();
    finalizeTextLayer(options.container);
    return { controller: layer, mode: 'native' };
  } catch (cause) {
    layer?.cancel?.();
    console.warn('PDF.js text layer failed; using Learnitive compatibility text layer', cause);
    renderFallbackTextLayer(options.container, options.content, options.viewport);
    return { mode: 'fallback' };
  }
}

function isPdfRenderCancellation(cause: unknown): boolean {
  return cause instanceof Error && /cancel|abort/i.test(cause.message);
}

function applySearchHighlights(options: {
  layer: HTMLElement;
  pageNumber: number;
  query: string;
  active?: PdfSearchResult;
}): HTMLElement | undefined {
  const spans = Array.from(options.layer.querySelectorAll<HTMLElement>('span'));
  for (const span of spans) {
    span.classList.remove('interactive-pdf-search-hit', 'interactive-pdf-search-active');
  }
  const query = options.query.trim().toLocaleLowerCase();
  if (!query) return undefined;
  let pageOrdinal = 0;
  let activeSpan: HTMLElement | undefined;
  for (const span of spans) {
    const occurrences = countOccurrences(span.textContent ?? '', query);
    if (occurrences === 0) continue;
    span.classList.add('interactive-pdf-search-hit');
    if (
      options.active?.page === options.pageNumber &&
      options.active.pageOrdinal >= pageOrdinal &&
      options.active.pageOrdinal < pageOrdinal + occurrences
    ) {
      span.classList.add('interactive-pdf-search-active');
      activeSpan = span;
    }
    pageOrdinal += occurrences;
  }
  return activeSpan;
}

function InstantPdfPatchOverlay({
  patch,
  displayScale,
  onAccept,
  onReject,
}: {
  patch: ProjectLivePdfInstantPatch;
  displayScale: number;
  onAccept: (generation: number) => void;
  onReject: (generation: number, reason: 'line-count-change' | 'viewer-paint-failure') => void;
}) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [ready, setReady] = React.useState(false);
  const [failed, setFailed] = React.useState(false);

  React.useEffect(() => {
    let cancelled = false;
    let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
    let renderTask: pdfjs.RenderTask | undefined;
    let documentProxy: pdfjs.PDFDocumentProxy | undefined;
    setReady(false);
    setFailed(false);

    void (async () => {
      try {
        loadingTask = pdfjs.getDocument({ data: patch.pdf.slice() });
        documentProxy = await loadingTask.promise;
        if (cancelled) return;
        const page = await documentProxy.getPage(1);
        const renderScale = 2;
        const viewport = page.getViewport({ scale: renderScale });
        const staging = window.document.createElement('canvas');
        staging.width = Math.max(1, Math.ceil(viewport.width));
        staging.height = Math.max(1, Math.ceil(viewport.height));
        const stagingContext = staging.getContext('2d', { alpha: false });
        if (!stagingContext) throw new Error('Instant Live PDF staging canvas is unavailable');
        renderTask = page.render({ canvas: staging, canvasContext: stagingContext, viewport });
        await renderTask.promise;
        if (cancelled) return;

        const text = await page.getTextContent();
        let minX = Number.POSITIVE_INFINITY;
        let minY = Number.POSITIVE_INFINITY;
        let maxX = Number.NEGATIVE_INFINITY;
        let maxY = Number.NEGATIVE_INFINITY;
        const baselines: number[] = [];
        for (const item of text.items) {
          if (!(item && typeof item === 'object' && 'str' in item && 'transform' in item && 'width' in item)) continue;
          const typed = item as { str: string; transform: number[]; width: number; height?: number };
          if (!typed.str.trim() || typed.transform.length < 6) continue;
          const tx = pdfjs.Util.transform(viewport.transform, typed.transform);
          const fontHeight = Math.max(
            1,
            Math.hypot(tx[2] ?? 0, tx[3] ?? 0),
            Math.abs((typed.height ?? 0) * renderScale),
          );
          const x = tx[4] ?? 0;
          const baseline = tx[5] ?? 0;
          const width = Math.max(1, Math.abs(typed.width * renderScale));
          baselines.push(baseline);
          minX = Math.min(minX, x);
          minY = Math.min(minY, baseline - fontHeight);
          maxX = Math.max(maxX, x + width);
          maxY = Math.max(maxY, baseline + fontHeight * 0.28);
        }
        if (!Number.isFinite(minX) || !Number.isFinite(minY) || maxX <= minX || maxY <= minY) {
          throw new Error('Instant Live PDF block did not expose a crop-safe text box');
        }
        baselines.sort((a, b) => a - b);
        let renderedLineCount = 0;
        let lastBaseline = Number.NEGATIVE_INFINITY;
        for (const baseline of baselines) {
          if (renderedLineCount === 0 || Math.abs(baseline - lastBaseline) > renderScale * 1.5) {
            renderedLineCount += 1;
            lastBaseline = baseline;
          }
        }
        if (renderedLineCount !== patch.target.lineCount) {
          onReject(patch.generation, 'line-count-change');
          setFailed(true);
          page.cleanup();
          return;
        }

        // Preserve engine glyph metrics. The tiny PDF crop is already in PDF
        // points, so stretching it to the inferred column width would visibly
        // distort fonts. We clear the full old column width, then paint the
        // crop at its native PDF scale.
        const sx = clamp(Math.floor(minX), 0, staging.width - 1);
        const sy = clamp(Math.floor(minY), 0, staging.height - 1);
        const sw = Math.max(1, Math.min(staging.width - sx, Math.ceil(maxX - minX)));
        const sh = Math.max(1, Math.min(staging.height - sy, Math.ceil(maxY - minY)));
        const columnWidthCss = Math.max(patch.target.width, patch.target.hsize) * TEX_PT_TO_PDF_PT * displayScale;
        const targetOldHeightCss = patch.target.height * TEX_PT_TO_PDF_PT * displayScale;
        const contentWidthCss = (sw / renderScale) * displayScale;
        const contentHeightCss = (sh / renderScale) * displayScale;
        const widthCss = Math.max(1, columnWidthCss, contentWidthCss);
        const heightCss = Math.max(1, targetOldHeightCss, contentHeightCss);
        const pixelRatio = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
        const canvas = canvasRef.current;
        if (!canvas || cancelled) return;
        canvas.width = Math.max(1, Math.ceil(widthCss * pixelRatio));
        canvas.height = Math.max(1, Math.ceil(heightCss * pixelRatio));
        canvas.style.width = `${widthCss}px`;
        canvas.style.height = `${heightCss}px`;
        const context = canvas.getContext('2d', { alpha: false });
        if (!context) throw new Error('Instant Live PDF canvas is unavailable');
        context.save();
        context.scale(pixelRatio, pixelRatio);
        context.fillStyle = '#ffffff';
        context.fillRect(0, 0, widthCss, heightCss);
        context.drawImage(staging, sx, sy, sw, sh, 0, 0, contentWidthCss, contentHeightCss);
        context.restore();
        if (!cancelled) {
          setReady(true);
          onAccept(patch.generation);
        }
        page.cleanup();
      } catch (error) {
        if (cancelled || (error instanceof DOMException && error.name === 'AbortError')) return;
        console.warn('Instant Live PDF overlay could not be painted; exact R5 preview remains active.', error);
        onReject(patch.generation, 'viewer-paint-failure');
        setFailed(true);
      }
    })();

    return () => {
      cancelled = true;
      try { renderTask?.cancel(); } catch { /* already complete */ }
      void loadingTask?.destroy();
    };
  }, [displayScale, onAccept, onReject, patch]);

  if (failed) return null;
  return (
    <canvas
      ref={canvasRef}
      className="pointer-events-none absolute z-[6]"
      data-instant-ready={ready ? 'true' : 'false'}
      data-testid="live-pdf-instant-patch"
      style={{
        left: patch.target.x * TEX_PT_TO_PDF_PT * displayScale,
        top: patch.target.y * TEX_PT_TO_PDF_PT * displayScale,
        opacity: ready ? 1 : 0,
      }}
    />
  );
}

function PdfPageSurface({
  document,
  pageNumber,
  scrollRoot,
  displayScale,
  estimatedSize,
  mappings,
  highlight,
  showFileMap,
  searchQuery,
  activeSearch,
  onTextItems,
  onSelection,
  onReverseSync,
  registerPage,
  instantPatch,
  onInstantPatchAccept,
  onInstantPatchReject,
}: {
  document: pdfjs.PDFDocumentProxy;
  pageNumber: number;
  scrollRoot: HTMLElement | null;
  displayScale: number;
  estimatedSize: PageSize;
  mappings: readonly SyncTexFileMapping[];
  highlight?: SyncTexForwardResult;
  showFileMap: boolean;
  searchQuery: string;
  activeSearch?: PdfSearchResult;
  onTextItems: (page: number, items: string[]) => void;
  onSelection: (page: number, layer: HTMLElement) => void;
  onReverseSync: (page: number, surface: HTMLElement, clientX: number, clientY: number) => void;
  registerPage: (page: number, element: HTMLElement | null) => void;
  instantPatch?: ProjectLivePdfInstantPatch;
  onInstantPatchAccept: (generation: number) => void;
  onInstantPatchReject: (generation: number, reason: 'line-count-change' | 'viewer-paint-failure') => void;
}) {
  const wrapperRef = React.useRef<HTMLDivElement>(null);
  const surfaceRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const outgoingCanvasRef = React.useRef<HTMLCanvasElement>(null);
  const fadeTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const textLayerRef = React.useRef<HTMLDivElement>(null);
  const renderTaskRef = React.useRef<pdfjs.RenderTask | null>(null);
  const textLayerTaskRef = React.useRef<PdfTextLayerController | null>(null);
  const [nearViewport, setNearViewport] = React.useState(pageNumber <= 2);
  const [pageSize, setPageSize] = React.useState<PageSize>(estimatedSize);
  const [renderError, setRenderError] = React.useState<string>();
  const [canvasStatus, setCanvasStatus] = React.useState<PdfPageCanvasStatus>('idle');
  const [textLayerStatus, setTextLayerStatus] = React.useState<PdfPageTextLayerStatus>('idle');
  const searchQueryRef = React.useRef(searchQuery);
  const activeSearchRef = React.useRef(activeSearch);
  searchQueryRef.current = searchQuery;
  activeSearchRef.current = activeSearch;

  React.useLayoutEffect(() => {
    registerPage(pageNumber, wrapperRef.current);
    return () => registerPage(pageNumber, null);
  }, [pageNumber, registerPage]);

  React.useEffect(() => {
    const element = wrapperRef.current;
    if (!element || !scrollRoot || typeof IntersectionObserver === 'undefined') {
      setNearViewport(true);
      return;
    }
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry) setNearViewport(entry.isIntersecting);
      },
      { root: scrollRoot, rootMargin: VIRTUAL_ROOT_MARGIN },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [scrollRoot]);

  React.useEffect(() => {
    if (!nearViewport) return;
    let cancelled = false;
    setRenderError(undefined);
    setCanvasStatus('rendering');
    setTextLayerStatus('idle');
    renderTaskRef.current?.cancel();
    textLayerTaskRef.current?.cancel?.();
    textLayerRef.current?.replaceChildren();

    void document
      .getPage(pageNumber)
      .then(async (page) => {
        try {
          if (cancelled) return;
          const baseViewport = page.getViewport({ scale: 1 });
          setPageSize({ width: baseViewport.width, height: baseViewport.height });
          const displayViewport = page.getViewport({ scale: displayScale });
          const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
          const renderViewport = page.getViewport({ scale: displayScale * pixelRatio });
          const canvas = canvasRef.current;
          const outgoingCanvas = outgoingCanvasRef.current;
          const textLayer = textLayerRef.current;
          if (!canvas || !textLayer) return;

          // Render into a staging canvas first. Mutating the visible canvas size
          // clears its pixels synchronously, which was the source of the Live
          // Preview white flash. The old page stays painted until staging is ready.
          const stagingCanvas = window.document.createElement('canvas');
          stagingCanvas.width = Math.ceil(renderViewport.width);
          stagingCanvas.height = Math.ceil(renderViewport.height);
          const stagingContext = stagingCanvas.getContext('2d');
          if (!stagingContext) throw new Error('Unable to create PDF canvas context');

          textLayer.style.width = `${displayViewport.width}px`;
          textLayer.style.height = `${displayViewport.height}px`;

          const canvasTask = page.render({
            canvas: stagingCanvas,
            canvasContext: stagingContext,
            viewport: renderViewport,
          });
          renderTaskRef.current = canvasTask;

          // Canvas rendering is authoritative. Secondary PDF features must never
          // replace a successfully rendered page with a fatal error overlay.
          await canvasTask.promise;
          if (cancelled) return;
          if (renderTaskRef.current === canvasTask) renderTaskRef.current = null;

          if (fadeTimerRef.current) {
            clearTimeout(fadeTimerRef.current);
            fadeTimerRef.current = undefined;
          }
          const hadVisibleFrame = canvas.width > 0 && canvas.height > 0;
          if (hadVisibleFrame && outgoingCanvas) {
            outgoingCanvas.width = canvas.width;
            outgoingCanvas.height = canvas.height;
            outgoingCanvas.style.width = canvas.style.width;
            outgoingCanvas.style.height = canvas.style.height;
            outgoingCanvas.getContext('2d')?.drawImage(canvas, 0, 0);
            outgoingCanvas.style.display = 'block';
            outgoingCanvas.style.opacity = '1';
            outgoingCanvas.style.transition = 'none';
          }

          canvas.width = stagingCanvas.width;
          canvas.height = stagingCanvas.height;
          canvas.style.width = `${displayViewport.width}px`;
          canvas.style.height = `${displayViewport.height}px`;
          const context = canvas.getContext('2d');
          if (!context) throw new Error('Unable to create visible PDF canvas context');
          context.drawImage(stagingCanvas, 0, 0);
          canvas.style.opacity = hadVisibleFrame ? '0' : '1';
          canvas.style.transition = 'none';
          setCanvasStatus('ready');

          if (hadVisibleFrame) {
            window.requestAnimationFrame(() => {
              if (cancelled) return;
              canvas.style.transition = 'opacity 140ms ease-out';
              canvas.style.opacity = '1';
              if (outgoingCanvas) {
                outgoingCanvas.style.transition = 'opacity 140ms ease-out';
                outgoingCanvas.style.opacity = '0';
              }
              fadeTimerRef.current = setTimeout(() => {
                if (outgoingCanvas) {
                  outgoingCanvas.style.display = 'none';
                  outgoingCanvas.style.transition = 'none';
                }
                canvas.style.transition = 'none';
                fadeTimerRef.current = undefined;
              }, 170);
            });
          }

          try {
            const textContent = await page.getTextContent();
            if (cancelled || !textLayerRef.current) return;
            onTextItems(pageNumber, textItemsFromContent(textContent));

            try {
              const rendered = await renderSelectableTextLayer({
                container: textLayerRef.current,
                content: textContent,
                viewport: displayViewport,
              });
              if (cancelled) {
                rendered.controller?.cancel?.();
                return;
              }
              textLayerTaskRef.current = rendered.controller ?? null;
              setTextLayerStatus(rendered.mode);
              applySearchHighlights({
                layer: textLayerRef.current,
                pageNumber,
                query: searchQueryRef.current,
                active: activeSearchRef.current,
              });
            } catch (cause) {
              if (cancelled || isPdfRenderCancellation(cause)) return;
              console.warn(
                `Unable to render selectable text for compiled PDF page ${pageNumber}; keeping canvas-only page`,
                cause,
              );
              textLayerRef.current.replaceChildren();
              textLayerTaskRef.current = null;
              setTextLayerStatus('unavailable');
            }
          } catch (cause) {
            if (cancelled || isPdfRenderCancellation(cause)) return;
            console.warn(
              `Unable to extract text for compiled PDF page ${pageNumber}; keeping canvas-only page`,
              cause,
            );
            textLayerRef.current?.replaceChildren();
            textLayerTaskRef.current = null;
            setTextLayerStatus('unavailable');
          }
        } finally {
          page.cleanup();
        }
      })
      .catch((cause) => {
        if (cancelled || isPdfRenderCancellation(cause)) return;
        console.error(`Unable to render compiled PDF canvas page ${pageNumber}`, cause);
        setCanvasStatus('error');
        setRenderError(t.app.pdfPreview.renderFailed);
      });

    return () => {
      cancelled = true;
      renderTaskRef.current?.cancel();
      renderTaskRef.current = null;
      textLayerTaskRef.current?.cancel?.();
      textLayerTaskRef.current = null;
      if (fadeTimerRef.current) {
        clearTimeout(fadeTimerRef.current);
        fadeTimerRef.current = undefined;
      }
    };
  }, [displayScale, document, nearViewport, onTextItems, pageNumber]);

  React.useEffect(() => {
    const layer = textLayerRef.current;
    if (!layer) return;
    const activeSpan = applySearchHighlights({
      layer,
      pageNumber,
      query: searchQuery,
      active: activeSearch,
    });
    if (activeSpan && activeSearch?.page === pageNumber) {
      activeSpan.scrollIntoView({ block: 'center', inline: 'nearest' });
    }
  }, [activeSearch, searchQuery, pageNumber]);

  const cssWidth = Math.max(1, pageSize.width * displayScale);
  const cssHeight = Math.max(1, pageSize.height * displayScale);
  const mappedRects = showFileMap
    ? mappings.filter((mapping) => mapping.page === pageNumber).flatMap((mapping) => mapping.rects).slice(0, 600)
    : [];
  const focusedHighlight = highlight?.page === pageNumber
    ? focusedSyncTexRect(highlight.rects)
    : undefined;

  return (
    <div
      ref={wrapperRef}
      className="relative shrink-0"
      data-pdf-page-wrapper={pageNumber}
      style={{ width: cssWidth, minHeight: cssHeight }}
    >
      <div
        ref={surfaceRef}
        className="relative bg-white shadow-sm"
        data-pdf-page={pageNumber}
        data-pdf-canvas-status={canvasStatus}
        data-pdf-text-layer-status={textLayerStatus}
        data-testid={pageNumber === 1 ? 'pdf-page-surface' : undefined}
        style={{ width: cssWidth, height: cssHeight }}
      >
        {nearViewport ? (
          <>
            <canvas
              ref={outgoingCanvasRef}
              className="pointer-events-none absolute inset-0 z-[1]"
              style={{ display: 'none' }}
            />
            <canvas ref={canvasRef} className="relative z-[2]" />
            {instantPatch && instantPatch.target.page === pageNumber && (
              <InstantPdfPatchOverlay patch={instantPatch} displayScale={displayScale} onAccept={onInstantPatchAccept} onReject={onInstantPatchReject} />
            )}
            <div
              ref={textLayerRef}
              className="textLayer interactive-pdf-text-layer absolute inset-0 z-10 cursor-text select-text overflow-hidden"
              data-testid={pageNumber === 1 ? 'pdf-text-layer' : undefined}
              role="document"
              aria-label={`Selectable text for PDF page ${pageNumber}`}
              onMouseUp={() => {
                const layer = textLayerRef.current;
                if (layer) onSelection(pageNumber, layer);
              }}
              onDoubleClick={(event) => {
                if (!event.altKey || !surfaceRef.current) return;
                event.preventDefault();
                event.stopPropagation();
                onReverseSync(
                  pageNumber,
                  surfaceRef.current,
                  event.clientX,
                  event.clientY,
                );
              }}
            />
            <div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
              {mappedRects.map((rect) => (
                <div
                  key={`mapping-${pageNumber}-${rect.x}-${rect.y}-${rect.width}-${rect.height}`}
                  data-testid="synctex-file-mapping"
                  className="absolute rounded-sm bg-sky-400/10 outline outline-1 outline-sky-400/20"
                  style={{
                    left: rect.x * TEX_PT_TO_PDF_PT * displayScale,
                    top: rect.y * TEX_PT_TO_PDF_PT * displayScale,
                    width: Math.max(1, rect.width * TEX_PT_TO_PDF_PT * displayScale),
                    height: Math.max(2, rect.height * TEX_PT_TO_PDF_PT * displayScale),
                  }}
                />
              ))}
              {focusedHighlight && (
                <div
                  key={`highlight-${pageNumber}-${focusedHighlight.x}-${focusedHighlight.y}-${focusedHighlight.width}-${focusedHighlight.height}`}
                  data-testid="synctex-source-highlight"
                  className="absolute rounded-sm bg-sky-400/10 outline outline-1 outline-sky-500/45"
                  style={{
                    left: focusedHighlight.x * TEX_PT_TO_PDF_PT * displayScale,
                    top: focusedHighlight.y * TEX_PT_TO_PDF_PT * displayScale,
                    width: Math.max(2, focusedHighlight.width * TEX_PT_TO_PDF_PT * displayScale),
                    height: Math.max(3, focusedHighlight.height * TEX_PT_TO_PDF_PT * displayScale),
                  }}
                />
              )}
            </div>
            {textLayerStatus === 'unavailable' && !renderError && (
              <div
                className="pointer-events-none absolute bottom-2 right-2 z-30 max-w-[75%] rounded bg-amber-50/95 px-2 py-1 text-[10px] text-amber-900 shadow-sm dark:bg-amber-950/90 dark:text-amber-100"
                role="status"
              >
                {t.app.pdfPreview.textLayerUnavailable}
              </div>
            )}
            {renderError && (
              <div className="absolute inset-0 z-30 flex items-center justify-center p-4 text-center text-sm text-destructive">
                {renderError}
              </div>
            )}
          </>
        ) : (
          <div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground" aria-hidden="true">
            Page {pageNumber}
          </div>
        )}
      </div>
      <div className="absolute -left-10 top-1 rounded bg-background/90 px-1.5 py-0.5 text-[10px] tabular-nums text-muted-foreground shadow-sm">
        {pageNumber}
      </div>
    </div>
  );
}

export function PdfPreviewPane({
  wsName,
  mobileOutputTabRequest,
  onCloseMobile,
  onRequestSource,
  livePdf,
  liveSynctex,
  liveRootFile,
  liveGeneration,
  livePdfActive = false,
  livePdfStatus,
  liveInstantPatch,
  liveInstantStatus,
}: {
  wsName: string;
  mobileOutputTabRequest?: { id: number; tab: 'pdf' | 'log' };
  onCloseMobile?: () => void;
  onRequestSource?: () => void;
  livePdf?: Uint8Array;
  liveSynctex?: Uint8Array;
  liveRootFile?: string;
  liveGeneration?: number;
  livePdfActive?: boolean;
  livePdfStatus?: 'certifying' | 'rendering' | 'ready' | 'error';
  liveInstantPatch?: ProjectLivePdfInstantPatch;
  liveInstantStatus?: ProjectLivePdfInstantStatus;
}) {
  const {
    latexSource,
    navigation,
    projectAi,
    projectCompile,
    projectEntitlements,
    projectPdf,
    projectLiveDraft,
  } = useCoreServices();
  useAtomValue(projectPdf.$version);
  useAtomValue(projectEntitlements.$state);
  const compileState = useAtomValue(projectCompile.$state);
  const preferences = useAtomValue(projectPdf.$preferences);
  const workspace = projectPdf.getWorkspaceState(wsName);

  const [scrollRoot, setScrollRoot] = React.useState<HTMLDivElement | null>(null);
  const pageElementsRef = React.useRef<Map<number, HTMLElement>>(new Map());
  const loadingTaskRef = React.useRef<pdfjs.PDFDocumentLoadingTask | null>(null);
  const activeLoadingTaskRef = React.useRef<pdfjs.PDFDocumentLoadingTask | null>(null);
  const activeDocumentRef = React.useRef<pdfjs.PDFDocumentProxy | null>(null);
  const pendingSwapAnchorRef = React.useRef<{ page: number; fraction: number; scrollTop: number } | undefined>(undefined);
  const pageTextCacheRef = React.useRef<Map<number, string[]>>(new Map());
  const searchGenerationRef = React.useRef(0);
  const searchInputRef = React.useRef<HTMLInputElement>(null);
  const reverseScrollTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const restoredScrollTopRef = React.useRef(0);
  const acceptedLiveGenerationRef = React.useRef<number | undefined>(undefined);

  const [viewStateLoaded, setViewStateLoaded] = React.useState(false);
  const [documentProxy, setDocumentProxy] = React.useState<pdfjs.PDFDocumentProxy | null>(null);
  const [pageCount, setPageCount] = React.useState(0);
  const [pageNumber, setPageNumber] = React.useState(1);
  const [firstPageSize, setFirstPageSize] = React.useState<PageSize>({ width: 612, height: 792 });
  const [zoom, setZoom] = React.useState(1);
  const [fitWidth, setFitWidth] = React.useState(true);
  const [showFileMap, setShowFileMap] = React.useState(false);
  const [fullPreview, setFullPreview] = React.useState(false);
  const [containerWidth, setContainerWidth] = React.useState(0);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState<string>();
  const [searchOpen, setSearchOpen] = React.useState(false);
  const [searchQuery, setSearchQuery] = React.useState('');
  const [searchResults, setSearchResults] = React.useState<PdfSearchResult[]>([]);
  const [activeSearchIndex, setActiveSearchIndex] = React.useState(0);
  const [searching, setSearching] = React.useState(false);
  const [selectedPdfText, setSelectedPdfText] = React.useState('');
  const [activeOutputTab, setActiveOutputTab] = React.useState<'pdf' | 'log'>('pdf');

  const relevantCompile: ProjectCompileState =
    compileState.wsName === wsName ? compileState : { status: 'idle' };

  React.useEffect(() => {
    let cancelled = false;
    setViewStateLoaded(false);
    void projectPdf.getViewState(wsName).then((state) => {
      if (cancelled) return;
      setPageNumber(state.page);
      setZoom(state.zoom);
      setFitWidth(state.fitMode === 'width');
      setActiveOutputTab(state.outputTab);
      restoredScrollTopRef.current = state.scrollTop;
      setViewStateLoaded(true);
    });
    return () => { cancelled = true; };
  }, [projectPdf, wsName]);

  React.useEffect(() => {
    if (!viewStateLoaded) return;
    projectPdf.updateViewState(wsName, {
      page: pageNumber,
      zoom,
      fitMode: fitWidth ? 'width' : 'custom',
      outputTab: activeOutputTab,
    });
  }, [activeOutputTab, fitWidth, pageNumber, projectPdf, viewStateLoaded, wsName, zoom]);

  React.useEffect(() => {
    if (!mobileOutputTabRequest) return;
    setActiveOutputTab(mobileOutputTabRequest.tab);
  }, [mobileOutputTabRequest]);

  React.useEffect(() => {
    if (!searchOpen) return;
    const frame = window.requestAnimationFrame(() => searchInputRef.current?.focus());
    return () => window.cancelAnimationFrame(frame);
  }, [searchOpen]);

  React.useEffect(() => {
    if (!livePdfActive) {
      acceptedLiveGenerationRef.current = undefined;
      projectPdf.clearLivePreview(wsName);
      return;
    }
    if (!livePdf?.byteLength || !liveRootFile || liveGeneration === undefined) return;
    // One immutable R5 generation carries one PDF+SyncTeX pair. Do not start a
    // second parse merely because another viewer state update rendered while
    // the first generation is still being accepted.
    if (acceptedLiveGenerationRef.current === liveGeneration) return;
    acceptedLiveGenerationRef.current = liveGeneration;
    void projectPdf.acceptLivePreview(
      wsName,
      liveRootFile,
      liveGeneration,
      livePdf,
      liveSynctex,
    );
  }, [liveGeneration, livePdf, livePdfActive, liveRootFile, liveSynctex, projectPdf, wsName]);

  const showingLivePdf = workspace?.origin === 'live';
  const [rejectedInstantGeneration, setRejectedInstantGeneration] = React.useState<number>();
  const acceptInstantPatch = React.useCallback((generation: number) => {
    projectLiveDraft.reportInstantPatchAccepted(generation);
  }, [projectLiveDraft]);
  const rejectInstantPatch = React.useCallback((generation: number, reason: 'line-count-change' | 'viewer-paint-failure') => {
    projectLiveDraft.reportInstantPatchRejected(generation, reason);
    setRejectedInstantGeneration(generation);
  }, [projectLiveDraft]);
  // Keep the R6 overlay until ProjectPdfService has accepted the newer exact
  // R5 generation. A viewer-side geometry rejection also fails closed locally.
  const visibleInstantPatch = showingLivePdf &&
    liveInstantPatch &&
    workspace?.generation === liveInstantPatch.baseGeneration &&
    rejectedInstantGeneration !== liveInstantPatch.generation
      ? liveInstantPatch
      : undefined;
  const pdf = workspace?.pdf;
  const highlight = workspace?.highlight;
  const currentFileMappings = workspace?.activeSourcePath
    ? projectPdf.getMappingsForFile(wsName, workspace.activeSourcePath)
    : [];

  React.useEffect(() => {
    const element = scrollRoot;
    if (!element || typeof ResizeObserver === 'undefined') return;
    const update = () => setContainerWidth(element.getBoundingClientRect().width);
    update();
    const observer = new ResizeObserver(update);
    observer.observe(element);
    return () => observer.disconnect();
  }, [scrollRoot]);

  React.useEffect(() => {
    let cancelled = false;
    setError(undefined);

    const root = scrollRoot;
    const currentPageElement = pageElementsRef.current.get(pageNumber);
    if (root && currentPageElement) {
      const rootRect = root.getBoundingClientRect();
      const pageRect = currentPageElement.getBoundingClientRect();
      const pageTop = root.scrollTop + pageRect.top - rootRect.top;
      const withinPage = Math.max(0, root.scrollTop - pageTop);
      pendingSwapAnchorRef.current = {
        page: pageNumber,
        fraction: pageRect.height > 0 ? Math.min(1, withinPage / pageRect.height) : 0,
        scrollTop: root.scrollTop,
      };
    } else if (root) {
      pendingSwapAnchorRef.current = { page: pageNumber, fraction: 0, scrollTop: root.scrollTop };
    }

    const pending = loadingTaskRef.current;
    loadingTaskRef.current = null;
    if (pending) void pending.destroy().catch(() => undefined);

    if (!pdf?.byteLength) {
      const previousTask = activeLoadingTaskRef.current;
      activeLoadingTaskRef.current = null;
      activeDocumentRef.current = null;
      setDocumentProxy(null);
      setPageCount(0);
      setSearchResults([]);
      setSelectedPdfText('');
      pageTextCacheRef.current.clear();
      pageElementsRef.current.clear();
      setLoading(false);
      if (previousTask) void previousTask.destroy().catch(() => undefined);
      return;
    }

    // Keep the last successful PDF painted while the replacement loads. This
    // avoids the white flash that used to occur on every Live Preview refresh.
    setLoading(true);
    const task = pdfjs.getDocument({ data: pdf.slice() });
    loadingTaskRef.current = task;
    void task.promise
      .then(async (document) => {
        if (cancelled) {
          await task.destroy();
          return;
        }
        const first = await document.getPage(1);
        const viewport = first.getViewport({ scale: 1 });
        first.cleanup();
        if (cancelled) {
          await task.destroy();
          return;
        }

        const previousTask = activeLoadingTaskRef.current;
        const previousDocument = activeDocumentRef.current;
        activeLoadingTaskRef.current = task;
        activeDocumentRef.current = document;
        if (loadingTaskRef.current === task) loadingTaskRef.current = null;
        setFirstPageSize({ width: viewport.width, height: viewport.height });
        setDocumentProxy(document);
        setPageCount(document.numPages);
        setPageNumber((current) => Math.min(document.numPages, Math.max(1, current)));
        setSearchResults([]);
        setSelectedPdfText('');
        pageTextCacheRef.current.clear();
        setLoading(false);

        if (previousTask && previousDocument && previousDocument !== document) {
          // Leave one paint for the new document before releasing the previous
          // PDF.js loading task/document. Page canvases cross-fade independently below.
          window.requestAnimationFrame(() => {
            void previousTask.destroy().catch(() => undefined);
          });
        }
      })
      .catch((cause) => {
        if (cancelled) return;
        if (loadingTaskRef.current === task) loadingTaskRef.current = null;
        console.error('Unable to load compiled PDF', cause);
        setError(t.app.pdfPreview.loadFailed);
        // A failed Live refresh must leave the last successful document visible.
        setLoading(false);
      });

    return () => {
      cancelled = true;
      if (loadingTaskRef.current === task) {
        loadingTaskRef.current = null;
        void task.destroy().catch(() => undefined);
      }
    };
  }, [pdf, scrollRoot]);

  React.useEffect(() => () => {
    if (reverseScrollTimerRef.current) clearTimeout(reverseScrollTimerRef.current);
    const pending = loadingTaskRef.current;
    loadingTaskRef.current = null;
    if (pending) void pending.destroy().catch(() => undefined);
    const activeTask = activeLoadingTaskRef.current;
    activeLoadingTaskRef.current = null;
    activeDocumentRef.current = null;
    if (activeTask) void activeTask.destroy().catch(() => undefined);
  }, []);

  React.useLayoutEffect(() => {
    if (!scrollRoot || !documentProxy || !viewStateLoaded) return;
    let secondFrame: number | undefined;
    const firstFrame = window.requestAnimationFrame(() => {
      secondFrame = window.requestAnimationFrame(() => {
        projectPdf.claimScrollOwnership(wsName, 'pdf-programmatic');
        const anchor = pendingSwapAnchorRef.current;
        pendingSwapAnchorRef.current = undefined;
        if (anchor) {
          const targetPage = Math.min(documentProxy.numPages, Math.max(1, anchor.page));
          const pageElement = pageElementsRef.current.get(targetPage);
          if (pageElement) {
            const rootRect = scrollRoot.getBoundingClientRect();
            const pageRect = pageElement.getBoundingClientRect();
            const pageTop = scrollRoot.scrollTop + pageRect.top - rootRect.top;
            const target = pageTop + Math.max(0, Math.min(1, anchor.fraction)) * pageRect.height;
            scrollRoot.scrollTo({ top: Math.max(0, target), behavior: 'auto' });
            setPageNumber(targetPage);
            restoredScrollTopRef.current = scrollRoot.scrollTop;
            return;
          }
          scrollRoot.scrollTop = Math.max(0, anchor.scrollTop);
          return;
        }
        scrollRoot.scrollTop = Math.max(0, restoredScrollTopRef.current);
      });
    });
    return () => {
      window.cancelAnimationFrame(firstFrame);
      if (secondFrame !== undefined) window.cancelAnimationFrame(secondFrame);
    };
  }, [documentProxy, projectPdf, scrollRoot, viewStateLoaded, wsName]);

  const availableWidth = Math.max(120, containerWidth - 54);
  const fitted = availableWidth / Math.max(1, firstPageSize.width);
  const displayScale = clamp((fitWidth ? fitted : 1) * zoom, MIN_ZOOM, MAX_ZOOM);

  React.useEffect(() => {
    setSelectedPdfText('');
    projectAi.setPdfContext(wsName, { page: pageNumber });
  }, [pageNumber, projectAi, wsName]);

  const registerPage = React.useCallback((page: number, element: HTMLElement | null) => {
    if (element) pageElementsRef.current.set(page, element);
    else pageElementsRef.current.delete(page);
  }, []);

  const handleTextItems = React.useCallback((targetPage: number, items: string[]) => {
    pageTextCacheRef.current.set(targetPage, items);
  }, []);

  const scrollToPage = React.useCallback(
    (page: number, options: { owner?: 'pdf-user' | 'pdf-programmatic'; yCss?: number } = {}) => {
      const container = scrollRoot;
      const element = pageElementsRef.current.get(page);
      if (!container || !element) return;
      projectPdf.claimScrollOwnership(wsName, options.owner ?? 'pdf-user');
      const containerRect = container.getBoundingClientRect();
      const pageRect = element.getBoundingClientRect();
      const target =
        container.scrollTop +
        (pageRect.top - containerRect.top) +
        (options.yCss ?? 0) -
        (options.yCss === undefined ? 16 : container.clientHeight / 2);
      container.scrollTo({ top: Math.max(0, target), behavior: 'auto' });
      setPageNumber(page);
    },
    [projectPdf, scrollRoot, wsName],
  );

  React.useEffect(() => {
    if (!highlight?.page || !preferences.pdfFollowsSource) return;
    // A Live PDF refresh is not a source-user scroll. Preserve an actively
    // user-controlled PDF position until the source/visual editor owns scroll
    // again; this prevents every background compile from yanking the viewer.
    if (projectPdf.isScrollOwnedByUser(wsName, 'pdf')) return;
    const rect = focusedSyncTexRect(highlight.rects);
    const y = rect?.y ?? 0;
    scrollToPage(highlight.page, {
      owner: 'pdf-programmatic',
      yCss: y * TEX_PT_TO_PDF_PT * displayScale,
    });
  }, [displayScale, documentProxy, highlight, pageCount, preferences.pdfFollowsSource, projectPdf, scrollToPage, wsName]);

  const getPageTextItems = React.useCallback(
    async (page: number): Promise<string[]> => {
      const cached = pageTextCacheRef.current.get(page);
      if (cached) return cached;
      const document = documentProxy;
      if (!document) return [];
      const proxy = await document.getPage(page);
      const content = await proxy.getTextContent();
      const items = textItemsFromContent(content);
      pageTextCacheRef.current.set(page, items);
      proxy.cleanup();
      return items;
    },
    [documentProxy],
  );

  React.useEffect(() => {
    const query = searchQuery.trim().toLocaleLowerCase();
    const generation = ++searchGenerationRef.current;
    if (!query || !documentProxy || pageCount === 0) {
      setSearchResults([]);
      setActiveSearchIndex(0);
      setSearching(false);
      return;
    }
    setSearching(true);
    const timer = window.setTimeout(() => {
      void (async () => {
        const results: PdfSearchResult[] = [];
        for (let page = 1; page <= pageCount; page += 1) {
          if (generation !== searchGenerationRef.current) return;
          const items = await getPageTextItems(page);
          let pageOrdinal = 0;
          for (const item of items) {
            const occurrences = countOccurrences(item, query);
            for (let occurrence = 0; occurrence < occurrences; occurrence += 1) {
              results.push({ page, pageOrdinal });
              pageOrdinal += 1;
            }
          }
        }
        if (generation !== searchGenerationRef.current) return;
        setSearchResults(results);
        setActiveSearchIndex(0);
        setSearching(false);
        if (results[0]) scrollToPage(results[0].page);
      })().catch((cause) => {
        if (generation !== searchGenerationRef.current) return;
        console.error('Unable to search compiled PDF', cause);
        setSearchResults([]);
        setSearching(false);
      });
    }, 180);
    return () => window.clearTimeout(timer);
  }, [documentProxy, getPageTextItems, pageCount, scrollToPage, searchQuery]);

  const moveSearch = React.useCallback(
    (delta: number) => {
      if (searchResults.length === 0) return;
      const next = (activeSearchIndex + delta + searchResults.length) % searchResults.length;
      setActiveSearchIndex(next);
      const result = searchResults[next];
      if (result) scrollToPage(result.page);
    },
    [activeSearchIndex, scrollToPage, searchResults],
  );

  const performReverseSync = React.useCallback(
    async (page: number, surface: HTMLElement, clientX: number, clientY: number, force = false) => {
      if (!workspace?.synctexAvailable) return;
      if (!force && !preferences.sourceFollowsPdf) return;
      const rect = surface.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) return;
      const pointsPerCssPixel = 1 / (displayScale * TEX_PT_TO_PDF_PT);
      const x = clamp(clientX - rect.left, 0, rect.width) * pointsPerCssPixel;
      const y = clamp(clientY - rect.top, 0, rect.height) * pointsPerCssPixel;
      const location = await projectPdf.reverseSync(wsName, page, x, y, { force });
      if (!location) return;
      const wsPath = WsFilePath.fromParts(wsName, location.projectPath).wsPath;
      if (force) latexSource.requestSelect(wsPath, location.line, location.column);
      else latexSource.requestReveal(wsPath, location.line, location.column);
      if (workspace?.activeSourcePath !== location.projectPath) {
        navigation.goWsFile(wsPath);
      }
      if (force) onRequestSource?.();
    },
    [
      displayScale,
      latexSource,
      navigation,
      onRequestSource,
      preferences.sourceFollowsPdf,
      projectPdf,
      workspace?.activeSourcePath,
      workspace?.synctexAvailable,
      wsName,
    ],
  );

  React.useEffect(() => {
    const container = scrollRoot;
    if (!container) return;
    const updateActivePage = () => {
      const containerRect = container.getBoundingClientRect();
      const centerY = containerRect.top + containerRect.height / 2;
      let bestPage = pageNumber;
      let bestDistance = Number.POSITIVE_INFINITY;
      let bestElement: HTMLElement | undefined;
      for (const [page, element] of pageElementsRef.current) {
        const rect = element.getBoundingClientRect();
        const distance = centerY < rect.top
          ? rect.top - centerY
          : centerY > rect.bottom
            ? centerY - rect.bottom
            : 0;
        if (distance < bestDistance) {
          bestDistance = distance;
          bestPage = page;
          bestElement = element;
        }
      }
      setPageNumber((current) => (current === bestPage ? current : bestPage));
      restoredScrollTopRef.current = container.scrollTop;
      if (viewStateLoaded) {
        projectPdf.updateViewState(wsName, { page: bestPage, scrollTop: container.scrollTop });
      }
      if (
        !bestElement ||
        !workspace?.synctexAvailable ||
        !preferences.sourceFollowsPdf ||
        !projectPdf.isScrollOwnedByUser(wsName, 'pdf')
      ) {
        return;
      }
      if (reverseScrollTimerRef.current) clearTimeout(reverseScrollTimerRef.current);
      reverseScrollTimerRef.current = setTimeout(() => {
        if (!projectPdf.isScrollOwnedByUser(wsName, 'pdf')) return;
        const active = pageElementsRef.current.get(bestPage);
        if (!active) return;
        const rect = active.getBoundingClientRect();
        const x = clamp(containerRect.left + containerRect.width / 2, rect.left + 1, rect.right - 1);
        const y = clamp(containerRect.top + containerRect.height / 2, rect.top + 1, rect.bottom - 1);
        void performReverseSync(bestPage, active, x, y);
      }, PDF_SCROLL_SYNC_DEBOUNCE_MS);
    };
    const claimPdfUserScroll = () => {
      projectPdf.claimScrollOwnership(wsName, 'pdf-user');
    };
    const claimPdfKeyboardScroll = (event: KeyboardEvent) => {
      if (SCROLL_OWNERSHIP_KEYS.has(event.key)) claimPdfUserScroll();
    };
    container.addEventListener('wheel', claimPdfUserScroll, { passive: true });
    container.addEventListener('touchstart', claimPdfUserScroll, { passive: true });
    container.addEventListener('pointerdown', claimPdfUserScroll, { passive: true });
    container.addEventListener('keydown', claimPdfKeyboardScroll);
    container.addEventListener('scroll', updateActivePage, { passive: true });
    updateActivePage();
    return () => {
      container.removeEventListener('wheel', claimPdfUserScroll);
      container.removeEventListener('touchstart', claimPdfUserScroll);
      container.removeEventListener('pointerdown', claimPdfUserScroll);
      container.removeEventListener('keydown', claimPdfKeyboardScroll);
      container.removeEventListener('scroll', updateActivePage);
      if (reverseScrollTimerRef.current) clearTimeout(reverseScrollTimerRef.current);
    };
  }, [pageNumber, performReverseSync, preferences.sourceFollowsPdf, projectPdf, scrollRoot, viewStateLoaded, workspace?.synctexAvailable, wsName]);

  const capturePdfSelection = React.useCallback(
    (page: number, layer: HTMLElement) => {
      const selection = window.getSelection();
      if (!selection || selection.rangeCount === 0) return;
      const range = selection.getRangeAt(0);
      if (!layer.contains(range.commonAncestorContainer)) return;
      const value = selection.toString().trim().slice(0, MAX_PDF_SELECTION);
      setSelectedPdfText(value);
      projectAi.setPdfContext(wsName, {
        page,
        ...(value ? { selection: value } : {}),
      });
    },
    [projectAi, wsName],
  );

  const locationLabel = workspace?.sourceLocation
    ? `${workspace.sourceLocation.projectPath}:${workspace.sourceLocation.line}`
    : undefined;
  const activeSearch = searchResults[activeSearchIndex];
  const syncScrollEnabled = Boolean(
    workspace?.synctexAvailable && preferences.pdfFollowsSource && preferences.sourceFollowsPdf,
  );
  const synctexDisabledReason =
    workspace?.synctexUnavailableReason ??
    workspace?.synctexError ??
    t.app.pdfPreview.synctexUnavailable;

  const logStatus =
    relevantCompile.status === 'initializing' || relevantCompile.status === 'compiling'
      ? {
          state: 'compiling' as const,
          label: t.app.pdfPreview.logCompiling,
          dotClass: 'bg-amber-500',
        }
      : relevantCompile.status === 'success'
        ? {
            state: 'success' as const,
            label: t.app.pdfPreview.logSuccess,
            dotClass: 'bg-emerald-500',
          }
        : relevantCompile.status === 'error'
          ? {
              state: 'failure' as const,
              label: t.app.pdfPreview.logFailure,
              dotClass: 'bg-destructive',
            }
          : {
              state: 'neutral' as const,
              label: t.app.pdfPreview.logNoCompile,
              dotClass: 'bg-muted-foreground/45',
            };

  const rootFile = workspace?.rootFile ?? relevantCompile.rootFile ?? 'main.tex';
  const compileLog =
    relevantCompile.result?.log ??
    (relevantCompile.error ? `Compilation failed before a TeX log was returned.\n\n${relevantCompile.error}` : '');

  const rootClass = fullPreview
    ? 'fixed inset-0 z-50 flex min-h-0 flex-col overflow-hidden bg-background'
    : 'relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-l bg-muted/20';

  return (
    <aside
      className={rootClass}
      data-fit-width={fitWidth ? 'true' : 'false'}
      data-output-tab={activeOutputTab}
      data-page-number={pageNumber}
      data-pdf-origin={workspace?.origin ?? 'none'}
      data-pdf-generation={workspace?.generation ?? 0}
      data-testid="interactive-pdf-preview"
      data-zoom={zoom.toFixed(2)}
      tabIndex={-1}
    >
      <div
        className="flex h-9 shrink-0 items-end gap-1 border-b bg-background px-2"
        data-testid="interactive-output-tabs"
      >
        <button
          className={
            activeOutputTab === 'pdf'
              ? 'h-9 border-b-2 border-primary px-3 font-medium text-sm'
              : 'h-9 px-3 text-muted-foreground text-sm hover:text-foreground'
          }
          type="button"
          onClick={() => setActiveOutputTab('pdf')}
        >
          PDF
        </button>
        <button
          className={
            activeOutputTab === 'log'
              ? 'flex h-9 items-center gap-1.5 border-b-2 border-primary px-3 font-medium text-sm'
              : 'flex h-9 items-center gap-1.5 px-3 text-muted-foreground text-sm hover:text-foreground'
          }
          type="button"
          onClick={() => setActiveOutputTab('log')}
        >
          <span
            aria-hidden="true"
            className={`size-2 shrink-0 rounded-full ${logStatus.dotClass}`}
            data-compile-status={logStatus.state}
            data-testid="interactive-log-status-dot"
          />
          <span>Log</span>
          <span className="sr-only"> — {logStatus.label}</span>
        </button>
        <span className="ml-auto pb-2 text-muted-foreground text-xs">
          {showingLivePdf
            ? livePdfStatus === 'rendering'
              ? 'Live PDF · updating…'
              : liveInstantStatus === 'rendering'
                ? 'Live PDF · instant…'
                : visibleInstantPatch
                  ? 'Live PDF · instant'
                  : 'Live PDF · BusyTeX LuaLaTeX'
            : relevantCompile.status === 'initializing' || relevantCompile.status === 'compiling'
            ? relevantCompile.progress?.message ?? 'Compiling…'
            : relevantCompile.status === 'error'
              ? 'Compile failed'
              : relevantCompile.status === 'success'
                ? `${relevantCompile.result?.compiler ?? ''} · ${relevantCompile.result?.backend ?? ''}`
                : ''}
        </span>
        {onCloseMobile && (
          <Button
            aria-label="Close PDF and return to editor"
            className="mb-1 ml-1 lg:hidden"
            size="icon"
            title="Return to editor"
            variant="ghost"
            onClick={onCloseMobile}
          >
            <X className="size-4" />
          </Button>
        )}
      </div>

      {activeOutputTab === 'pdf' ? (
        <>
      <div className="flex min-h-11 shrink-0 flex-wrap items-center gap-1 border-b bg-background px-2 py-1" data-testid="interactive-pdf-toolbar">
        <span className="mr-auto truncate text-sm font-medium">{t.app.pdfPreview.title}</span>
        <Button
          aria-label="Download compiled PDF"
          disabled={!pdf?.byteLength}
          size="icon"
          title="Download PDF"
          variant="ghost"
          onClick={() => {
            if (!pdf) return;
            void (async () => {
              const exportBytes = projectEntitlements.can('export.pdf.clean')
                ? pdf
                : await createFreeWatermarkedPdf(pdf, documentProxy);
              downloadBytes(
                exportBytes,
                compiledOutputName(rootFile, 'pdf'),
                'application/pdf',
              );
            })().catch((reason) => console.error('[Learnitive] PDF export failed.', reason));
          }}
        >
          <Download className="size-4" />
        </Button>
        <Button
          aria-label="Sync editor and PDF scrolling"
          data-synctex-state={workspace?.synctexAvailable ? 'ready' : 'unavailable'}
          data-testid="synctex-scroll-link"
          title={workspace?.synctexAvailable ? 'Sync editor and PDF scrolling' : synctexDisabledReason}
          disabled={!workspace?.synctexAvailable}
          size="icon"
          variant={syncScrollEnabled ? 'secondary' : 'ghost'}
          onClick={() => {
            const next = !syncScrollEnabled;
            projectPdf.setPreferences({ pdfFollowsSource: next, sourceFollowsPdf: next });
          }}
        >
          <Link2 className="size-4" />
        </Button>
        <Button
          aria-label="Search PDF"
          size="icon"
          variant={searchOpen ? 'secondary' : 'ghost'}
          onClick={() => setSearchOpen((value) => !value)}
        >
          <SearchIcon className="size-4" />
        </Button>
        <Button
          aria-label={t.app.pdfPreview.previousPage}
          disabled={pageNumber <= 1}
          size="icon"
          variant="ghost"
          onClick={() => scrollToPage(Math.max(1, pageNumber - 1))}
        >
          <ChevronLeft className="size-4" />
        </Button>
        <span className="min-w-16 text-center text-xs tabular-nums">
          {pageCount > 0 ? `${pageNumber} / ${pageCount}` : '—'}
        </span>
        <Button
          aria-label={t.app.pdfPreview.nextPage}
          disabled={pageCount === 0 || pageNumber >= pageCount}
          size="icon"
          variant="ghost"
          onClick={() => scrollToPage(Math.min(pageCount, pageNumber + 1))}
        >
          <ChevronRight className="size-4" />
        </Button>
        <Button
          aria-label={t.app.pdfPreview.zoomOut}
          size="icon"
          variant="ghost"
          onClick={() => {
            setFitWidth(false);
            setZoom((value) => Math.max(MIN_ZOOM, value - ZOOM_STEP));
          }}
        >
          <ZoomOut className="size-4" />
        </Button>
        <Button
          aria-label={t.app.pdfPreview.fitWidth}
          size="sm"
          variant={fitWidth ? 'secondary' : 'ghost'}
          onClick={() => setFitWidth((value) => !value)}
        >
          {t.app.pdfPreview.fit}
        </Button>
        <Button
          aria-label={t.app.pdfPreview.zoomIn}
          size="icon"
          variant="ghost"
          onClick={() => {
            setFitWidth(false);
            setZoom((value) => Math.min(MAX_ZOOM, value + ZOOM_STEP));
          }}
        >
          <ZoomIn className="size-4" />
        </Button>
        <Button
          aria-label={t.app.pdfPreview.showFileMap}
          data-synctex-state={workspace?.synctexAvailable ? 'ready' : 'unavailable'}
          data-testid="synctex-file-map-toggle"
          title={workspace?.synctexAvailable ? t.app.pdfPreview.showFileMap : synctexDisabledReason}
          disabled={!workspace?.synctexAvailable}
          size="icon"
          variant={showFileMap ? 'secondary' : 'ghost'}
          onClick={() => setShowFileMap((value) => !value)}
        >
          <MapPinned className="size-4" />
        </Button>
        <Button
          aria-label={fullPreview ? t.app.pdfPreview.exitFull : t.app.pdfPreview.full}
          size="icon"
          variant="ghost"
          onClick={() => setFullPreview((value) => !value)}
        >
          {fullPreview ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
        </Button>
      </div>

      {searchOpen && (
        <div className="flex shrink-0 items-center gap-1 border-b bg-background px-2 py-1" data-testid="pdf-search-toolbar">
          <SearchIcon className="size-4 text-muted-foreground" />
          <input
            ref={searchInputRef}
            aria-label="Search compiled PDF"
            className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
            placeholder="Search PDF text"
            value={searchQuery}
            onChange={(event) => setSearchQuery(event.currentTarget.value)}
            onKeyDown={(event) => {
              if (event.key === 'Enter') {
                event.preventDefault();
                moveSearch(event.shiftKey ? -1 : 1);
              }
              if (event.key === 'Escape') setSearchOpen(false);
            }}
          />
          <span className="min-w-20 text-center text-xs tabular-nums text-muted-foreground">
            {searching
              ? 'Searching…'
              : searchResults.length > 0
                ? `${activeSearchIndex + 1} / ${searchResults.length}`
                : searchQuery.trim()
                  ? '0 results'
                  : '—'}
          </span>
          <Button aria-label="Previous PDF search result" disabled={searchResults.length === 0} size="icon" variant="ghost" onClick={() => moveSearch(-1)}>
            <ArrowUp className="size-4" />
          </Button>
          <Button aria-label="Next PDF search result" disabled={searchResults.length === 0} size="icon" variant="ghost" onClick={() => moveSearch(1)}>
            <ArrowDown className="size-4" />
          </Button>
          <Button aria-label="Close PDF search" size="icon" variant="ghost" onClick={() => setSearchOpen(false)}>
            <X className="size-4" />
          </Button>
        </div>
      )}

      <div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b bg-background px-3 py-1 text-xs text-muted-foreground">
        <span
          data-synctex-state={workspace?.synctexAvailable ? 'ready' : 'unavailable'}
          data-testid="synctex-status"
          title={workspace?.synctexAvailable ? t.app.pdfPreview.synctexReady : synctexDisabledReason}
        >
          {workspace?.synctexAvailable
            ? syncScrollEnabled
              ? 'SyncTeX ready · scroll sync on'
              : t.app.pdfPreview.synctexReady
            : workspace?.synctexError
              ? `${t.app.pdfPreview.synctexFailed}: ${workspace.synctexError}`
              : workspace?.synctexUnavailableReason ?? t.app.pdfPreview.synctexUnavailable}
        </span>
        {workspace?.activeSourcePath && (
          <span className="truncate">
            {workspace.activeSourcePath} · {t.app.pdfPreview.pages}: {formatPages(workspace.activeSourcePages)}
          </span>
        )}
        {selectedPdfText && (
          <span className="truncate rounded bg-muted px-1.5 py-0.5" title={selectedPdfText}>
            {selectedPdfText.length} chars selected
          </span>
        )}
        {locationLabel && (
          <span className="ml-auto flex items-center gap-1 font-mono">
            <LocateFixed className="size-3" />
            {locationLabel}
          </span>
        )}
      </div>

      <div
        ref={setScrollRoot}
        className="relative min-h-0 flex-1 overflow-auto overscroll-contain p-4"
        data-testid="pdf-continuous-scroll"
        // biome-ignore lint/a11y/noNoninteractiveTabindex: The scrollable PDF viewport must be keyboard-focusable so keyboard scrolling can claim PDF sync ownership.
        tabIndex={0}
      >
        {!pdf && (
          <div className="flex min-h-48 items-center justify-center text-center text-sm text-muted-foreground">
            {t.app.pdfPreview.compileFirst}
          </div>
        )}
        {loading && (
          <div className="sticky top-4 z-40 text-center text-sm text-muted-foreground">
            {t.app.pdfPreview.loading}
          </div>
        )}
        {error && (
          <div className="m-4 rounded border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive" role="alert">
            {error}
          </div>
        )}
        {documentProxy && !error && (
          <div className="flex min-h-full flex-col items-center" style={{ gap: PAGE_GAP }}>
            {Array.from({ length: pageCount }, (_, index) => index + 1).map((page) => (
              <PdfPageSurface
                key={page}
                document={documentProxy}
                pageNumber={page}
                scrollRoot={scrollRoot}
                displayScale={displayScale}
                estimatedSize={firstPageSize}
                mappings={currentFileMappings}
                highlight={highlight}
                showFileMap={showFileMap}
                searchQuery={searchQuery}
                activeSearch={activeSearch}
                onTextItems={handleTextItems}
                onSelection={capturePdfSelection}
                onReverseSync={(targetPage, surface, x, y) => void performReverseSync(targetPage, surface, x, y, true)}
                registerPage={registerPage}
                instantPatch={visibleInstantPatch}
                onInstantPatchAccept={acceptInstantPatch}
                onInstantPatchReject={rejectInstantPatch}
              />
            ))}
          </div>
        )}
      </div>

      <div className="flex shrink-0 flex-wrap items-center gap-4 border-t bg-background px-3 py-2 text-xs" data-testid="pdf-scroll-sync-controls">
        {workspace?.synctexAvailable && (
          <>
            <label className="flex items-center gap-2">
              <input
                checked={preferences.pdfFollowsSource}
                type="checkbox"
                onChange={(event) => projectPdf.setPreferences({ pdfFollowsSource: event.currentTarget.checked })}
              />
              PDF follows editor scroll
            </label>
            <label className="flex items-center gap-2">
              <input
                checked={preferences.sourceFollowsPdf}
                type="checkbox"
                onChange={(event) => projectPdf.setPreferences({ sourceFollowsPdf: event.currentTarget.checked })}
              />
              Editor follows PDF scroll
            </label>
          </>
        )}
        {activeSearch && (
          <span className="ml-auto text-muted-foreground">
            Result on page {activeSearch.page}
          </span>
        )}
      </div>
        </>
      ) : (
        <div className="flex min-h-0 flex-1 flex-col bg-background" data-testid="interactive-compile-log">
          <div className="flex min-h-11 shrink-0 items-center gap-2 border-b px-2 py-1">
            <div className="min-w-0 flex-1">
              <div className="truncate font-medium text-sm">Compiler log</div>
              <div className="truncate text-muted-foreground text-xs">
                {relevantCompile.result
                  ? `${relevantCompile.result.compiler} · ${relevantCompile.result.backend} · ${(relevantCompile.result.durationMs / 1000).toFixed(2)}s · exit ${relevantCompile.result.exitCode}`
                  : relevantCompile.status === 'error'
                    ? 'Compilation failed before a complete compiler result was returned.'
                    : 'Compile the document to generate a log.'}
              </div>
            </div>
            <Button
              aria-label="Copy compiler log"
              disabled={!compileLog}
              size="icon"
              title="Copy log"
              variant="ghost"
              onClick={() => void navigator.clipboard?.writeText(compileLog)}
            >
              <Copy className="size-4" />
            </Button>
            <Button
              aria-label="Download compiler log"
              disabled={!compileLog}
              size="icon"
              title="Download log"
              variant="ghost"
              onClick={() => downloadText(compileLog, compiledOutputName(rootFile, 'log'))}
            >
              <Download className="size-4" />
            </Button>
          </div>
          {relevantCompile.error && (
            <div className="border-b border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm" role="alert">
              {relevantCompile.error}
            </div>
          )}
          {relevantCompile.result?.diagnostics?.length ? (
            <CompilerDiagnosticsList
              wsName={wsName}
              diagnostics={relevantCompile.result.diagnostics}
              onOpenSource={onRequestSource}
            />
          ) : null}
          {relevantCompile.result?.performance ? (
            <details
              className="border-b bg-muted/10"
              data-testid="interactive-compile-performance"
            >
              <summary className="cursor-pointer px-3 py-2 text-xs font-medium">
                {t.app.compile.performance} · {relevantCompile.result.performance.runtimeWasWarm
                  ? t.app.compile.runtimeWarm
                  : t.app.compile.runtimeCold}
              </summary>
              <div className="grid grid-cols-2 gap-x-4 gap-y-1 border-t px-3 py-2 text-xs sm:grid-cols-4">
                <span className="text-muted-foreground">{t.app.compile.runtimeInit}</span>
                <span>{relevantCompile.result.performance.runtimeInitMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.packageFetch}</span>
                <span>{relevantCompile.result.performance.packageFetchMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.bibliographyTiming}</span>
                <span>{relevantCompile.result.performance.bibliographyMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.engineCompile}</span>
                <span>{relevantCompile.result.performance.engineCompileMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.pdfReady}</span>
                <span>{relevantCompile.result.performance.pdfReadyMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.totalTiming}</span>
                <span>{relevantCompile.result.performance.totalMs.toFixed(0)} ms</span>
                <span className="text-muted-foreground">{t.app.compile.packagesFetched}</span>
                <span>{relevantCompile.result.performance.packagesFetched}</span>
              </div>
            </details>
          ) : null}
          {relevantCompile.result?.artifacts?.length ? (
            <details className="border-b bg-muted/10" data-testid="interactive-generated-artifacts">
              <summary className="cursor-pointer px-3 py-2 text-xs font-medium">
                {t.app.compile.generatedFiles} · {relevantCompile.result.artifacts.length}
              </summary>
              <div className="max-h-72 overflow-auto border-t">
                {relevantCompile.result.artifacts.map((artifact) => (
                  <details key={artifact.path} className="border-b last:border-b-0">
                    <summary className="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs">
                      <span className="min-w-0 flex-1 truncate font-mono">{artifact.path}</span>
                      <span className="text-muted-foreground">{artifact.kind}{artifact.size !== undefined ? ` · ${artifact.size.toLocaleString()} B` : ''}</span>
                    </summary>
                    <div className="border-t bg-background p-2">
                      {artifact.text && artifact.textContent !== undefined ? (
                        <pre className="max-h-52 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/20 p-2 font-mono text-[11px]">{artifact.textContent || '(empty generated file)'}</pre>
                      ) : (
                        <p className="text-xs text-muted-foreground">{t.app.compile.binaryArtifact}</p>
                      )}
                      {artifact.data && (
                        <Button
                          className="mt-2"
                          size="sm"
                          variant="outline"
                          onClick={() => downloadBytes(artifact.data!, artifact.path, artifact.mimeType ?? 'application/octet-stream')}
                        >
                          <Download className="mr-1 size-3.5" />{t.app.compile.downloadArtifact}
                        </Button>
                      )}
                    </div>
                  </details>
                ))}
              </div>
            </details>
          ) : null}
          <pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-xs leading-relaxed">
            {compileLog || 'No compiler log yet.'}
          </pre>
        </div>
      )}
    </aside>
  );
}
