package de.gecheckt.pdf.umbenenner.bootstrap;

import java.io.IOException;
import java.io.StringReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;

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

import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.in.scheduler.FileChannelConfigurationAccessAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.scheduler.ScheduledExecutorServiceSchedulerAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiApplicationContextInitializer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiCreateNewDatabasePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.ClaudeModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.OpenAiCompatibleModelCatalogAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.pathcheck.FilesystemPathCheckAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDatabaseCreationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFileRenameAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
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.ManualFileCopyUseCase;
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.ManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteHistoryQueryAdapter;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultSchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jLogDiagnosticsAdapter;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.SqliteActiveDatabaseContextAdapter;
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.AnotherInstanceRunningException;
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;

/**
 * Orchestrator for the complete startup sequence and object graph construction.
 * <p>
 * Selects the appropriate inbound adapter based on the parsed startup mode and
 * routes control to either the headless batch processing pipeline or the JavaFX
 * desktop GUI adapter. Exactly one inbound adapter is started per process run.
 *
 * <h2>Startup modes</h2>
 * <ul>
 *   <li>{@link StartupMode#GUI} — Bootstrap creates and starts the
 *       {@link GuiAdapter}. The GUI adapter manages the JavaFX lifecycle.
 *       Bootstrap returns exit code 0 on normal GUI shutdown and exit code 1 on
 *       any exception thrown by the GUI adapter before or during startup.</li>
 *   <li>{@link StartupMode#HEADLESS} — Bootstrap runs the two-phase headless batch
 *       pipeline described below.</li>
 * </ul>
 *
 * <h2>Headless batch pipeline phases</h2>
 * <ol>
 *   <li><strong>Bootstrap Phase:</strong> Load and validate configuration, initialize persistence
 *       schema, establish run-lock, and prepare all adapters and ports.</li>
 *   <li><strong>Execution Phase:</strong> Wire and execute the batch processing use case, then
 *       map outcome to exit code.</li>
 * </ol>
 *
 * <h2>Configuration path semantics ({@code --config} option)</h2>
 * <p>
 * The {@code --config <path>} option is available in both startup modes but is enforced
 * differently:
 * <ul>
 *   <li><strong>Headless:</strong> When a path override is present, the file must exist before
 *       migration or loading is attempted. A missing file is a hard startup failure (exit code 1).
 *       No silent fallback to the default path occurs. When no override is present, the default
 *       path {@code config/application.properties} relative to the working directory is used.</li>
 *   <li><strong>GUI:</strong> When a path override is present and the file exists, the path is
 *       confirmed and the adapter is started normally. When the file does not exist, an error
 *       is logged and a startup notice is forwarded to the adapter; the GUI then starts without
 *       any configuration override, behaving as if {@code --config} had not been specified.</li>
 * </ul>
 *
 * <h2>Active AI provider</h2>
 * <p>
 * The active AI provider family is determined from the configuration and logged at run start.
 * The {@link AiProviderSelector} in the bootstrap layer selects the appropriate
 * {@link AiInvocationPort} implementation. Exactly one provider is active per run.
 *
 * <h2>Exit code semantics</h2>
 * <ul>
 *   <li>{@code 0}: Headless batch run executed successfully (individual document failures do
 *       not change the exit code), or the GUI terminated normally after the window was shown.</li>
 *   <li>{@code 1}: Hard start, bootstrap, configuration, persistence, or GUI startup failure.</li>
 * </ul>
 *
 * <h2>Adapter wiring (headless path)</h2>
 * <p>
 * The production constructor wires the following key adapters:
 * <ul>
 *   <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from the multi-provider
 *       properties schema and environment.</li>
 *   <li>{@link AiProviderSelector} — selects the active {@link AiInvocationPort} implementation
 *       based on {@code ai.provider.active}.</li>
 *   <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li>
 *   <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.</li>
 *   <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li>
 *   <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
 *   <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
 *   <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li>
 *   <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured
 *       target folder.</li>
 *   <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder
 *       via a temporary file and final move/rename.</li>
 * </ul>
 */
public class BootstrapRunner {
    private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
    private static final String FINGERPRINT_NOT_NULL = "fingerprint must not be null";
    private static final String UNEXPECTED_ERROR_PREFIX = "Unerwarteter Fehler: ";
    private static final String CONFIG_LOAD_FAILED_PREFIX = "Konfiguration konnte nicht geladen werden: ";
    private static final String CONFIG_FILE_NOT_FOUND_PREFIX = "Konfigurationsdatei nicht gefunden: ";
    private static final String CONFIG_NOT_RUNNABLE_PREFIX = "Die Konfiguration ist nicht lauffähig: ";



    private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
    private static final Path DEFAULT_CONFIG_PATH = Paths.get("config/application.properties");

    private final MigrationStep migrationStep;
    private final ConfigurationPortFactory configPortFactory;
    private final RunLockPortFactory runLockPortFactory;
    private final ValidatorFactory validatorFactory;
    private final SchemaInitializationPortFactory schemaInitPortFactory;
    private final UseCaseFactory useCaseFactory;
    private final CommandFactory commandFactory;
    private final GuiAdapterFactory guiAdapterFactory;
    private final SingleInstanceGuardFactory singleInstanceGuardFactory;

    /**
     * Eigentümer der zur Laufzeit aktiven SQLite-Datenbankreferenz. Wird im
     * GUI-Pfad genutzt, damit der Use-Case
     * {@link DefaultCreateNewDatabaseUseCase} die aktive Datenbank umstellen kann,
     * ohne die {@code .properties}-Datei sofort schreiben zu müssen.
     * <p>
     * Solange kein Override gesetzt ist, verhalten sich alle DB-Adapter wie
     * bisher (Pfad aus der jeweils geladenen {@link StartConfiguration}).
     */
    private final SqliteActiveDatabaseContextAdapter activeDatabaseContext = new SqliteActiveDatabaseContextAdapter();

    /**
     * Adapter zum Anlegen einer neuen, leeren SQLite-Datenbank. Zustandslos und
     * threadsicher; wird im GUI-Pfad pro Anlage-Aufruf wiederverwendet.
     */
    private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter();

    /**
     * The application run context built once at GUI startup from the initially loaded
     * configuration file. When present, it carries a fully validated
     * {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration} and
     * the resolved JDBC URL, so that batch runs and reset operations can skip the
     * migrate → load → validate → schema-init sequence for each call.
     * <p>
     * Written by {@link #initializeApplicationRunContext(Path)} — either directly during
     * {@link #buildGuiStartupContext(Optional)} (when {@code --config} is supplied and valid),
     * or via the {@link GuiApplicationContextInitializer} callback that the workspace invokes
     * on a background thread after each successful file open. Read by
     * {@link #launchGuiBatchRun}, {@link #launchGuiMiniBatchRun}, and
     * {@link #resetDocumentStatusForGui}. {@link AtomicReference} ensures atomic publication
     * of the context across threads.
     */
    private final AtomicReference<Optional<ApplicationRunContext>> guiApplicationRunContext =
            new AtomicReference<>(Optional.empty());

    /**
     * Der Scheduler-Use-Case, der beim GUI-Start mit einer gültigen Konfiguration
     * initialisiert wird. Enthält den {@link DefaultSchedulerControlUseCase}, der den
     * automatischen Scheduler-Lebenszyklus verwaltet.
     * <p>
     * Wird beim Start via {@link #tryInitializeScheduler(Path)} gesetzt. Leer, wenn
     * beim Start keine gültige Konfiguration vorlag oder die Scheduler-Initialisierung
     * fehlschlug. {@code volatile} sichert die Sichtbarkeit über Thread-Grenzen hinweg.
     */
    private volatile Optional<DefaultSchedulerControlUseCase> guiSchedulerUseCase = Optional.empty();

    /**
     * Functional interface encapsulating the legacy configuration migration step.
     * <p>
     * The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy}
     * on the effective configuration file before any configuration is loaded. In tests, a
     * no-op lambda is injected so that migration does not interfere with mock configuration ports.
     */
    @FunctionalInterface
    public interface MigrationStep {
        /**
         * Runs the legacy configuration migration if the configuration file at the given
         * path is in legacy form.
         *
         * @param effectiveConfigPath the resolved configuration file path to check and migrate
         */
        void runIfNeeded(Path effectiveConfigPath);
    }

    /**
     * Functional interface for creating a {@link ConfigurationPort} bound to the resolved
     * configuration file path.
     */
    @FunctionalInterface
    public interface ConfigurationPortFactory {
        /**
         * Creates a {@link ConfigurationPort} that loads configuration from the given path.
         *
         * @param effectiveConfigPath the resolved configuration file path; never {@code null}
         * @return a new {@link ConfigurationPort}; never {@code null}
         */
        ConfigurationPort create(Path effectiveConfigPath);
    }

    /**
     * Functional interface for creating a RunLockPort from the configured lock file path.
     */
    @FunctionalInterface
    public interface RunLockPortFactory {
        /**
         * Creates a {@link RunLockPort} bound to the given lock file path.
         *
         * @param lockFilePath the lock file path; never {@code null}
         * @return a new {@link RunLockPort}; never {@code null}
         */
        RunLockPort create(Path lockFilePath);
    }

    /**
     * Functional interface for creating a StartConfigurationValidator.
     */
    @FunctionalInterface
    public interface ValidatorFactory {
        /** Creates a new {@link StartConfigurationValidator}. */
        StartConfigurationValidator create();
    }

    /**
     * Functional interface for creating a PersistenceSchemaInitializationPort from the JDBC URL.
     */
    @FunctionalInterface
    public interface SchemaInitializationPortFactory {
        /**
         * Creates a schema initialization port bound to the given JDBC URL.
         *
         * @param jdbcUrl the JDBC URL for the SQLite database; never {@code null}
         * @return a new {@link PersistenceSchemaInitializationPort}; never {@code null}
         */
        PersistenceSchemaInitializationPort create(String jdbcUrl);
    }

    /**
     * Factory for creating a properly wired BatchRunProcessingUseCase.
     * <p>
     * The factory receives the complete startup configuration (for infrastructure adapter wiring)
     * and the run lock port. Its responsibility is to:
     * <ol>
     *   <li>Extract the minimal runtime configuration needed by the application layer.</li>
     *   <li>Construct all outbound adapter ports (document candidates, PDF extraction,
     *       fingerprint, persistence).</li>
     *   <li>Wire the use case with all required ports and dependencies.</li>
     * </ol>
     * <p>
     * This factory is the primary responsibility boundary between startup configuration
     * (complete technical infrastructure setup) and runtime configuration (minimal application
     * needs).
     */
    @FunctionalInterface
    public interface UseCaseFactory {
        /**
         * Creates a wired {@link BatchRunProcessingUseCase}.
         *
         * @param startConfig  the validated startup configuration; never {@code null}
         * @param runLockPort  the acquired run lock port; never {@code null}
         * @return a ready-to-execute {@link BatchRunProcessingUseCase}; never {@code null}
         */
        BatchRunProcessingUseCase create(StartConfiguration startConfig, RunLockPort runLockPort);
    }

    /**
     * Functional interface for creating a SchedulerBatchCommand.
     */
    @FunctionalInterface
    public interface CommandFactory {
        /**
         * Creates a {@link SchedulerBatchCommand} wrapping the given use case.
         *
         * @param useCase the batch processing use case; never {@code null}
         * @return a new {@link SchedulerBatchCommand}; never {@code null}
         */
        SchedulerBatchCommand create(BatchRunProcessingUseCase useCase);
    }

    /**
     * Functional interface for creating a {@link GuiAdapter}.
     * <p>
     * The production implementation returns {@code new GuiAdapter()}. In tests, a
     * lambda that returns a controllable stub can be injected to verify Bootstrap's
     * dispatch and error-handling behaviour without starting an actual JavaFX runtime.
     */
    @FunctionalInterface
    public interface GuiAdapterFactory {
        /**
         * Creates a new {@link GuiAdapter} ready to be started.
         *
         * @return a new {@link GuiAdapter}; never {@code null}
         */
        GuiAdapter create();
    }

    /**
     * Factory für den prozessweiten Einzelinstanz-Schutz.
     * <p>
     * Die Produktionsimplementierung liefert {@code new SingleInstanceGuard()}.
     * In Tests kann eine No-Op-Lambda injiziert werden, damit kein Test den
     * Produktionsport belegt und parallele Testläufe einander nicht behindern.
     */
    @FunctionalInterface
    public interface SingleInstanceGuardFactory {
        /**
         * Erstellt eine neue {@link SingleInstanceGuard}-Instanz.
         *
         * @return eine neue {@link SingleInstanceGuard}-Instanz; niemals {@code null}
         */
        SingleInstanceGuard create();
    }

    /**
     * Creates the BootstrapRunner with default factories for production use.
     * <p>
     * Wires the processing pipeline with the following adapters:
     * <ul>
     *   <li>{@link PropertiesConfigurationPortAdapter} for configuration loading.</li>
     *   <li>{@link AiProviderSelector} for selecting the active AI provider implementation.</li>
     *   <li>{@link FilesystemRunLockPortAdapter} for exclusive run locking.</li>
     *   <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li>
     *   <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li>
     *   <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
     *   <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.</li>
     *   <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
     *   <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
     *   <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
     *   <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution.</li>
     *   <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents via a
     *       temporary file and final atomic move/rename.</li>
     *   <li>{@link GuiAdapter} as the inbound adapter for the GUI startup path.</li>
     * </ul>
     */
    public BootstrapRunner() {
        this.migrationStep = path -> new LegacyConfigurationMigrator().migrateIfLegacy(path);
        this.configPortFactory = PropertiesConfigurationPortAdapter::new;
        this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
        this.validatorFactory = StartConfigurationValidator::new;
        this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
        this.useCaseFactory = (startConfig, lock) -> buildProductionBatchUseCase(
                startConfig, lock,
                de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
                de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled(),
                true);
        this.commandFactory = SchedulerBatchCommand::new;
        this.guiAdapterFactory = GuiAdapter::new;
        this.singleInstanceGuardFactory = SingleInstanceGuard::new;
    }

    /**
     * Wires the production batch-processing use case with the supplied progress observer and
     * cancellation token.
     * <p>
     * Shared wiring for the headless path (where observer and token default to no-ops) and the
     * GUI processing-run path (where both carry live callbacks from the UI). The method is
     * intentionally factored out so a single wiring description serves both entry points.
     *
     * @param startConfig        validated startup configuration; must not be null
     * @param runLockPort        acquired run-lock port; must not be null
     * @param progressObserver   observer forwarded into the use case; must not be null
     * @param cancellationToken  cancellation token forwarded into the use case; must not be null
     * @return a fully wired production batch use case; never null
     */
    private BatchRunProcessingUseCase buildProductionBatchUseCase(
            StartConfiguration startConfig,
            RunLockPort runLockPort,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
        return buildProductionBatchUseCase(
                startConfig, runLockPort, progressObserver, cancellationToken, false);
    }

    /**
     * Erweiterte Variante mit Headless-Modus-Flag fuer den Token-Tracking-Hook.
     *
     * @param headlessMode {@code true} fuer headless Laufkontext; aktiviert den
     *                     CLI-Hinweis bei fehlendem Modell-Preis-Eintrag
     * @return verdrahteter Use Case
     */
    private BatchRunProcessingUseCase buildProductionBatchUseCase(
            StartConfiguration startConfig,
            RunLockPort runLockPort,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken,
            boolean headlessMode) {
        AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
        RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
                startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);

        AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
        ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
        AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);

        String jdbcUrl = resolveActiveJdbcUrl(startConfig);
        FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
        DocumentRecordRepository documentRecordRepository =
                new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
        ProcessingAttemptRepository processingAttemptRepository =
                new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
        UnitOfWorkPort unitOfWorkPort =
                new SqliteUnitOfWorkAdapter(jdbcUrl);
        ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
                DocumentProcessingCoordinator.class, aiContentSensitivity);
        TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
        TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
        de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository modelPriceRepository =
                new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
        DocumentProcessingCoordinator documentProcessingCoordinator =
                new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
                        unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
                        startConfig.maxRetriesTransient(),
                        startConfig.maxTitleLength(),
                        activeFamily.getIdentifier(),
                        modelPriceRepository,
                        headlessMode);

        PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
        ClockPort clockPort = new SystemClockAdapter();
        AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
        AiNamingService aiNamingService = new AiNamingService(
                aiInvocationPort,
                promptPort,
                aiResponseValidator,
                providerConfig.model(),
                startConfig.maxTextCharacters(),
                startConfig.maxTitleLength());

        ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
                DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
        return new DefaultBatchRunProcessingUseCase(
                runtimeConfig,
                runLockPort,
                new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
                new PdfTextExtractionPortAdapter(),
                fingerprintPort,
                documentProcessingCoordinator,
                aiNamingService,
                useCaseLogger,
                progressObserver,
                cancellationToken);
    }

    /**
     * Creates the BootstrapRunner with custom factories for testing, without a migration step.
     * <p>
     * The migration step is set to a no-op so tests that provide a pre-built
     * {@link ConfigurationPort} are not affected by file-based migration logic.
     * The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()};
     * tests that need to control the GUI path use the full eight-parameter constructor.
     *
     * @param configPortFactory         factory for creating ConfigurationPort instances
     * @param runLockPortFactory        factory for creating RunLockPort instances
     * @param validatorFactory          factory for creating StartConfigurationValidator instances
     * @param schemaInitPortFactory     factory for creating PersistenceSchemaInitializationPort instances
     * @param useCaseFactory            factory for creating BatchRunProcessingUseCase instances
     * @param commandFactory            factory for creating SchedulerBatchCommand instances
     */
    public BootstrapRunner(ConfigurationPortFactory configPortFactory,
                           RunLockPortFactory runLockPortFactory,
                           ValidatorFactory validatorFactory,
                           SchemaInitializationPortFactory schemaInitPortFactory,
                           UseCaseFactory useCaseFactory,
                           CommandFactory commandFactory) {
        this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ },
                configPortFactory, runLockPortFactory, validatorFactory,
                schemaInitPortFactory, useCaseFactory, commandFactory,
                GuiAdapter::new,
                noOpSingleInstanceGuardFactory());
    }

    /**
     * Creates the BootstrapRunner with all factories including an explicit migration step.
     * <p>
     * Use this constructor in tests that need to exercise the full migration-then-load path.
     * The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()}.
     *
     * @param migrationStep             the legacy configuration migration step to run before loading
     * @param configPortFactory         factory for creating ConfigurationPort instances
     * @param runLockPortFactory        factory for creating RunLockPort instances
     * @param validatorFactory          factory for creating StartConfigurationValidator instances
     * @param schemaInitPortFactory     factory for creating PersistenceSchemaInitializationPort instances
     * @param useCaseFactory            factory for creating BatchRunProcessingUseCase instances
     * @param commandFactory            factory for creating SchedulerBatchCommand instances
     */
    public BootstrapRunner(MigrationStep migrationStep,
                           ConfigurationPortFactory configPortFactory,
                           RunLockPortFactory runLockPortFactory,
                           ValidatorFactory validatorFactory,
                           SchemaInitializationPortFactory schemaInitPortFactory,
                           UseCaseFactory useCaseFactory,
                           CommandFactory commandFactory) {
        this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory,
                schemaInitPortFactory, useCaseFactory, commandFactory, GuiAdapter::new,
                noOpSingleInstanceGuardFactory());
    }

    /**
     * Creates the BootstrapRunner with full control over all factories, including the GUI
     * adapter factory.
     * <p>
     * Use this constructor in tests that need to exercise the GUI startup dispatch path,
     * where the GUI adapter factory must be controlled to avoid starting a real JavaFX runtime.
     *
     * @param migrationStep             the legacy configuration migration step to run before loading
     * @param configPortFactory         factory for creating ConfigurationPort instances
     * @param runLockPortFactory        factory for creating RunLockPort instances
     * @param validatorFactory          factory for creating StartConfigurationValidator instances
     * @param schemaInitPortFactory     factory for creating PersistenceSchemaInitializationPort instances
     * @param useCaseFactory            factory for creating BatchRunProcessingUseCase instances
     * @param commandFactory            factory for creating SchedulerBatchCommand instances
     * @param guiAdapterFactory         factory for creating GuiAdapter instances
     */
    public BootstrapRunner(MigrationStep migrationStep,
                           ConfigurationPortFactory configPortFactory,
                           RunLockPortFactory runLockPortFactory,
                           ValidatorFactory validatorFactory,
                           SchemaInitializationPortFactory schemaInitPortFactory,
                           UseCaseFactory useCaseFactory,
                           CommandFactory commandFactory,
                           GuiAdapterFactory guiAdapterFactory) {
        this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory,
                schemaInitPortFactory, useCaseFactory, commandFactory, guiAdapterFactory,
                noOpSingleInstanceGuardFactory());
    }

    /**
     * Erstellt den BootstrapRunner mit vollständiger Kontrolle über alle Factories,
     * einschließlich der Einzelinstanz-Guard-Factory.
     * <p>
     * Dieser Konstruktor ist für Tests gedacht, die den Guard-Pfad oder das
     * Zusammenspiel aller Factories prüfen müssen.
     *
     * @param migrationStep                 der Migrationsschritt vor dem Laden der Konfiguration
     * @param configPortFactory             Factory für den ConfigurationPort
     * @param runLockPortFactory            Factory für den RunLockPort
     * @param validatorFactory              Factory für den StartConfigurationValidator
     * @param schemaInitPortFactory         Factory für den PersistenceSchemaInitializationPort
     * @param useCaseFactory                Factory für den BatchRunProcessingUseCase
     * @param commandFactory                Factory für den SchedulerBatchCommand
     * @param guiAdapterFactory             Factory für den GuiAdapter
     * @param singleInstanceGuardFactory    Factory für den SingleInstanceGuard
     */
    public BootstrapRunner(MigrationStep migrationStep,
                           ConfigurationPortFactory configPortFactory,
                           RunLockPortFactory runLockPortFactory,
                           ValidatorFactory validatorFactory,
                           SchemaInitializationPortFactory schemaInitPortFactory,
                           UseCaseFactory useCaseFactory,
                           CommandFactory commandFactory,
                           GuiAdapterFactory guiAdapterFactory,
                           SingleInstanceGuardFactory singleInstanceGuardFactory) {
        this.migrationStep = migrationStep;
        this.configPortFactory = configPortFactory;
        this.runLockPortFactory = runLockPortFactory;
        this.validatorFactory = validatorFactory;
        this.schemaInitPortFactory = schemaInitPortFactory;
        this.useCaseFactory = useCaseFactory;
        this.commandFactory = commandFactory;
        this.guiAdapterFactory = guiAdapterFactory;
        this.singleInstanceGuardFactory = singleInstanceGuardFactory;
    }

    /**
     * Liefert eine No-Op-{@link SingleInstanceGuardFactory}, die einen Guard erzeugt,
     * der beim Aufruf von {@code acquire()} nichts tut.
     * <p>
     * Wird in Test-Konstruktoren verwendet, damit parallele Testläufe keine Portkonflikte
     * auf dem Produktionsport erzeugen.
     *
     * @return eine No-Op-Guard-Factory; niemals {@code null}
     */
    private static SingleInstanceGuardFactory noOpSingleInstanceGuardFactory() {
        return () -> new SingleInstanceGuard() {
            @Override
            public void acquire() { /* kein Port belegen in Tests */ }
            @Override
            public void close() { /* keine Aktion */ }
        };
    }

    /**
     * Runs the complete application startup sequence for the given startup arguments.
     * <p>
     * Vor dem Start beider Modus-Pfade wird die prozessweite Einzelinstanz-Sicherung
     * aktiviert. Ist bereits eine andere Instanz der Anwendung aktiv, wird der Start
     * sofort abgebrochen:
     * <ul>
     *   <li>Im GUI-Modus erscheint ein Hinweisdialog (Swing, da JavaFX noch nicht
     *       initialisiert ist) und die Methode gibt Exit-Code 1 zurück.</li>
     *   <li>Im Headless-Modus wird eine Fehlermeldung geloggt und Exit-Code 1 zurückgegeben.</li>
     * </ul>
     * <p>
     * Dispatches to the GUI or headless batch adapter based on the startup mode carried
     * in {@code startupArguments}:
     * <ul>
     *   <li>{@link StartupMode#GUI} — delegates to {@link #startGuiMode(Optional)}, which
     *       validates the optional {@code --config} path and creates and starts the GUI inbound
     *       adapter. When the supplied path does not exist on disk, a startup notice is passed
     *       to the adapter and the GUI starts without a configuration override. Any exception
     *       thrown by the adapter before or during startup is treated as a hard startup failure
     *       (exit code 1). Normal adapter termination returns exit code 0.</li>
     *   <li>{@link StartupMode#HEADLESS} — runs the two-phase headless batch pipeline via
     *       {@link #runHeadlessBatch(StartupArguments)}. When a {@code --config} path override
     *       is present but the file does not exist, the run is immediately aborted with exit
     *       code 1 (hard startup failure). No silent fallback to the default path occurs.</li>
     * </ul>
     *
     * @param startupArguments the parsed startup arguments containing the startup mode and
     *                         optional configuration path override; must not be {@code null}
     * @return exit code: 0 for successful completion, 1 for any hard bootstrap,
     *         configuration, persistence, or GUI startup failure
     */
    public int run(StartupArguments startupArguments) {
        Objects.requireNonNull(startupArguments, "startupArguments must not be null");
        LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());

        try (SingleInstanceGuard guard = singleInstanceGuardFactory.create()) {
            guard.acquire();
            return switch (startupArguments.mode()) {
                case GUI -> startGuiMode(startupArguments.configPath());
                case HEADLESS -> runHeadlessBatch(startupArguments);
            };
        } catch (AnotherInstanceRunningException e) {
            return behandleWeitereInstanz(startupArguments.mode(), e);
        }
    }

    /**
     * Behandelt den Fall, dass beim Start bereits eine andere Instanz läuft.
     * <p>
     * Im GUI-Modus wird ein modaler Swing-Dialog angezeigt (JavaFX ist zu diesem Zeitpunkt
     * noch nicht initialisiert). Im Headless-Modus wird eine Fehlermeldung geloggt.
     * In beiden Fällen wird Exit-Code 1 zurückgegeben.
     *
     * @param mode  der aktive Startmodus; nicht {@code null}
     * @param cause die ausgelöste Ausnahme; nicht {@code null}
     * @return immer 1
     */
    private int behandleWeitereInstanz(StartupMode mode, AnotherInstanceRunningException cause) {
        String meldung = "Es läuft bereits eine Instanz des PDF-Umbenenners. Diese Instanz wird beendet.";
        LOG.error("Einzelinstanz-Schutz: {}. Startmodus: {}", meldung, mode);
        if (mode == StartupMode.GUI) {
            javax.swing.JOptionPane.showMessageDialog(
                    null,
                    meldung,
                    "PDF-Umbenenner",
                    javax.swing.JOptionPane.WARNING_MESSAGE);
        }
        return 1;
    }

    /**
     * Runs the complete application startup sequence using the headless batch mode defaults.
     * <p>
     * This convenience overload preserves backward compatibility for callers that do not
     * supply explicit startup arguments. It delegates to {@link #run(StartupArguments)}
     * with {@link StartupMode#HEADLESS} and no configuration path override, which
     * replicates the pre-GUI startup behaviour.
     *
     * @return exit code: 0 for a technically completed run, 1 for any hard bootstrap,
     *         configuration, or persistence failure
     */
    public int run() {
        return run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
    }

    /**
     * Starts the GUI inbound adapter and waits for it to terminate.
     * <p>
     * When a {@code --config} path override is present in {@code configPathOverride}, the file
     * existence is checked before the adapter is started:
     * <ul>
     *   <li>If the file <em>exists</em>, the path is confirmed at INFO level. The GUI starts
     *       normally. The effective configuration path is available for later milestones that
     *       implement configuration loading in the GUI.</li>
     *   <li>If the file does <em>not exist</em>, the error is logged and a startup notice is
     *       prepared and forwarded to the adapter. The GUI then starts without any configuration
     *       override, behaving as if {@code --config} had not been specified.</li>
     * </ul>
     * <p>
     * Creates the GUI adapter via the configured factory and delegates to
     * {@link GuiAdapter#start(Optional)}. Any exception thrown by the adapter before or
     * during startup is treated as a hard GUI startup failure and mapped to exit code 1.
     * Normal termination (user closes the window) returns exit code 0.
     * <p>
     * The headless batch pipeline is not entered from this method. When {@code --config} is
     * supplied and the file exists, configuration loading and schema initialisation run
     * immediately so the application run context is pre-built. For all other startup paths
     * (no {@code --config}, missing file, load failure) a {@link GuiApplicationContextInitializer}
     * callback is wired into the startup context; the workspace calls it on a background thread
     * each time a configuration file is successfully opened.
     *
     * @param configPathOverride the optional {@code --config} path string from startup arguments;
     *                           must not be {@code null}
     * @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure
     */
    private int startGuiMode(Optional<String> configPathOverride) {
        GuiStartupContext startupContext = buildGuiStartupContext(configPathOverride);
        LOG.info("GUI startup: launching GUI adapter.");
        try {
            GuiAdapter guiAdapter = guiAdapterFactory.create();
            guiAdapter.start(startupContext);
            LOG.info("GUI adapter terminated normally.");
            return 0;
        } catch (Exception e) {
            LOG.error("GUI startup failed: {}", e.getMessage(), e);
            return 1;
        } finally {
            stopGuiSchedulerIfActive();
        }
    }

    /**
     * Stoppt den aktiven Scheduler, wenn die GUI beendet wird.
     * <p>
     * Wird im {@code finally}-Block von {@link #startGuiMode(Optional)} aufgerufen,
     * damit der Scheduler-Thread ordentlich beendet wird, bevor der JVM-Prozess endet.
     * Ist kein Scheduler aktiv oder bereits gestoppt, hat dieser Aufruf keine Wirkung.
     */
    private void stopGuiSchedulerIfActive() {
        guiSchedulerUseCase.ifPresent(scheduler -> {
            if (scheduler.getStatus().state().isActive()) {
                LOG.info("GUI-Beendigung: laufender Scheduler wird gestoppt.");
                scheduler.stop();
            }
        });
        guiSchedulerUseCase = Optional.empty();
    }

    /**
     * Runs the headless batch processing pipeline for the given startup arguments.
     * <p>
     * When a {@code --config} path override is present, the file existence is checked
     * immediately. If the file does not exist, the run is aborted with exit code 1.
     * No silent fallback to the default path occurs: a missing file at a supplied
     * {@code --config} path is always a hard startup failure in headless mode.
     * <p>
     * After confirming the path (or when no override is present), the effective path is
     * resolved and the two-phase batch bootstrap executes:
     * <ol>
     *   <li><strong>Bootstrap Phase (hard failures only):</strong> Legacy migration, configuration
     *       loading and validation, SQLite schema initialization.</li>
     *   <li><strong>Execution Phase (document failures tolerated):</strong> Run lock acquisition,
     *       use case wiring, batch execution, and outcome-to-exit-code mapping.</li>
     * </ol>
     * <p>
     * Any configuration loading, validation, or schema initialization failure is a hard startup
     * error and produces exit code 1. Document-level failures during the batch loop do not
     * affect the exit code.
     *
     * @param startupArguments the parsed startup arguments; must not be {@code null}
     * @return exit code: 0 for a technically completed run, 1 for any hard startup failure
     */
    private int runHeadlessBatch(StartupArguments startupArguments) {
        try {
            if (startupArguments.configPath().isPresent()) {
                Path overridePath = Paths.get(startupArguments.configPath().get());
                if (!Files.exists(overridePath)) {
                    LOG.error("Headless startup failed: configuration file not found at --config path: {}",
                            overridePath.toAbsolutePath());
                    return 1;
                }
            }
            Path effectiveConfigPath = resolveEffectiveConfigPath(startupArguments.configPath());
            migrateConfigurationIfNeeded(effectiveConfigPath);
            StartConfiguration config = loadAndValidateConfiguration(effectiveConfigPath);
            initializeSchema(config);
            return executeWithStartConfiguration(config);
        } catch (ConfigurationLoadingException e) {
            LOG.error("Configuration loading failed: {}", e.getMessage());
            return 1;
        } catch (InvalidStartConfigurationException e) {
            LOG.error("Configuration validation failed: {}", e.getMessage());
            return 1;
        } catch (DocumentPersistenceException e) {
            LOG.error("Schema initialization failed: {}", e.getMessage(), e);
            return 1;
        } catch (Exception e) {
            LOG.error("Unexpected startup failure.", e);
            return 1;
        }
    }

    /**
     * Resolves the effective configuration file path.
     * <p>
     * When a {@code --config} path override is present in {@code configPathOverride}, that
     * path is used. Otherwise the default path {@code config/application.properties} relative
     * to the working directory is applied.
     *
     * @param configPathOverride the optional {@code --config} path string from startup arguments
     * @return the resolved configuration file path; never {@code null}
     */
    static Path resolveEffectiveConfigPath(Optional<String> configPathOverride) {
        return configPathOverride
                .map(Paths::get)
                .orElse(DEFAULT_CONFIG_PATH);
    }

    private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
        GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
        GuiConfigurationFileWriter writer = new GuiConfigurationPropertiesWriter();
        AiModelCatalogPort modelCatalogPort = buildModelCatalogDispatcher();
        de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort apiKeyResolutionPort =
                new de.gecheckt.pdf.umbenenner.adapter.out.validation.EnvironmentApiKeyResolutionAdapter();
        ProviderTechnicalTestService providerTechnicalTestService =
                new ProviderTechnicalTestService(modelCatalogPort, apiKeyResolutionPort);
        de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort pathCheckPort =
                new FilesystemPathCheckAdapter();
        TechnicalTestOrchestrator technicalTestOrchestrator = new TechnicalTestOrchestrator(
                new EditorConfigurationValidator(),
                pathCheckPort,
                providerTechnicalTestService,
                new Log4jLogDiagnosticsAdapter());
        de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
        GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun;
        de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher miniRunLauncher =
                this::launchGuiMiniBatchRun;
        de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
                this::resetDocumentStatusForGui;
        GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
        GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
        GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
        GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
        de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort modelPricePort =
                buildGuiModelPriceManagementPort();
        GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
        GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
        GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
        GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
        // Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
        String applicationVersion = ApplicationVersionProvider.resolveVersion();

        // Initializer that the workspace calls on a background thread after every successful
        // file open (auto-load at startup and manual open). Builds the ApplicationRunContext
        // and wires the scheduler for the newly loaded configuration file.
        GuiApplicationContextInitializer contextInitializer = configFilePath -> {
            stopGuiSchedulerIfActive();
            Optional<String> initError = initializeApplicationRunContext(configFilePath);
            if (initError.isEmpty()) {
                tryInitializeScheduler(configFilePath);
            }
            return new GuiApplicationContextInitializer.InitResult(
                    initError,
                    guiSchedulerUseCase.map(s -> (SchedulerControlUseCase) s));
        };

        if (configPathOverride.isEmpty()) {
            return new GuiStartupContext(
                    GuiConfigurationEditorStateFactory.createBlankStartState(),
                    Optional.empty(),
                    loader,
                    writer,
                    modelCatalogPort,
                    apiKeyResolutionPort,
                    providerTechnicalTestService,
                    pathCheckPort,
                    technicalTestOrchestrator,
                    correctionExecutionService,
                    batchRunLauncher,
                    miniRunLauncher,
                    resetPort,
                    manualRenamePort,
                    manualCopyPort,
                    historicalDocumentContextPort,
                    applicationVersion,
                    noOpGuiPromptEditorPort(),
                    historyOverviewPort,
                    historyDetailsPort,
                    historyResetPort,
                    deleteHistoryPort,
                    this::buildGuiPromptEditorPort,
                    createNewDatabasePort,
                    Optional.empty(),
                    Optional.empty(),
                    Optional.empty(),
                    contextInitializer,
                    Optional.of(modelPricePort));
        }

        Path configPath = Paths.get(configPathOverride.get());
        if (!Files.exists(configPath)) {
            LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
                    configPath.toAbsolutePath());
            return new GuiStartupContext(
                    GuiConfigurationEditorStateFactory.createBlankStartState(),
                    Optional.of(CONFIG_FILE_NOT_FOUND_PREFIX + configPath.toAbsolutePath()
                            + "\nDie GUI startet ohne Konfigurationsdatei."),
                    loader,
                    writer,
                    modelCatalogPort,
                    apiKeyResolutionPort,
                    providerTechnicalTestService,
                    pathCheckPort,
                    technicalTestOrchestrator,
                    correctionExecutionService,
                    batchRunLauncher,
                    miniRunLauncher,
                    resetPort,
                    manualRenamePort,
                    manualCopyPort,
                    historicalDocumentContextPort,
                    applicationVersion,
                    noOpGuiPromptEditorPort(),
                    historyOverviewPort,
                    historyDetailsPort,
                    historyResetPort,
                    deleteHistoryPort,
                    this::buildGuiPromptEditorPort,
                    createNewDatabasePort,
                    Optional.empty(),
                    Optional.empty(),
                    Optional.empty(),
                    contextInitializer,
                    Optional.of(modelPricePort));
        }

        LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
        try {
            GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
            GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
                    loadedState.values().promptTemplateFile());
            Optional<String> contextError = initializeApplicationRunContext(configPath);
            Optional<ConfigurationFileLockPort> guiRunLockPort = Optional.empty();
            if (contextError.isEmpty()) {
                tryInitializeScheduler(configPath);
                guiRunLockPort = Optional.of(new FileChannelConfigurationAccessAdapter(configPath));
            }
            Optional<SchedulerControlUseCase> schedulerUseCase =
                    guiSchedulerUseCase.map(s -> (SchedulerControlUseCase) s);
            return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
                    modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
                    technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
                    miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
                    historicalDocumentContextPort, applicationVersion, promptEditorPort,
                    historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
                    this::buildGuiPromptEditorPort, createNewDatabasePort, contextError,
                    schedulerUseCase, guiRunLockPort, contextInitializer,
                    Optional.of(modelPricePort));
        } catch (GuiConfigurationLoadException e) {
            LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
                    e.getMessage(), e);
            return new GuiStartupContext(
                    GuiConfigurationEditorStateFactory.createBlankStartState(),
                    Optional.of(CONFIG_LOAD_FAILED_PREFIX + e.getMessage()),
                    loader,
                    writer,
                    modelCatalogPort,
                    apiKeyResolutionPort,
                    providerTechnicalTestService,
                    pathCheckPort,
                    technicalTestOrchestrator,
                    correctionExecutionService,
                    batchRunLauncher,
                    miniRunLauncher,
                    resetPort,
                    manualRenamePort,
                    manualCopyPort,
                    historicalDocumentContextPort,
                    applicationVersion,
                    noOpGuiPromptEditorPort(),
                    historyOverviewPort,
                    historyDetailsPort,
                    historyResetPort,
                    deleteHistoryPort,
                    this::buildGuiPromptEditorPort,
                    createNewDatabasePort,
                    Optional.empty(),
                    Optional.empty(),
                    Optional.empty(),
                    contextInitializer,
                    Optional.of(modelPricePort));
        }
    }

    /**
     * Initialisiert den automatischen Scheduler für den GUI-Betrieb.
     * <p>
     * Erzeugt {@link FileChannelConfigurationAccessAdapter}, {@link ScheduledExecutorServiceSchedulerAdapter}
     * und {@link DefaultSchedulerControlUseCase} und speichert den Use Case in
     * {@link #guiSchedulerUseCase}. Der Scheduler wird beim Programmstart
     * niemals automatisch gestartet; der Benutzer startet ihn ausschließlich
     * bewusst über den Start-Button im Scheduler-Tab.
     * <p>
     * Schlägt die Initialisierung fehl, wird {@link #guiSchedulerUseCase} auf
     * {@link Optional#empty()} gesetzt und der Fehler als Warnung geloggt.
     * Der GUI-Start wird dadurch nicht abgebrochen.
     *
     * @param configFilePath Pfad zur Konfigurationsdatei; muss auf Platte existieren
     */
    private void tryInitializeScheduler(Path configFilePath) {
        try {
            FileChannelConfigurationAccessAdapter configAccessAdapter =
                    new FileChannelConfigurationAccessAdapter(configFilePath);
            ScheduledExecutorServiceSchedulerAdapter schedulerAdapter =
                    new ScheduledExecutorServiceSchedulerAdapter(
                            result -> LOG.debug("Scheduler-Tick-Ergebnis: {}",
                                    result.getClass().getSimpleName()));
            BatchRunTrigger batchRunTrigger = () -> {
                Optional<ApplicationRunContext> ctxOpt = guiApplicationRunContext.get();
                if (ctxOpt.isEmpty()) {
                    return new BatchRunTriggerResult.Failed(
                            "Kein Anwendungskontext verfügbar.",
                            "guiApplicationRunContext ist leer – Scheduler-Tick übersprungen.");
                }
                ApplicationRunContext ctx = ctxOpt.get();
                try {
                    RunLockPort runLockPort = runLockPortFactory.create(
                            resolveLockFilePath(ctx.startConfiguration()));
                    BatchRunContext runContext = createRunContext();
                    java.util.concurrent.atomic.AtomicReference<de.gecheckt.pdf.umbenenner.application.port.in.RunSummary>
                            capturedSummary = new java.util.concurrent.atomic.AtomicReference<>();
                    de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver capturingObserver =
                            new de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver() {
                                @Override
                                public void onRunStarted(RunId runId, int totalCandidates) {
                                    // No GUI feedback for scheduler-driven runs.
                                }

                                @Override
                                public void onDocumentCompleted(
                                        de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent event) {
                                    // No per-document feedback for scheduler-driven runs.
                                }

                                @Override
                                public void onRunEnded(
                                        de.gecheckt.pdf.umbenenner.application.port.in.RunSummary summary) {
                                    capturedSummary.set(summary);
                                }
                            };
                    BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
                            ctx.startConfiguration(), runLockPort,
                            capturingObserver,
                            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
                    BatchRunOutcome outcome = useCase.execute(runContext);
                    runContext.setEndInstant(Instant.now());
                    if (outcome.isLockUnavailable()) {
                        return new BatchRunTriggerResult.SkippedBusy();
                    } else if (outcome.isSuccess()) {
                        de.gecheckt.pdf.umbenenner.application.port.in.RunSummary inSummary =
                                capturedSummary.get();
                        RunSummary outSummary = inSummary == null
                                ? RunSummary.noOp()
                                : new RunSummary(
                                        inSummary.successCount(),
                                        inSummary.failedCount(),
                                        inSummary.skippedCount());
                        return new BatchRunTriggerResult.Started(Instant.now(), outSummary);
                    } else {
                        return new BatchRunTriggerResult.Failed(
                                "Verarbeitungslauf fehlgeschlagen.",
                                "BatchRunOutcome: " + outcome);
                    }
                } catch (RuntimeException e) {
                    LOG.error("Scheduler-Tick: Unerwarteter Fehler: {}", e.getMessage(), e);
                    return new BatchRunTriggerResult.Failed(
                            UNEXPECTED_ERROR_PREFIX + e.getMessage(),
                            e.getClass().getSimpleName());
                }
            };
            DefaultSchedulerControlUseCase schedulerUseCase = new DefaultSchedulerControlUseCase(
                    schedulerAdapter, configAccessAdapter, configAccessAdapter, batchRunTrigger);
            guiSchedulerUseCase = Optional.of(schedulerUseCase);
        } catch (Exception e) {
            LOG.warn("Scheduler: Initialisierung fehlgeschlagen – Scheduler nicht verfügbar: {}",
                    e.getMessage(), e);
            guiSchedulerUseCase = Optional.empty();
        }
    }

    /**
     * Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen
     * Prompt-Dateipfad.
     * <p>
     * Kombiniert {@link FilesystemPromptPortAdapter} und
     * {@link de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter}
     * in einem {@link DefaultPromptEditorUseCase}. Der zurückgegebene Port delegiert alle
     * drei Operationen (Laden, Speichern, Standard-Anlegen) an den Use-Case.
     *
     * @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
     * @return vollständig verdrahteter Port; nie {@code null}
     */
    private GuiPromptEditorPort buildGuiPromptEditorPort(String promptFilePath) {
        FilesystemPromptPortAdapter promptPortAdapter =
                new FilesystemPromptPortAdapter(Paths.get(promptFilePath));
        de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort
                resourceCreationPort =
                new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter();
        DefaultPromptEditorUseCase useCase = new DefaultPromptEditorUseCase(
                promptPortAdapter, resourceCreationPort);
        return new GuiPromptEditorPort() {
            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
                return useCase.loadPrompt();
            }

            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
                return useCase.savePrompt(content);
            }

            @Override
            public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
                    createDefaultPromptIfMissing(
                            de.gecheckt.pdf.umbenenner.application.validation.technicaltest
                                    .CorrectionSuggestion.CreatePromptFile suggestion) {
                return useCase.createDefaultPromptIfMissing(suggestion);
            }
        };
    }

    /**
     * Gibt einen No-Op-{@link GuiPromptEditorPort} zurück, der alle Operationen als
     * nicht verfügbar meldet.
     * <p>
     * Wird eingesetzt, wenn beim GUI-Start noch keine Konfiguration geladen ist und daher
     * kein Prompt-Dateipfad bekannt ist.
     *
     * @return no-op Port; nie {@code null}
     */
    private static GuiPromptEditorPort noOpGuiPromptEditorPort() {
        return new GuiPromptEditorPort() {
            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
                return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
                        "NO_OP", "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.");
            }

            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
                return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
                        "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.", null);
            }

            @Override
            public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
                    createDefaultPromptIfMissing(
                            de.gecheckt.pdf.umbenenner.application.validation.technicaltest
                                    .CorrectionSuggestion.CreatePromptFile suggestion) {
                return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
                        .CorrectionOutcome.NotAttempted(
                                suggestion, "Kein Prompt-Pfad konfiguriert.");
            }
        };
    }

    /**
     * Attempts to build and store an {@link ApplicationRunContext} from the given configuration
     * file path.
     * <p>
     * Runs the full init sequence (migrate → load → validate → schema-init) and, on success,
     * stores the result in {@link #guiApplicationRunContext}. On any failure the field is
     * cleared and an Optional carrying a human-readable German error message is returned so
     * the caller can surface it in the startup context without aborting the GUI launch.
     *
     * @param configFilePath path to the {@code .properties} configuration file; must exist on disk
     * @return {@link Optional#empty()} on success; an Optional with the error message on failure
     */
    private Optional<String> initializeApplicationRunContext(Path configFilePath) {
        try {
            migrateConfigurationIfNeeded(configFilePath);
            StartConfiguration config = loadAndValidateConfiguration(configFilePath);
            // Im GUI-Modus wird ohne --config gestartet; das konfigurierte Log-Verzeichnis
            // wird erst hier bekannt. Log4j2 (Best-Effort) darauf umstellen, damit die Logs
            // im konfigurierten Verzeichnis landen und nicht im Standard-Fallback.
            new de.gecheckt.pdf.umbenenner.bootstrap.startup.RuntimeLogDirectoryReconfigurer()
                    .apply(config.logDirectory());
            initializeSchema(config);
            guiApplicationRunContext.set(Optional.of(
                    new ApplicationRunContext(config, resolveActiveJdbcUrl(config))));
            LOG.info("GUI-Anwendungskontext initialisiert für Konfiguration: {}", configFilePath);
            return Optional.empty();
        } catch (ConfigurationLoadingException e) {
            LOG.warn("GUI-Anwendungskontext: Konfiguration konnte nicht geladen werden: {}",
                    e.getMessage());
            guiApplicationRunContext.set(Optional.empty());
            return Optional.of(CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.warn("GUI-Anwendungskontext: Konfiguration nicht lauffähig: {}", e.getMessage());
            guiApplicationRunContext.set(Optional.empty());
            return Optional.of("Konfiguration nicht lauffähig: " + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.warn("GUI-Anwendungskontext: SQLite-Initialisierung fehlgeschlagen: {}",
                    e.getMessage());
            guiApplicationRunContext.set(Optional.empty());
            return Optional.of("SQLite konnte nicht initialisiert werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.warn("GUI-Anwendungskontext: Unerwarteter Fehler bei Initialisierung: {}",
                    e.getMessage());
            guiApplicationRunContext.set(Optional.empty());
            return Optional.of("Unerwarteter Fehler bei der Kontextinitialisierung: "
                    + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Executes one batch run using a pre-built {@link ApplicationRunContext}.
     * <p>
     * Skips the migrate → load → validate → schema-init sequence because the context was
     * already built at startup. Only the run-lock, use-case wiring and execution are performed.
     *
     * @param ctx               the validated application run context; must not be null
     * @param progressObserver  observer forwarded into the use case; must not be null
     * @param cancellationToken token forwarded into the use case; must not be null
     * @return the outcome for the run; never null
     */
    private GuiBatchRunLaunchOutcome executeRun(
            ApplicationRunContext ctx,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
        try {
            RunLockPort runLockPort = runLockPortFactory.create(
                    resolveLockFilePath(ctx.startConfiguration()));
            BatchRunContext runContext = createRunContext();
            BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
                    ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken);
            BatchRunOutcome outcome = useCase.execute(runContext);
            runContext.setEndInstant(Instant.now());
            return mapGuiRunOutcome(outcome, runContext);
        } catch (RuntimeException e) {
            LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.failedAfterStart(
                    "Unerwarteter Fehler im Verarbeitungslauf: "
                            + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Executes one targeted mini-run using a pre-built {@link ApplicationRunContext}.
     * <p>
     * Skips the migrate → load → validate → schema-init sequence because the context was
     * already built at startup. Only the run-lock, use-case wiring, fingerprint filter and
     * execution are performed.
     *
     * @param ctx               the validated application run context; must not be null
     * @param fingerprintFilter the set of document fingerprints to process; must not be null
     * @param progressObserver  observer forwarded into the use case; must not be null
     * @param cancellationToken token forwarded into the use case; must not be null
     * @return the outcome for the mini-run; never null
     */
    private GuiBatchRunLaunchOutcome executeRunWithFilter(
            ApplicationRunContext ctx,
            Set<DocumentFingerprint> fingerprintFilter,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
        try {
            RunLockPort runLockPort = runLockPortFactory.create(
                    resolveLockFilePath(ctx.startConfiguration()));
            BatchRunContext runContext = createRunContext();
            BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter);
            BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
                    ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken);
            BatchRunOutcome outcome = useCase.execute(filteredContext);
            filteredContext.setEndInstant(Instant.now());
            return mapGuiRunOutcome(outcome, filteredContext);
        } catch (RuntimeException e) {
            LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.failedAfterStart(
                    "Unerwarteter Fehler im Mini-Verarbeitungslauf: "
                            + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Executes a document-status reset using a pre-built {@link ApplicationRunContext}.
     * <p>
     * Skips the migrate → load → validate → schema-init sequence. Only the run-lock
     * acquisition and the reset use-case execution are performed.
     *
     * @param ctx          the validated application run context; must not be null
     * @param fingerprints the set of fingerprints to reset; must not be null
     * @return the result of the reset operation; never null
     */
    private ResetDocumentStatusResult executeResetWithContext(
            ApplicationRunContext ctx,
            Set<DocumentFingerprint> fingerprints) {
        try {
            UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(ctx.jdbcUrl());
            ProcessingLogger resetLogger = new Log4jProcessingLogger(
                    DefaultResetDocumentStatusUseCase.class,
                    resolveAiContentSensitivity(ctx.startConfiguration().logAiSensitive()));
            RunLockPort runLockPort = runLockPortFactory.create(
                    resolveLockFilePath(ctx.startConfiguration()));
            try {
                runLockPort.acquire();
            } catch (de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException e) {
                LOG.warn("GUI-Status-Reset: Laufsperre nicht verfügbar — Reset abgelehnt.");
                return allFailures(fingerprints, "Lauf blockiert — Lock nicht verfügbar");
            }
            try {
                DefaultResetDocumentStatusUseCase useCase =
                        new DefaultResetDocumentStatusUseCase(unitOfWorkPort, resetLogger);
                ResetDocumentStatusResult result = useCase.reset(fingerprints);
                LOG.info("GUI-Status-Reset abgeschlossen: {} erfolgreich, {} fehlgeschlagen.",
                        result.successCount(), result.failureCount());
                return result;
            } finally {
                try {
                    runLockPort.release();
                } catch (RuntimeException e) {
                    LOG.warn("GUI-Status-Reset: Laufsperre konnte nicht freigegeben werden: {}",
                            e.getMessage(), e);
                }
            }
        } catch (RuntimeException e) {
            LOG.error("GUI-Status-Reset: Unerwarteter Fehler: {}", e.getMessage(), e);
            return allFailures(fingerprints, UNEXPECTED_ERROR_PREFIX
                    + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Executes exactly one batch run triggered by the GUI's processing-run tab.
     * <p>
     * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
     * it is used directly and the migrate → load → validate → schema-init sequence is
     * skipped. When no context is available (e.g., configuration was invalid at startup),
     * the full init sequence runs per call.
     * <p>
     * Forwards the supplied observer and cancellation token into the wired use case so the
     * GUI receives live progress callbacks and can request a soft-stop.
     * <p>
     * All known hard startup failures are mapped to
     * {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions
     * that escape after startup are mapped to
     * {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} so the GUI can reach the
     * defined terminal state instead of becoming stuck.
     *
     * @param configFilePath   path to the {@code .properties} configuration; must exist on disk
     * @param progressObserver observer forwarded into the use case; must not be null
     * @param cancellationToken token forwarded into the use case; must not be null
     * @return the outcome for the run; never null
     */
    GuiBatchRunLaunchOutcome launchGuiBatchRun(
            Path configFilePath,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(progressObserver, "progressObserver must not be null");
        Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
        LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", configFilePath);

        Optional<ApplicationRunContext> ctx = guiApplicationRunContext.get();
        if (ctx.isPresent()) {
            LOG.debug("GUI-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext.");
            return executeRun(ctx.get(), progressObserver, cancellationToken);
        }

        try {
            if (!Files.exists(configFilePath)) {
                return GuiBatchRunLaunchOutcome.rejected(
                        "Konfigurationsdatei wurde nicht gefunden: " + configFilePath);
            }
            migrateConfigurationIfNeeded(configFilePath);
            StartConfiguration config = loadAndValidateConfiguration(configFilePath);
            initializeSchema(config);
            RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
            BatchRunContext runContext = createRunContext();
            BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
                    config, runLockPort, progressObserver, cancellationToken);
            BatchRunOutcome outcome = useCase.execute(runContext);
            runContext.setEndInstant(Instant.now());
            return mapGuiRunOutcome(outcome, runContext);
        } catch (ConfigurationLoadingException e) {
            LOG.error("GUI-Verarbeitungslauf: Konfiguration konnte nicht geladen werden: {}",
                    e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.rejected(
                    CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.error("GUI-Verarbeitungslauf: Konfiguration ist nicht lauffähig: {}", e.getMessage());
            return GuiBatchRunLaunchOutcome.rejected(
                    "Die gespeicherte Konfiguration ist nicht lauffähig: " + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.error("GUI-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
                    e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.rejected(
                    "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.failedAfterStart(
                    "Unerwarteter Fehler im Verarbeitungslauf: "
                            + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Executes a targeted mini batch run restricted to the supplied fingerprint set,
     * triggered by the GUI's "Erneut verarbeiten" action.
     * <p>
     * Mirrors the full headless bootstrap pipeline (legacy migration, configuration loading
     * and validation, SQLite schema initialisation, run-lock acquisition, use-case wiring,
     * and execution) but builds a {@link BatchRunContext} with a fingerprint filter so only
     * the specified documents are processed.
     * <p>
     * The run lock is acquired to prevent racing with a concurrent headless run. If the lock
     * is unavailable, all fingerprints are returned as failures with a German message.
     *
     * @param configFilePath    path to the {@code .properties} configuration; must exist on disk
     * @param fingerprintFilter the set of document fingerprints to process; must not be null
     * @param progressObserver  observer forwarded into the use case; must not be null
     * @param cancellationToken token forwarded into the use case; must not be null
     * @return the outcome for the mini-run; never null
     */
    /**
     * Executes a targeted mini batch run restricted to the supplied fingerprint set,
     * triggered by the GUI's "Erneut verarbeiten" action.
     * <p>
     * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
     * it is used directly and the migrate → load → validate → schema-init sequence is
     * skipped. When no context is available, the full init sequence runs per call.
     * <p>
     * The run lock is acquired to prevent racing with a concurrent headless run. If the lock
     * is unavailable, all fingerprints are returned as failures with a German message.
     *
     * @param configFilePath    path to the {@code .properties} configuration; must exist on disk
     * @param fingerprintFilter the set of document fingerprints to process; must not be null
     * @param progressObserver  observer forwarded into the use case; must not be null
     * @param cancellationToken token forwarded into the use case; must not be null
     * @return the outcome for the mini-run; never null
     */
    GuiBatchRunLaunchOutcome launchGuiMiniBatchRun(
            Path configFilePath,
            Set<DocumentFingerprint> fingerprintFilter,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
            de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
        Objects.requireNonNull(progressObserver, "progressObserver must not be null");
        Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
        LOG.info("GUI-Mini-Verarbeitungslauf: Startanforderung für {} Dokument(e), Konfiguration {}.",
                fingerprintFilter.size(), configFilePath);

        Optional<ApplicationRunContext> ctx = guiApplicationRunContext.get();
        if (ctx.isPresent()) {
            LOG.debug("GUI-Mini-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext.");
            return executeRunWithFilter(ctx.get(), fingerprintFilter, progressObserver, cancellationToken);
        }

        try {
            if (!Files.exists(configFilePath)) {
                return GuiBatchRunLaunchOutcome.rejected(
                        "Konfigurationsdatei wurde nicht gefunden: " + configFilePath);
            }
            migrateConfigurationIfNeeded(configFilePath);
            StartConfiguration config = loadAndValidateConfiguration(configFilePath);
            initializeSchema(config);
            RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
            BatchRunContext runContext = createRunContext();
            BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter);
            BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
                    config, runLockPort, progressObserver, cancellationToken);
            BatchRunOutcome outcome = useCase.execute(filteredContext);
            filteredContext.setEndInstant(Instant.now());
            return mapGuiRunOutcome(outcome, filteredContext);
        } catch (ConfigurationLoadingException e) {
            LOG.error("GUI-Mini-Verarbeitungslauf: Konfiguration konnte nicht geladen werden: {}",
                    e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.rejected(
                    CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.error("GUI-Mini-Verarbeitungslauf: Konfiguration ist nicht lauffähig: {}", e.getMessage());
            return GuiBatchRunLaunchOutcome.rejected(
                    "Die gespeicherte Konfiguration ist nicht lauffähig: " + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.error("GUI-Mini-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
                    e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.rejected(
                    "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
            return GuiBatchRunLaunchOutcome.failedAfterStart(
                    "Unerwarteter Fehler im Mini-Verarbeitungslauf: "
                            + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Resets the processing status of the specified documents by deleting all persistence
     * data for their fingerprints, triggered by the GUI's "Status zurücksetzen" action.
     * <p>
     * This method initialises the SQLite schema (if needed) and runs
     * {@link DefaultResetDocumentStatusUseCase} with best-effort semantics. The run lock is
     * acquired to avoid racing with a concurrent headless run. If the lock is unavailable,
     * all fingerprints are returned as failures with a German message.
     * <p>
     * Configuration migration and validation are applied before the reset executes to ensure
     * the database path is resolvable.
     *
     * @param configFilePath path to the {@code .properties} configuration; must exist on disk
     * @param fingerprints   the set of document fingerprints to reset; must not be null
     * @return the result of the reset operation; never null
     */
    /**
     * Resets the processing status of the specified documents by deleting all persistence
     * data for their fingerprints, triggered by the GUI's "Status zurücksetzen" action.
     * <p>
     * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
     * it is used directly and the migrate → load → validate → schema-init sequence is
     * skipped. When no context is available, the full init sequence runs per call.
     * <p>
     * The run lock is acquired to avoid racing with a concurrent headless run. If the lock
     * is unavailable, all fingerprints are returned as failures with a German message.
     *
     * @param configFilePath path to the {@code .properties} configuration; must exist on disk
     * @param fingerprints   the set of document fingerprints to reset; must not be null
     * @return the result of the reset operation; never null
     */
    ResetDocumentStatusResult resetDocumentStatusForGui(
            Path configFilePath,
            Set<DocumentFingerprint> fingerprints) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprints, "fingerprints must not be null");
        LOG.info("GUI-Status-Reset: {} Dokument(e) zurücksetzen, Konfiguration {}.",
                fingerprints.size(), configFilePath);

        Optional<ApplicationRunContext> ctx = guiApplicationRunContext.get();
        if (ctx.isPresent()) {
            LOG.debug("GUI-Status-Reset: Verwende vorbereiteten Anwendungskontext.");
            return executeResetWithContext(ctx.get(), fingerprints);
        }

        if (!Files.exists(configFilePath)) {
            String msg = CONFIG_FILE_NOT_FOUND_PREFIX + configFilePath;
            LOG.error("GUI-Status-Reset: {}", msg);
            return allFailures(fingerprints, msg);
        }

        try {
            migrateConfigurationIfNeeded(configFilePath);
            StartConfiguration config = loadAndValidateConfiguration(configFilePath);
            initializeSchema(config);

            String jdbcUrl = resolveActiveJdbcUrl(config);
            UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
            ProcessingLogger resetLogger = new Log4jProcessingLogger(
                    DefaultResetDocumentStatusUseCase.class,
                    resolveAiContentSensitivity(config.logAiSensitive()));

            RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
            try {
                runLockPort.acquire();
            } catch (de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException e) {
                LOG.warn("GUI-Status-Reset: Laufsperre nicht verfügbar — Reset abgelehnt.");
                return allFailures(fingerprints, "Lauf blockiert — Lock nicht verfügbar");
            }

            try {
                DefaultResetDocumentStatusUseCase useCase =
                        new DefaultResetDocumentStatusUseCase(unitOfWorkPort, resetLogger);
                ResetDocumentStatusResult result = useCase.reset(fingerprints);
                LOG.info("GUI-Status-Reset abgeschlossen: {} erfolgreich, {} fehlgeschlagen.",
                        result.successCount(), result.failureCount());
                return result;
            } finally {
                try {
                    runLockPort.release();
                } catch (RuntimeException e) {
                    LOG.warn("GUI-Status-Reset: Laufsperre konnte nicht freigegeben werden: {}",
                            e.getMessage(), e);
                }
            }

        } catch (ConfigurationLoadingException e) {
            LOG.error("GUI-Status-Reset: Konfiguration konnte nicht geladen werden: {}", e.getMessage(), e);
            return allFailures(fingerprints, CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.error("GUI-Status-Reset: Konfiguration ist nicht lauffähig: {}", e.getMessage());
            return allFailures(fingerprints, CONFIG_NOT_RUNNABLE_PREFIX + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.error("GUI-Status-Reset: SQLite-Initialisierung fehlgeschlagen: {}", e.getMessage(), e);
            return allFailures(fingerprints, "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.error("GUI-Status-Reset: Unerwarteter Fehler: {}", e.getMessage(), e);
            return allFailures(fingerprints, UNEXPECTED_ERROR_PREFIX
                    + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
        }
    }

    /**
     * Erstellt einen vollständig verdrahteten {@link ManualFileRenameUseCase} für den
     * gegebenen Startkonfigurations-Stand.
     * <p>
     * Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
     * und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
     *
     * @param startConfig die validierte Startkonfiguration; darf nicht null sein
     * @return ein einsatzbereiter Use-Case; nie null
     */
    private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
            StartConfiguration startConfig) {
        String jdbcUrl = resolveActiveJdbcUrl(startConfig);
        DocumentRecordRepository documentRecordRepository =
                new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
        UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
        TargetFolderPort targetFolderPort =
                new FilesystemTargetFolderAdapter(startConfig.targetFolder());
        TargetFileRenamePort targetFileRenamePort =
                new FilesystemTargetFileRenameAdapter(startConfig.targetFolder());
        ClockPort clockPort = new SystemClockAdapter();
        AiContentSensitivity aiContentSensitivity =
                resolveAiContentSensitivity(startConfig.logAiSensitive());
        ProcessingLogger processingLogger = new Log4jProcessingLogger(
                DefaultManualFileRenameUseCase.class, aiContentSensitivity);
        return new DefaultManualFileRenameUseCase(
                documentRecordRepository,
                targetFolderPort,
                targetFileRenamePort,
                unitOfWorkPort,
                clockPort,
                processingLogger);
    }

    /**
     * Erstellt einen vollständig verdrahteten {@link ManualFileCopyUseCase} für den
     * gegebenen Startkonfigurations-Stand.
     * <p>
     * Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
     * und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
     *
     * @param startConfig die validierte Startkonfiguration; darf nicht null sein
     * @return ein einsatzbereiter Use-Case; nie null
     */
    private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
            StartConfiguration startConfig) {
        String jdbcUrl = resolveActiveJdbcUrl(startConfig);
        DocumentRecordRepository documentRecordRepository =
                new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
        UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
        TargetFolderPort targetFolderPort =
                new FilesystemTargetFolderAdapter(startConfig.targetFolder());
        TargetFileCopyPort targetFileCopyPort =
                new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
        ClockPort clockPort = new SystemClockAdapter();
        AiContentSensitivity aiContentSensitivity =
                resolveAiContentSensitivity(startConfig.logAiSensitive());
        ProcessingLogger processingLogger = new Log4jProcessingLogger(
                DefaultManualFileCopyUseCase.class, aiContentSensitivity);
        return new DefaultManualFileCopyUseCase(
                documentRecordRepository,
                targetFolderPort,
                targetFileCopyPort,
                unitOfWorkPort,
                clockPort,
                processingLogger);
    }

    /**
     * Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
     * <p>
     * Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den
     * Use-Case auf und delegiert die Umbenennung. Alle Fehler beim Laden oder
     * Validieren der Konfiguration werden als strukturiertes {@link ManualFileRenameResult}
     * zurückgegeben.
     *
     * @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
     * @param request        die Umbenennungsanfrage; darf nicht null sein
     * @return das Ergebnis der Umbenennung; nie null
     */
    ManualFileRenameResult performGuiManualFileRename(
            Path configFilePath,
            ManualFileRenameRequest request) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(request, "request must not be null");
        LOG.info("GUI-Umbenennung: Anfrage für Fingerprint={}, Zielname={}.",
                request.fingerprint().sha256Hex(), request.desiredBaseFileName());

        if (!Files.exists(configFilePath)) {
            String msg = CONFIG_FILE_NOT_FOUND_PREFIX + configFilePath;
            LOG.error("GUI-Umbenennung: {}", msg);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileRenameFileSystemFailure(msg);
        }

        try {
            StartConfiguration config = resolveStartConfigurationForGui(configFilePath);
            ManualFileRenameUseCase useCase = buildProductionManualFileRenameUseCase(config);
            ManualFileRenameResult result = useCase.rename(request);
            LOG.info("GUI-Umbenennung abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
            return result;
        } catch (ConfigurationLoadingException e) {
            LOG.error("GUI-Umbenennung: Konfiguration konnte nicht geladen werden: {}",
                    e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileRenamePersistenceFailure(
                            CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.error("GUI-Umbenennung: Konfiguration ist nicht lauffähig: {}", e.getMessage());
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileRenamePersistenceFailure(
                            CONFIG_NOT_RUNNABLE_PREFIX + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.error("GUI-Umbenennung: SQLite-Initialisierung fehlgeschlagen: {}",
                    e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileRenamePersistenceFailure(
                            "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.error("GUI-Umbenennung: Unerwarteter Fehler: {}", e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileRenameFileSystemFailure(
                            UNEXPECTED_ERROR_PREFIX
                                    + (e.getMessage() == null
                                            ? e.getClass().getSimpleName()
                                            : e.getMessage()));
        }
    }

    /**
     * Führt eine manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
     * verarbeiteten Dokuments mit benutzerdefiniertem Zieldateinamen ins Zielverzeichnis
     * durch, ausgelöst von der GUI.
     * <p>
     * Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den Use-Case
     * auf und delegiert die Operation. Alle Fehler beim Laden oder Validieren der
     * Konfiguration werden als strukturiertes {@link ManualFileCopyResult} zurückgegeben.
     *
     * @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
     * @param request        die Kopieranfrage; darf nicht null sein
     * @return das Ergebnis der Kopieroperation; nie null
     */
    ManualFileCopyResult performGuiManualFileCopy(
            Path configFilePath,
            ManualFileCopyRequest request) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(request, "request must not be null");
        LOG.info("GUI-Dateikopie: Anfrage für Fingerprint={}, Zielname={}.",
                request.fingerprint().sha256Hex(), request.desiredBaseFileName());

        if (!Files.exists(configFilePath)) {
            String msg = CONFIG_FILE_NOT_FOUND_PREFIX + configFilePath;
            LOG.error("GUI-Dateikopie: {}", msg);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileCopyFileSystemFailure(msg);
        }

        try {
            StartConfiguration config = resolveStartConfigurationForGui(configFilePath);
            ManualFileCopyUseCase useCase = buildProductionManualFileCopyUseCase(config);
            ManualFileCopyResult result = useCase.copy(request);
            LOG.info("GUI-Dateikopie abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
            return result;
        } catch (ConfigurationLoadingException e) {
            LOG.error("GUI-Dateikopie: Konfiguration konnte nicht geladen werden: {}",
                    e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileCopyPersistenceFailure(
                            CONFIG_LOAD_FAILED_PREFIX + e.getMessage());
        } catch (InvalidStartConfigurationException e) {
            LOG.error("GUI-Dateikopie: Konfiguration ist nicht lauffähig: {}", e.getMessage());
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileCopyPersistenceFailure(
                            CONFIG_NOT_RUNNABLE_PREFIX + e.getMessage());
        } catch (DocumentPersistenceException e) {
            LOG.error("GUI-Dateikopie: SQLite-Initialisierung fehlgeschlagen: {}",
                    e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileCopyPersistenceFailure(
                            "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
        } catch (RuntimeException e) {
            LOG.error("GUI-Dateikopie: Unerwarteter Fehler: {}", e.getMessage(), e);
            return new de.gecheckt.pdf.umbenenner.application.port.in
                    .ManualFileCopyFileSystemFailure(
                            UNEXPECTED_ERROR_PREFIX
                                    + (e.getMessage() == null
                                            ? e.getClass().getSimpleName()
                                            : e.getMessage()));
        }
    }

    /**
     * Resolves the historical processing context for the document identified by
     * {@code fingerprint}, using the configuration at {@code configFilePath}.
     * <p>
     * Loads the configuration, initialises the schema and delegates to
     * {@link ResolveHistoricalDocumentContextUseCase}. Technical errors during loading or
     * querying are caught and returned as an empty {@link Optional}; they are never propagated
     * to the caller.
     * <p>
     * Runs on the GUI worker thread. Blocking I/O is therefore acceptable.
     *
     * @param configFilePath path to the active {@code .properties} file; must not be {@code null}
     * @param fingerprint    content-based document identity; must not be {@code null}
     * @return the historical processing context, or empty if not available
     */
    Optional<HistoricalDocumentContext> resolveHistoricalDocumentContextForGui(
            Path configFilePath,
            DocumentFingerprint fingerprint) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);

        if (!Files.exists(configFilePath)) {
            LOG.debug("Historischer Kontext: Konfigurationsdatei nicht gefunden: {}", configFilePath);
            return Optional.empty();
        }

        try {
            String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
            DocumentRecordRepository documentRecordRepository =
                    new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
            ResolveHistoricalDocumentContextUseCase useCase =
                    new DefaultResolveHistoricalDocumentContextUseCase(documentRecordRepository);
            return useCase.resolveHistoricalDocumentContext(fingerprint);
        } catch (Exception e) {
            LOG.debug("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
                    fingerprint.sha256Hex(), e.getMessage());
            return Optional.empty();
        }
    }

    /**
     * Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
     * <p>
     * Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryOverviewUseCase}
     * frisch pro Aufruf, da keine persistente Verbindung über GUI-Interaktionen hinweg
     * gehalten wird.
     *
     * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
     * @param query          Abfrageparameter; darf nicht {@code null} sein
     * @return Ergebnis mit gefundenen Zeilen und hasMore-Flag; nie {@code null}
     */
    DefaultHistoryOverviewUseCase.HistoryOverviewResult loadHistoryOverviewForGui(
            Path configFilePath,
            de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery query) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(query, "query must not be null");
        try {
            String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
            HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
            DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
            return useCase.loadOverview(query);
        } catch (Exception e) {
            LOG.error("Historienübersicht konnte nicht geladen werden: {}", e.getMessage(), e);
            throw new DocumentPersistenceException(
                    "Historienübersicht konnte nicht geladen werden: " + e.getMessage(), e);
        }
    }

    /**
     * Lädt Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
     * <p>
     * Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryDetailsUseCase}
     * frisch pro Aufruf.
     *
     * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
     * @param fingerprint    der Dokumentbezeichner; darf nicht {@code null} sein
     * @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
     */
    Optional<DefaultHistoryDetailsUseCase.HistoryDetailsResult> loadHistoryDetailsForGui(
            Path configFilePath,
            DocumentFingerprint fingerprint) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
        try {
            String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
            HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
            DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
            return useCase.loadDetails(fingerprint);
        } catch (Exception e) {
            LOG.error("Historiendetails für {} konnten nicht geladen werden: {}",
                    fingerprint.sha256Hex(), e.getMessage(), e);
            throw new DocumentPersistenceException(
                    "Historiendetails konnten nicht geladen werden: " + e.getMessage(), e);
        }
    }

    /**
     * Führt den feldgenauen Status-Reset für das angegebene Dokument durch.
     * <p>
     * Setzt {@code overall_status}, {@code content_error_count}, {@code transient_error_count}
     * und {@code last_failure_instant} zurück. Die Versuchshistorie bleibt erhalten.
     *
     * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
     * @param fingerprint    der Dokumentbezeichner; darf nicht {@code null} sein
     */
    void resetHistoryDocumentStatusForGui(
            Path configFilePath,
            DocumentFingerprint fingerprint) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
        LOG.info("Historien-Status-Reset für Fingerprint: {}", fingerprint.sha256Hex());
        try {
            String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
            UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
            DefaultHistoryResetDocumentStatusUseCase useCase =
                    new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
            useCase.resetStatus(fingerprint);
            LOG.info("Historien-Status-Reset abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
        } catch (Exception e) {
            LOG.error("Historien-Status-Reset fehlgeschlagen für {}: {}",
                    fingerprint.sha256Hex(), e.getMessage(), e);
            throw new DocumentPersistenceException(
                    "Status-Reset fehlgeschlagen: " + e.getMessage(), e);
        }
    }

    /**
     * Löscht den Stammsatz und alle Verarbeitungsversuche für das angegebene Dokument.
     * <p>
     * Die Löschung ist destruktiv und nicht rückgängig zu machen.
     *
     * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
     * @param fingerprint    der Dokumentbezeichner; darf nicht {@code null} sein
     */
    void deleteDocumentHistoryForGui(
            Path configFilePath,
            DocumentFingerprint fingerprint) {
        Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
        LOG.info("Historien-Löschen für Fingerprint: {}", fingerprint.sha256Hex());
        try {
            String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
            UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
            DefaultDeleteDocumentHistoryUseCase useCase =
                    new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
            useCase.deleteHistory(fingerprint);
            LOG.info("Historien-Löschen abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
        } catch (Exception e) {
            LOG.error("Historien-Löschen fehlgeschlagen für {}: {}",
                    fingerprint.sha256Hex(), e.getMessage(), e);
            throw new DocumentPersistenceException(
                    "Löschen fehlgeschlagen: " + e.getMessage(), e);
        }
    }

    /**
     * Bridge-Methode zum Anlegen einer neuen, leeren SQLite-Datenbank für die GUI.
     * <p>
     * Verdrahtet pro Aufruf einen frischen {@link DefaultCreateNewDatabaseUseCase} mit dem
     * gemeinsam gehaltenen {@link #databaseCreationPort} und dem
     * {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
     * DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
     * {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
     * Existiert weder ein Override noch eine ladbare Konfiguration, gibt es keine aktive
     * Datenbank: die Lambda liefert dann {@code null}, wodurch der Use-Case die
     * Selbst-Überschreib-Prüfung überspringt und die Anlage zulässt.
     * <p>
     * Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
     * Anlage, Migration und Verbindungstest aus.
     *
     * @param configFilePath der aktuell in der GUI bekannte Konfigurationspfad oder
     *                       {@code null}, wenn noch keine Konfiguration geladen wurde
     * @param targetFile     der vom Benutzer gewählte Zielpfad; darf nicht {@code null} sein
     * @return strukturiertes Ergebnis des Use-Cases; nie {@code null}
     */
    CreateNewDatabaseUseCase.CreateNewDatabaseResult createNewDatabaseForGui(
            Path configFilePath, Path targetFile) {
        Objects.requireNonNull(targetFile, "targetFile must not be null");
        LOG.info("GUI-Datenbankanlage: angeforderter Zielpfad = {}", targetFile);
        DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier activeDbSupplier = () -> {
            Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
            if (override.isPresent()) {
                return override.get();
            }
            if (configFilePath == null || !Files.exists(configFilePath)) {
                // Ohne Override und ohne geladene Konfiguration existiert keine aktive
                // Datenbank. null signalisiert dem Use-Case: keine Selbst-Überschreib-Prüfung
                // nötig, die Anlage darf fortfahren.
                return null;
            }
            try {
                migrateConfigurationIfNeeded(configFilePath);
                StartConfiguration config = loadAndValidateConfiguration(configFilePath);
                return config.sqliteFile().toAbsolutePath().normalize();
            } catch (Exception e) {
                LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
                                + "es wird keine aktive DB angenommen.",
                        configFilePath, e.getMessage());
                return null;
            }
        };
        DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
                databaseCreationPort, activeDatabaseContext, activeDbSupplier);
        return useCase.createNewDatabase(targetFile);
    }

    /**
     * Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
     * recorded as a failure with the given error message.
     * <p>
     * Used to produce a consistently structured failure result when a hard prerequisite
     * (configuration load, lock acquisition) prevents any individual reset from running.
     *
     * @param fingerprints the full set of requested fingerprints; must not be null
     * @param errorMessage the German error message to attach to every failure entry
     * @return a result with all fingerprints in the failure map; never null
     */
    private static ResetDocumentStatusResult allFailures(
            Set<DocumentFingerprint> fingerprints, String errorMessage) {
        java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
        for (DocumentFingerprint fp : fingerprints) {
            failures.put(fp, errorMessage);
        }
        return new ResetDocumentStatusResult(fingerprints.size(), java.util.Set.of(), failures);
    }

    private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
        return switch (outcome) {
            case SUCCESS -> {
                LOG.info("GUI-Verarbeitungslauf: Lauf beendet. RunId: {}", runContext.runId());
                yield GuiBatchRunLaunchOutcome.completed();
            }
            case LOCK_UNAVAILABLE -> {
                LOG.warn("GUI-Verarbeitungslauf: Laufsperre bereits vergeben. RunId: {}",
                        runContext.runId());
                yield GuiBatchRunLaunchOutcome.rejected(
                        "Ein anderer Verarbeitungslauf hält bereits die Laufsperre.");
            }
            case FAILURE -> {
                LOG.error("GUI-Verarbeitungslauf: Lauf mit Fehler beendet. RunId: {}",
                        runContext.runId());
                yield GuiBatchRunLaunchOutcome.failedAfterStart(
                        "Der Verarbeitungslauf konnte nicht erfolgreich abgeschlossen werden.");
            }
        };
    }

    /**
     * Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
     * <p>
     * Instantiates one stateless concrete adapter per supported provider family and wires
     * them into an {@link AiModelCatalogDispatcher}. The adapters are stateless: all
     * provider-specific values (API key, base URL, timeout) are supplied per call via the
     * {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest},
     * so no provider configuration needs to be resolved at this point.
     *
     * @return a ready-to-use model catalogue port; never {@code null}
     */
    private AiModelCatalogPort buildModelCatalogDispatcher() {
        return new AiModelCatalogDispatcher(
                new ClaudeModelCatalogAdapter(),
                new OpenAiCompatibleModelCatalogAdapter());
    }

    private GuiConfigurationEditorState loadGuiConfigurationState(Path configFilePath) {
        try {
            boolean legacyDetected = detectedLegacyConfiguration(configFilePath);
            migrateConfigurationIfNeeded(configFilePath);
            GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
                    configFilePath,
                    readPropertiesSnapshot(configFilePath));
            Optional<String> migrationMessage = legacyDetected
                    ? Optional.of("Konfiguration wurde aus einer Legacy-Datei übernommen.")
                    : Optional.empty();
            return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, migrationMessage);
        } catch (ConfigurationLoadingException e) {
            throw new GuiConfigurationLoadException("Failed to load configuration from " + configFilePath, e);
        }
    }

    private boolean detectedLegacyConfiguration(Path configFilePath) {
        try {
            String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
            Properties props = new Properties();
            props.load(new StringReader(content.replace("\\", "\\\\")));
            boolean hasLegacyKey = props.containsKey("api.baseUrl")
                    || props.containsKey("api.model")
                    || props.containsKey("api.timeoutSeconds")
                    || props.containsKey("api.key");
            return hasLegacyKey && !props.containsKey("ai.provider.active");
        } catch (IOException e) {
            throw new GuiConfigurationLoadException("Failed to inspect legacy configuration at " + configFilePath, e);
        }
    }

    private Properties readPropertiesSnapshot(Path configFilePath) {
        try {
            String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
            Properties props = new Properties();
            props.load(new StringReader(content.replace("\\", "\\\\")));
            return props;
        } catch (IOException e) {
            throw new GuiConfigurationLoadException("Failed to create snapshot for " + configFilePath, e);
        }
    }

    /**
     * Runs the legacy configuration migration step against the effective configuration path.
     * <p>
     * If the configuration file is in the legacy flat-key format, it is migrated in-place to the
     * multi-provider schema before the normal configuration loading path is entered. If the file
     * is already in the current schema, this method returns immediately without any I/O side effect.
     * <p>
     * A migration failure is a hard startup error and propagates as a
     * {@link ConfigurationLoadingException}.
     *
     * @param effectiveConfigPath the resolved configuration file path; never {@code null}
     */
    private void migrateConfigurationIfNeeded(Path effectiveConfigPath) {
        migrationStep.runIfNeeded(effectiveConfigPath);
    }

    /**
     * Erstellt eine GUI-Bridge-Implementierung fuer den Modell-Preis-Tab.
     *
     * <p>Pro Methodenaufruf wird ein frischer SQLite-Adapter aus der zur
     * Konfigurationsdatei gehoerenden JDBC-URL aufgebaut. Die Methoden des
     * Ports laufen auf einem GUI-Worker-Thread; die Connections werden
     * try-with-resources sofort wieder geschlossen.
     *
     * @return Bridge-Port-Implementierung; nie {@code null}
     */
    private de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort
            buildGuiModelPriceManagementPort() {
        return new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort() {
            @Override
            public java.util.List<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
                    findAll(Path configFilePath) {
                Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
                String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
                return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
                        .findAll();
            }

            @Override
            public Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
                    findByProviderAndModelName(Path configFilePath, String provider, String modelName) {
                Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
                String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
                return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
                        .findByProviderAndModelName(provider, modelName);
            }

            @Override
            public void saveAllChanges(Path configFilePath,
                    de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet changeSet) {
                Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
                String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
                de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository repository =
                        new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
                de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase useCase =
                        new de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase(
                                repository, new SystemClockAdapter());
                useCase.saveAllChanges(changeSet);
            }
        };
    }

    /**
     * Liefert die aktive JDBC-URL für GUI-Aufrufe, die keinen vollständigen
     * {@link StartConfiguration} benötigen.
     * <p>
     * Steht der GUI-Anwendungskontext bereits, wird die JDBC-URL daraus übernommen –
     * die Konfigurationsdatei wird in diesem Fall nicht erneut gelesen und das Schema
     * nicht erneut initialisiert. Das vermeidet Kollisionen mit einem aktiven
     * Scheduler-Lock auf der Konfigurationsdatei. Steht kein Kontext, läuft die
     * gewohnte Sequenz Migration → Laden → Validieren → Schema-Init durch.
     *
     * @param configFilePath Pfad zur Konfigurationsdatei; nur relevant ohne aktiven Kontext
     * @return aktive JDBC-URL; nie {@code null}
     */
    private String resolveJdbcUrlForGui(Path configFilePath) {
        Optional<ApplicationRunContext> ctx = guiApplicationRunContext.get();
        if (ctx.isPresent()) {
            return ctx.get().jdbcUrl();
        }
        migrateConfigurationIfNeeded(configFilePath);
        StartConfiguration config = loadAndValidateConfiguration(configFilePath);
        initializeSchema(config);
        return resolveActiveJdbcUrl(config);
    }

    /**
     * Liefert die {@link StartConfiguration} für GUI-Aufrufe, die zusätzlich zur
     * JDBC-URL die volle Konfiguration benötigen (z. B. manuelle Umbenennung/Kopie).
     * <p>
     * Steht der GUI-Anwendungskontext bereits, wird die Konfiguration daraus übernommen,
     * ohne die Konfigurationsdatei erneut zu lesen oder das Schema erneut zu initialisieren.
     * Steht kein Kontext, läuft die gewohnte Sequenz Migration → Laden → Validieren →
     * Schema-Init durch.
     *
     * @param configFilePath Pfad zur Konfigurationsdatei; nur relevant ohne aktiven Kontext
     * @return validierte Konfiguration; nie {@code null}
     */
    private StartConfiguration resolveStartConfigurationForGui(Path configFilePath) {
        Optional<ApplicationRunContext> ctx = guiApplicationRunContext.get();
        if (ctx.isPresent()) {
            return ctx.get().startConfiguration();
        }
        migrateConfigurationIfNeeded(configFilePath);
        StartConfiguration config = loadAndValidateConfiguration(configFilePath);
        initializeSchema(config);
        return config;
    }

    /**
     * Loads configuration from the effective configuration path and validates it.
     * <p>
     * After successful validation, the active AI provider identifier is logged at INFO level.
     *
     * @param effectiveConfigPath the resolved configuration file path; never {@code null}
     * @return the validated startup configuration; never {@code null}
     * @throws ConfigurationLoadingException     if the file cannot be read or parsed
     * @throws InvalidStartConfigurationException if validation fails
     */
    private StartConfiguration loadAndValidateConfiguration(Path effectiveConfigPath) {
        ConfigurationPort configPort = configPortFactory.create(effectiveConfigPath);
        StartConfiguration config = configPort.loadConfiguration();
        validatorFactory.create().validate(config);
        LOG.info("Active AI provider: {}",
                config.multiProviderConfiguration().activeProviderFamily().getIdentifier());
        return config;
    }

    /**
     * Initialises the SQLite persistence schema once at startup, before the batch loop begins.
     * Failure here is a hard bootstrap error and results in exit code 1.
     *
     * @param config the validated startup configuration containing the SQLite file path
     */
    private void initializeSchema(StartConfiguration config) {
        schemaInitPortFactory.create(resolveActiveJdbcUrl(config)).initializeSchema();
    }

    /**
     * Executes the batch processing pipeline with the prepared startup configuration.
     * <p>
     * Wires all runtime dependencies, constructs adapters and the batch use case via
     * the use case factory, invokes the CLI command, and maps the outcome to an exit code.
     *
     * @param config the validated startup configuration (complete technical configuration)
     * @return exit code: 0 for batch completion, 1 for critical runtime failures
     */
    private int executeWithStartConfiguration(StartConfiguration config) {
        RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
        BatchRunContext runContext = createRunContext();
        BatchRunProcessingUseCase useCase = useCaseFactory.create(config, runLockPort);
        SchedulerBatchCommand command = commandFactory.create(useCase);
        BatchRunOutcome outcome = command.run(runContext);
        runContext.setEndInstant(Instant.now());
        return mapOutcomeToExitCode(outcome, runContext);
    }

    /**
     * Löst den Lock-Datei-Pfad aus der Konfiguration auf.
     * <p>
     * Ist der konfigurierte Pfad absolut, wird er unverändert verwendet (kein Fallback).
     * Ist er relativ oder nicht konfiguriert, wird zweistufig aufgelöst:
     * zuerst relativ zum JAR-Verzeichnis, dann relativ zu {@code user.home}.
     * Der final verwendete absolute Pfad wird auf INFO-Ebene geloggt.
     *
     * @param config die validierte Startkonfiguration
     * @return der absolut aufgelöste Lock-Datei-Pfad; niemals {@code null}
     * @throws IllegalStateException wenn weder JAR-Verzeichnis noch {@code user.home} auflösbar sind
     */
    private Path resolveLockFilePath(StartConfiguration config) {
        Path configuredPath = config.runtimeLockFile();

        // Absoluter Pfad: direkt verwenden, kein Fallback
        if (configuredPath != null && !configuredPath.toString().isBlank() && configuredPath.isAbsolute()) {
            LOG.info("Lock-Datei: {}", configuredPath);
            return configuredPath;
        }

        // Dateiname aus Konfiguration oder Standard
        String lockFileName;
        if (configuredPath != null && !configuredPath.toString().isBlank()) {
            lockFileName = configuredPath.toString();
        } else {
            lockFileName = "pdf-umbenenner.lock";
        }

        // Stufe 1: JAR-Verzeichnis
        Path jarDir = resolveJarDirectory();
        if (jarDir != null) {
            Path resolved = jarDir.resolve(lockFileName).normalize().toAbsolutePath();
            LOG.info("Lock-Datei: {}", resolved);
            return resolved;
        }

        // Stufe 2: user.home als Fallback
        String userHome = System.getProperty("user.home");
        if (userHome != null && !userHome.isBlank()) {
            Path resolved = Path.of(userHome).resolve(lockFileName).normalize().toAbsolutePath();
            LOG.info("Lock-Datei (Fallback auf user.home): {}", resolved);
            return resolved;
        }

        // Beide Stufen gescheitert
        throw new IllegalStateException(
                "Lock-Datei-Pfad konnte nicht aufgelöst werden: JAR-Verzeichnis nicht bestimmbar"
                        + " und system property 'user.home' nicht gesetzt.");
    }

    /**
     * Ermittelt das Verzeichnis der aktuell ausgeführten JAR-Datei über {@code CodeSource.getLocation()}.
     *
     * @return das JAR-Verzeichnis als {@link Path}, oder {@code null} wenn nicht bestimmbar
     */
    private Path resolveJarDirectory() {
        try {
            URL codeSource = BootstrapRunner.class.getProtectionDomain()
                    .getCodeSource().getLocation();
            if (codeSource == null) {
                LOG.debug("CodeSource.getLocation() lieferte null – JAR-Verzeichnis nicht bestimmbar.");
                return null;
            }
            Path jarPath = Path.of(codeSource.toURI());
            // Bei einer JAR-Datei: übergeordnetes Verzeichnis; bei Klassen-Verzeichnis: direkt verwenden
            return Files.isDirectory(jarPath) ? jarPath : jarPath.getParent();
        } catch (Exception e) {
            LOG.debug("JAR-Verzeichnis konnte nicht bestimmt werden: {}", e.getMessage());
            return null;
        }
    }

    /**
     * Creates a new {@link BatchRunContext} with a fresh run ID and the current timestamp.
     *
     * @return a new batch run context; never {@code null}
     */
    private BatchRunContext createRunContext() {
        RunId runId = new RunId(UUID.randomUUID().toString());
        LOG.info("Preparing batch run. RunId: {}", runId);
        return new BatchRunContext(runId, Instant.now());
    }

    /**
     * Maps a {@link BatchRunOutcome} to a process exit code and logs the run result.
     *
     * @param outcome    the outcome of the batch run; never {@code null}
     * @param runContext the batch run context used for logging; never {@code null}
     * @return 0 if the batch run completed successfully; 1 otherwise
     */
    private int mapOutcomeToExitCode(BatchRunOutcome outcome, BatchRunContext runContext) {
        if (outcome.isSuccess()) {
            LOG.info("Batch run completed successfully. RunId: {}", runContext.runId());
            return 0;
        } else if (outcome.isLockUnavailable()) {
            LOG.warn("Batch run aborted: another instance is already running. RunId: {}",
                    runContext.runId());
            return 1;
        } else {
            LOG.error("Batch run failed. RunId: {}", runContext.runId());
            return 1;
        }
    }

    /**
     * Derives the {@link AiContentSensitivity} decision from the raw {@code log.ai.sensitive}
     * configuration flag.
     * <p>
     * The safe default is {@link AiContentSensitivity#PROTECT_SENSITIVE_CONTENT}.
     * {@link AiContentSensitivity#LOG_SENSITIVE_CONTENT} is only produced when
     * {@code logAiSensitive} is explicitly {@code true}.
     *
     * @param logAiSensitive the parsed boolean value of the {@code log.ai.sensitive} property
     * @return the appropriate sensitivity decision; never {@code null}
     */
    static AiContentSensitivity resolveAiContentSensitivity(boolean logAiSensitive) {
        return logAiSensitive
                ? AiContentSensitivity.LOG_SENSITIVE_CONTENT
                : AiContentSensitivity.PROTECT_SENSITIVE_CONTENT;
    }

    /**
     * Builds the JDBC URL for the SQLite database from the configured file path.
     * <p>
     * Diese Variante berücksichtigt {@em kein} Override – sie wird ausschließlich für
     * den Headless-Pfad und für GUI-Tests aufgerufen, in denen kein
     * {@link ActiveDatabaseContextPort} verfügbar ist.
     *
     * @param config the startup configuration containing the SQLite file path
     * @return the JDBC URL in the form {@code jdbc:sqlite:/path/to/file.db}
     */
    static String buildJdbcUrl(StartConfiguration config) {
        return buildSqliteJdbcUrl(config.sqliteFile());
    }

    /**
     * Liefert die JDBC-URL der zur Laufzeit aktiven SQLite-Datenbank.
     * <p>
     * Hat der {@link ActiveDatabaseContextPort} einen Override gesetzt (z. B. nachdem
     * der Benutzer im GUI eine neue Datenbank angelegt hat), wird dieser verwendet.
     * Andernfalls greift der Wert aus der übergebenen {@link StartConfiguration}.
     *
     * @param config die geladene Startup-Konfiguration; nie {@code null}
     * @return die JDBC-URL der aktiven SQLite-Datei
     */
    String resolveActiveJdbcUrl(StartConfiguration config) {
        Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
        if (override.isPresent()) {
            return buildSqliteJdbcUrl(override.get());
        }
        return buildSqliteJdbcUrl(config.sqliteFile());
    }

    /**
     * Liefert den Pfad der zur Laufzeit aktiven SQLite-Datei.
     *
     * @param config die geladene Startup-Konfiguration; nie {@code null}
     * @return der absolute Pfad der aktuell aktiven Datenbankdatei
     */
    Path resolveActiveDatabasePath(StartConfiguration config) {
        return activeDatabaseContext.activeDatabaseOverride()
                .orElseGet(() -> config.sqliteFile().toAbsolutePath().normalize());
    }

    private static String buildSqliteJdbcUrl(Path sqliteFile) {
        return "jdbc:sqlite:" + sqliteFile.toAbsolutePath().toString().replace('\\', '/');
    }
}
