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

import java.nio.file.Path;
import java.time.Duration;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenamePersistenceFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFileMissing;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;

/**
 * Zweiter Haupt-Tab des JavaFX-Editorfensters: die Live-Verarbeitungslauf-Ansicht.
 *
 * <p>Der Tab kapselt die gesamte UI zum Starten, Beobachten und Abbrechen eines
 * Batch-Laufs. Er arbeitet mit einem {@link GuiBatchRunCoordinator} zusammen, der
 * den Hintergrund-Worker-Thread besitzt und Fortschritts-Callbacks auf dem JavaFX
 * Application Thread zurückmeldet.
 *
 * <p>Nach einem Lauf kann der Benutzer eine oder mehrere Zeilen auswählen und
 * entweder „Erneut verarbeiten" oder „Status zurücksetzen" auslösen. Zusätzlich
 * kann der Benutzer den von der KI vorgeschlagenen Dateinamen für erfolgreich
 * verarbeitete Dokumente direkt in der GUI bearbeiten und speichern.
 *
 * <h2>Layout</h2>
 * <pre>
 *   ┌──────────────────────────────────────────────────────────┐
 *   │ [Fortschrittsbalken]                  12 / 47 Dateien   │
 *   ├───────────────────────────┬──────────────────────────────┤
 *   │ Ergebnisliste (60%)       │ Detailbereich (40%)          │
 *   │ (TableView + Checkboxen)  │   KI-Begründung (kompakt)    │
 *   │                           │   Dateiname-Editor           │
 *   ├───────────────────────────┤   PDF-Vorschau (Restplatz)   │
 *   │ [Erneut ver.] [Zurückset.]│                              │
 *   │ Meldungsbereich           │                              │
 *   └───────────────────────────┴──────────────────────────────┘
 *   [Starten]  [Abbrechen]
 * </pre>
 *
 * <h2>Threading</h2>
 * <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
 * werden. Die Klasse ist nicht thread-sicher; der Coordinator ist verantwortlich
 * dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
 */
public final class GuiBatchRunTab {
    private static final String COPY_FAILED_LOG = "Manuelle Dateikopie fehlgeschlagen: {}";
    private static final String RENAME_FAILED_LOG = "Manuelle Dateiumbenennung fehlgeschlagen: {}";
    private static final String DIRTY_STATE_MSG = "Dateiname-Editor: Ungespeicherte Änderungen";



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

    /** Platzhalter im Detailbereich vor der ersten Zeilenselektion. */
    static final String DETAIL_PLACEHOLDER = "Datei auswählen für Details";

    /** Hinweis wenn kein KI-Reasoning für die selektierte Zeile vorliegt. */
    static final String NO_REASONING_TEXT = "Für diesen Eintrag liegt kein KI-Reasoning vor.";

    /** Hinweis beim Start gegen einen leeren Quellordner. */
    static final String EMPTY_SOURCE_FOLDER_HINT =
            "Keine verarbeitbaren Dateien im Quellordner gefunden";

    /** Hinweis wenn ein zweiter Startversuch bei laufendem Lauf ausgelöst wird. */
    static final String ALREADY_RUNNING_HINT = "Ein Verarbeitungslauf ist bereits aktiv.";

    /** Angezeigt wenn der DB-Reset vor „Erneut verarbeiten" für alle Dokumente scheiterte. */
    static final String REPROCESS_RESET_FAILED_HINT =
            "Fehler: Status-Reset fehlgeschlagen – Mini-Lauf wurde nicht gestartet.";

    /** Hinweis wenn keine gespeicherte Konfiguration vorliegt. */
    static final String NO_SAVED_CONFIGURATION_HINT =
            "Bitte speichern Sie die Konfiguration, bevor ein Verarbeitungslauf gestartet wird.";

    /** Platzhalter in leeren Tabellenzellen. */
    static final String EMPTY_CELL_TEXT = "\u2014"; // —

    private static final String TAB_TITLE = "Verarbeitungslauf";
    private static final double PROGRESS_BAR_MAX_WIDTH = Double.MAX_VALUE;
    private static final double PROGRESS_BAR_PREF_HEIGHT = 20;
    private static final double DETAIL_PANE_MIN_WIDTH = 280;
    private static final double LIST_MIN_HEIGHT = 240;
    private static final double CHECKBOX_COL_WIDTH = 40;
    private static final int SECONDARY_SPACING = 12;
    private static final double SPLIT_DIVIDER_POSITION = 0.6;
    private static final int DETAIL_AREA_ROW_COUNT = 4;

    private final Tab tab = new Tab(TAB_TITLE);
    private final ProgressBar progressBar = new ProgressBar(0);
    private final Label counterLabel = new Label("0 / 0 Dateien");
    private final TableView<GuiBatchRunResultRow> resultTable = new TableView<>();
    private final ObservableList<GuiBatchRunResultRow> resultItems = FXCollections.observableArrayList();

    /**
     * {@code true} wenn der aktive Lauf ein gezielter Mini-Lauf ist. Steuert, ob
     * {@link #onDocumentCompleted} Zeilen in-place aktualisiert oder neu anhängt.
     */
    private boolean activeRunIsMiniRun = false;

    /**
     * Snapshot der beim Mini-Lauf-Start ausgewählten Fingerabdrücke mit zugehörigen
     * Originaldateinamen. Wird verwendet um fehlende Quelldateien zu erkennen.
     */
    private Map<DocumentFingerprint, String> miniRunSnapshotFilenames = Map.of();

    /**
     * Fingerabdrücke die während des aktuellen Mini-Laufs ein {@code onDocumentCompleted}-
     * Callback erhalten haben. Zum Erkennen still übersprungener Dokumente.
     */
    private Set<DocumentFingerprint> miniRunCompletedFingerprints = new HashSet<>();

    /**
     * Logische Selektionsmenge – Mitgliedschaft definiert welche Zeilen „angehakt" sind.
     * TableView-Selektionsmodell und Zeilencheckboxen bleiben mit dieser Menge synchron.
     */
    private final ObservableSet<GuiBatchRunResultRow> selectedRows =
            FXCollections.observableSet();

    /**
     * {@code true} wenn Selektionsänderungs-Listener nicht weiter propagieren sollen.
     * Verhindert Feedback-Schleifen während programmgesteuerter Synchronisation.
     */
    private boolean selectionSyncInProgress = false;

    /** Master-Checkbox in der Checkbox-Spalten-Kopfzeile – Tri-State. */
    private final CheckBox masterCheckBox = new CheckBox();

    private final TextArea detailArea = new TextArea(DETAIL_PLACEHOLDER);
    private final TextArea messageArea = new TextArea();
    private final Button startButton = new Button("Starten");
    private final Button cancelButton = new Button("Abbrechen");
    private final Button reprocessButton = new Button("Erneut verarbeiten");
    private final Button resetStatusButton = new Button("Status zurücksetzen");
    private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);

    /** {@code true} while the automatic scheduler is in any non-{@code STOPPED} state. */
    private boolean schedulerActive = false;

    /** Dateiname-Editor-Komponente im Detailbereich. */
    private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();

    /** PDF-Vorschau-Komponente im Detailbereich. */
    private final PdfPreviewPane pdfPreview = new PdfPreviewPane();

    /** Summary-Banner unterhalb des Fortschrittsbalkens – sichtbar nach Laufabschluss. */
    private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();

    private final Supplier<Path> configPathSupplier;
    private final BooleanSupplier savedConfigurationReadyCheck;
    private final Runnable onRunStateChanged;
    private final GuiBatchRunCoordinator coordinator;
    private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
    private final Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier;
    private final Supplier<Optional<Path>> sourceFolderSupplier;
    private final Supplier<Optional<String>> targetFolderSupplier;

    /** Hintergrund-Thread-Pool für manuelle Umbenennungsoperationen. */
    private final ExecutorService renameExecutor =
            Executors.newSingleThreadExecutor(r -> {
                Thread t = new Thread(r, "gui-rename-worker");
                t.setDaemon(true);
                return t;
            });

    /** Aktuell selektierte Zeile; null wenn keine Zeile selektiert ist. */
    private GuiBatchRunResultRow currentlySelectedRow = null;

    private int totalCandidates;
    private int completedCandidates;
    private int successCount;
    private int failedCount;
    private int skippedCount;

    /**
     * Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und
     * Rücksetz-Fähigkeiten sowie dem Dateiname-Editor, der PDF-Vorschau und einem
     * optionalen OS-Lock auf die Konfigurationsdatei.
     *
     * @param launcherSupplier              Supplier für den aktiven Batch-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param miniRunLauncherSupplier       Supplier für den Mini-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param resetPortSupplier             Supplier für den Rücksetz-Port;
     *                                      darf nicht null sein
     * @param configPathSupplier            Supplier für den letzten gespeicherten
     *                                      Konfigurationspfad; darf null zurückliefern
     * @param savedConfigurationReadyCheck  Prüfung vor jedem Startversuch; darf nicht
     *                                      null sein
     * @param onRunStateChanged             Callback wenn das Lauf-Flag kippt; darf nicht
     *                                      null sein
     * @param manualFileRenamePortSupplier   Supplier für den manuellen Umbenennungs-Port;
     *                                       darf nicht null sein
     * @param manualFileCopyPortSupplier     Supplier für den manuellen Kopier-Port (für FAILED-/
     *                                       SKIPPED-Zeilen ohne existierende Zieldatei);
     *                                       darf nicht null sein
     * @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
     *                                              darf nicht null sein
     * @param sourceFolderSupplier           Supplier für den konfigurierten Quellordner;
     *                                       darf leeres Optional zurückliefern
     * @param targetFolderSupplier           Supplier für den konfigurierten Zielordner als
     *                                       Pfad-String; darf leeres Optional zurückliefern
     * @param configurationFileLockPort      optionaler OS-Lock auf die Konfigurationsdatei;
     *                                       wird vor jedem Lauf erworben und danach freigegeben;
     *                                       {@code null} wird als leer behandelt
     */
    public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
                          Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
                          Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
                          Supplier<Path> configPathSupplier,
                          BooleanSupplier savedConfigurationReadyCheck,
                          Runnable onRunStateChanged,
                          Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
                          Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
                          Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
                          Supplier<Optional<Path>> sourceFolderSupplier,
                          Supplier<Optional<String>> targetFolderSupplier,
                          Optional<ConfigurationFileLockPort> configurationFileLockPort) {
        Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
        Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
        Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
        this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier must not be null");
        this.savedConfigurationReadyCheck = Objects.requireNonNull(
                savedConfigurationReadyCheck, "savedConfigurationReadyCheck must not be null");
        this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
        this.manualFileRenamePortSupplier = Objects.requireNonNull(
                manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
        this.manualFileCopyPortSupplier = Objects.requireNonNull(
                manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null");
        Objects.requireNonNull(
                historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
        this.sourceFolderSupplier = Objects.requireNonNull(
                sourceFolderSupplier, "sourceFolderSupplier must not be null");
        this.targetFolderSupplier = Objects.requireNonNull(
                targetFolderSupplier, "targetFolderSupplier must not be null");

        Optional<ConfigurationFileLockPort> effectiveLockPort =
                Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
        this.coordinator = new GuiBatchRunCoordinator(
                (configPath, observer, token) ->
                        launcherSupplier.get().launch(configPath, observer, token),
                (configPath, filter, observer, token) ->
                        miniRunLauncherSupplier.get().launch(configPath, filter, observer, token),
                (configPath, fingerprints) ->
                        resetPortSupplier.get().reset(configPath, fingerprints),
                new CoordinatorListener(),
                historicalDocumentContextPortSupplier.get(),
                effectiveLockPort);

        this.tab.setClosable(false);
        this.tab.setContent(buildContent());

        // Detailbereich-Controls deaktivieren/aktivieren wenn Lauf startet/endet
        runningProperty.addListener((obs, wasRunning, running) -> {
            fileNameEditor.setEnabled(!running);
            pdfPreview.setEnabled(!running);
        });

        // Dateiname-Speichern-Callback verdrahten
        fileNameEditor.setOnSaveRequested(this::handleSaveFileName);

        resetMetrics();
        updateCounterLabel();
        updateButtonStates();
    }

    /**
     * Rückwärtskompatible Variante ohne OS-Lock auf die Konfigurationsdatei.
     * <p>
     * Alle bestehenden Aufrufer, die vor der Lock-Port-Erweiterung erstellt wurden,
     * nutzen diesen Konstruktor. Er delegiert an den kanonischen Konstruktor mit
     * {@code configurationFileLockPort = Optional.empty()}.
     *
     * @param launcherSupplier              Supplier für den aktiven Batch-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param miniRunLauncherSupplier       Supplier für den Mini-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param resetPortSupplier             Supplier für den Rücksetz-Port;
     *                                      darf nicht null sein
     * @param configPathSupplier            Supplier für den letzten gespeicherten
     *                                      Konfigurationspfad; darf null zurückliefern
     * @param savedConfigurationReadyCheck  Prüfung vor jedem Startversuch; darf nicht
     *                                      null sein
     * @param onRunStateChanged             Callback wenn das Lauf-Flag kippt; darf nicht
     *                                      null sein
     * @param manualFileRenamePortSupplier   Supplier für den manuellen Umbenennungs-Port;
     *                                       darf nicht null sein
     * @param manualFileCopyPortSupplier     Supplier für den manuellen Kopier-Port;
     *                                       darf nicht null sein
     * @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
     *                                              darf nicht null sein
     * @param sourceFolderSupplier           Supplier für den konfigurierten Quellordner
     * @param targetFolderSupplier           Supplier für den konfigurierten Zielordner
     */
    public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
                          Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
                          Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
                          Supplier<Path> configPathSupplier,
                          BooleanSupplier savedConfigurationReadyCheck,
                          Runnable onRunStateChanged,
                          Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
                          Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
                          Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
                          Supplier<Optional<Path>> sourceFolderSupplier,
                          Supplier<Optional<String>> targetFolderSupplier) {
        this(launcherSupplier, miniRunLauncherSupplier, resetPortSupplier, configPathSupplier,
                savedConfigurationReadyCheck, onRunStateChanged, manualFileRenamePortSupplier,
                manualFileCopyPortSupplier, historicalDocumentContextPortSupplier,
                sourceFolderSupplier, targetFolderSupplier, Optional.empty());
    }

    /**
     * Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten.
     *
     * @param launcherSupplier            Supplier für den aktiven Batch-Lauf-Launcher;
     *                                    darf nicht null sein
     * @param configPathSupplier          Supplier für den gespeicherten Konfigurationspfad
     * @param savedConfigurationReadyCheck Prüfung vor Startversuchen; darf nicht null sein
     * @param onRunStateChanged           Callback beim Lauf-Zustandswechsel; darf nicht
     *                                    null sein
     */
    public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
                          Supplier<Path> configPathSupplier,
                          BooleanSupplier savedConfigurationReadyCheck,
                          Runnable onRunStateChanged) {
        this(launcherSupplier,
                () -> GuiBatchRunTab::rejectingMiniLaunch,
                () -> GuiBatchRunTab::rejectingReset,
                configPathSupplier,
                savedConfigurationReadyCheck,
                onRunStateChanged,
                () -> GuiBatchRunTab::rejectingRename,
                () -> GuiBatchRunTab::rejectingCopy,
                () -> (cfgPath, fp) -> Optional.empty(),
                Optional::empty,
                Optional::empty);
    }

    /**
     * Rückwärtskompatible Variante mit Mini-Lauf- und Rücksetz-Fähigkeit, aber ohne
     * manuellen Umbenennungs-Port und Ordner-Supplier.
     *
     * @param launcherSupplier              Supplier für den Batch-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param miniRunLauncherSupplier       Supplier für den Mini-Lauf-Launcher;
     *                                      darf nicht null sein
     * @param resetPortSupplier             Supplier für den Rücksetz-Port;
     *                                      darf nicht null sein
     * @param configPathSupplier            Supplier für den gespeicherten Konfigurationspfad
     * @param savedConfigurationReadyCheck  Prüfung vor Startversuchen; darf nicht null sein
     * @param onRunStateChanged             Callback beim Lauf-Zustandswechsel; darf nicht
     *                                      null sein
     */
    public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
                          Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
                          Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
                          Supplier<Path> configPathSupplier,
                          BooleanSupplier savedConfigurationReadyCheck,
                          Runnable onRunStateChanged) {
        this(launcherSupplier,
                miniRunLauncherSupplier,
                resetPortSupplier,
                configPathSupplier,
                savedConfigurationReadyCheck,
                onRunStateChanged,
                () -> GuiBatchRunTab::rejectingRename,
                () -> GuiBatchRunTab::rejectingCopy,
                () -> (cfgPath, fp) -> Optional.empty(),
                Optional::empty,
                Optional::empty);
    }

    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------

    /**
     * Liefert den JavaFX-{@link Tab}-Knoten, der die Verarbeitungslauf-Ansicht enthält.
     *
     * @return der Tab; nie null
     */
    public Tab tab() {
        return tab;
    }

    /**
     * Liefert eine schreibgeschützte Eigenschaft, die {@code true} ist solange ein Lauf aktiv ist.
     *
     * @return schreibgeschützte Lauf-Eigenschaft
     */
    public ReadOnlyBooleanProperty runningProperty() {
        return runningProperty.getReadOnlyProperty();
    }

    /**
     * Gibt an ob ein Lauf auf dem Worker-Thread aktuell in Bearbeitung ist.
     *
     * @return {@code true} während der Coordinator einen Lauf verarbeitet
     */
    public boolean isRunning() {
        return runningProperty.get();
    }

    /**
     * Fordert Soft-Stop-Abbruch des aktuell laufenden Batch-Laufs an.
     * Wenn kein Lauf aktiv ist, hat der Aufruf keinen Effekt.
     */
    public void requestCancellation() {
        coordinator.requestCancellation();
        cancelButton.setDisable(true);
    }

    /**
     * Gibt an ob im Dateiname-Editor ungespeicherte Änderungen vorliegen.
     *
     * @return {@code true} wenn der Editor einen Dirty-State hat
     */
    public boolean hasUnsavedFilenameEdits() {
        return fileNameEditor.isDirty();
    }

    /**
     * Zeigt ggf. einen Bestätigungsdialog für das Verwerfen ungespeicherter
     * Dateinamen-Änderungen und gibt zurück ob fortgefahren werden darf.
     * Wenn kein Dirty-State vorliegt, wird immer {@code true} zurückgeliefert.
     *
     * @return {@code true} wenn fortgefahren werden darf (Benutzer hat „Verwerfen"
     *         gewählt oder kein Dirty-State vorlag)
     */
    public boolean confirmDiscardUnsavedFilenameEdits() {
        if (!fileNameEditor.isDirty()) {
            return true;
        }
        return askDiscardFilenameChanges();
    }

    /**
     * Aktualisiert den Tab-Zustand anhand des aktuellen Scheduler-Status.
     * <p>
     * Deaktiviert den Starten-Button und setzt einen erklärenden Tooltip, solange
     * der Scheduler aktiv ist. Wenn der Scheduler gestoppt ist, wird der normale
     * Button-Zustand wiederhergestellt (Starten erlaubt sofern kein Lauf läuft).
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param status aktueller Scheduler-Status; darf nicht {@code null} sein
     */
    public void updateSchedulerState(SchedulerStatus status) {
        schedulerActive = status.state().isActive();
        startButton.setDisable(runningProperty.get() || schedulerActive);
        startButton.setTooltip(new Tooltip(schedulerActive
                ? "Manuelle Läufe sind während aktivem Scheduler nicht möglich."
                : GuiTooltipTexts.BATCHRUN_STARTEN));
    }

    // -------------------------------------------------------------------------
    // Paket-private Accessor für Tests
    // -------------------------------------------------------------------------

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

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

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

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

    /** Visible for tests. */
    ProgressBar progressBar() { return progressBar; }

    /** Visible for tests. */
    TableView<GuiBatchRunResultRow> resultTable() { return resultTable; }

    /** Visible for tests. */
    TextArea messageArea() { return messageArea; }

    /** Visible for tests. */
    TextArea detailArea() { return detailArea; }

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

    /** Visible for tests. */
    GuiBatchRunCoordinator coordinator() { return coordinator; }

    /** Visible for tests. */
    ObservableSet<GuiBatchRunResultRow> selectedRows() { return selectedRows; }

    /** Visible for tests. */
    CheckBox masterCheckBox() { return masterCheckBox; }

    /** Visible for tests. */
    FileNameEditorPane fileNameEditor() { return fileNameEditor; }

    /** Visible for tests. */
    PdfPreviewPane pdfPreview() { return pdfPreview; }

    // -------------------------------------------------------------------------
    // Layout-Aufbau
    // -------------------------------------------------------------------------

    private BorderPane buildContent() {
        BorderPane layout = new BorderPane();
        layout.setPadding(new Insets(12));

        layout.setTop(buildProgressHeader());
        layout.setCenter(buildCenterSplit());
        layout.setBottom(buildFooter());

        return layout;
    }

    private Region buildProgressHeader() {
        progressBar.setMaxWidth(PROGRESS_BAR_MAX_WIDTH);
        progressBar.setPrefHeight(PROGRESS_BAR_PREF_HEIGHT);
        HBox.setHgrow(progressBar, Priority.ALWAYS);

        counterLabel.setId("batch-run-counter");
        HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
        progressRow.setAlignment(Pos.CENTER_LEFT);

        // Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
        HBox bannerNode = summaryBanner.getNode();
        bannerNode.setId("batch-run-summary-banner");

        VBox header = new VBox(0, progressRow, bannerNode);
        header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
        return header;
    }

    private Region buildCenterSplit() {
        configureResultTable();
        ScrollPane tableScroll = new ScrollPane(resultTable);
        tableScroll.setFitToWidth(true);
        tableScroll.setFitToHeight(true);
        tableScroll.setId("batch-run-result-scroll");
        resultTable.setMinHeight(LIST_MIN_HEIGHT);

        // Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
        reprocessButton.setId("batch-run-reprocess");
        reprocessButton.setOnAction(event -> handleReprocessSelected());
        reprocessButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ERNEUT_VERARBEITEN));

        resetStatusButton.setId("batch-run-reset-status");
        resetStatusButton.setOnAction(event -> handleResetSelected());
        resetStatusButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STATUS_ZURUECKSETZEN));

        HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
        selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
        selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, SECONDARY_SPACING / 2.0, 0));

        // Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
        messageArea.setId("batch-run-message-area");
        messageArea.setEditable(false);
        messageArea.setWrapText(true);
        messageArea.setPrefRowCount(3);
        messageArea.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MESSAGE_AREA));
        // Hinweisbereich erst einblenden wenn eine Meldung vorliegt
        messageArea.setVisible(false);
        messageArea.setManaged(false);

        // Linke Spalte: Tabelle wächst, Buttons und Meldungsbereich darunter
        VBox leftColumn = new VBox(0, tableScroll, selectionButtonBar, messageArea);
        VBox.setVgrow(tableScroll, Priority.ALWAYS);

        // Detailbereich rechts erstreckt sich vollständig von oben bis unten
        VBox detailBox = buildDetailPane();

        SplitPane splitPane = new SplitPane(leftColumn, detailBox);
        splitPane.setId("batch-run-split-pane");
        splitPane.setDividerPositions(SPLIT_DIVIDER_POSITION);
        SplitPane.setResizableWithParent(detailBox, true);

        return splitPane;
    }

    private VBox buildDetailPane() {
        // --- KI-Begründung (kompakt oben) ---
        detailArea.setId("batch-run-detail");
        detailArea.setEditable(false);
        detailArea.setWrapText(true);
        detailArea.setPrefRowCount(DETAIL_AREA_ROW_COUNT);
        detailArea.setMinWidth(DETAIL_PANE_MIN_WIDTH);

        Label detailTitle = new Label("KI-Begründung");
        detailTitle.setStyle("-fx-font-weight: bold;");
        VBox reasoningBox = new VBox(SECONDARY_SPACING / 2.0, detailTitle, detailArea);

        // --- Dateiname-Editor (Mitte) ---
        Region editorNode = fileNameEditor.getNode();

        // --- PDF-Vorschau (Restplatz unten) ---
        Region previewNode = pdfPreview.getNode();
        VBox.setVgrow(previewNode, Priority.ALWAYS);

        VBox detailBox = new VBox(SECONDARY_SPACING / 2.0, reasoningBox, editorNode, previewNode);
        detailBox.setPadding(new Insets(0, SECONDARY_SPACING, 0, SECONDARY_SPACING));
        detailBox.setMinWidth(DETAIL_PANE_MIN_WIDTH);
        return detailBox;
    }

    private void configureResultTable() {
        resultTable.setItems(resultItems);
        resultTable.setId("batch-run-result-table");
        resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
        resultTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);

        // Checkbox-Spalte mit Master-Checkbox-Kopf
        TableColumn<GuiBatchRunResultRow, Void> checkboxCol = new TableColumn<>();
        checkboxCol.setId("batch-run-checkbox-col");
        checkboxCol.setPrefWidth(CHECKBOX_COL_WIDTH);
        checkboxCol.setMaxWidth(CHECKBOX_COL_WIDTH);
        checkboxCol.setResizable(false);
        checkboxCol.setSortable(false);

        masterCheckBox.setId("batch-run-master-checkbox");
        masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
        masterCheckBox.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MASTER_CHECKBOX));
        checkboxCol.setGraphic(masterCheckBox);

        checkboxCol.setCellFactory(col -> new CheckBoxCell());
        checkboxCol.setEditable(true);

        TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>();
        iconCol.setGraphic(columnHeader("Status", GuiTooltipTexts.BATCHRUN_COL_STATUS));
        iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
        iconCol.setPrefWidth(64);
        iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
            @Override
            protected void updateItem(String icon, boolean empty) {
                super.updateItem(icon, empty);
                if (empty || icon == null) {
                    setText(null);
                    setStyle(null);
                    setTooltip(null);
                    return;
                }
                setText(icon);
                TableRow<GuiBatchRunResultRow> tableRow = getTableRow();
                GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
                if (data != null && data.resetPending()) {
                    setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
                    setTooltip(new Tooltip(data.statusTooltip()));
                } else if (data != null) {
                    // Farbe aus zentralem Mapping – nie alleiniges Unterscheidungsmerkmal
                    String color = ProcessingStatusPresentation.cssColorFor(data.status());
                    setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
                    setTooltip(new Tooltip(data.statusTooltip()));
                } else {
                    setStyle("-fx-alignment: CENTER; -fx-font-size: 14;");
                    setTooltip(null);
                }
            }
        });

        TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>();
        nameCol.setGraphic(columnHeader("Originaldateiname", GuiTooltipTexts.BATCHRUN_COL_ORIGINALDATEINAME));
        nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
        nameCol.setPrefWidth(280);

        TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>();
        newNameCol.setGraphic(columnHeader("Neuer Dateiname", GuiTooltipTexts.BATCHRUN_COL_NEUER_DATEINAME));
        newNameCol.setCellValueFactory(data -> {
            GuiBatchRunResultRow row = data.getValue();
            if (row.resetPending()) {
                return new SimpleStringProperty(GuiBatchRunResultRow.RESET_PENDING_LABEL);
            }
            return new SimpleStringProperty(row.effectiveFileName().orElse(EMPTY_CELL_TEXT));
        });
        newNameCol.setPrefWidth(280);

        TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>();
        dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.BATCHRUN_COL_DATUM));
        dateCol.setCellValueFactory(data -> new SimpleStringProperty(
                data.getValue().resolvedDate()
                        .map(DateTimeFormatter.ISO_LOCAL_DATE::format)
                        .orElse(EMPTY_CELL_TEXT)));
        dateCol.setPrefWidth(100);

        TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>();
        durationCol.setGraphic(columnHeader("Dauer", GuiTooltipTexts.BATCHRUN_COL_DAUER));
        durationCol.setCellValueFactory(data -> new SimpleStringProperty(
                formatDuration(data.getValue().processingDuration())));
        durationCol.setPrefWidth(80);
        durationCol.setCellFactory(col -> new TableCell<>() {
            @Override
            protected void updateItem(String value, boolean empty) {
                super.updateItem(value, empty);
                setText(empty || value == null ? null : value);
                setStyle(empty ? null : "-fx-alignment: CENTER_RIGHT;");
            }
        });

        resultTable.getColumns().setAll(List.of(
                checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol));

        // Selektion im TableView synchronisiert selectedRows und Checkboxen.
        resultTable.getSelectionModel().getSelectedItems().addListener(
                (javafx.collections.ListChangeListener<GuiBatchRunResultRow>) change -> {
                    if (selectionSyncInProgress) return;
                    if (runningProperty.get()) return;
                    selectionSyncInProgress = true;
                    try {
                        selectedRows.clear();
                        selectedRows.addAll(
                                resultTable.getSelectionModel().getSelectedItems());
                        updateMasterCheckBox();
                    } finally {
                        selectionSyncInProgress = false;
                    }
                });

        // Detailbereich bei Zeilen-Klick aktualisieren – mit Dirty-State-Prüfung.
        resultTable.getSelectionModel().selectedItemProperty().addListener((obs, oldRow, newRow) -> {
            if (selectionSyncInProgress) return;
            handleSelectionChange(oldRow, newRow);
        });

        // Master-Checkbox-Zustand bei Listenänderungen aktualisieren.
        resultItems.addListener(
                (javafx.collections.ListChangeListener<GuiBatchRunResultRow>) change ->
                        updateMasterCheckBox());

        // Selektions-Aktions-Buttons bei Selektionsänderung neu bewerten.
        selectedRows.addListener(
                (javafx.collections.SetChangeListener<GuiBatchRunResultRow>) change ->
                        updateButtonStates());
    }

    /**
     * Verarbeitet den Selektionswechsel in der Ergebnistabelle.
     * Zeigt ggf. einen Hinweis-Dialog wenn ungespeicherte Dateinamen-Änderungen vorliegen.
     *
     * @param oldRow vorherige Zeile; darf null sein
     * @param newRow neue Zeile; null wenn keine Zeile selektiert ist
     */
    private void handleSelectionChange(GuiBatchRunResultRow oldRow, GuiBatchRunResultRow newRow) {
        // Dirty-State-Prüfung
        if (fileNameEditor.isDirty()) {
            boolean discard = askDiscardFilenameChanges();
            if (!discard) {
                // Selektion zurücksetzen (zurück auf alte Zeile)
                selectionSyncInProgress = true;
                try {
                    if (oldRow != null) {
                        resultTable.getSelectionModel().select(oldRow);
                    } else {
                        resultTable.getSelectionModel().clearSelection();
                    }
                } finally {
                    selectionSyncInProgress = false;
                }
                LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat zurückgekehrt");
                return;
            }
            fileNameEditor.discardChanges();
            LOG.debug(DIRTY_STATE_MSG);
        }

        // Neue Zeile laden
        currentlySelectedRow = newRow;
        if (newRow == null) {
            detailArea.setText(DETAIL_PLACEHOLDER);
            fileNameEditor.clearSelection();
            pdfPreview.clear();
            return;
        }
        detailArea.setText(buildDetailText(newRow));
        String targetFolder = targetFolderSupplier.get().orElse("");
        fileNameEditor.loadSelection(newRow, targetFolder);
        loadPdfPreviewForRow(newRow);
    }

    /**
     * Zeigt den Bestätigungsdialog für das Verwerfen ungespeicherter Dateinamen-Änderungen.
     *
     * @return {@code true} wenn der Benutzer „Verwerfen" gewählt hat
     */
    private boolean askDiscardFilenameChanges() {
        Alert dialog = new Alert(Alert.AlertType.CONFIRMATION);
        dialog.setTitle("Ungespeicherte Änderung");
        dialog.setHeaderText("Dateiname nicht gespeichert");
        dialog.setContentText(
                "Der Dateiname wurde geändert aber nicht gespeichert. Änderungen verwerfen?");
        ButtonType discard = new ButtonType("Verwerfen");
        ButtonType back = new ButtonType("Zurück");
        dialog.getButtonTypes().setAll(discard, back);
        Optional<ButtonType> result = dialog.showAndWait();
        return result.filter(discard::equals).isPresent();
    }

    /**
     * Lädt die PDF-Vorschau für die angegebene Zeile asynchron. Der Quellordner wird
     * über den Supplier ermittelt und mit dem Originaldateinamen kombiniert.
     *
     * @param row die selektierte Zeile
     */
    private void loadPdfPreviewForRow(GuiBatchRunResultRow row) {
        Optional<Path> sourceFolder = sourceFolderSupplier.get();
        if (sourceFolder.isEmpty()) {
            pdfPreview.clear();
            return;
        }
        Path sourceFile = sourceFolder.get().resolve(row.originalFileName());
        pdfPreview.loadSource(sourceFile);
    }

    /**
     * Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft die eigentliche
     * Aktion auf einem Hintergrund-Worker-Thread; das Ergebnis wird per
     * {@code Platform.runLater} zurück auf den FX-Thread übertragen.
     * <p>
     * Die konkrete Aktion hängt vom Status der Zeile ab:
     * <ul>
     *   <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → bestehende Zieldatei
     *       wird umbenannt (via {@link GuiManualFileRenamePort}).</li>
     *   <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
     *       {@code SKIPPED_FINAL_FAILURE} → Quelldatei wird mit dem manuellen Namen
     *       ins Zielverzeichnis kopiert (via {@link GuiManualFileCopyPort}); der
     *       Dokumentstatus wechselt anschließend auf {@code SUCCESS}.</li>
     * </ul>
     *
     * @param desiredBaseName der gewünschte Basisname ohne {@code .pdf}-Endung
     */
    private void handleSaveFileName(String desiredBaseName) {
        GuiBatchRunResultRow row = currentlySelectedRow;
        if (row == null) {
            return;
        }
        Path configPath = configPathSupplier.get();
        if (configPath == null) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }

        if (requiresCopyAction(row.status())) {
            GuiManualFileCopyPort copyPort = manualFileCopyPortSupplier.get();
            ManualFileCopyRequest copyRequest =
                    new ManualFileCopyRequest(row.fingerprint(), desiredBaseName);

            LOG.info("Manuelle Dateikopie angefordert: {} (Status {}) → {}.pdf",
                    row.originalFileName(), row.status(), desiredBaseName);

            renameExecutor.submit(() -> {
                ManualFileCopyResult result = copyPort.copy(configPath, copyRequest);
                Platform.runLater(() -> handleCopyResult(result, row));
            });
            return;
        }

        GuiManualFileRenamePort renamePort = manualFileRenamePortSupplier.get();
        ManualFileRenameRequest request =
                new ManualFileRenameRequest(row.fingerprint(), desiredBaseName);

        LOG.info("Manuelle Dateiumbenennung angefordert: {} → {}.pdf",
                row.effectiveFileName().orElse("?"), desiredBaseName);

        renameExecutor.submit(() -> {
            ManualFileRenameResult result = renamePort.rename(configPath, request);
            Platform.runLater(() -> handleRenameResult(result, row));
        });
    }

    /**
     * Liefert {@code true}, wenn der Status der Zeile dazu führt, dass die Quelldatei
     * neu kopiert werden muss (statt eine bestehende Zieldatei umzubenennen).
     *
     * @param status der aggregierte Abschlussstatus der Zeile
     * @return {@code true} bei FAILED_*- und SKIPPED_FINAL_FAILURE-Status
     */
    private static boolean requiresCopyAction(DocumentCompletionStatus status) {
        return switch (status) {
            case FAILED_RETRYABLE,
                 FAILED_PERMANENT,
                 SKIPPED_FINAL_FAILURE -> true;
            case SUCCESS,
                 SKIPPED_ALREADY_PROCESSED -> false;
        };
    }

    /**
     * Verarbeitet das Ergebnis einer manuellen Dateikopie auf dem FX-Thread.
     * <p>
     * Bei Erfolg wird die Zeile in der Tabelle so aktualisiert, dass sie als
     * {@code SUCCESS} mit dem neu vergebenen Zieldateinamen erscheint und der
     * Detailbereich anschließend wie für eine erfolgreich verarbeitete Zeile bedienbar
     * ist (Umbenennen statt erneutem Kopieren).
     *
     * @param result das Ergebnis des Use-Case-Aufrufs
     * @param row    die Zeile, für die die Kopie angefordert wurde
     */
    private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
        switch (result) {
            case ManualFileCopySuccess success -> applyCopySuccess(success, row);
            case ManualFileCopyNoOpIdenticalTarget noOp -> applyCopyNoOpIdentical(noOp, row);
            case ManualFileCopyDocumentNotFound notFound -> {
                LOG.warn(COPY_FAILED_LOG, notFound.reason());
                showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
            }
            case ManualFileCopyInvalidState invalidState -> {
                LOG.warn(COPY_FAILED_LOG, invalidState.reason());
                showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
            }
            case ManualFileCopyFileSystemFailure fsFail -> {
                LOG.warn(COPY_FAILED_LOG, fsFail.message());
                showMessage("Dateisystemfehler: " + fsFail.message());
            }
            case ManualFileCopyPersistenceFailure persistFail -> {
                LOG.warn(COPY_FAILED_LOG, persistFail.message());
                showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " + persistFail.message());
            }
        }
    }

    private void applyCopySuccess(ManualFileCopySuccess success, GuiBatchRunResultRow row) {
        LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
                row.originalFileName(), success.appliedFileName(), success.conflictSuffixApplied());
        GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
        currentlySelectedRow = updatedRow;
        fileNameEditor.clearDirtyState();
        upsertResultRowByFingerprint(updatedRow);
        fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
        String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
        if (success.conflictSuffixApplied()) {
            msg += " (Suffix wegen Namenskonflikt angehängt)";
        }
        showMessage(msg);
        refreshAggregateCountersFromItems();
    }

    private void applyCopyNoOpIdentical(ManualFileCopyNoOpIdenticalTarget noOp, GuiBatchRunResultRow row) {
        LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden – kein Schreibvorgang.",
                noOp.existingFileName());
        GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
        currentlySelectedRow = updatedRow;
        fileNameEditor.clearDirtyState();
        upsertResultRowByFingerprint(updatedRow);
        fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
        showMessage("Identische Datei bereits vorhanden – Status auf SUCCESS gesetzt");
        refreshAggregateCountersFromItems();
    }

    /**
     * Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
     * {@code SUCCESS} gehoben wurde. Status, korrigierter Dateiname und das Zurücksetzen
     * der Fehlermeldung machen die Zeile danach im Detailbereich behandelbar wie eine
     * regulär erfolgreich verarbeitete Zeile.
     *
     * @param previous          die ursprüngliche Zeile (FAILED- oder SKIPPED-Status)
     * @param appliedFileName   der finale Zieldateiname inklusive Endung
     * @return eine aktualisierte Zeile mit Status {@code SUCCESS}
     */
    private static GuiBatchRunResultRow buildSuccessRowAfterCopy(
            GuiBatchRunResultRow previous, String appliedFileName) {
        return new GuiBatchRunResultRow(
                previous.originalFileName(),
                previous.fingerprint(),
                DocumentCompletionStatus.SUCCESS,
                Optional.of(appliedFileName),
                Optional.empty(),
                previous.resolvedDate(),
                previous.aiReasoning(),
                Optional.empty(),
                previous.processingDuration(),
                false,
                Optional.empty());
    }

    /**
     * Aktualisiert die aggregierten Zähler (Erfolg, Fehler, Übersprungen) anhand der
     * aktuellen Tabellenzeilen. Wird nach Statusänderungen einzelner Zeilen außerhalb
     * eines Laufs aufgerufen, damit die Anzeige konsistent bleibt.
     */
    private void refreshAggregateCountersFromItems() {
        int success = 0;
        int failed = 0;
        int skipped = 0;
        for (GuiBatchRunResultRow item : resultItems) {
            switch (item.status()) {
                case SUCCESS -> success++;
                case FAILED_RETRYABLE, FAILED_PERMANENT -> failed++;
                case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skipped++;
            }
        }
        this.successCount = success;
        this.failedCount = failed;
        this.skippedCount = skipped;
        updateCounterLabel();
    }

    /**
     * Verarbeitet das Ergebnis einer manuellen Dateiumbenennung auf dem FX-Thread.
     *
     * @param result das Ergebnis des Use-Case-Aufrufs
     * @param row    die Zeile, für die die Umbenennung angefordert wurde
     */
    private void handleRenameResult(ManualFileRenameResult result, GuiBatchRunResultRow row) {
        switch (result) {
            case ManualFileRenameSuccess success -> {
                LOG.info("Manuelle Dateiumbenennung erfolgreich: {} → {} (Suffix: {})",
                        success.previousFileName(), success.appliedFileName(),
                        success.conflictSuffixApplied());
                // Zeile aktualisieren: correctedFileName setzen
                GuiBatchRunResultRow updatedRow = new GuiBatchRunResultRow(
                        row.originalFileName(),
                        row.fingerprint(),
                        row.status(),
                        row.finalFileName(),
                        Optional.of(success.appliedFileName()),
                        row.resolvedDate(),
                        row.aiReasoning(),
                        row.aiFailureMessage(),
                        row.processingDuration(),
                        row.resetPending(),
                        row.historicalContext());
                currentlySelectedRow = updatedRow;
                // Dirty-State vor dem Zeilen-Upsert zurücksetzen, damit das folgende
                // resultItems.set() keinen Verwerfen-Dialog über den selectedItemProperty-Listener
                // auslöst (isDirty() wäre sonst noch true, obwohl die Umbenennung erfolgreich war).
                fileNameEditor.clearDirtyState();
                upsertResultRowByFingerprint(updatedRow);
                // Editor-Zustand auf neuen Namen zurücksetzen
                String targetFolder = targetFolderSupplier.get().orElse("");
                fileNameEditor.loadSelection(updatedRow, targetFolder);
                String msg = "Dateiname gespeichert: " + success.appliedFileName();
                if (success.conflictSuffixApplied()) {
                    msg += " (Suffix wegen Namenskonflikt angehängt)";
                }
                showMessage(msg);
            }
            case ManualFileRenameNoOpIdenticalTarget noOp -> {
                String baseName = noOp.existingFileName();
                if (baseName.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
                    baseName = baseName.substring(0, baseName.length() - 4);
                }
                fileNameEditor.updateLastSavedName(baseName);
                showMessage("Identische Datei bereits vorhanden – keine Umbenennung nötig");
                LOG.info("Manuelle Dateiumbenennung: Identische Zieldatei {} – kein Handlungsbedarf.",
                        noOp.existingFileName());
            }
            case ManualFileRenameDocumentNotFound notFound -> {
                LOG.warn(RENAME_FAILED_LOG, notFound.reason());
                showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
            }
            case ManualFileRenameInvalidState invalidState -> {
                LOG.warn(RENAME_FAILED_LOG, invalidState.reason());
                showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
            }
            case ManualFileRenameSourceFileMissing sourceMissing -> {
                LOG.warn(RENAME_FAILED_LOG,
                        sourceMissing.expectedFileName());
                showMessage("Zieldatei nicht gefunden – Umbenennung nicht möglich");
            }
            case ManualFileRenameFileSystemFailure fsFail -> {
                LOG.warn(RENAME_FAILED_LOG, fsFail.message());
                showMessage("Dateisystemfehler: " + fsFail.message());
            }
            case ManualFileRenamePersistenceFailure persistFail -> {
                LOG.warn(RENAME_FAILED_LOG, persistFail.message());
                showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
                        + persistFail.message());
            }
        }
    }

    /**
     * Benutzerdefinierte TableCell, die in jeder Datenzeile eine {@link CheckBox} rendert
     * und mit {@link #selectedRows} synchronisiert bleibt.
     */
    private final class CheckBoxCell extends TableCell<GuiBatchRunResultRow, Void> {

        private final CheckBox checkBox = new CheckBox();

        CheckBoxCell() {
            checkBox.setOnAction(e -> {
                if (selectionSyncInProgress) return;
                if (runningProperty.get()) {
                    // Während eines Laufs keine Selektionsänderungen erlauben.
                    GuiBatchRunResultRow item = getTableRow() != null
                            ? getTableRow().getItem() : null;
                    if (item != null) {
                        checkBox.setSelected(selectedRows.contains(item));
                    }
                    return;
                }
                selectionSyncInProgress = true;
                try {
                    GuiBatchRunResultRow item = getTableRow() != null ? getTableRow().getItem() : null;
                    if (item == null) return;
                    if (checkBox.isSelected()) {
                        selectedRows.add(item);
                        resultTable.getSelectionModel().select(item);
                    } else {
                        selectedRows.remove(item);
                        resultTable.getSelectionModel().clearSelection(
                                resultTable.getItems().indexOf(item));
                    }
                    updateMasterCheckBox();
                } finally {
                    selectionSyncInProgress = false;
                }
            });
            setGraphic(checkBox);
        }

        @Override
        protected void updateItem(Void item, boolean empty) {
            super.updateItem(item, empty);
            if (empty || getTableRow() == null || getTableRow().getItem() == null) {
                setGraphic(null);
                return;
            }
            GuiBatchRunResultRow row = getTableRow().getItem();
            checkBox.setSelected(selectedRows.contains(row));
            checkBox.setDisable(runningProperty.get());
            setGraphic(checkBox);
        }
    }

    // -------------------------------------------------------------------------
    // Selektions-Helfer
    // -------------------------------------------------------------------------

    private void updateMasterCheckBox() {
        int total = resultItems.size();
        int selected = selectedRows.size();
        if (total == 0 || selected == 0) {
            masterCheckBox.setSelected(false);
            masterCheckBox.setIndeterminate(false);
        } else if (selected == total) {
            masterCheckBox.setIndeterminate(false);
            masterCheckBox.setSelected(true);
        } else {
            masterCheckBox.setIndeterminate(true);
        }
    }

    private void handleMasterCheckBoxAction() {
        if (runningProperty.get()) {
            updateMasterCheckBox();
            return;
        }
        selectionSyncInProgress = true;
        try {
            boolean selectAll = !masterCheckBox.isIndeterminate() && masterCheckBox.isSelected();
            if (selectAll) {
                resultTable.getSelectionModel().selectAll();
                selectedRows.addAll(resultItems);
            } else {
                resultTable.getSelectionModel().clearSelection();
                selectedRows.clear();
            }
            masterCheckBox.setIndeterminate(false);
            masterCheckBox.setSelected(selectAll);
        } finally {
            selectionSyncInProgress = false;
        }
        resultTable.refresh();
    }

    // -------------------------------------------------------------------------
    // Footer / Button-Leiste
    // -------------------------------------------------------------------------

    private Region buildFooter() {
        // Lauf-Steuerungs-Buttons
        startButton.setId("batch-run-start");
        startButton.setOnAction(event -> handleStart());
        startButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STARTEN));

        cancelButton.setId("batch-run-cancel");
        cancelButton.setOnAction(event -> requestCancellation());
        cancelButton.setDisable(true);
        cancelButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ABBRECHEN));

        HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
        runButtonBar.setAlignment(Pos.CENTER_LEFT);
        runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, 0, 0));

        return runButtonBar;
    }

    // -------------------------------------------------------------------------
    // Aktions-Handler
    // -------------------------------------------------------------------------

    private void handleStart() {
        if (isRunning()) {
            showMessage(ALREADY_RUNNING_HINT);
            return;
        }
        // Dirty-State-Prüfung vor Laufstart
        if (fileNameEditor.isDirty()) {
            boolean discard = askDiscardFilenameChanges();
            if (!discard) {
                LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat zurückgekehrt");
                return;
            }
            fileNameEditor.discardChanges();
            LOG.debug(DIRTY_STATE_MSG);
        }
        if (!savedConfigurationReadyCheck.getAsBoolean()) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        Path configPath = configPathSupplier.get();
        if (configPath == null) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        // Alle UI-Zustände vor neuem Lauf zurücksetzen.
        resultItems.clear();
        selectedRows.clear();
        currentlySelectedRow = null;
        detailArea.setText(DETAIL_PLACEHOLDER);
        fileNameEditor.clearSelection();
        pdfPreview.clear();
        messageArea.clear();
        messageArea.setVisible(false);
        messageArea.setManaged(false);
        messageArea.setStyle(null);
        summaryBanner.clear();
        resetMetrics();
        updateCounterLabel();
        progressBar.setProgress(0);
        updateMasterCheckBox();

        boolean started = coordinator.start(configPath);
        if (!started) {
            showMessage(ALREADY_RUNNING_HINT);
            return;
        }
        LOG.info("GUI-Verarbeitungslauf: Start ausgelöst für Konfiguration {}.", configPath);
        activeRunIsMiniRun = false;
        runningProperty.set(true);
        notifyRunStateChanged();
        updateButtonStates();
    }

    private void handleReprocessSelected() {
        LOG.info("GUI-Erneut-Verarbeiten: handleReprocessSelected aufgerufen. "
                + "Laufend={}, Selektion={}.", isRunning(), selectedRows.size());
        if (isRunning() || selectedRows.isEmpty()) {
            return;
        }
        // Dirty-State-Prüfung vor Mini-Laufstart
        if (fileNameEditor.isDirty()) {
            boolean discard = askDiscardFilenameChanges();
            if (!discard) {
                LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat zurückgekehrt");
                return;
            }
            fileNameEditor.discardChanges();
            LOG.debug(DIRTY_STATE_MSG);
        }
        if (!savedConfigurationReadyCheck.getAsBoolean()) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        Path configPath = configPathSupplier.get();
        if (configPath == null) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        Map<DocumentFingerprint, String> snapshotFilenames = selectedRows.stream()
                .collect(Collectors.toUnmodifiableMap(
                        GuiBatchRunResultRow::fingerprint,
                        GuiBatchRunResultRow::originalFileName,
                        (existing, duplicate) -> existing));
        Set<DocumentFingerprint> snapshot = snapshotFilenames.keySet();

        markSelectedRowsAsResetPending();

        boolean started = coordinator.startReprocessing(configPath, snapshot);
        if (!started) {
            showMessage(REPROCESS_RESET_FAILED_HINT);
            return;
        }
        LOG.info("GUI-Erneut-Verarbeiten: Mini-Lauf gestartet für {} Dokument(e), "
                + "Konfiguration {}.", snapshot.size(), configPath);
        activeRunIsMiniRun = true;
        miniRunSnapshotFilenames = snapshotFilenames;
        miniRunCompletedFingerprints = new HashSet<>();
        runningProperty.set(true);
        notifyRunStateChanged();
        updateButtonStates();
    }

    private void handleResetSelected() {
        if (isRunning() || selectedRows.isEmpty()) {
            return;
        }
        if (!savedConfigurationReadyCheck.getAsBoolean()) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        Path configPath = configPathSupplier.get();
        if (configPath == null) {
            showMessage(NO_SAVED_CONFIGURATION_HINT);
            return;
        }
        Set<DocumentFingerprint> snapshot = selectedRows.stream()
                .map(GuiBatchRunResultRow::fingerprint)
                .collect(Collectors.toUnmodifiableSet());

        boolean started = coordinator.startReset(configPath, snapshot);
        if (!started) {
            showMessage(ALREADY_RUNNING_HINT);
            return;
        }
        LOG.info("GUI-Status-Reset: Reset angefordert für {} Dokument(e), "
                + "Konfiguration {}.", snapshot.size(), configPath);
        runningProperty.set(true);
        notifyRunStateChanged();
        updateButtonStates();
    }

    /**
     * Ersetzt ausgewählte Zeilen durch Reset-Pending-Marker zur sofortigen visuellen
     * Rückmeldung bevor ein Mini-Lauf startet.
     */
    private void markSelectedRowsAsResetPending() {
        List<GuiBatchRunResultRow> toMark = new ArrayList<>(selectedRows);
        selectionSyncInProgress = true;
        try {
            for (GuiBatchRunResultRow row : toMark) {
                upsertResultRowByFingerprint(GuiBatchRunResultRow.resetMarker(row));
            }
        } finally {
            selectionSyncInProgress = false;
        }
    }

    // -------------------------------------------------------------------------
    // In-Place-Zeilen-Aktualisierung
    // -------------------------------------------------------------------------

    /**
     * Ersetzt eine vorhandene Zeile mit gleichem Fingerabdruck in-place, oder hängt
     * die Zeile an wenn kein übereinstimmender Fingerabdruck gefunden wird.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param newRow die neue Zeile; darf nicht null sein
     */
    void upsertResultRowByFingerprint(GuiBatchRunResultRow newRow) {
        for (int i = 0; i < resultItems.size(); i++) {
            if (resultItems.get(i).fingerprint().equals(newRow.fingerprint())) {
                // resultItems.set() feuert selectedItemProperty mehrfach (JavaFX-intern:
                // oldRow → null → newRow), was ohne diesen Schutz handleSelectionChange()
                // und damit fälschlicherweise den Verwerfen-Dialog auslösen würde.
                selectionSyncInProgress = true;
                try {
                    resultItems.set(i, newRow);
                } finally {
                    selectionSyncInProgress = false;
                }
                // Falls die aktuell selektierte Zeile aktualisiert wurde, Referenz erneuern
                if (currentlySelectedRow != null
                        && currentlySelectedRow.fingerprint().equals(newRow.fingerprint())) {
                    currentlySelectedRow = newRow;
                }
                return;
            }
        }
        resultItems.add(newRow);
    }

    // -------------------------------------------------------------------------
    // UI-Zustandsverwaltung
    // -------------------------------------------------------------------------

    private void showMessage(String message) {
        messageArea.setText(message);
        boolean hatInhalt = message != null && !message.isBlank();
        messageArea.setVisible(hatInhalt);
        messageArea.setManaged(hatInhalt);
        if (hatInhalt) {
            applyMessageStyle(message);
        } else {
            messageArea.setStyle(null);
        }
    }

    private void appendMessage(String message) {
        if (messageArea.getText() == null || messageArea.getText().isBlank()) {
            messageArea.setText(message);
        } else {
            messageArea.setText(messageArea.getText() + System.lineSeparator() + message);
        }
        messageArea.setVisible(true);
        messageArea.setManaged(true);
        applyMessageStyle(messageArea.getText());
    }

    /**
     * Passt die Hintergrundfarbe des Hinweisbereichs dem Meldungsinhalt an.
     * Fehlermeldungen erhalten einen roten, Erfolgsmeldungen einen grünen Hintergrund.
     *
     * @param text aktueller Inhalt des Hinweisbereichs
     */
    private void applyMessageStyle(String text) {
        String lower = text == null ? "" : text.toLowerCase(Locale.ROOT);
        if (lower.contains("fehler") || lower.contains("fehlgeschlagen")
                || lower.contains("nicht gefunden") || lower.contains("nicht möglich")) {
            messageArea.setStyle("-fx-control-inner-background: #ffebee;");
        } else if (lower.contains("kopiert") || lower.contains("gespeichert")
                || lower.contains("erfolgreich")) {
            messageArea.setStyle("-fx-control-inner-background: #e8f5e9;");
        } else {
            messageArea.setStyle(null);
        }
    }

    private void updateCounterLabel() {
        counterLabel.setText(completedCandidates + " / " + totalCandidates + " Dateien");
    }

    private void updateProgressBar() {
        if (totalCandidates <= 0) {
            progressBar.setProgress(0);
            return;
        }
        progressBar.setProgress((double) completedCandidates / (double) totalCandidates);
    }

    private void updateButtonStates() {
        boolean running = runningProperty.get();
        startButton.setDisable(running || schedulerActive);
        if (!running) {
            cancelButton.setDisable(true);
        } else {
            cancelButton.setDisable(coordinator.isCancellationRequested());
        }
        boolean canAct = !running && !selectedRows.isEmpty();
        reprocessButton.setDisable(!canAct);
        resetStatusButton.setDisable(!canAct);
        masterCheckBox.setDisable(running);
        resultTable.refresh();
    }

    private void resetMetrics() {
        totalCandidates = 0;
        completedCandidates = 0;
        successCount = 0;
        failedCount = 0;
        skippedCount = 0;
    }

    private void notifyRunStateChanged() {
        try {
            onRunStateChanged.run();
        } catch (RuntimeException e) {
            LOG.warn("GUI-Verarbeitungslauf: Listener für Laufzustand warf eine Exception: {}",
                    e.getMessage(), e);
        }
    }

    // -------------------------------------------------------------------------
    // Statische Helfer
    // -------------------------------------------------------------------------

    // statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.

    /**
     * Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
     * Wird anstelle von {@code column.setText()} verwendet, da TableColumn
     * kein direktes {@code setTooltip()} unterstützt.
     *
     * @param title   sichtbarer Spaltentext; darf nicht leer sein
     * @param tooltip Tooltip-Text; darf nicht leer sein
     * @return ein Label mit gesetztem Tooltip
     */
    private static Label columnHeader(String title, String tooltip) {
        Label label = new Label(title);
        label.setTooltip(new Tooltip(tooltip));
        return label;
    }

    private static String formatDuration(Duration duration) {
        double seconds = duration.toMillis() / 1000.0;
        if (seconds < 10) {
            return String.format("%.2f s", seconds);
        }
        return String.format("%.1f s", seconds);
    }

    private static final DateTimeFormatter DETAIL_DATE_FORMAT =
            DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMANY);

    private static String buildDetailText(GuiBatchRunResultRow row) {
        StringBuilder builder = new StringBuilder();
        builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
        if (row.resetPending()) {
            builder.append('\n').append(GuiBatchRunResultRow.RESET_PENDING_LABEL);
            return builder.toString();
        }
        if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
            return appendSkippedAlreadyProcessed(builder, row);
        }
        if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
            return appendSkippedFinalFailure(builder, row);
        }
        if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
            builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
            row.aiFailureMessage().ifPresent(msg ->
                    builder.append("\n\nFehlerdetail: ")
                            .append(AiFailureMessageTranslator.translate(msg)));
            return builder.toString();
        }
        row.effectiveFileName()
                .ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
        row.resolvedDate()
                .ifPresent(date -> builder.append("Datum: ")
                        .append(DateTimeFormatter.ISO_LOCAL_DATE.format(date)).append('\n'));
        builder.append('\n');
        row.aiReasoning().ifPresentOrElse(
                reasoning -> builder.append(reasoning),
                () -> row.aiFailureMessage().ifPresentOrElse(
                        msg -> builder.append("\u26A0 ").append(AiFailureMessageTranslator.translate(msg)),
                        () -> builder.append(NO_REASONING_TEXT)));
        return builder.toString();
    }

    private static String appendSkippedAlreadyProcessed(StringBuilder builder, GuiBatchRunResultRow row) {
        builder.append('\n');
        row.historicalContext().ifPresentOrElse(ctx -> {
            ctx.lastSuccessInstant().ifPresentOrElse(
                    instant -> builder.append("Bereits erfolgreich verarbeitet am ")
                            .append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
                            .append('.'),
                    () -> builder.append("Bereits erfolgreich verarbeitet."));
            ctx.lastTargetFileName().ifPresent(name ->
                    builder.append('\n').append("Zieldatei: ").append(name).append('.'));
        }, () -> builder.append("Bereits erfolgreich verarbeitet."));
        return builder.toString();
    }

    private static String appendSkippedFinalFailure(StringBuilder builder, GuiBatchRunResultRow row) {
        builder.append('\n');
        row.historicalContext().ifPresentOrElse(ctx ->
                ctx.lastFailureInstant().ifPresentOrElse(
                        instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
                                .append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
                                .append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
                        () -> builder.append(
                                "Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
                () -> builder.append(
                        "Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
        return builder.toString();
    }

    private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
            Path p, Set<DocumentFingerprint> f,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken t) {
        return GuiBatchRunLaunchOutcome.rejected(
                "Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
    }

    private static de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult
            rejectingReset(Path p, Set<DocumentFingerprint> fingerprints) {
        java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
        for (DocumentFingerprint fp : fingerprints) {
            failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
        }
        return new de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult(
                fingerprints.size(), Set.of(), failures);
    }

    private static ManualFileRenameResult rejectingRename(
            Path p, ManualFileRenameRequest req) {
        return new ManualFileRenameFileSystemFailure(
                "Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
    }

    private static ManualFileCopyResult rejectingCopy(
            Path p, ManualFileCopyRequest req) {
        return new ManualFileCopyFileSystemFailure(
                "Kein Kopier-Port in diesem Startkontext verfügbar.");
    }

    // -------------------------------------------------------------------------
    // Coordinator-Listener
    // -------------------------------------------------------------------------

    private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {

        @Override
        public void onRunStarted(RunId runId, int totalCandidatesFromObserver) {
            totalCandidates = Math.max(0, totalCandidatesFromObserver);
            completedCandidates = 0;
            successCount = 0;
            failedCount = 0;
            skippedCount = 0;
            updateCounterLabel();
            updateProgressBar();
            if (totalCandidates == 0) {
                showMessage(EMPTY_SOURCE_FOLDER_HINT);
            }
            LOG.info("GUI-Verarbeitungslauf: RunId={} mit {} Kandidat(en) gestartet.",
                    runId, totalCandidates);
        }

        @Override
        public void onDocumentCompleted(GuiBatchRunResultRow row) {
            if (activeRunIsMiniRun) {
                miniRunCompletedFingerprints.add(row.fingerprint());
                upsertResultRowByFingerprint(row);
            } else {
                resultItems.add(row);
            }
            completedCandidates = Math.min(totalCandidates, completedCandidates + 1);
            switch (row.status()) {
                case SUCCESS -> successCount++;
                case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
                case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skippedCount++;
                default -> throw new IllegalStateException(
                        "Unerwarteter Status: " + row.status());
            }
            updateCounterLabel();
            updateProgressBar();
        }

        @Override
        public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
            runningProperty.set(false);
            if (activeRunIsMiniRun) {
                if (outcome.successfullyStarted() && outcome.batchCompletedNormally()) {
                    synthesizeMissingSourceFileRows();
                }
                miniRunSnapshotFilenames = Map.of();
                miniRunCompletedFingerprints = new HashSet<>();
            }
            selectedRows.clear();
            // Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
            Map<DocumentCompletionStatus, Integer> counts =
                    BatchRunSummaryBanner.aggregateCounts(resultItems);
            summaryBanner.update(counts);
            appendSummary(outcome);
            updateButtonStates();
            notifyRunStateChanged();
            LOG.info("GUI-Verarbeitungslauf: Lauf beendet. successfullyStarted={}, completed={}, "
                            + "erfolgreich={}, fehlgeschlagen={}, übersprungen={}.",
                    outcome.successfullyStarted(), outcome.batchCompletedNormally(),
                    successCount, failedCount, skippedCount);
        }

        /**
         * Erkennt Fingerabdrücke, die beim Mini-Lauf-Start ausgewählt waren, aber kein
         * Completion-Callback erhalten haben. Ersetzt Reset-Pending-Marker durch
         * permanente Fehlerzeilen.
         */
        private void synthesizeMissingSourceFileRows() {
            for (Map.Entry<DocumentFingerprint, String> entry
                    : miniRunSnapshotFilenames.entrySet()) {
                DocumentFingerprint fp = entry.getKey();
                if (miniRunCompletedFingerprints.contains(fp)) {
                    continue;
                }
                String originalName = entry.getValue();
                String message = "Quelldatei nicht gefunden: " + originalName;
                GuiBatchRunResultRow missingRow = new GuiBatchRunResultRow(
                        originalName,
                        fp,
                        DocumentCompletionStatus.FAILED_PERMANENT,
                        Optional.empty(),
                        Optional.empty(),
                        Optional.of(message),
                        Optional.empty(),
                        Duration.ZERO,
                        false);
                upsertResultRowByFingerprint(missingRow);
                appendMessage(message);
                failedCount++;
                LOG.warn("GUI-Mini-Lauf: Quelldatei fehlt für Auswahl '{}' – Status "
                        + "permanent fehlgeschlagen.", originalName);
            }
        }

        @Override
        public void onResetCompleted(ResetDocumentStatusResult result) {
            runningProperty.set(false);

            for (DocumentFingerprint fp : result.successfullyReset()) {
                for (int i = 0; i < resultItems.size(); i++) {
                    if (resultItems.get(i).fingerprint().equals(fp)) {
                        resultItems.set(i, GuiBatchRunResultRow.resetMarker(resultItems.get(i)));
                        break;
                    }
                }
            }
            selectedRows.clear();

            String summary = result.requestedCount() + " ausgewählt, "
                    + result.successCount() + " erfolgreich zurückgesetzt, "
                    + result.failureCount() + " Fehler.";
            appendMessage(summary);

            if (result.failureCount() > 0) {
                StringBuilder failedNames = new StringBuilder("Fehler bei: ");
                boolean first = true;
                for (DocumentFingerprint failedFp : result.failures().keySet()) {
                    String name = resultItems.stream()
                            .filter(r -> r.fingerprint().equals(failedFp))
                            .map(GuiBatchRunResultRow::originalFileName)
                            .findFirst()
                            .orElse(failedFp.sha256Hex().substring(0, 8) + "…");
                    if (!first) failedNames.append(", ");
                    failedNames.append(name);
                    first = false;
                }
                appendMessage(failedNames.toString());
            }

            updateButtonStates();
            notifyRunStateChanged();
            LOG.info("GUI-Status-Reset: Abgeschlossen. {} von {} zurückgesetzt, {} Fehler.",
                    result.successCount(), result.requestedCount(), result.failureCount());
        }

        private void appendSummary(GuiBatchRunLaunchOutcome outcome) {
            if (!outcome.successfullyStarted()) {
                outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
                return;
            }
            if (!outcome.batchCompletedNormally()) {
                outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
            }
            // Zusammenfassungsmeldung basierend auf tatsächlichen Zählern
            String summaryText = buildSummaryMessage();
            if (messageArea.getText() == null || messageArea.getText().isBlank()) {
                messageArea.setText(summaryText);
            } else {
                messageArea.setText(messageArea.getText() + System.lineSeparator() + summaryText);
            }
            messageArea.setVisible(true);
            messageArea.setManaged(true);

            // Farbe basierend auf tatsächlichem FAILED-Zähler, nicht auf Keywords im Text
            if (failedCount > 0) {
                messageArea.setStyle("-fx-control-inner-background: #ffebee;");
            } else if (successCount > 0) {
                messageArea.setStyle("-fx-control-inner-background: #e8f5e9;");
            } else {
                messageArea.setStyle(null);
            }
        }

        /**
         * Erzeugt die Abschlussmeldung des Verarbeitungslaufs basierend auf den
         * tatsächlichen Zählern (erfolgreich, fehlgeschlagen, übersprungen).
         *
         * @return die formatierte Zusammenfassungsmeldung
         */
        private String buildSummaryMessage() {
            if (failedCount == 0 && skippedCount == 0) {
                // Alle erfolgreich verarbeitet
                return "Lauf erfolgreich abgeschlossen. " + successCount + " erfolgreich.";
            } else if (failedCount == 0) {
                // Nur übersprungene Dateien, keine Fehler
                return "Lauf abgeschlossen. " + successCount + " erfolgreich, "
                        + skippedCount + " übersprungen.";
            } else {
                // Mit fehlgeschlagenen Dateien
                return "Lauf abgeschlossen. " + successCount + " erfolgreich, "
                        + failedCount + " fehlgeschlagen, " + skippedCount + " übersprungen.";
            }
        }
    }

    /** Klassifikation verwendet von {@link #updateButtonStates()} in Tests. */
    DocumentCompletionStatus sentinelForTests() {
        return DocumentCompletionStatus.SUCCESS;
    }
}
