import { h, clear } from '../core/dom';
import { store } from '../core/store';
import { loadPdf, isPasswordException, type PDFDocumentProxy } from '../core/pdf';
import { newDoc, type PageState, type YeetDoc } from '../core/model';
import { exportDoc } from '../core/export';
import { readBytes, download, uid, isProbablyPdf, debounce, clamp, bufferOf } from '../core/util';
import { decryptPdf } from '../core/qpdf';
import { autosave, getRecovery, clearRecovery } from '../core/storage';
import { toast, celebrateExport } from '../ui/toast';
import { modal } from '../ui/modal';
import { dropzone, pickFiles } from '../ui/dropzone';
import { field, textInput } from '../ui/controls';
import { icon } from '../ui/icons';
import { goHome } from '../core/nav';
import { Viewer } from './viewer';
import { Overlay } from './overlay';
import { ContentEditor } from './content-edit';
import { PageRail } from './page-rail';
import { Properties } from './properties';
import { buildToolbar } from './toolbar';
import { buildMenubar } from './menubar';
import { TOOLS } from './tools';
import type { ToolContext } from '../features/registry';

let pdfDoc: PDFDocumentProxy | null = null;
let viewer: Viewer;
let overlay: Overlay;
let contentEditor: ContentEditor;
let rail: PageRail;
// Not on `store` (store.clearListeners() won't touch it) — tracked separately
// so wireKeyboard() can remove the previous mount's handler before attaching
// its own, instead of stacking a new global keydown listener on every mount.
let keydownHandler: ((e: KeyboardEvent) => void) | null = null;

// Native (mupdf) content edits mutate the whole PDF byte stream, which the
// annotation snapshot undo (store) deliberately excludes (multi-MB per step).
// So byte-level edits keep their own history here. `lastActionWasNative` biases
// which stack Cmd+Z consults first; each stack is still a fallback for the other.
let nativeUndo: Uint8Array[] = [];
let nativeRedo: Uint8Array[] = [];
let lastActionWasNative = false;

export async function mountEditor(root: HTMLElement, file?: File | null) {
  clear(root);
  // The editor is always a full singleton replacement of the previous mount
  // (there's never two alive at once) — drop every listener a prior mount's
  // Viewer/Overlay/ContentEditor/PageRail/Properties/toolbar/menubar left on
  // the store before any of those get rebuilt below, or events fire once per
  // accumulated mount instead of once (e.g. a toast per past document open).
  store.clearListeners();
  // Same reasoning for state, not just listeners: "Close document" never
  // clears store.doc, so without this a document closed in a previous mount
  // stays "open" as far as the store is concerned — the empty-state card
  // paints, but the status bar / filename pill / pages-rail visibility (all
  // gated on store.doc) still reflect the stale document. If `file` is set
  // below, openBytes()/finishLoad() immediately overwrites this via setDoc().
  store.reset();
  resetNativeHistory();

  const railEl = h('div.rail');
  const viewerScroll = h('div.viewer-scroll');
  const propsEl = h('div.properties-panel');
  const statusEl = h('div.statusbar');

  const toolCtx: ToolContext = {
    currentBytes: () => (store.doc ? store.doc.bytes : null),
    currentName: () => store.doc?.name ?? 'document.pdf',
    loadIntoEditor: (bytes, name) => loadDocument(bytes, name),
  };

  const menubar = buildMenubar({
    onOpen: openFile,
    onExport: doExport,
    onPrint: doPrint,
    onHome: goHome,
    onMeta: openMeta,
    onZoom: (dir) => zoomStep(dir),
    onFit: fitWidth,
    onActualSize: () => setZoom(1),
    onInsertBlank: insertBlankAtCurrent,
    toolCtx,
  });

  const toolbar = buildToolbar({
    onZoom: setZoom,
    onFit: fitWidth,
    onUndo: unifiedUndo,
    onRedo: unifiedRedo,
    canUndo: unifiedCanUndo,
    canRedo: unifiedCanRedo,
  });

  // The tool list lives inside the workspace as the left rail (desktop) / top
  // strip (mobile) — see .toolbar in editor.css.
  const workspace = h('div.workspace tool-select', toolbar, railEl, viewerScroll, propsEl);
  root.appendChild(h('div.editor-root', menubar, workspace, statusEl));

  viewer = new Viewer(viewerScroll, null);
  overlay = new Overlay(viewer);
  contentEditor = new ContentEditor(viewer, applyEngineEdit);
  rail = new PageRail(railEl, null, refreshPages, (id) => viewer.scrollToPage(id));
  new Properties(propsEl);
  buildStatus(statusEl);

  // The pages rail and properties panel have nothing to show until a
  // document is open — hiding them (instead of leaving two reserved-but-empty
  // columns) lets the canvas/empty-state expand to use that space.
  const syncEmptyLayout = () => workspace.classList.toggle('no-doc', !store.doc);
  store.on('doc', syncEmptyLayout);
  syncEmptyLayout();

  wireKeyboard();
  wireAutosave();
  // a real annotation checkpoint means the newest change is an annotation, so
  // Cmd+Z should prefer the annotation stack until the next native edit
  store.onCheckpoint = () => { lastActionWasNative = false; };

  // populate the Tools menu (features self-register on import)
  import('../features').then(() => menubar.dispatchEvent(new Event('features-ready')));

  if (file) {
    await openBytes(await readBytes(file), file.name);
  } else {
    await showEmptyState(viewerScroll);
  }
}

async function showEmptyState(container: HTMLElement) {
  const rec = await getRecovery();
  const dz = dropzone({
    accept: 'application/pdf,.pdf',
    label: 'Drop a PDF here or click to browse',
    hint: 'It opens right here — nothing uploads.',
    onFiles: (files) => openBytes0(files[0]),
  });
  const empty = h(
    'div.editor-empty',
    h('div.editor-empty-card glass',
      h('div.editor-empty-icon', icon('file', 34)),
      h('h2', 'Open a PDF to start'),
      h('p', 'Everything happens on your device. Nothing is ever uploaded.'),
      dz,
      rec ? h('button.btn btn-ghost recovery-btn', { onclick: () => recover(rec) }, icon('undo', 16), `Restore “${rec.name}” from your last session`) : null,
    ),
  );
  container.replaceChildren(empty);
}

async function openBytes0(file: File) {
  await openBytes(await readBytes(file), file.name);
}

function recover(rec: any) {
  const doc: YeetDoc = newDoc(rec.name, rec.bytes);
  doc.pages = rec.pages;
  doc.annos = rec.annos;
  doc.meta = rec.meta;
  finishLoad(doc);
}

async function openFile() {
  const files = await pickFiles('application/pdf,.pdf', false);
  if (files.length) await openBytes(await readBytes(files[0]), files[0].name);
}

/** Public loader used by feature tools that replace the working document. */
export async function loadDocument(bytes: Uint8Array, name: string) {
  await openBytes(bytes, name);
}

async function openBytes(bytes: Uint8Array, name: string) {
  if (!isProbablyPdf(bytes)) {
    toast('That doesn’t look like a PDF file.', { type: 'error' });
    return;
  }
  const t = toast('Opening…', { type: 'progress' });
  try {
    let working = bytes;
    let password: string | undefined;
    let doc: PDFDocumentProxy;
    // Fast path: let pdf.js parse directly. Only spin up qpdf (heavy WASM) if the
    // file is actually password-protected.
    try {
      doc = await loadPdf(working);
    } catch (err) {
      if (!isPasswordException(err)) throw err;
      t.close();
      const pw = await promptPassword(name);
      if (pw == null) return;
      const res = await decryptPdf(bytes, pw);
      if (!res.ok || !res.output) { toast('Wrong password, or the file couldn’t be unlocked.', { type: 'error' }); return; }
      working = res.output;
      password = pw;
      doc = await loadPdf(working);
    }
    const pages: PageState[] = [];
    for (let i = 0; i < doc.numPages; i++) {
      const page = await doc.getPage(i + 1);
      const v = page.view; // [x0,y0,x1,y1] unrotated media box
      pages.push({ id: uid('p_'), srcIndex: i, width: v[2] - v[0], height: v[3] - v[1], baseRotation: page.rotate % 360, rotation: 0 });
    }
    const yeet = newDoc(name, working);
    yeet.pages = pages;
    yeet.password = password;
    t.close();
    finishLoad(yeet, doc);
  } catch (err) {
    t.close();
    if (isPasswordException(err)) toast('This PDF is password-protected.', { type: 'error' });
    else toast('Could not open this PDF. It may be damaged — try the Repair tool.', { type: 'error' });
    console.error(err);
  }
}

async function finishLoad(yeet: YeetDoc, existingDoc?: PDFDocumentProxy) {
  if (existingDoc) pdfDoc = existingDoc;
  else pdfDoc = await loadPdf(yeet.bytes);
  resetNativeHistory();
  store.setDoc(yeet);
  viewer.setPdfDoc(pdfDoc);
  viewer.build();
  overlay.attach();
  contentEditor.attach();
  rail.setPdfDoc(pdfDoc);
  rail.render();
  fitWidth();
  autosave(yeet);
}

/** Rebuild viewer + rail after page-structure changes. */
function refreshPages() {
  viewer.build();
  overlay.attach();
  contentEditor.attach();
}

/**
 * Swap in new PDF bytes produced by a native (mupdf) content edit — true
 * redaction, in-place text edit, image transform. Page structure is unchanged
 * by content edits, so we re-render canvases in place (keeps scroll/overlays)
 * rather than rebuilding the viewer DOM.
 */
export async function applyEngineEdit(newBytes: Uint8Array, changedPages?: number[]) {
  if (!store.doc) return;
  nativeUndo.push(store.doc.bytes);
  if (nativeUndo.length > 30) nativeUndo.shift();
  nativeRedo = [];
  lastActionWasNative = true;
  await swapBytes(newBytes, changedPages);
}

// Native content edits replace the whole byte stream but don't fire the store
// events wireAutosave() listens on, so swapBytes persists them itself — debounced
// so a burst of edits doesn't write the whole (multi-MB) document to IndexedDB
// on every keystroke-commit.
const scheduleContentAutosave = debounce(() => { if (store.doc) autosave(store.doc); }, 1500);

/**
 * Swap in new PDF bytes from a native (mupdf) content edit. `changedPages` are
 * the source indexes the edit actually touched — when known, we re-render only
 * those page canvases + thumbnails (keeping the rest), which avoids re-rendering
 * every page on a large document. Undo/redo passes no scope → full refresh.
 */
async function swapBytes(bytes: Uint8Array, changedPages?: number[]) {
  if (!store.doc) return;
  store.doc.bytes = bytes;
  pdfDoc = await loadPdf(bytes); // pdf.ts copies the buffer, so `bytes` stays valid
  viewer.setPdfDoc(pdfDoc);
  if (changedPages?.length) {
    viewer.reloadContent(changedPages);
    rail.setPdfDoc(pdfDoc, /* keepCache */ true);
    for (const s of new Set(changedPages)) rail.refreshThumb(s);
  } else {
    viewer.reloadContent();
    rail.setPdfDoc(pdfDoc);
    rail.render();
  }
  overlay.renderAll(); // re-stamp annotations over the refreshed canvases
  store.emit('history'); // refresh toolbar undo/redo enabled state
  scheduleContentAutosave();
}

async function engineUndo(): Promise<boolean> {
  if (!store.doc || !nativeUndo.length) return false;
  nativeRedo.push(store.doc.bytes);
  await swapBytes(nativeUndo.pop()!);
  return true;
}
async function engineRedo(): Promise<boolean> {
  if (!store.doc || !nativeRedo.length) return false;
  nativeUndo.push(store.doc.bytes);
  await swapBytes(nativeRedo.pop()!);
  return true;
}
function resetNativeHistory() {
  nativeUndo = [];
  nativeRedo = [];
  lastActionWasNative = false;
}

// ---------- unified undo/redo (annotation snapshots + native byte edits) ----------
function unifiedUndo() {
  if (lastActionWasNative && nativeUndo.length) engineUndo();
  else if (store.canUndo()) store.undo();
  else engineUndo();
}
function unifiedRedo() {
  if (lastActionWasNative && nativeRedo.length) engineRedo();
  else if (store.canRedo()) store.redo();
  else engineRedo();
}
function unifiedCanUndo() { return store.canUndo() || nativeUndo.length > 0; }
function unifiedCanRedo() { return store.canRedo() || nativeRedo.length > 0; }

function insertBlankAtCurrent() {
  const live = store.doc!.pages.filter((p) => !p.deleted);
  const ref = live[0];
  const blank: PageState = { id: uid('p_'), srcIndex: -1, width: ref?.width ?? 612, height: ref?.height ?? 792, baseRotation: 0, rotation: 0, blank: true };
  store.checkpoint();
  const idx = store.currentPage + 1;
  const all = store.doc!.pages.filter((p) => !p.deleted);
  all.splice(idx, 0, blank);
  store.doc!.pages = all;
  rail.render();
  refreshPages();
}

// ---------- zoom ----------
function setZoom(scale: number) {
  const z = clamp(scale, 0.2, 5);
  store.setZoom(z);
  viewer.setScale(z);
}
function zoomStep(dir: number) {
  setZoom(Math.round((store.zoom + dir * 0.1) * 10) / 10);
}
function fitWidth() {
  requestAnimationFrame(() => setZoom(clamp(viewer.fitWidth(), 0.2, 3)));
}

// ---------- export / print ----------
const LARGE_EXPORT_BYTES = 5 * 1024 * 1024;

async function doExport() {
  if (!store.doc) return;
  // Annotations force the pdf-lib rebuild path (see core/export.ts tiers) instead
  // of the fast mupdf-only compaction, so a large file + overlays is the one case
  // that's genuinely slower — worth setting expectations up front.
  const slow = store.doc.bytes.length > LARGE_EXPORT_BYTES && store.doc.annos.length > 0;
  const t = toast(
    slow ? 'Building your PDF… large files with annotations can take a little longer.' : 'Building your PDF…',
    { type: 'progress' },
  );
  try {
    const bytes = await exportDoc(store.doc);
    t.close();
    download(bytes, store.doc.name.replace(/\.pdf$/i, '') + '.pdf');
    celebrateExport(store.doc.name);
  } catch (err) {
    t.close();
    toast('Export failed. See the console for details.', { type: 'error' });
    console.error(err);
  }
}

async function doPrint() {
  if (!store.doc) return;
  const t = toast('Preparing to print…', { type: 'progress' });
  try {
    const bytes = await exportDoc(store.doc);
    t.close();
    const blob = new Blob([bufferOf(bytes)], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const frame = h('iframe', { src: url, style: { position: 'fixed', right: '0', bottom: '0', width: '0', height: '0', border: '0' } }) as HTMLIFrameElement;
    document.body.appendChild(frame);
    frame.onload = () => { frame.contentWindow?.focus(); frame.contentWindow?.print(); };
    setTimeout(() => { frame.remove(); URL.revokeObjectURL(url); }, 60000);
  } catch (err) {
    t.close();
    toast('Could not prepare the document for printing.', { type: 'error' });
  }
}

// ---------- metadata modal ----------
function openMeta() {
  if (!store.doc) return;
  const m = store.doc.meta;
  const dlg = modal({ title: 'Document properties', subtitle: 'Edit the title, author and keywords stored in the PDF.', icon: icon('meta', 20), width: 480 });
  const title = textInput({ value: m.title ?? '' });
  const author = textInput({ value: m.author ?? '' });
  const subject = textInput({ value: m.subject ?? '' });
  const keywords = textInput({ value: m.keywords ?? '' });
  dlg.body.append(field('Title', title), field('Author', author), field('Subject', subject), field('Keywords', keywords, 'Comma-separated'));
  dlg.footer.append(
    h('button.btn btn-soft', { onclick: () => dlg.close() }, 'Cancel'),
    h('button.btn btn-primary', { onclick: () => { store.checkpoint(); store.setMeta({ title: title.value, author: author.value, subject: subject.value, keywords: keywords.value }); dlg.close(); toast('Document properties updated.', { type: 'success' }); } }, 'Save'),
  );
}

function promptPassword(name: string): Promise<string | null> {
  return new Promise((resolve) => {
    const m = modal({ title: 'Password required', subtitle: `“${name}” is protected. Enter its password to open — it never leaves your device.`, icon: icon('lock', 20), width: 440, dismissable: true });
    const inp = h('input.input', { type: 'password', placeholder: 'Password' }) as HTMLInputElement;
    inp.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') m.close(inp.value); });
    m.body.appendChild(field('Password', inp));
    m.footer.append(
      h('button.btn btn-soft', { onclick: () => m.close(null) }, 'Cancel'),
      h('button.btn btn-primary', { onclick: () => m.close(inp.value) }, 'Unlock'),
    );
    m.onClose((r) => resolve(r === undefined ? null : r));
    setTimeout(() => inp.focus(), 60);
  });
}

// ---------- status bar ----------
function buildStatus(el: HTMLElement) {
  const render = () => {
    const live = store.doc?.pages.filter((p) => !p.deleted) ?? [];
    const toolLabel = TOOLS.find((t) => t.id === store.tool)?.label ?? 'Select';
    clear(el);
    el.append(
      h('span', store.doc ? `Page ${store.currentPage + 1} of ${live.length}` : 'No document'),
      h('span.status-sep', '·'),
      h('span', `${toolLabel} tool`),
      h('span.status-ok', h('span.status-dot'), 'All changes stay on this machine'),
    );
  };
  store.on('page', render);
  store.on('tool', render);
  store.on('doc', render);
  store.on('annos', render);
  render();
}

// ---------- keyboard ----------
function wireKeyboard() {
  // Remove the previous mount's handler first — store.clearListeners() only
  // covers store subscribers, not this raw window listener.
  if (keydownHandler) window.removeEventListener('keydown', keydownHandler);
  keydownHandler = (e) => {
    const target = e.target as HTMLElement;
    const typing = target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
    const mod = e.metaKey || e.ctrlKey;
    // while typing in a field/textarea, let the browser handle text undo/redo
    if (mod && e.key.toLowerCase() === 'z' && !typing) {
      e.preventDefault();
      e.shiftKey ? unifiedRedo() : unifiedUndo();
      return;
    }
    if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); doExport(); return; }
    if (mod && e.key.toLowerCase() === 'o') { e.preventDefault(); openFile(); return; }
    if (mod && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomStep(1); return; }
    if (mod && e.key === '-') { e.preventDefault(); zoomStep(-1); return; }
    if (typing) return;
    if ((e.key === 'Delete' || e.key === 'Backspace') && store.selection.length) { e.preventDefault(); store.checkpoint(); store.removeAnnos(store.selection); return; }
    if (e.key === 'Escape') { store.setTool('select'); store.clearSelection(); return; }
    if (!mod) {
      const t = TOOLS.find((x) => x.hotkey === e.key.toLowerCase());
      if (t) { store.setTool(t.id); }
    }
  };
  window.addEventListener('keydown', keydownHandler);
}

// ---------- autosave ----------
function wireAutosave() {
  const save = debounce(() => { if (store.doc) autosave(store.doc); }, 1200);
  store.on('annos', save);
  store.on('page', save);
  store.on('meta', save);
}
