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

import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;

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

import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
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.out.ConfigurationFileLockException;
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 javafx.application.Platform;
import javafx.scene.control.Alert;

/**
 * Coordinates a single batch run (regular or targeted mini-run) triggered from the
 * JavaFX GUI, and optional reset-only operations on selected document fingerprints.
 * <p>
 * The coordinator owns the background worker thread that executes the run, maintains the
 * cancellation flag, and translates the
 * {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
 * callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
 *
 * <h2>Threading</h2>
 * <ul>
 *   <li>The batch run and reset operations execute on a daemon worker thread created by
 *       {@link #threadFactory}. No JavaFX code touches this thread.</li>
 *   <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
 *       via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
 *       {@code Control}s without taking any further precautions.</li>
 *   <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
 *       between candidates (soft-stop). It never interrupts the worker thread; the
 *       currently-processed candidate always completes in full.</li>
 * </ul>
 *
 * <h2>Lifecycle</h2>
 * <ol>
 *   <li>Construct with a regular launcher, a mini-run launcher, a reset port, a thread
 *       factory and a listener.</li>
 *   <li>Call {@link #start(Path)} to begin a regular run, or
 *       {@link #startMiniRun(Path, Set)} for a targeted mini-run, or
 *       {@link #startReset(Path, Set)} for a status-reset-only operation.</li>
 *   <li>Optionally call {@link #requestCancellation()} to trigger soft-stop for runs.</li>
 *   <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} or
 *       {@link Listener#onResetCompleted(ResetDocumentStatusResult)} on the FX thread.</li>
 *   <li>Start a new operation only after the previous one has ended.</li>
 * </ol>
 */
public final class GuiBatchRunCoordinator {
    private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";



    private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
    private static final String WORKER_THREAD_NAME = "gui-batch-run";

    /**
     * Listener interface invoked on the JavaFX Application Thread during a run or reset.
     */
    public interface Listener {

        /**
         * Invoked once, after the batch use case has scanned the source folder and knows
         * the total candidate count.
         *
         * @param runId           the identifier of the run; never {@code null}
         * @param totalCandidates the number of candidates detected in the source folder;
         *                        never negative
         */
        void onRunStarted(RunId runId, int totalCandidates);

        /**
         * Invoked once per candidate whose processing reached a terminal resolution.
         *
         * @param row the row describing the candidate result; never {@code null}
         */
        void onDocumentCompleted(GuiBatchRunResultRow row);

        /**
         * Invoked once after the run has fully terminated on the worker thread.
         *
         * @param summary the final outcome counts; never {@code null}
         * @param outcome a description of how the run terminated; never {@code null}
         */
        void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);

        /**
         * Invoked once after a reset-only operation has completed on the worker thread.
         * <p>
         * The default implementation does nothing so existing {@link Listener}
         * implementations need not override this method until they need reset
         * notifications.
         *
         * @param result the full outcome of the reset operation; never {@code null}
         */
        default void onResetCompleted(ResetDocumentStatusResult result) {
            // no-op default
        }
    }

    private final GuiBatchRunLauncher launcher;
    private final GuiMiniRunLauncher miniRunLauncher;
    private final GuiResetDocumentStatusPort resetPort;
    private final Function<Runnable, Thread> threadFactory;
    private final Consumer<Runnable> fxDispatcher;
    private final Listener listener;
    private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
    private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
    private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
    private final AtomicBoolean cancellationRequested = new AtomicBoolean();

    /**
     * Creates the coordinator with the default worker-thread factory and the default
     * JavaFX Application Thread dispatcher.
     * <p>
     * Mini-run and reset capabilities are unavailable; all such requests will return
     * {@code false}.
     *
     * @param launcher bridge to Bootstrap used to execute the batch; must not be null
     * @param listener GUI listener invoked on the FX thread; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
        this(launcher,
                rejectingMiniRunLauncher(),
                rejectingResetPort(),
                defaultThreadFactory(),
                defaultFxDispatcher(),
                listener);
    }

    /**
     * Creates the coordinator with all ports and the default worker-thread factory and
     * JavaFX Application Thread dispatcher.
     *
     * @param launcher        bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort       bridge to Bootstrap for status-reset-only operations; must
     *                        not be null
     * @param listener        GUI listener invoked on the FX thread; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Listener listener) {
        this(launcher, miniRunLauncher, resetPort,
                defaultThreadFactory(), defaultFxDispatcher(), listener);
    }

    /**
     * Creates the coordinator with all ports and the historical file name port, using the
     * default worker-thread factory and JavaFX Application Thread dispatcher.
     *
     * @param launcher               bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher        bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort              bridge to Bootstrap for status-reset-only operations; must
     *                               not be null
     * @param listener               GUI listener invoked on the FX thread; must not be null
     * @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
     *                               skipped documents; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Listener listener,
                                  GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
        this(launcher, miniRunLauncher, resetPort,
                defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
    }

    /**
     * Creates the coordinator with all ports and the configuration file lock port, using
     * the default worker-thread factory and JavaFX Application Thread dispatcher.
     * <p>
     * This constructor is intended for production wiring in {@code GuiBatchRunTab} where
     * the lock port is supplied by Bootstrap.
     *
     * @param launcher                      bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher               bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort                     bridge to Bootstrap for status-reset-only operations; must
     *                                      not be null
     * @param listener                      GUI listener invoked on the FX thread; must not be null
     * @param historicalDocumentContextPort port for resolving historical context; must not be null
     * @param configurationFileLockPort     optional OS-lock on the configuration file; when present,
     *                                      acquired before each run; {@code null} is treated as empty
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Listener listener,
                                  GuiHistoricalDocumentContextPort historicalDocumentContextPort,
                                  Optional<ConfigurationFileLockPort> configurationFileLockPort) {
        this(launcher, miniRunLauncher, resetPort,
                defaultThreadFactory(), defaultFxDispatcher(), listener,
                historicalDocumentContextPort, configurationFileLockPort);
    }

    /**
     * Creates the coordinator with custom hooks for the worker-thread factory and the
     * UI-thread dispatcher.
     * <p>
     * Tests use this constructor to execute batches synchronously or to verify which
     * thread UI callbacks run on, without depending on an actual JavaFX runtime being
     * initialised.
     *
     * @param launcher       bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort       bridge to Bootstrap for status-reset-only operations; must
     *                        not be null
     * @param threadFactory  factory returning a ready-to-start worker thread; must not
     *                       be null
     * @param fxDispatcher   dispatcher that schedules a runnable on the JavaFX Application
     *                       Thread; must not be null
     * @param listener       GUI listener; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Function<Runnable, Thread> threadFactory,
                                  Consumer<Runnable> fxDispatcher,
                                  Listener listener) {
        this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
                noOpHistoricalDocumentContextPort());
    }

    /**
     * Creates the coordinator with all ports, custom thread factory, FX dispatcher,
     * historical file name port, and an optional configuration file lock port.
     * <p>
     * This is the canonical constructor. All other constructors delegate here.
     *
     * @param launcher                      bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher               bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort                     bridge to Bootstrap for status-reset-only operations; must
     *                                      not be null
     * @param threadFactory                 factory returning a ready-to-start worker thread; must not
     *                                      be null
     * @param fxDispatcher                  dispatcher that schedules a runnable on the JavaFX Application
     *                                      Thread; must not be null
     * @param listener                      GUI listener; must not be null
     * @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
     *                                      skipped documents; must not be null
     * @param configurationFileLockPort     optional OS-lock on the configuration file; when present,
     *                                      acquired before each run and released in a finally block;
     *                                      {@code null} is treated as empty
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Function<Runnable, Thread> threadFactory,
                                  Consumer<Runnable> fxDispatcher,
                                  Listener listener,
                                  GuiHistoricalDocumentContextPort historicalDocumentContextPort,
                                  Optional<ConfigurationFileLockPort> configurationFileLockPort) {
        this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
        this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
        this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
        this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
        this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
        this.listener = Objects.requireNonNull(listener, "listener must not be null");
        this.historicalDocumentContextPort = Objects.requireNonNull(
                historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
        this.configurationFileLockPort =
                Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
    }

    /**
     * Backward-compatible constructor that omits the configuration file lock port.
     * <p>
     * Preserves existing callers that were written before the lock port was added.
     * Delegates to the canonical constructor with {@code configurationFileLockPort} empty.
     *
     * @param launcher                      bridge to Bootstrap for regular batch runs; must not be null
     * @param miniRunLauncher               bridge to Bootstrap for targeted mini-runs; must not be null
     * @param resetPort                     bridge to Bootstrap for status-reset-only operations; must
     *                                      not be null
     * @param threadFactory                 factory returning a ready-to-start worker thread; must not
     *                                      be null
     * @param fxDispatcher                  dispatcher that schedules a runnable on the JavaFX Application
     *                                      Thread; must not be null
     * @param listener                      GUI listener; must not be null
     * @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
     *                                      skipped documents; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  GuiMiniRunLauncher miniRunLauncher,
                                  GuiResetDocumentStatusPort resetPort,
                                  Function<Runnable, Thread> threadFactory,
                                  Consumer<Runnable> fxDispatcher,
                                  Listener listener,
                                  GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
        this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
                historicalDocumentContextPort, Optional.empty());
    }

    /**
     * Legacy constructor retained for backward compatibility with tests that do not
     * require mini-run or reset capabilities.
     *
     * @param launcher       bridge to Bootstrap; must not be null
     * @param threadFactory  factory returning a ready-to-start worker thread; must not
     *                       be null
     * @param fxDispatcher   dispatcher that schedules a runnable on the JavaFX Application
     *                       Thread; must not be null
     * @param listener       GUI listener; must not be null
     */
    public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
                                  Function<Runnable, Thread> threadFactory,
                                  Consumer<Runnable> fxDispatcher,
                                  Listener listener) {
        this(launcher,
                rejectingMiniRunLauncher(),
                rejectingResetPort(),
                threadFactory,
                fxDispatcher,
                listener);
    }

    /**
     * Returns whether a run or reset is currently active.
     *
     * @return {@code true} while a worker thread is executing
     */
    public boolean isRunning() {
        Thread worker = activeWorker.get();
        return worker != null && worker.isAlive();
    }

    /**
     * Starts a new regular run for the supplied configuration file.
     * <p>
     * Immediately returns once the worker thread has been started. All further progress
     * is communicated through the configured {@link Listener} on the JavaFX Application
     * Thread. An attempt to start a new run while another is still active is rejected
     * with {@code false} and leaves the currently running batch untouched.
     *
     * @param configFilePath the configuration file the run shall read from; must not be
     *                       {@code null}
     * @return {@code true} when a new worker thread was started, {@code false} when a run
     *         was already in progress
     * @throws NullPointerException if {@code configFilePath} is {@code null}
     */
    public boolean start(Path configFilePath) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        if (isRunning()) {
            return false;
        }
        cancellationRequested.set(false);
        Runnable task = () -> executeRun(configFilePath);
        return startWorker(task);
    }

    /**
     * Starts a targeted mini-run for the supplied fingerprint filter.
     * <p>
     * The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
     * the full processing pipeline to only the specified documents. Progress callbacks
     * are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
     * way as for a regular run.
     *
     * @param configFilePath  the configuration file; must not be {@code null}
     * @param fingerprintFilter the set of document fingerprints to process; must not be
     *                          {@code null}
     * @return {@code true} when a new worker thread was started, {@code false} when a run
     *         was already in progress
     * @throws NullPointerException if any argument is {@code null}
     */
    public boolean startMiniRun(Path configFilePath,
                                Set<DocumentFingerprint> fingerprintFilter) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
        if (isRunning()) {
            return false;
        }
        cancellationRequested.set(false);
        Runnable task = () -> executeMiniRun(configFilePath, fingerprintFilter);
        return startWorker(task);
    }

    /**
     * Starts a reprocessing operation: resets the database status of the specified
     * fingerprints and immediately launches a targeted mini-run for them.
     * <p>
     * This method is the preferred entry point for "Erneut verarbeiten" (reprocess)
     * actions in the GUI. It ensures that documents marked as FAILED_FINAL or otherwise
     * ineligible for processing are reset before the mini-run begins, so they are
     * reprocessed rather than skipped.
     * <p>
     * The reset executes synchronously on the caller's thread before the worker thread
     * is started. This guarantees that the mini-run sees the documents in a
     * reprocessable state.
     *
     * @param configFilePath  the configuration file; must not be {@code null}
     * @param fingerprintFilter the set of document fingerprints to reset and process;
     *                          must not be {@code null}
     * @return {@code true} when a new worker thread was started, {@code false} when a run
     *         was already in progress or when the reset failed for all fingerprints
     * @throws NullPointerException if any argument is {@code null}
     */
    public boolean startReprocessing(Path configFilePath,
                                     Set<DocumentFingerprint> fingerprintFilter) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
        if (isRunning()) {
            return false;
        }
        // Reset the database status synchronously before starting the mini-run.
        // This ensures that documents are not skipped due to FAILED_FINAL or other
        // terminal states.
        LOG.info("GUI-Erneut-Verarbeiten: Starte Status-Reset für {} Dokument(e), Konfiguration={}.",
                fingerprintFilter.size(), configFilePath);
        ResetDocumentStatusResult resetResult = resetPort.reset(configFilePath, fingerprintFilter);
        LOG.info("GUI-Erneut-Verarbeiten: Status-Reset abgeschlossen – {} erfolgreich, {} fehlgeschlagen.",
                resetResult.successCount(), resetResult.failureCount());
        if (resetResult.successCount() == 0) {
            LOG.warn("GUI-Reprocessing: Reset für alle {} Dokumente fehlgeschlagen; "
                    + "Mini-Lauf wird nicht gestartet.", fingerprintFilter.size());
            return false;
        }
        LOG.info("GUI-Reprocessing: {} von {} Dokumenten erfolgreich zurückgesetzt.",
                resetResult.successCount(), resetResult.requestedCount());
        // Now start the mini-run with the reset fingerprints.
        return startMiniRun(configFilePath, fingerprintFilter);
    }

    /**
     * Starts a reset-only operation for the supplied fingerprint set.
     * <p>
     * The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
     * persistence data for the specified fingerprints. No reprocessing run is triggered.
     * On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
     * is invoked on the JavaFX Application Thread.
     *
     * @param configFilePath the configuration file that identifies the database; must not
     *                       be {@code null}
     * @param fingerprints   the set of document fingerprints to reset; must not be
     *                       {@code null}
     * @return {@code true} when a new worker thread was started, {@code false} when a run
     *         was already in progress
     * @throws NullPointerException if any argument is {@code null}
     */
    public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprints, "fingerprints must not be null");
        if (isRunning()) {
            return false;
        }
        // Reset does not support cancellation; set the flag to false so the
        // running state is consistent with the pattern used by run operations.
        cancellationRequested.set(false);
        Runnable task = () -> executeReset(configFilePath, fingerprints);
        return startWorker(task);
    }

    /**
     * Requests soft-stop cancellation of the currently running batch or mini-run.
     * <p>
     * The flag is honoured between candidates — the candidate that is currently being
     * processed is always completed in full and persisted before the run ends. Calling
     * this method when no run is active has no effect. Reset operations ignore this flag.
     */
    public void requestCancellation() {
        if (isRunning()) {
            cancellationRequested.set(true);
        }
    }

    /**
     * Returns whether cancellation has been requested for the current (or last) run.
     *
     * @return {@code true} when a cancellation request is pending or was pending when
     *         the last run ended; {@code false} before the first run
     */
    public boolean isCancellationRequested() {
        return cancellationRequested.get();
    }

    // -------------------------------------------------------------------------
    // Worker helpers
    // -------------------------------------------------------------------------

    private boolean startWorker(Runnable task) {
        Thread worker = threadFactory.apply(task);
        Objects.requireNonNull(worker, "threadFactory must not return null");
        activeWorker.set(worker);
        worker.start();
        return true;
    }

    private void executeRun(Path configFilePath) {
        LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
                configFilePath);
        observerSummary.set(null);

        if (configurationFileLockPort.isPresent()) {
            try {
                configurationFileLockPort.get().acquireLock();
            } catch (ConfigurationFileLockException e) {
                LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
                        e.getMessage());
                fxDispatcher.accept(() -> showLockErrorAlert());
                finishRun(GuiBatchRunLaunchOutcome.rejected(
                        "Konfigurationsdatei gesperrt – Lauf wurde abgebrochen."));
                return;
            }
        }

        try {
            BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
            BatchRunCancellationToken token = cancellationRequested::get;
            GuiBatchRunLaunchOutcome outcome;
            try {
                outcome = launcher.launch(configFilePath, observer, token);
                if (outcome == null) {
                    outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
                            "Launcher hat kein Ergebnis geliefert.");
                }
            } catch (RuntimeException e) {
                LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
                        e.getMessage(), e);
                outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
                        "Unerwarteter technischer Fehler: "
                                + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
            }
            finishRun(outcome);
        } finally {
            configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
        }
    }

    private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
        LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
                + "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
        observerSummary.set(null);

        if (configurationFileLockPort.isPresent()) {
            try {
                configurationFileLockPort.get().acquireLock();
            } catch (ConfigurationFileLockException e) {
                LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
                        e.getMessage());
                fxDispatcher.accept(() -> showLockErrorAlert());
                finishRun(GuiBatchRunLaunchOutcome.rejected(
                        "Konfigurationsdatei gesperrt – Mini-Lauf wurde abgebrochen."));
                return;
            }
        }

        try {
            BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
            BatchRunCancellationToken token = cancellationRequested::get;
            GuiBatchRunLaunchOutcome outcome;
            try {
                outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token);
                if (outcome == null) {
                    outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
                            "Mini-Run-Launcher hat kein Ergebnis geliefert.");
                }
            } catch (RuntimeException e) {
                LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
                        e.getMessage(), e);
                outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
                        "Unerwarteter technischer Fehler im Mini-Lauf: "
                                + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
            }
            finishRun(outcome);
        } finally {
            configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
        }
    }

    private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
        LOG.info("GUI-Status-Reset: Worker-Thread gestartet für {} Dokument(e), "
                + "Konfiguration {}.", fingerprints.size(), configFilePath);
        ResetDocumentStatusResult result;
        try {
            result = resetPort.reset(configFilePath, fingerprints);
            if (result == null) {
                result = new ResetDocumentStatusResult(fingerprints.size(),
                        Set.of(), allFailureMap(fingerprints,
                                "Reset-Port hat kein Ergebnis geliefert."));
            }
        } catch (RuntimeException e) {
            LOG.error("GUI-Status-Reset: Unerwarteter Fehler im Worker-Thread: {}",
                    e.getMessage(), e);
            String msg = "Unerwarteter technischer Fehler beim Status-Reset: "
                    + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
            result = new ResetDocumentStatusResult(fingerprints.size(),
                    Set.of(), allFailureMap(fingerprints, msg));
        }
        ResetDocumentStatusResult finalResult = result;
        activeWorker.set(null);
        fxDispatcher.accept(() -> listener.onResetCompleted(finalResult));
        LOG.info("GUI-Status-Reset: Worker-Thread beendet.");
    }

    private void finishRun(GuiBatchRunLaunchOutcome outcome) {
        RunSummary summary = observerSummary.get();
        if (summary == null) {
            summary = new RunSummary(0, 0, 0);
        }
        GuiBatchRunLaunchOutcome finalOutcome = outcome;
        RunSummary finalSummary = summary;
        activeWorker.set(null);
        fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
        LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
    }

    private static java.util.Map<DocumentFingerprint, String> allFailureMap(
            Set<DocumentFingerprint> fingerprints, String message) {
        java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
        for (DocumentFingerprint fp : fingerprints) {
            map.put(fp, message);
        }
        return map;
    }

    /**
     * Captures the final summary supplied by the application layer. Written on the
     * worker thread; read only after the run has ended.
     */
    private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();

    private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
        return new BatchRunProgressObserver() {
            @Override
            public void onRunStarted(RunId runId, int totalCandidates) {
                fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
            }

            @Override
            public void onDocumentCompleted(DocumentCompletionEvent event) {
                GuiBatchRunResultRow row = toRow(event, configFilePath);
                fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
            }

            @Override
            public void onRunEnded(RunSummary summary) {
                observerSummary.set(summary);
                // Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
                // auf, nachdem der Launcher zurückgekehrt ist.
            }
        };
    }

    /**
     * Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
     * <p>
     * Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
     * und {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}) wird der historische
     * Verarbeitungskontext über den {@link GuiHistoricalDocumentContextPort} nachgeladen.
     * Für SKIPPED_ALREADY_PROCESSED wird der letzte Zieldateiname aus dem Kontext als
     * {@code finalName} übernommen. Schlägt die Abfrage fehl, bleibt der Kontext leer.
     * Die Methode läuft auf dem Worker-Thread.
     *
     * @param event          das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
     * @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
     * @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
     */
    private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
        Optional<String> finalName = event.finalFileName() == null
                ? Optional.empty() : Optional.of(event.finalFileName());
        Optional<LocalDate> date = event.resolvedDate() == null
                ? Optional.empty() : Optional.of(event.resolvedDate());
        Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
                ? Optional.empty() : Optional.of(event.aiReasoning());
        Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
                ? Optional.empty() : Optional.of(event.failureMessage());
        Duration duration = event.processingDuration();

        // Historischen Kontext für übersprungene Dokumente nachladen
        boolean isSkipped = event.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
                || event.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
        Optional<HistoricalDocumentContext> historicalContext = Optional.empty();
        if (isSkipped) {
            try {
                historicalContext = historicalDocumentContextPort
                        .resolveHistoricalDocumentContext(configFilePath, event.fingerprint());
            } catch (Exception e) {
                LOG.warn("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
                        event.originalFileName(), e.getMessage(), e);
            }
            // Zieldateiname für SKIPPED_ALREADY_PROCESSED aus Kontext übernehmen
            if (finalName.isEmpty()) {
                finalName = historicalContext
                        .flatMap(HistoricalDocumentContext::lastTargetFileName);
            }
        }

        return new GuiBatchRunResultRow(
                event.originalFileName(),
                event.fingerprint(),
                event.status(),
                finalName,
                Optional.empty(),
                date,
                reasoning,
                failureMessage,
                duration,
                false,
                historicalContext);
    }

    private static void showLockErrorAlert() {
        Alert alert = new Alert(Alert.AlertType.ERROR);
        alert.setTitle("Verarbeitungslauf nicht möglich");
        alert.setHeaderText("Konfigurationsdatei gesperrt");
        alert.setContentText(
                "Der Verarbeitungslauf konnte nicht gestartet werden, da die "
                + "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
                + "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
                + "ein anderer Prozess hält die Datei belegt.\n\n"
                + "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
        alert.showAndWait();
    }

    private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
        return (configPath, fingerprint) -> Optional.empty();
    }

    private static Function<Runnable, Thread> defaultThreadFactory() {
        return task -> {
            Thread thread = new Thread(task, WORKER_THREAD_NAME);
            thread.setDaemon(true);
            return thread;
        };
    }

    private static Consumer<Runnable> defaultFxDispatcher() {
        return Platform::runLater;
    }

    private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
        return (configFilePath, fingerprintFilter, observer, cancellationToken) ->
                GuiBatchRunLaunchOutcome.rejected(
                        "Kein Mini-Run-Launcher in diesem Kontext verfügbar.");
    }

    private static GuiResetDocumentStatusPort rejectingResetPort() {
        return (configFilePath, fingerprints) ->
                new ResetDocumentStatusResult(fingerprints.size(),
                        Set.of(), allFailureMapStatic(fingerprints,
                                "Kein Reset-Port in diesem Kontext verfügbar."));
    }

    private static java.util.Map<DocumentFingerprint, String> allFailureMapStatic(
            Set<DocumentFingerprint> fingerprints, String message) {
        java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
        for (DocumentFingerprint fp : fingerprints) {
            map.put(fp, message);
        }
        return map;
    }
}
