import sharp from "sharp";
import crypto from "crypto";
import path from "path";
import fs from "fs";
import { createRequire } from "module";
import {
  DetectedFileKind,
  DocumentReadError,
  RECEIPT_ALLOWED_KINDS,
  UnsupportedFileTypeError,
  buildAllowedKindsMessage,
  detectFileKind,
} from "../ingest/acceptedTypes";
import { decodeHeicToJpeg } from "./heic";
import { cropBuffer, needsContrastBoost, perspectiveCorrectBuffer } from "./enhance";
import { computeReceiptResize } from "./resize";

export {
  AI_IMAGE_LONG_EDGE_PX,
  AI_IMAGE_MAX_LONG_EDGE_PX,
  AI_IMAGE_MIN_WIDTH_PX,
  computeReceiptResize,
} from "./resize";
export { needsContrastBoost } from "./enhance";

export interface ProcessedImageResult {
  /** JPEG-Buffer (AI-kompatibel) — wird an die Vision-Provider geschickt. */
  buffer: Buffer;
  /** MIME-Type des AI-Buffers (immer "image/jpeg"). */
  mimeType: string;
  width: number;
  height: number;
  sha256Hash: string;
  /** JPEG-Data-URL für die AI-Extraktion (Tests / Legacy; Scan nutzt `buffer`). */
  base64DataUrl?: string;
  /**
   * Kompakt kodierter Archiv-/Vorschau-Buffer (AVIF bzw. WebP). Dieser wird in
   * der DB (storage_url) und IndexedDB gespeichert — nicht der AI-Buffer.
   */
  previewBuffer: Buffer;
  /** Data-URL des archivierten Bildes (AVIF/WebP) für Anzeige & Download. */
  previewUrl: string;
  /** MIME-Type des archivierten Bildes, z. B. "image/avif". */
  storedMimeType: string;
  originalSizeBytes: number;
  /** Größe des archivierten (komprimierten) Bildes. */
  processedSizeBytes: number;
  /** 1-basierte Seitennummer (bei Bildern immer 1) */
  pageNumber?: number;
  /** Gesamtanzahl verarbeiteter Seiten des Ursprungsdokuments */
  pageCount?: number;
  /** Erkannter Typ der hochgeladenen Datei */
  sourceKind?: DetectedFileKind;
}

/**
 * Archivformat für Vorschau & Speicherung. AVIF spart gegenüber JPEG
 * typischerweise 50–70 % Speicherplatz — kritisch, wenn viele Nutzer Belege
 * hochladen. Kann bei Bedarf auf "webp" (kompatibler, aber größer) umgestellt
 * werden; die AI-Extraktion läuft davon unabhängig weiter über JPEG.
 */
export const STORAGE_IMAGE_FORMAT = "avif" as const;
export const STORAGE_MIME_TYPE: string = `image/${STORAGE_IMAGE_FORMAT}`;
export const STORAGE_IMAGE_QUALITY = 45;

export interface ProcessedDocumentResult {
  /** Eine verarbeitete Bildseite pro Dokumentseite (mindestens eine) */
  pages: ProcessedImageResult[];
  /** Anzahl der zurückgegebenen Seiten (== pages.length) */
  pageCount: number;
  /** Seitenanzahl im Originaldokument (kann größer als pageCount sein) */
  sourcePageCount: number;
  /** true, wenn wegen MAX_PDF_PAGES nicht alle Seiten verarbeitet wurden */
  truncated: boolean;
  sourceKind: DetectedFileKind;
  /** SHA-256 der Originaldatei (identisch auf allen Seiten) */
  sha256Hash: string;
  originalSizeBytes: number;
}

export interface ProcessDocumentOptions {
  /** Maximale Anzahl zu rasternder PDF-Seiten (Standard: MAX_PDF_PAGES) */
  maxPages?: number;
  /**
   * When false, skip AVIF/WebP archive encoding. The scan route extracts first
   * and encodes a preview afterwards so the vision call is not blocked on it.
   */
  encodePreview?: boolean;
}

/** Obergrenze für PDF-Seiten pro Upload — schützt vor Speicher- und Timeout-Problemen. */
export const MAX_PDF_PAGES = 20;

/** Max images in a single vision call. Extra PDF pages: keep head + last two (totals). */
export const MAX_VISION_PAGES = 8;
export const LONG_RECEIPT_ASPECT = 1.65;
/** Safety ceiling, not a coverage target. Tile height grows if this is reached. */
export const MAX_RECEIPT_DETAIL_TILES = 24;
export const MAX_DETAIL_VIEWS_PER_VISION_CALL = 5;

export interface ReceiptDetailView {
  buffer: Buffer;
  sourceView: number;
  region: TileRegion;
  qualityScore: number;
  difficult: boolean;
}

export interface VisionInput {
  images: Buffer[];
  mode: "pages" | "detail_views" | "long_receipt_summary";
  detailViews: ReceiptDetailView[];
  forceLineItemVerification: boolean;
}

export interface TileRegion {
  left: number;
  top: number;
  width: number;
  height: number;
}

/**
 * Overlapping top-to-bottom regions for a long thermal receipt. Sending only
 * one 1:4 overview makes vision providers downsample the price column too
 * aggressively. Neighbouring views overlap so no row is lost at a boundary.
 */
export function computeReceiptTileRegions(width: number, height: number): TileRegion[] {
  const w = Math.max(1, Math.round(width));
  const h = Math.max(1, Math.round(height));
  if (h / w < LONG_RECEIPT_ASPECT) return [];

  let tileHeight = Math.min(h, Math.round(w * 1.35));
  const desiredStep = Math.max(1, Math.round(tileHeight * 0.78));
  const naturalCount = Math.max(2, Math.ceil((h - tileHeight) / desiredStep) + 1);
  const count = Math.min(MAX_RECEIPT_DETAIL_TILES, naturalCount);

  // If the safety ceiling is reached, enlarge every tile just enough to keep
  // the complete receipt covered. We never spread fixed-height tiles apart:
  // that creates invisible gaps on 8:1, 12:1 or 20:1 thermal receipts.
  if (naturalCount > MAX_RECEIPT_DETAIL_TILES) {
    tileHeight = Math.min(h, Math.ceil(h / (1 + 0.78 * (count - 1))));
  }
  const maxTop = h - tileHeight;
  return Array.from({ length: count }, (_, index) => ({
    left: 0,
    top: count === 1 ? 0 : Math.round((maxTop * index) / (count - 1)),
    width: w,
    height: tileHeight,
  }));
}

function clamp01(value: number): number {
  return Math.max(0, Math.min(1, value));
}

/** Cheap local readability estimate used only to decide whether OCR needs verification. */
export async function assessReceiptDetailQuality(buffer: Buffer): Promise<number> {
  const probe = await sharp(buffer, { failOnError: false })
    .grayscale()
    .resize({ width: 240, withoutEnlargement: true, fastShrinkOnLoad: true })
    .raw()
    .toBuffer({ resolveWithObject: true });
  const { width, height } = probe.info;
  if (width < 2 || height < 2) return 0;

  let sum = 0;
  let sumSquares = 0;
  let gradient = 0;
  let gradientSamples = 0;
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const index = y * width + x;
      const value = probe.data[index];
      sum += value;
      sumSquares += value * value;
      if (x > 0) {
        gradient += Math.abs(value - probe.data[index - 1]);
        gradientSamples++;
      }
      if (y > 0) {
        gradient += Math.abs(value - probe.data[index - width]);
        gradientSamples++;
      }
    }
  }
  const count = width * height;
  const mean = sum / count;
  const stdev = Math.sqrt(Math.max(0, sumSquares / count - mean * mean));
  const averageGradient = gradientSamples > 0 ? gradient / gradientSamples : 0;
  return Number((0.55 * clamp01(stdev / 34) + 0.45 * clamp01(averageGradient / 11)).toFixed(3));
}

async function mapWithConcurrency<T, R>(
  values: T[],
  limit: number,
  mapper: (value: T, index: number) => Promise<R>
): Promise<R[]> {
  const results = new Array<R>(values.length);
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
    while (next < values.length) {
      const index = next++;
      results[index] = await mapper(values[index], index);
    }
  });
  await Promise.all(workers);
  return results;
}

/** PDFs remain page-based. A single long receipt gets an overview plus details. */
export async function buildVisionInput(pages: ProcessedImageResult[]): Promise<VisionInput> {
  const selected = selectVisionPages(pages);
  if (selected.length !== 1) {
    return {
      images: selected.map((page) => page.buffer),
      mode: "pages",
      detailViews: [],
      forceLineItemVerification: false,
    };
  }

  const page = selected[0];
  const regions = computeReceiptTileRegions(page.width, page.height);
  if (regions.length === 0) {
    return {
      images: [page.buffer],
      mode: "pages",
      detailViews: [],
      forceLineItemVerification: false,
    };
  }

  const detailViews = await mapWithConcurrency(regions, 4, async (region, index) => {
    const buffer = await sharp(page.buffer, { failOnError: false })
        .extract(region)
        .resize({ width: 1400, withoutEnlargement: false, fit: "inside" })
        .jpeg({ quality: 88, progressive: true, mozjpeg: false })
        .toBuffer();
    const qualityScore = await assessReceiptDetailQuality(buffer);
    return {
      buffer,
      sourceView: index + 1,
      region,
      qualityScore,
      difficult: qualityScore < 0.42,
    };
  });

  if (detailViews.length <= MAX_DETAIL_VIEWS_PER_VISION_CALL) {
    return {
      images: [page.buffer, ...detailViews.map((view) => view.buffer)],
      mode: "detail_views",
      detailViews,
      forceLineItemVerification: detailViews.some((view) => view.difficult),
    };
  }

  // For an extreme strip, the first call only needs the overview plus dedicated
  // head/footer views. Line rows are read from every gap-free tile in bounded
  // batches by the extractor.
  return {
    images: [page.buffer, detailViews[0].buffer, detailViews[detailViews.length - 1].buffer],
    mode: "long_receipt_summary",
    detailViews,
    forceLineItemVerification: true,
  };
}

/**
 * Picks which rasterized pages go to the model. Short documents keep every
 * page; long ones keep the first six (header + line items) and the last two
 * (tax / total), which is how multi-page invoices are laid out.
 */
export function selectVisionPages<T>(pages: T[]): T[] {
  if (pages.length <= MAX_VISION_PAGES) return pages;
  const picked: T[] = [];
  const seen = new Set<T>();
  for (const page of [...pages.slice(0, 6), ...pages.slice(-2)]) {
    if (seen.has(page)) continue;
    seen.add(page);
    picked.push(page);
  }
  return picked;
}

/**
 * Preprocesses receipt images for optimal AI OCR & Vision extraction:
 * 1. Auto-rotates using EXIF orientation
 * 2. Scales with a min-width so tall thermal slips stay readable
 * 3. Crops photos; contrast boost only when the image is washed out
 * 4. Generates SHA-256 hash for duplicate detection
 *
 * PDFs werden vorher seitenweise gerastert; zurückgegeben wird die erste Seite.
 * Für mehrseitige Dokumente `processReceiptDocument()` verwenden.
 */
export async function processReceiptImage(
  inputBuffer: Buffer,
  mimeType: string = "image/jpeg"
): Promise<ProcessedImageResult> {
  const document = await processReceiptDocument(inputBuffer, mimeType, { maxPages: 1 });
  return document.pages[0];
}

/**
 * Verarbeitet einen Upload vollständig: erkennt den echten Dateityp über Magic
 * Bytes, rastert PDFs seitenweise und schickt jede Seite durch die sharp-
 * Pipeline. Bilder ergeben genau eine Seite.
 *
 * Wirft `UnsupportedFileTypeError` (nicht erkannte Dateitypen) bzw.
 * `DocumentReadError` (unlesbares PDF, HEIC-Dekodierfehler oder nicht
 * dekodierbares Bild), statt unverarbeitete Bytes weiterzureichen. Für Uploads
 * ist vorgeschaltet das Whitelist-Gate `assertAllowedUploadKind` in der
 * API-Route die autoritative Freigabestelle; dieser Pfad schützt zusätzlich
 * direkte Aufrufer.
 */
export async function processReceiptDocument(
  inputBuffer: Buffer,
  mimeType: string = "image/jpeg",
  options: ProcessDocumentOptions = {}
): Promise<ProcessedDocumentResult> {
  const originalSizeBytes = inputBuffer.length;

  // SHA-256 immer über die unveränderte Originaldatei (Duplikaterkennung)
  const sha256Hash = crypto.createHash("sha256").update(inputBuffer).digest("hex");

  const detected = detectFileKind(inputBuffer, mimeType);

  // Defense in depth: ein nicht erkannter Typ darf für Scan-Zwecke nie
  // verarbeitet werden (die API-Route lehnt ihn bereits über das Whitelist-
  // Gate ab; dieser Pfad schützt direkte Aufrufer wie processReceiptImage).
  if (detected.kind === "unknown") {
    throw new UnsupportedFileTypeError(
      buildAllowedKindsMessage(RECEIPT_ALLOWED_KINDS),
      "unknown"
    );
  }

  let imageBuffer = inputBuffer;

  if (detected.kind === "heic") {
    // sharp's prebuilds have AV1 (AVIF) but not HEVC. Convert to JPEG first
    // via libheif-js so iPhone photos take the same pipeline as a gallery JPEG.
    imageBuffer = await decodeHeicToJpeg(inputBuffer);
  }

  if (detected.kind === "pdf") {
    const maxPages = clampMaxPages(options.maxPages);
    const raster = await rasterizePdf(inputBuffer, maxPages);

    const pages: ProcessedImageResult[] = [];
    const encodePreview = options.encodePreview !== false;
    for (let i = 0; i < raster.pages.length; i++) {
      pages.push(
        await runSharpPipeline(raster.pages[i], {
          sha256Hash,
          originalSizeBytes,
          pageNumber: i + 1,
          pageCount: raster.pages.length,
          sourceKind: "pdf",
          encodePreview,
        })
      );
    }

    if (pages.length === 0) {
      throw new DocumentReadError(
        "Das PDF enthält keine lesbaren Seiten. Bitte lade den Beleg als Bild (JPEG/PNG) hoch."
      );
    }

    console.log(
      `[ImageProcessor] PDF gerastert: ${pages.length} von ${raster.sourcePageCount} Seite(n) verarbeitet.`
    );

    return {
      pages,
      pageCount: pages.length,
      sourcePageCount: raster.sourcePageCount,
      truncated: raster.sourcePageCount > pages.length,
      sourceKind: "pdf",
      sha256Hash,
      originalSizeBytes,
    };
  }

  // Normaler Bild-Pfad — jpeg/png/webp plus HEIC, das oben nach JPEG gewandelt
  // wurde. Unbekannte Typen wurden bereits abgelehnt.
  const page = await runSharpPipeline(imageBuffer, {
    sha256Hash,
    originalSizeBytes,
    pageNumber: 1,
    pageCount: 1,
    sourceKind: detected.kind,
    encodePreview: options.encodePreview !== false,
  });

  return {
    pages: [page],
    pageCount: 1,
    sourcePageCount: 1,
    truncated: false,
    sourceKind: detected.kind,
    sha256Hash,
    originalSizeBytes,
  };
}

function clampMaxPages(requested?: number): number {
  if (!requested || !Number.isFinite(requested) || requested < 1) return MAX_PDF_PAGES;
  return Math.min(Math.floor(requested), MAX_PDF_PAGES);
}

interface SharpPipelineContext {
  sha256Hash: string;
  originalSizeBytes: number;
  pageNumber: number;
  pageCount: number;
  sourceKind: DetectedFileKind;
  encodePreview: boolean;
}

/**
 * Encodes the AI JPEG into the compact archive format used for `previewUrl`
 * / `storage_url`. Called after extraction so vision is not blocked on AVIF.
 */
export async function encodeStoragePreview(aiJpeg: Buffer): Promise<{
  previewBuffer: Buffer;
  previewUrl: string;
  storedMimeType: string;
  processedSizeBytes: number;
}> {
  let previewBuffer: Buffer = aiJpeg;
  let previewMime = "image/jpeg";
  try {
    previewBuffer = await sharp(aiJpeg)
      .toFormat(STORAGE_IMAGE_FORMAT, { quality: STORAGE_IMAGE_QUALITY })
      .toBuffer();
    previewMime = STORAGE_MIME_TYPE;
  } catch (previewError) {
    console.warn(
      `[ImageProcessor] ${STORAGE_MIME_TYPE}-Encoding nicht verfügbar, nutze WebP für die Vorschau.`,
      previewError
    );
    try {
      previewBuffer = await sharp(aiJpeg).toFormat("webp", { quality: 70 }).toBuffer();
      previewMime = "image/webp";
    } catch (webpError) {
      console.warn("[ImageProcessor] WebP-Encoding ebenfalls fehlgeschlagen, nutze JPEG.", webpError);
      previewBuffer = aiJpeg;
      previewMime = "image/jpeg";
    }
  }
  return {
    previewBuffer,
    previewUrl: `data:${previewMime};base64,${previewBuffer.toString("base64")}`,
    storedMimeType: previewMime,
    processedSizeBytes: previewBuffer.length,
  };
}

async function runSharpPipeline(
  inputBuffer: Buffer,
  ctx: SharpPipelineContext
): Promise<ProcessedImageResult> {
  try {
    let working = await sharp(inputBuffer, { failOnError: false })
      .rotate()
      .flatten({ background: "#ffffff" })
      .toBuffer();

    if (ctx.sourceKind !== "pdf") {
      try {
        working = await cropBuffer(working);
      } catch (cropError) {
        console.warn("[ImageProcessor] Crop übersprungen:", cropError);
      }
      try {
        working = await perspectiveCorrectBuffer(working);
      } catch (perspectiveError) {
        console.warn("[ImageProcessor] Perspektivkorrektur übersprungen:", perspectiveError);
      }
      // Do not auto-deskew full-scene photos here. The former projection
      // heuristic was driven by table edges/wood grain and could rotate a
      // readable receipt by the maximum 8 degrees, blurring adjacent rows.
      // Vision handles the small natural camera angle better than a false warp.
    }

    const meta = await sharp(working, { failOnError: false }).metadata();
    const target = computeReceiptResize(meta.width ?? 1, meta.height ?? 1, {
      allowUpscale: ctx.sourceKind === "pdf",
    });

    let pipeline = sharp(working, { failOnError: false }).resize({
      width: target.width,
      height: target.height,
      fit: "fill",
      fastShrinkOnLoad: true,
    });

    const stats = await pipeline.clone().stats();
    if (needsContrastBoost(stats)) {
      pipeline = pipeline.normalize().sharpen({ sigma: 0.8, m1: 0.4, m2: 1.5 });
    }

    const { data: aiBuffer, info } = await pipeline
      .jpeg({
        quality: 82,
        progressive: true,
        mozjpeg: false,
      })
      .toBuffer({ resolveWithObject: true });

    const preview = ctx.encodePreview
      ? await encodeStoragePreview(aiBuffer)
      : {
          previewBuffer: aiBuffer,
          previewUrl: "",
          storedMimeType: "image/jpeg",
          processedSizeBytes: aiBuffer.length,
        };

    return {
      buffer: aiBuffer,
      mimeType: "image/jpeg",
      width: info.width,
      height: info.height,
      sha256Hash: ctx.sha256Hash,
      base64DataUrl: ctx.encodePreview
        ? `data:image/jpeg;base64,${aiBuffer.toString("base64")}`
        : undefined,
      previewBuffer: preview.previewBuffer,
      previewUrl: preview.previewUrl,
      storedMimeType: preview.storedMimeType,
      originalSizeBytes: ctx.originalSizeBytes,
      processedSizeBytes: preview.processedSizeBytes,
      pageNumber: ctx.pageNumber,
      pageCount: ctx.pageCount,
      sourceKind: ctx.sourceKind,
    };
  } catch (error) {
    // Sicherheitslücke geschlossen: Die Original-Bytes werden NIE mehr als
    // Data-URL durchgereicht — weder bei unbekannten Typen noch bei Dateien,
    // die sharp nicht dekodieren kann (z. B. beschädigte oder polyglotte
    // Dateien). PDF-Raster-Seiten sind intern PNG und treffen denselben Pfad.
    throw new DocumentReadError(
      ctx.sourceKind === "pdf"
        ? "Die gerasterte PDF-Seite konnte nicht weiterverarbeitet werden. Bitte lade den Beleg als Bild (JPEG/PNG) hoch."
        : "Die Datei konnte nicht als Bild gelesen werden. Bitte lade den Beleg als JPEG, PNG, WebP oder PDF hoch.",
      { cause: error }
    );
  }
}

/* -------------------------------------------------------------------------- */
/*                              PDF-Rasterisierung                             */
/* -------------------------------------------------------------------------- */

interface RasterizedPdf {
  pages: Buffer[];
  sourcePageCount: number;
}

/** Minimal benötigte Ausschnitte der pdfjs-Laufzeit-API (bewusst lose typisiert). */
interface PdfjsCanvasHandle {
  canvas: { toBuffer(mime: "image/png"): Buffer };
  context: unknown;
}

interface PdfjsPage {
  getViewport(params: { scale: number }): { width: number; height: number };
  render(params: Record<string, unknown>): { promise: Promise<void> };
  cleanup(): void;
}

interface PdfjsDocument {
  numPages: number;
  getPage(pageNumber: number): Promise<PdfjsPage>;
  canvasFactory: {
    create(width: number, height: number): PdfjsCanvasHandle;
    destroy(handle: PdfjsCanvasHandle): void;
  };
}

interface PdfjsModule {
  getDocument(params: Record<string, unknown>): {
    promise: Promise<PdfjsDocument>;
    destroy(): Promise<void>;
  };
}

let pdfjsModulePromise: Promise<PdfjsModule> | null = null;

async function loadPdfjs(): Promise<PdfjsModule> {
  if (!pdfjsModulePromise) {
    // Der Legacy-Build ist der von pdfjs offiziell für Node.js vorgesehene
    // Einstiegspunkt; er nutzt @napi-rs/canvas (vorkompilierte Binaries, kein
    // node-gyp/Build-Schritt unter Windows) für die Rasterisierung.
    pdfjsModulePromise = import("pdfjs-dist/legacy/build/pdf.mjs").then(
      (mod) => (mod as unknown as { default?: PdfjsModule }).default ?? (mod as unknown as PdfjsModule)
    );
  }
  return pdfjsModulePromise;
}

let cachedAssetRoot: string | null | undefined;

/**
 * Findet das pdfjs-dist-Paketverzeichnis, um Standardschriften und CMaps zu
 * laden. pdfjs liest diese Pfade in Node via `fs.readFile` — es muss daher ein
 * echter Dateipfad mit abschließendem `/` sein, keine `file://`-URL.
 */
function resolvePdfjsAssetRoot(): string | null {
  if (cachedAssetRoot !== undefined) return cachedAssetRoot;

  const candidates: Array<() => string> = [
    () => path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json")),
    () => path.join(process.cwd(), "node_modules", "pdfjs-dist"),
  ];

  for (const candidate of candidates) {
    try {
      const root = candidate();
      if (fs.existsSync(path.join(root, "standard_fonts"))) {
        cachedAssetRoot = root;
        return cachedAssetRoot;
      }
    } catch {
      // nächsten Kandidaten versuchen
    }
  }

  console.warn("[ImageProcessor] pdfjs-Standardschriften nicht gefunden — Rendering nutzt Ersatzschriften.");
  cachedAssetRoot = null;
  return cachedAssetRoot;
}

/** Wandelt einen Windows-/POSIX-Pfad in die von pdfjs erwartete Form mit `/` am Ende um. */
function toAssetPath(root: string, folder: string): string {
  return `${path.join(root, folder).split(path.sep).join("/")}/`;
}

async function rasterizePdf(inputBuffer: Buffer, maxPages: number): Promise<RasterizedPdf> {
  let pdfjs: PdfjsModule;
  try {
    pdfjs = await loadPdfjs();
  } catch (error) {
    console.error("[ImageProcessor] pdfjs-dist konnte nicht geladen werden:", error);
    throw new DocumentReadError(
      "PDF-Verarbeitung ist auf diesem Server nicht verfügbar. Bitte lade den Beleg als Bild (JPEG/PNG) hoch.",
      { cause: error }
    );
  }

  const assetRoot = resolvePdfjsAssetRoot();
  const loadingTask = pdfjs.getDocument({
    // Uint8Array-Kopie: pdfjs übernimmt den Puffer und würde ihn sonst leeren
    data: new Uint8Array(inputBuffer),
    isEvalSupported: false,
    useSystemFonts: false,
    disableFontFace: true,
    ...(assetRoot
      ? {
          standardFontDataUrl: toAssetPath(assetRoot, "standard_fonts"),
          cMapUrl: toAssetPath(assetRoot, "cmaps"),
          cMapPacked: true,
        }
      : {}),
  });

  let doc: PdfjsDocument;
  try {
    doc = await loadingTask.promise;
  } catch (error) {
    console.error("[ImageProcessor] PDF konnte nicht geöffnet werden:", error);
    await loadingTask.destroy().catch(() => undefined);
    throw new DocumentReadError(
      "Das PDF konnte nicht gelesen werden. Möglicherweise ist es beschädigt oder passwortgeschützt. " +
        "Bitte lade den Beleg als Bild (JPEG/PNG) hoch.",
      { cause: error }
    );
  }

  const sourcePageCount = doc.numPages;
  const pageLimit = Math.min(sourcePageCount, maxPages);
  const pages: Buffer[] = [];

  try {
    for (let pageNumber = 1; pageNumber <= pageLimit; pageNumber++) {
      const page = await doc.getPage(pageNumber);
      const baseViewport = page.getViewport({ scale: 1 });
      const target = computeReceiptResize(baseViewport.width, baseViewport.height, {
        allowUpscale: true,
      });
      const scale = target.width / (baseViewport.width || 1);
      const viewport = page.getViewport({ scale });

      const handle = doc.canvasFactory.create(
        Math.ceil(viewport.width),
        Math.ceil(viewport.height)
      );

      try {
        await page.render({
          canvasContext: handle.context,
          canvas: handle.canvas,
          viewport,
          background: "#ffffff",
        }).promise;

        pages.push(handle.canvas.toBuffer("image/png"));
      } finally {
        doc.canvasFactory.destroy(handle);
        page.cleanup();
      }
    }
  } catch (error) {
    console.error("[ImageProcessor] PDF-Seite konnte nicht gerendert werden:", error);
    throw new DocumentReadError(
      "Eine Seite des PDFs konnte nicht in ein Bild umgewandelt werden. Bitte lade den Beleg als Bild (JPEG/PNG) hoch.",
      { cause: error }
    );
  } finally {
    await loadingTask.destroy().catch(() => undefined);
  }

  if (sourcePageCount > pageLimit) {
    console.warn(
      `[ImageProcessor] PDF hat ${sourcePageCount} Seiten — es werden nur die ersten ${pageLimit} verarbeitet.`
    );
  }

  return { pages, sourcePageCount };
}
