package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;

import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;

import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.scene.Cursor;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;

/**
 * Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
 *
 * <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
 * in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
 * {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
 * umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
 * Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
 *
 * <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
 * 10 % pro Raste (Bereich {@value #ZOOM_MIN}–{@value #ZOOM_MAX}, d. h. 10 %–500 %).
 * Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
 * {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
 * Zoom automatisch auf Fit-to-View zurück.
 *
 * <p><strong>Grab &amp; Pan:</strong> Im manuellen Zoom-Modus kann die Vorschau durch
 * Klicken und Ziehen (linke Maustaste) verschoben werden. Der Mauszeiger wechselt im
 * Zoom-Modus auf {@link Cursor#OPEN_HAND} und während der Geste auf
 * {@link Cursor#CLOSED_HAND}.
 *
 * <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
 * dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
 * Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache
 * ({@code Map<Integer, Image>}) gehalten, sodass wiederholte Navigation kein
 * erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert.
 *
 * <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade- und
 * Rendering-Ergebnisse werden anhand einer Sequenznummer erkannt und verworfen,
 * sobald eine neue Anforderung eingeht.
 *
 * <h2>Fehlerfälle</h2>
 * <ul>
 *   <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
 *   <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
 *   <li>PDF passwortgeschützt → Meldungstext im Vorschaubereich</li>
 *   <li>Keine Selektion → neutraler Platzhaltertext</li>
 * </ul>
 *
 * <h2>Threading</h2>
 * <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
 * werden. Das PDF-Öffnen, die Speicherhaltung des {@link PDDocument} und das
 * Rendering einzelner Seiten laufen ausschließlich auf dem Worker-Thread.
 */
public final class PdfPreviewPane {

    private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);

    static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
    static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
    static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
    static final String PDF_PASSWORD_PROTECTED_TEXT =
            "PDF ist passwortgeschützt und kann nicht angezeigt werden";

    /** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
    private static final float RENDER_DPI = 120f;

    /** Minimaler Zoomfaktor (10 %). */
    static final double ZOOM_MIN = 0.10;

    /** Maximaler Zoomfaktor (500 %). */
    static final double ZOOM_MAX = 5.00;

    /** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
    private static final double ZOOM_STEP = 0.10;

    /** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
    private static final double ZOOM_NOTCH_THRESHOLD = 40.0;

    private final VBox root = new VBox(4);
    private final StackPane viewStack = new StackPane();
    private final ImageView imageView = new ImageView();
    private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
    private final ProgressIndicator progressIndicator = new ProgressIndicator();
    private final Label pageLabel = new Label();
    private final Button prevButton = new Button("◀ Vorherige");
    private final Button nextButton = new Button("Nächste ▶");
    private final Label sectionTitle = new Label("PDF-Vorschau");
    private final ScrollPane scrollPane = new ScrollPane(viewStack);

    /** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
    private double zoomLevel = 1.0;

    /** Akkumulator für sub-Rasten-Scroll-Deltas. */
    private double zoomAccumulator = 0.0;

    /**
     * Referenzbreite für die manuelle Zoom-Skalierung; gilt
     * {@code imageView.fitWidth = naturalViewportWidth × zoomLevel} im manuellen
     * Zoom-Modus. Beim Verlassen des Fit-Modus wird der Wert auf die natürliche
     * Bildbreite gesetzt, sodass {@code zoomLevel = 1.0} der pixel-genauen
     * Originalgröße entspricht und {@code zoomLevel} damit gleich dem visuellen
     * Skalierungsfaktor ist. {@code 0.0} bedeutet Fit-to-View-Modus ist aktiv.
     */
    private double naturalViewportWidth = 0.0;

    /** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
    private double panStartX = -1;

    /** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
    private double panStartY = -1;

    /** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
    private double panStartHvalue = 0.0;

    /** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
    private double panStartVvalue = 0.0;

    /**
     * Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
     * (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse
     * mit veralteter Sequenznummer werden verworfen.
     */
    private final AtomicLong currentRequestSequence = new AtomicLong(0);

    /**
     * Cache bereits gerenderter Seiten für die aktuell geladene Quelldatei.
     * Schlüssel ist die 1-basierte Seitennummer. Wird beim Wechsel der Quelldatei geleert.
     */
    private final Map<Integer, Image> pageCache = new ConcurrentHashMap<>();

    /** Hintergrund-Thread-Pool für Lade- und Rendering-Aufgaben. */
    private final ExecutorService executor =
            Executors.newSingleThreadExecutor(r -> {
                Thread t = new Thread(r, "pdf-preview-worker");
                t.setDaemon(true);
                return t;
            });

    /**
     * Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
     * Leerer Referenzwert wenn kein Dokument geöffnet ist.
     */
    private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();

    /**
     * Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
     * Leerer Referenzwert wenn kein Dokument geöffnet ist.
     */
    private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();

    /** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
    private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();

    /** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
    private volatile int currentPage = 0;

    /** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
    private volatile int totalPages = -1;

    /** Gibt an ob die Navigation bedienbar ist. */
    private boolean enabled = true;

    /**
     * Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
     */
    public PdfPreviewPane() {
        sectionTitle.setStyle("-fx-font-weight: bold;");

        imageView.setId("pdf-preview-image-view");
        imageView.setPreserveRatio(true);
        imageView.setSmooth(true);
        // Fit-to-view: ImageView füllt den verfügbaren Bereich unter Wahrung des Seitenverhältnisses
        imageView.fitWidthProperty().bind(viewStack.widthProperty());
        imageView.fitHeightProperty().bind(viewStack.heightProperty());

        overlayLabel.setId("pdf-preview-overlay-label");
        overlayLabel.setStyle("-fx-text-fill: #555555;");
        overlayLabel.setWrapText(true);
        overlayLabel.setVisible(true);
        overlayLabel.setManaged(true);

        progressIndicator.setId("pdf-preview-progress");
        progressIndicator.setVisible(false);
        progressIndicator.setManaged(false);
        progressIndicator.setMaxWidth(60);
        progressIndicator.setMaxHeight(60);

        // Stack: ImageView hinter dem Overlay; Overlay überlagert das Bild bei Fehlern/Laden
        viewStack.getChildren().addAll(imageView, overlayLabel, progressIndicator);
        StackPane.setAlignment(imageView, Pos.CENTER);
        StackPane.setAlignment(overlayLabel, Pos.CENTER);
        StackPane.setAlignment(progressIndicator, Pos.CENTER);

        scrollPane.setId("pdf-preview-scroll-pane");
        scrollPane.setFitToWidth(true);
        scrollPane.setFitToHeight(true);
        scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
        scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
        // 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen
        scrollPane.setPrefSize(0, 0);
        viewStack.setMinSize(0, 0);
        VBox.setVgrow(scrollPane, Priority.ALWAYS);
        // Strg + Mausrad → Zoom; ohne Strg → normales Scrollen
        scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
            if (event.isControlDown()) {
                accumulateAndApplyZoomDelta(event.getDeltaY());
                event.consume();
            }
        });
        // Grab & Pan – im manuellen Zoom-Modus mit Maus verschiebbar
        viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed);
        viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged);
        viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased);
        // viewStack ist immer mindestens so groß wie der Viewport. Ist der Inhalt
        // (ImageView) kleiner als der Viewport, sorgt diese Mindestgröße zusammen
        // mit StackPane.Pos.CENTER dafür, dass die ImageView automatisch zentriert
        // wird – ohne manuelle setHvalue/setVvalue-Eingriffe. Ist der Inhalt größer,
        // bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.
        scrollPane.viewportBoundsProperty().addListener((obs, old, bounds) -> {
            viewStack.setMinWidth(bounds.getWidth());
            viewStack.setMinHeight(bounds.getHeight());
        });

        prevButton.setId("pdf-preview-prev-button");
        prevButton.setOnAction(e -> navigateToPreviousPage());
        Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
        prevTooltip.setShowDelay(Duration.millis(300));
        prevButton.setTooltip(prevTooltip);

        nextButton.setId("pdf-preview-next-button");
        nextButton.setOnAction(e -> navigateToNextPage());
        Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
        nextTooltip.setShowDelay(Duration.millis(300));
        nextButton.setTooltip(nextTooltip);

        pageLabel.setId("pdf-preview-page-label");
        pageLabel.setStyle("-fx-text-fill: #555555;");

        HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
        navBar.setAlignment(Pos.CENTER);
        navBar.setPadding(new Insets(4, 0, 4, 0));

        root.getChildren().addAll(sectionTitle, scrollPane, navBar);
        root.setPadding(new Insets(4, 0, 0, 0));

        showPlaceholder();
        updateNavigationButtons();
    }

    /**
     * Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
     *
     * @return das Root-Control; nie null
     */
    public Region getNode() {
        return root;
    }

    /**
     * Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
     * Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
     * Der Seiten-Cache wird geleert und ein etwaiges bereits geöffnetes PDF-Dokument
     * wird geschlossen.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
     */
    public void loadSource(Path sourceFile) {
        if (sourceFile == null) {
            clear();
            return;
        }
        currentSourceFile.set(sourceFile);
        currentPage = 0;
        totalPages = -1;
        pageCache.clear();
        resetToFitView();
        requestLoad(sourceFile);
    }

    /**
     * Leert die Komponente und zeigt den neutralen Platzhaltertext.
     * Das aktuell geöffnete PDF-Dokument wird asynchron auf dem Worker-Thread geschlossen.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     */
    public void clear() {
        currentSourceFile.set(null);
        currentPage = 0;
        totalPages = -1;
        pageCache.clear();
        // Neue Sequenznummer: laufende Requests werden verworfen
        currentRequestSequence.incrementAndGet();
        // Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
        executor.submit(this::closeCurrentDocumentOnWorker);
        resetToFitView();
        imageView.setImage(null);
        showPlaceholder();
        updateNavigationButtons();
    }

    /**
     * Aktiviert oder deaktiviert die Navigations-Buttons.
     * Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
     * Die Vorschau-Anzeige bleibt sichtbar.
     *
     * @param enabled {@code true} wenn Navigation erlaubt ist
     */
    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
        updateNavigationButtons();
    }

    /**
     * Beendet den internen Executor sauber und schließt das eventuell noch offene
     * PDF-Dokument. Muss beim Schließen der Anwendung aufgerufen werden.
     */
    public void shutdown() {
        try {
            executor.submit(this::closeCurrentDocumentOnWorker);
        } catch (RuntimeException ignored) {
            // Executor wurde bereits beendet – keine Aktion erforderlich
        }
        executor.shutdown();
    }

    // --- Test-Accessoren ------------------------------------------------------

    /** Visible for tests. */
    Label overlayLabel() {
        return overlayLabel;
    }

    /** Visible for tests. */
    Button prevButton() {
        return prevButton;
    }

    /** Visible for tests. */
    Button nextButton() {
        return nextButton;
    }

    /** Visible for tests. */
    Label pageLabel() {
        return pageLabel;
    }

    /** Visible for tests. */
    ProgressIndicator progressIndicator() {
        return progressIndicator;
    }

    /** Visible for tests. */
    ScrollPane scrollPane() {
        return scrollPane;
    }

    /** Visible for tests. */
    double zoomLevel() {
        return zoomLevel;
    }

    // --- Navigation -----------------------------------------------------------

    private void navigateToPreviousPage() {
        if (!enabled || currentPage <= 1) {
            return;
        }
        goToPage(currentPage - 1);
    }

    private void navigateToNextPage() {
        if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
            return;
        }
        goToPage(currentPage + 1);
    }

    /**
     * Wechselt zur angegebenen Seite. Bereits gerenderte Seiten werden direkt aus dem
     * Cache angezeigt; ansonsten wird ein Rendering-Auftrag auf den Worker-Thread gelegt.
     *
     * @param targetPage Ziel-Seite (1-basiert, muss im gültigen Bereich liegen)
     */
    private void goToPage(int targetPage) {
        currentPage = targetPage;
        updatePageLabel();
        updateNavigationButtons();

        Image cached = pageCache.get(targetPage);
        if (cached != null) {
            imageView.setImage(cached);
            showContent();
            return;
        }

        long seq = currentRequestSequence.incrementAndGet();
        showLoading();
        executor.submit(() -> renderPageOnWorker(targetPage, seq));
    }

    // --- Asynchrones Laden und Rendering --------------------------------------

    /**
     * Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
     * Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
     *
     * @param file die zu ladende Quelldatei
     */
    private void requestLoad(Path file) {
        long seq = currentRequestSequence.incrementAndGet();
        LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);

        showLoading();
        updateNavigationButtons();

        executor.submit(() -> loadAndRenderFirstPageOnWorker(file, seq));
    }

    /**
     * Öffnet die PDF-Datei, ermittelt die Seitenzahl und rendert die erste Seite.
     * Läuft ausschließlich auf dem Worker-Thread.
     *
     * @param file die zu ladende Datei
     * @param seq  die Sequenznummer dieser Anforderung
     */
    private void loadAndRenderFirstPageOnWorker(Path file, long seq) {
        File ioFile = file.toFile();

        if (!ioFile.exists()) {
            LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – Datei nicht gefunden: {}", file);
            publishError(seq, FILE_NOT_FOUND_TEXT);
            return;
        }

        // Vorheriges Dokument schließen bevor ein neues geöffnet wird
        closeCurrentDocumentOnWorker();

        try {
            PDDocument doc = Loader.loadPDF(ioFile);
            currentDocument.set(doc);
            PDFRenderer renderer = new PDFRenderer(doc);
            currentRenderer.set(renderer);

            int pages = Math.max(1, doc.getNumberOfPages());
            BufferedImage buffered =
                    renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
            Image fxImage = SwingFXUtils.toFXImage(buffered, null);

            final int totalPagesFinal = pages;
            Platform.runLater(() -> {
                if (currentRequestSequence.get() != seq) {
                    return; // Veraltet – verwerfen
                }
                totalPages = totalPagesFinal;
                currentPage = 1;
                pageCache.put(1, fxImage);
                imageView.setImage(fxImage);
                showContent();
                updateNavigationButtons();
                updatePageLabel();
                LOG.debug("PDF-Vorschau: Rendering abgeschlossen – {} Seite(n)", totalPagesFinal);
            });
        } catch (InvalidPasswordException ipe) {
            LOG.warn("PDF-Vorschau: PDF ist passwortgeschützt: {}", file, ipe);
            closeCurrentDocumentOnWorker();
            publishError(seq, PDF_PASSWORD_PROTECTED_TEXT);
        } catch (Exception e) {
            LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen: {}", file, e);
            closeCurrentDocumentOnWorker();
            publishError(seq, PDF_UNREADABLE_TEXT);
        }
    }

    /**
     * Rendert eine einzelne Seite des aktuell geöffneten Dokuments.
     * Läuft ausschließlich auf dem Worker-Thread.
     *
     * @param page 1-basierte Seitennummer
     * @param seq  die Sequenznummer dieser Anforderung
     */
    private void renderPageOnWorker(int page, long seq) {
        PDFRenderer renderer = currentRenderer.get();
        if (renderer == null) {
            // Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
            return;
        }
        try {
            BufferedImage buffered = renderer.renderImageWithDPI(page - 1, RENDER_DPI, ImageType.RGB);
            Image fxImage = SwingFXUtils.toFXImage(buffered, null);
            Platform.runLater(() -> {
                if (currentRequestSequence.get() != seq) {
                    return; // Veraltet – verwerfen
                }
                pageCache.put(page, fxImage);
                if (currentPage == page) {
                    imageView.setImage(fxImage);
                    showContent();
                }
            });
        } catch (Exception e) {
            LOG.warn("PDF-Vorschau: Rendering von Seite {} fehlgeschlagen", page, e);
            publishError(seq, PDF_UNREADABLE_TEXT);
        }
    }

    /**
     * Schließt das aktuell geöffnete PDF-Dokument, falls vorhanden. Läuft ausschließlich
     * auf dem Worker-Thread und ist idempotent.
     */
    private void closeCurrentDocumentOnWorker() {
        PDDocument doc = currentDocument.getAndSet(null);
        currentRenderer.set(null);
        if (doc != null) {
            try {
                doc.close();
            } catch (Exception e) {
                LOG.debug("PDF-Vorschau: Schließen des Dokuments schlug fehl", e);
            }
        }
    }

    /**
     * Übergibt eine Fehlermeldung auf den FX-Thread. Veraltete Meldungen werden verworfen.
     *
     * @param seq     Sequenznummer der Anforderung, zu der die Meldung gehört
     * @param message anzuzeigende Fehlermeldung
     */
    private void publishError(long seq, String message) {
        Platform.runLater(() -> {
            if (currentRequestSequence.get() != seq) {
                return;
            }
            showError(message);
            updateNavigationButtons();
        });
    }

    // --- Grab & Pan -----------------------------------------------------------

    /**
     * Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand.
     * Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist.
     *
     * @param event das Maus-Pressed-Ereignis
     */
    private void onPanMousePressed(MouseEvent event) {
        if (scrollPane.isFitToWidth()) {
            return; // Im Fit-Modus kein Pan nötig
        }
        panStartX = event.getScreenX();
        panStartY = event.getScreenY();
        panStartHvalue = scrollPane.getHvalue();
        panStartVvalue = scrollPane.getVvalue();
        viewStack.setCursor(Cursor.CLOSED_HAND);
        event.consume();
    }

    /**
     * Verschiebt den Viewport relativ zur Startposition der Pan-Geste.
     * Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert.
     *
     * @param event das Maus-Dragged-Ereignis
     */
    private void onPanMouseDragged(MouseEvent event) {
        if (panStartX < 0 || scrollPane.isFitToWidth()) {
            return;
        }
        double dx = event.getScreenX() - panStartX;
        double dy = event.getScreenY() - panStartY;

        Bounds viewport = scrollPane.getViewportBounds();
        double contentWidth = viewStack.getWidth();
        double contentHeight = viewStack.getHeight();
        double viewportWidth = viewport != null ? viewport.getWidth() : 0;
        double viewportHeight = viewport != null ? viewport.getHeight() : 0;

        double scrollableWidth = contentWidth - viewportWidth;
        double scrollableHeight = contentHeight - viewportHeight;

        if (scrollableWidth > 0) {
            double newHval = panStartHvalue - dx / scrollableWidth;
            scrollPane.setHvalue(Math.max(0, Math.min(1, newHval)));
        }
        if (scrollableHeight > 0) {
            double newVval = panStartVvalue - dy / scrollableHeight;
            scrollPane.setVvalue(Math.max(0, Math.min(1, newVval)));
        }
        event.consume();
    }

    /**
     * Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her.
     *
     * @param event das Maus-Released-Ereignis
     */
    private void onPanMouseReleased(MouseEvent event) {
        panStartX = -1;
        panStartY = -1;
        if (!scrollPane.isFitToWidth()) {
            viewStack.setCursor(Cursor.OPEN_HAND);
        }
        event.consume();
    }

    // --- Zoom -----------------------------------------------------------------

    /**
     * Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
     * Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
     * um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet.
     *
     * <p>Der Rohwert von {@code deltaY} wird vor der Akkumulation auf einen
     * Notch-Wert ({@value #ZOOM_NOTCH_THRESHOLD}) begrenzt. Plattformspezifische
     * Scroll-Multiplikatoren (z. B. Windows-Mausgeschwindigkeit, hohe DPI-Mäuse)
     * können sonst Werte wie 120 oder mehr pro Raste liefern, was einen
     * Akkumulator-Überlauf in Folge-Events verursacht.
     *
     * @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
     */
    private void accumulateAndApplyZoomDelta(double deltaY) {
        // Normierung: maximal einen Notch-Wert pro Event akkumulieren, um
        // plattformspezifische deltaY-Überhöhungen (z. B. 120 statt 40) abzufangen
        double capped = Math.signum(deltaY) * Math.min(Math.abs(deltaY), ZOOM_NOTCH_THRESHOLD);
        zoomAccumulator += capped;
        if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
            zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
            applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
        } else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
            zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
            applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
        }
    }

    /**
     * Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
     * <p>
     * Beim ersten Aufruf (Wechsel aus dem Fit-Modus) wird {@code zoomLevel} auf
     * den aktuellen visuellen Skalierungsfaktor kalibriert: aktuelle visuelle
     * Breite der ImageView (mit {@code preserveRatio} bereits aspekt-korrekt
     * verkleinert) geteilt durch die natürliche Bildbreite. Damit entspricht
     * {@code zoomLevel = 1.0} der pixel-genauen Originalgröße, und der erste
     * Zoom-Schritt addiert sich auf den realen Skalierungsfaktor. Ohne diese
     * Kalibrierung springt die ImageView abrupt auf {@code Viewport-Breite × 1.10},
     * weil im Fit-Modus die {@code fitHeight}-Bindung das Bild aspekt-erhaltend
     * deutlich kleiner zwingt als {@code naturalViewportWidth × 1.0} ergibt.
     * Da der Caller den Delta-Schritt auf dem alten {@code zoomLevel = 1.0}
     * berechnet hat, wird er nach der Kalibrierung auf den neuen, kalibrierten
     * {@code zoomLevel} re-appliziert.
     * <p>
     * Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte
     * zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle
     * Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der
     * Positionswiederherstellung stellt sicher, dass die neuen Inhaltsgrenzen
     * bereits berechnet sind.
     *
     * @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
     */
    private void applyZoom(double newZoom) {
        double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));

        boolean wasInFitMode = scrollPane.isFitToWidth();
        if (wasInFitMode) {
            Image image = imageView.getImage();
            if (image == null || image.getWidth() <= 0) {
                return; // Kein Bild – Zoom-Kalibrierung nicht möglich
            }
            double naturalImageWidth = image.getWidth();
            double currentVisualWidth = imageView.getBoundsInLocal().getWidth();
            if (currentVisualWidth <= 0) {
                Bounds viewport = scrollPane.getViewportBounds();
                currentVisualWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
                if (currentVisualWidth <= 0) {
                    return; // Layout noch nicht abgeschlossen
                }
            }

            // Vom Caller intendierten Delta-Schritt vor der Kalibrierung sichern
            double requestedDelta = newZoom - zoomLevel;

            // zoomLevel auf den aktuellen visuellen Skalierungsfaktor kalibrieren
            naturalViewportWidth = naturalImageWidth;
            zoomLevel = currentVisualWidth / naturalImageWidth;

            // effective neu berechnen, weil zoomLevel sich geändert hat
            effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel + requestedDelta));

            scrollPane.setFitToWidth(false);
            scrollPane.setFitToHeight(false);
            imageView.fitWidthProperty().unbind();
            imageView.fitHeightProperty().unbind();
            // Mauszeiger signalisiert Pan-Modus
            viewStack.setCursor(Cursor.OPEN_HAND);
        }

        if (effective == zoomLevel) {
            return;
        }

        zoomLevel = effective;
        imageView.setFitWidth(naturalViewportWidth * zoomLevel);
        imageView.setFitHeight(0);
        // Keine manuellen setHvalue/setVvalue-Eingriffe nötig: viewStack hat
        // dank des viewportBoundsProperty-Listeners im Konstruktor mindestens
        // Viewport-Größe, und Pos.CENTER sorgt für automatische Zentrierung,
        // wenn der Inhalt kleiner als der Viewport ist.
    }

    /**
     * Setzt Zoom, Akkumulator und Pan-Zustand zurück und reaktiviert den Fit-to-View-Modus.
     * Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
     *
     * <p>Reihenfolge der Aktionen ist kritisch:
     * <ol>
     *   <li>{@code setFitToWidth(true)} und {@code setFitToHeight(true)} sofort,
     *       damit der nächste Layout-Pass den {@code viewStack} auf Viewport-Größe
     *       zurückrechnet.</li>
     *   <li>Property-Bindungen und H/V-Reset im {@code Platform.runLater}, damit
     *       sie auf die bereits zurückgerechneten {@code viewStack}-Dimensionen
     *       wirken und nicht auf die noch zoom-große Breite.</li>
     * </ol>
     * Ohne diese Reihenfolge würden die Bindungen die imageView kurz an die
     * Zoom-Größe koppeln, und ein verbleibender H/V-Wert aus dem Pan-/Zoom-Modus
     * (z. B. {@code hvalue=0.0} nach Pan zum linken Rand) würde die PDF wegen
     * kleinster Rounding-/Border-Differenzen links/oben bündig statt zentriert
     * anzeigen, obwohl der ScrollPane fit-aktiv ist.
     */
    private void resetToFitView() {
        zoomLevel = 1.0;
        zoomAccumulator = 0.0;
        naturalViewportWidth = 0.0;
        // Pan-Zustand und Mauszeiger zurücksetzen
        panStartX = -1;
        panStartY = -1;
        viewStack.setCursor(null);
        if (!scrollPane.isFitToWidth()) {
            // 1. ScrollPane in Fit-Modus schalten, damit der nächste Layout-Pass
            //    den viewStack auf Viewport-Größe zurückrechnet
            scrollPane.setFitToWidth(true);
            scrollPane.setFitToHeight(true);
            // 2. Bindings erst nach abgeschlossenem Layout-Pass, damit sie auf
            //    die zurückgerechneten viewStack-Dimensionen wirken
            Platform.runLater(() -> {
                imageView.fitWidthProperty().bind(viewStack.widthProperty());
                imageView.fitHeightProperty().bind(viewStack.heightProperty());
            });
        }
    }

    // --- UI-Zustandshelfer ---------------------------------------------------

    private void showPlaceholder() {
        overlayLabel.setText(PLACEHOLDER_TEXT);
        overlayLabel.setVisible(true);
        overlayLabel.setManaged(true);
        imageView.setVisible(false);
        imageView.setManaged(false);
        progressIndicator.setVisible(false);
        progressIndicator.setManaged(false);
        pageLabel.setText("");
    }

    private void showLoading() {
        progressIndicator.setVisible(true);
        progressIndicator.setManaged(true);
        overlayLabel.setVisible(false);
        overlayLabel.setManaged(false);
        imageView.setVisible(false);
        imageView.setManaged(false);
    }

    private void showContent() {
        progressIndicator.setVisible(false);
        progressIndicator.setManaged(false);
        overlayLabel.setVisible(false);
        overlayLabel.setManaged(false);
        imageView.setVisible(true);
        imageView.setManaged(true);
    }

    private void showError(String message) {
        overlayLabel.setText(message);
        overlayLabel.setVisible(true);
        overlayLabel.setManaged(true);
        imageView.setVisible(false);
        imageView.setManaged(false);
        progressIndicator.setVisible(false);
        progressIndicator.setManaged(false);
        pageLabel.setText("");
    }

    private void updateNavigationButtons() {
        boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
        prevButton.setDisable(!canNavigate || currentPage <= 1);
        nextButton.setDisable(!canNavigate || currentPage >= totalPages);
    }

    private void updatePageLabel() {
        if (totalPages > 0 && currentPage > 0) {
            pageLabel.setText("Seite " + currentPage + " / " + totalPages);
        } else {
            pageLabel.setText("");
        }
    }
}
