import { h } from '../core/dom';
import { renderPage, type PDFDocumentProxy, type PDFPageProxy } from '../core/pdf';
import { displaySize, effectiveRotation, type PageState } from '../core/model';
import { store } from '../core/store';
import { debounce } from '../core/util';

interface PageEntry {
  state: PageState;
  wrap: HTMLElement;
  canvas: HTMLCanvasElement;
  layer: HTMLElement;
  editLayer: HTMLElement;
  renderedScale: number;
  rendering: boolean;
}

export class Viewer {
  scale = 1;
  private entries = new Map<string, PageEntry>();
  private io: IntersectionObserver;
  private pageCache = new Map<number, PDFPageProxy>();
  onRelayout: ((pageId: string) => void) | null = null;

  constructor(
    private scrollEl: HTMLElement,
    private pdfDoc: PDFDocumentProxy | null,
  ) {
    this.io = new IntersectionObserver(
      (ents) => {
        for (const e of ents) {
          const id = (e.target as HTMLElement).dataset.page!;
          if (e.isIntersecting) this.renderEntry(id);
        }
      },
      { root: scrollEl, rootMargin: '600px 0px' },
    );
    scrollEl.addEventListener('scroll', debounce(() => this.updateCurrentPage(), 120));
  }

  setPdfDoc(doc: PDFDocumentProxy | null) {
    this.pdfDoc = doc;
    this.pageCache.clear();
  }

  build() {
    this.io.disconnect();
    this.entries.clear();
    this.scrollEl.replaceChildren();
    const col = h('div.page-column');
    const live = store.doc!.pages.filter((p) => !p.deleted);
    live.forEach((ps, i) => {
      const entry = this.makeEntry(ps, i + 1);
      this.entries.set(ps.id, entry);
      col.appendChild(entry.wrap);
      this.io.observe(entry.wrap);
    });
    this.scrollEl.appendChild(col);
    this.updateCurrentPage();
  }

  private makeEntry(ps: PageState, num: number): PageEntry {
    const disp = displaySize(ps);
    const canvas = h('canvas.page-canvas') as HTMLCanvasElement;
    const layer = h('div.anno-layer');
    // a sibling layer above the annotations used by the native-content tools
    // (redact / edit-text / edit-image); inert unless a content tool activates it
    const editLayer = h('div.edit-layer');
    const wrap = h(
      'div.page-wrap',
      { dataset: { page: ps.id }, style: { width: `${disp.w * this.scale}px`, height: `${disp.h * this.scale}px` } },
      ps.blank ? h('div.page-blank') : canvas,
      layer,
      editLayer,
      h('div.page-badge', String(num)),
    );
    this.sizeLayer(ps, layer);
    this.sizeLayer(ps, editLayer);
    return { state: ps, wrap, canvas, layer, editLayer, renderedScale: 0, rendering: false };
  }

  private sizeLayer(ps: PageState, layer: HTMLElement) {
    const rot = effectiveRotation(ps);
    const disp = displaySize(ps);
    const uw = ps.width * this.scale;
    const uh = ps.height * this.scale;
    layer.style.width = `${uw}px`;
    layer.style.height = `${uh}px`;
    // center the unrotated layer over the (possibly rotated) wrap, then rotate it
    layer.style.left = `${(disp.w * this.scale - uw) / 2}px`;
    layer.style.top = `${(disp.h * this.scale - uh) / 2}px`;
    layer.style.transform = rot ? `rotate(${rot}deg)` : '';
  }

  private async getPage(srcIndex: number): Promise<PDFPageProxy | null> {
    if (!this.pdfDoc) return null;
    if (this.pageCache.has(srcIndex)) return this.pageCache.get(srcIndex)!;
    const page = await this.pdfDoc.getPage(srcIndex + 1);
    this.pageCache.set(srcIndex, page);
    return page;
  }

  private async renderEntry(id: string) {
    const entry = this.entries.get(id);
    if (!entry || entry.state.blank) return;
    if (entry.rendering || entry.renderedScale === this.scale) return;
    entry.rendering = true;
    try {
      const page = await this.getPage(entry.state.srcIndex);
      if (!page) return;
      await renderPage(page, this.scale, entry.canvas, { rotation: effectiveRotation(entry.state) });
      entry.renderedScale = this.scale;
      entry.canvas.classList.add('ready');
    } catch {
      /* render can be cancelled on rapid zoom; ignore */
    } finally {
      entry.rendering = false;
    }
  }

  setScale(scale: number) {
    this.scale = scale;
    for (const entry of this.entries.values()) {
      const disp = displaySize(entry.state);
      entry.wrap.style.width = `${disp.w * scale}px`;
      entry.wrap.style.height = `${disp.h * scale}px`;
      this.sizeLayer(entry.state, entry.layer);
      this.sizeLayer(entry.state, entry.editLayer);
      entry.renderedScale = 0; // force re-render at new scale when visible
      entry.canvas.classList.remove('ready');
      this.onRelayout?.(entry.state.id);
    }
    // re-render currently visible pages
    this.renderVisible();
  }

  private renderVisible() {
    const rect = this.scrollEl.getBoundingClientRect();
    for (const [id, entry] of this.entries) {
      const r = entry.wrap.getBoundingClientRect();
      if (r.bottom > rect.top - 600 && r.top < rect.bottom + 600) this.renderEntry(id);
    }
  }

  /** Fit-to-width scale for the widest live page. */
  fitWidth(): number {
    const avail = this.scrollEl.clientWidth - 64;
    let maxW = 1;
    for (const entry of this.entries.values()) maxW = Math.max(maxW, displaySize(entry.state).w);
    return Math.max(0.1, avail / maxW);
  }

  layerFor(pageId: string): HTMLElement | null {
    return this.entries.get(pageId)?.layer ?? null;
  }
  editLayerFor(pageId: string): HTMLElement | null {
    return this.entries.get(pageId)?.editLayer ?? null;
  }
  wrapFor(pageId: string): HTMLElement | null {
    return this.entries.get(pageId)?.wrap ?? null;
  }

  /**
   * Re-render page canvases from a freshly-swapped pdf.js document WITHOUT
   * rebuilding the DOM — preserves scroll position, overlays and edit layers.
   * Used after a native (mupdf) content edit replaces the working bytes.
   * Page structure (count/size) is unchanged by content edits, so the existing
   * page-wraps stay valid.
   */
  reloadContent(changedSrcIndexes?: number[]) {
    // setPdfDoc() already dropped the stale page proxies. For a targeted reload
    // (a content edit that touched specific source pages) only those canvases
    // need re-rendering — untouched pages keep their current bitmap, so we don't
    // re-render every visible page on each edit.
    const only = changedSrcIndexes?.length ? new Set(changedSrcIndexes) : null;
    if (!only) this.pageCache.clear();
    for (const entry of this.entries.values()) {
      if (only && !only.has(entry.state.srcIndex)) continue;
      entry.renderedScale = 0;
      entry.canvas.classList.remove('ready');
    }
    this.renderVisible();
  }
  entriesInOrder(): PageEntry[] {
    return store.doc!.pages.filter((p) => !p.deleted).map((p) => this.entries.get(p.id)!).filter(Boolean);
  }

  scrollToPage(pageId: string) {
    this.entries.get(pageId)?.wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }

  private updateCurrentPage() {
    const mid = this.scrollEl.scrollTop + this.scrollEl.clientHeight / 2;
    let best = 0;
    let bestDist = Infinity;
    const live = store.doc!.pages.filter((p) => !p.deleted);
    live.forEach((ps, i) => {
      const wrap = this.entries.get(ps.id)?.wrap;
      if (!wrap) return;
      const center = wrap.offsetTop + wrap.offsetHeight / 2;
      const d = Math.abs(center - mid);
      if (d < bestDist) { bestDist = d; best = i; }
    });
    store.setCurrentPage(best);
  }

  destroy() {
    this.io.disconnect();
  }
}
