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

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.prefs.Preferences;

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

import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
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.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationSeverity;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.Separator;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.TitledPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.Window;

/**
 * Builds the editor workspace shown after the JavaFX application starts.
 * <p>
 * The workspace owns the unloaded start state, the optional startup notice, the file-loading
 * callback, the visible section scaffold for the single-tab shell and the unsaved-changes
 * guard that protects the user from accidental data loss when switching contexts.
 *
 * <p>The dirty state is derived by comparing the current {@code values} against
 * {@code baselineValues} in the editor state. When dirty, two visual markers are shown:
 * a small label in the header and a leading asterisk prefix in the window title.
 *
 * <p>Before "Neu", "Oeffnen" and window-close, the workspace consults the
 * {@link GuiUnsavedChangesGuard} when the editor is dirty. The guard's dialog supplier can
 * be replaced by tests for deterministic verification without a running scene.
 *
 * <p>The "Pfade" section renders text fields with native file- and folder-chooser buttons for
 * all path-based configuration values. The "Provider" section contains a non-editable
 * {@link ComboBox} for provider selection; only the currently selected provider block is
 * visible while the hidden block's data is preserved in {@link GuiVisibleProviderSection}.
 * Each provider block contains a model field container that switches between a non-editable
 * {@link ComboBox} (when a remote model list is available) and a manual text field (when no
 * list can be retrieved), as well as editable fields for base URL, timeout and API key.
 * The "Verarbeitungslimits" section provides text fields for the numeric limits and a checkbox
 * for the sensitive-logging flag. "Tests" and "Meldungen" are structural placeholders for
 * functionality added in later iterations.
 *
 * <p>All filesystem checks (e.g. the overwrite confirmation for "Speichern unter") are
 * performed on a background worker thread. UI updates always return to the FX Application
 * Thread via {@code Platform.runLater}.
 */
public final class GuiConfigurationEditorWorkspace {
    private static final String NO_PROMPT_PATH_MSG = "Kein Prompt-Pfad konfiguriert.";
    private static final String OPERATION_VALIDATE = "Validierung";
    private static final String PROPERTIES_FILTER_EXT = "*.properties";
    private static final String PROPERTIES_FILTER_DESC = "Properties-Dateien";



    private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class);
    private static final String WELCOME_TEXT =
            "Willkommen. Legen Sie mit \u201eNeu\u201c eine Standardvorlage an"
            + " oder \u00f6ffnen Sie eine bestehende Konfiguration.";

    private final BorderPane root = new BorderPane();
    private final Label statusLabel = new Label();
    private final Label configurationPathValueLabel = new Label();
    /** Package-private to allow visibility assertions in smoke tests. */
    final Label dirtyMarkerLabel = new Label("geändert");
    /**
     * Package-private to allow startup-notice visibility assertions in smoke tests.
     * Returns the status label used to display startup notices and status messages in the header.
     */
    Label statusNoticeLabel() {
        return statusLabel;
    }
    private final Label welcomeTitleLabel = new Label("Willkommen");
    private final Label welcomeTextLabel = new Label(WELCOME_TEXT);
    /** Package-private to allow node lookups in smoke tests. */
    final TabPane tabPane = new TabPane();
    // Kompakter Abstand (6px) zwischen Konfigurationssektionen
    private final VBox sectionsBox = new VBox(6);
    private final Button newButton = new Button("Neu");
    private final Button openButton = new Button("Öffnen");
    private final Button saveButton = new Button("Speichern");
    private final Button saveAsButton = new Button("Speichern unter");
    /**
     * The "Validieren" button in the "Tests" section.
     * Package-private to allow CSS-ID and state assertions in smoke tests.
     */
    final Button validateButton = new Button("Validieren");

    /**
     * The "Technische Tests ausführen" button in the "Tests" section.
     * Disabled while a test run is in progress to prevent concurrent runs.
     * Package-private to allow CSS-ID and state assertions in smoke tests.
     */
    final Button technicalTestsButton = new Button("Technische Tests ausführen");

    /**
     * The "Meldungen leeren" button below the central message area.
     * Package-private to allow state assertions in smoke tests.
     */
    final Button clearMessagesButton = new Button("Meldungen leeren");

    private static final Path DEFAULT_SAVE_PATH = Paths.get("config/application.properties");

    private final GuiConfigurationFileLoader configurationFileLoader;
    private final GuiConfigurationFileWriter configurationFileWriter;

    /**
     * Serialisiert Lade-Auftraege fuer Konfigurationsdateien, sodass mehrere schnell
     * aufeinanderfolgende Oeffnen-Aktionen (z.B. Doppelklick) keine konkurrierenden
     * Worker-Threads erzeugen. Der Executor ist einzel-threadig: Auftraege werden der
     * Reihe nach abgearbeitet. Der zugrundeliegende Thread ist als Daemon konfiguriert,
     * damit die JVM beim Beenden nicht blockiert wird.
     */
    private final ExecutorService configLoaderExecutor = Executors.newSingleThreadExecutor(runnable -> {
        Thread thread = new Thread(runnable, "gui-config-loader");
        thread.setDaemon(true);
        return thread;
    });

    /**
     * The current editor state. Package-private to allow direct state injection in smoke tests
     * that need to set a specific dirty state without going through the full load/save pipeline.
     */
    GuiConfigurationEditorState editorState;
    private boolean welcomeGuidanceVisible;

    /**
     * Tracks which provider section is currently visible and preserves the hidden provider's
     * state so it is not lost when the user switches providers. Updated by
     * {@link #buildVisibleProviderSection()} when the provider section is refreshed, and
     * by {@link #handleProviderSwitch(AiProviderFamily)} when the ComboBox selection changes.
     * <p>
     * May be {@code null} when the editor has not yet rendered a configuration with both
     * known provider entries (e.g. on blank start before "Neu" or "Öffnen").
     * Package-private to allow assertions in smoke tests.
     */
    GuiVisibleProviderSection visibleProviderSection;

    /**
     * Factory for the save-file chooser dialog; package-private to allow substitution in tests.
     * The default creates a standard {@link FileChooser} instance.
     */
    Supplier<FileChooser> saveFileChooserFactory = FileChooser::new;

    /**
     * Function that performs the actual save-dialog call; package-private to allow substitution
     * in tests where native dialogs are unavailable. Receives the owner window and returns the
     * selected {@link java.io.File}, or {@code null} when the dialog was cancelled.
     * <p>
     * The default delegates to {@link FileChooser#showSaveDialog(javafx.stage.Window)}.
     * Tests may replace this with a lambda that returns a fixed file without opening a native dialog.
     */
    java.util.function.BiFunction<FileChooser, Window, java.io.File> saveDialogFunction =
            FileChooser::showSaveDialog;

    /**
     * Supplier for the overwrite-confirmation dialog result; package-private to allow
     * substitution in tests. The default shows a blocking {@link Alert} on the FX Application
     * Thread and returns the chosen {@link ButtonType}.
     * <p>
     * Must only be called from the FX Application Thread.
     */
    java.util.function.Supplier<Optional<ButtonType>> overwriteConfirmationSupplier = null;

    /**
     * Factory for the background path-checker thread; package-private to allow thread-identity
     * capture in tests. The default creates a daemon thread named {@code gui-path-checker}.
     * <p>
     * The factory receives the runnable to execute and must return a ready-to-start
     * (but not yet started) {@link Thread}.
     */
    java.util.function.Function<Runnable, Thread> pathCheckerThreadFactory = null;

    /**
     * Dialog function for directory-picker buttons; package-private to allow substitution in tests.
     * Receives the dialog title and the current field text (used as an initial path hint), and
     * returns the selected absolute path string or {@code null} when the dialog is cancelled.
     * <p>
     * The default implementation opens a native {@link DirectoryChooser}. Tests may replace
     * this with a lambda that returns a fixed string without opening a native dialog.
     */
    java.util.function.BiFunction<String, String, String> directoryPickerDialog =
            this::showNativeDirectoryChooser;

    /**
     * Dialog-Hook fuer Datei-Auswaehldialoge; package-private, damit Tests eine Stub-Implementierung
     * injizieren koennen. Empfaengt Titel, aktuellen Feldtext (als Anfangspfad-Hinweis) und die
     * Liste der Dateitypfilter; gibt den ausgewaehlten absoluten Pfad oder {@code null} zurueck.
     * <p>
     * Die Standardimplementierung oeffnet einen nativen {@link FileChooser} und wendet die
     * uebergebenen Filter an. Test-Stubs koennen die Filter ignorieren und einen festen Pfad
     * zurueckgeben.
     */
    FilePickerDialog filePickerDialog =
            (title, initialPath, filters) -> showNativeFileChooser(title, initialPath, filters);

    /**
     * Guard that mediates the protection dialog before destructive actions.
     * Package-private to allow dialog-supplier substitution in tests.
     */
    final GuiUnsavedChangesGuard unsavedChangesGuard;

    /**
     * Listener that receives the formatted window title whenever it changes.
     * Package-private so {@link PdfUmbenennerGuiApplication} can wire the Stage title.
     * Defaults to a no-op so the workspace functions without a Stage binding.
     */
    Consumer<String> titleUpdateListener = title -> { };

    /**
     * Listener der bei jedem Zustandswechsel des Editor-Zustands aufgerufen wird und
     * den neuen Zustand an die Statuszeile weiterleitet.
     * <p>
     * Package-private, damit {@link PdfUmbenennerGuiApplication} die Statuszeile verdrahten kann.
     * Standard ist ein No-Op, damit der Workspace auch ohne Statuszeile funktioniert.
     */
    Consumer<GuiConfigurationEditorState> statusBarStateListener = state -> { };

    /**
     * Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
     * Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
     * the {@link GuiModelCatalogCoordinator}. Package-private to allow assertions in smoke tests.
     */
    final Map<AiProviderFamily, GuiModelFieldContainer> modelFieldContainers =
            new EnumMap<>(AiProviderFamily.class);

    /**
     * Coordinator that manages asynchronous model catalogue retrieval and result delivery.
     * Package-private to allow thread-factory substitution in smoke tests.
     */
    final GuiModelCatalogCoordinator modelCatalogCoordinator;

    /**
     * Coordinator that manages asynchronous execution of the "Technische Tests ausführen" action.
     * Package-private to allow thread-factory substitution in smoke tests.
     */
    final GuiTechnicalTestCoordinator technicalTestCoordinator;

    /**
     * Coordinator that manages the correction confirmation dialog and asynchronous execution
     * of corrective actions after a technical test run.
     * Package-private to allow dialog-supplier substitution in smoke tests.
     */
    final GuiCorrectionDialogCoordinator correctionDialogCoordinator;

    /**
     * Mutable list of pending message entries produced by the model catalogue coordinator
     * and the editor validation. Entries are appended on the JavaFX Application Thread.
     * Later GUI layers can read and display these entries. Package-private for test inspection.
     */
    final List<GuiMessageEntry> pendingMessages = new ArrayList<>();

    /**
     * Off-screen holder that mirrors every rendered message row as a {@link TextFlow} child.
     * Never added to the scene graph; kept solely so smoke tests can assert on child count and
     * node structure without the display component being involved.
     */
    final VBox messagesAreaBox = new VBox(4);

    /**
     * Backing data for {@link #messagesListView}. Replaced on every call to
     * {@link #refreshMessagesArea()} so the list view updates atomically.
     */
    private final ObservableList<GuiMessageEntry> messagesListItems =
            FXCollections.observableArrayList();

    /**
     * The visible list view that renders each message as a coloured {@link TextFlow} row.
     * Supports multi-row selection ({@link SelectionMode#MULTIPLE}) and copies the selected
     * rows as plain text to the system clipboard when Ctrl+C is pressed.
     */
    private final ListView<GuiMessageEntry> messagesListView =
            new ListView<>(messagesListItems);

    /**
     * Maps property-key strings to their associated field-level error {@link Label} widgets.
     * The map is populated when the "Pfade" section and each provider block are constructed.
     * {@link #refreshFieldErrorLabels()} iterates the map and shows or hides each label based
     * on the current {@link #pendingFieldFindings}. Package-private for test inspection.
     */
    final Map<String, Label> fieldErrorLabels = new java.util.HashMap<>();

    /**
     * Maps each provider family to the informational {@link Label} shown directly below its
     * API-key input field. The label reports whether the effective key originates from an
     * environment variable, a property value, or is absent.
     * Package-private for test inspection.
     */
    final Map<AiProviderFamily, Label> apiKeyOriginLabels = new EnumMap<>(AiProviderFamily.class);

    /**
     * Mutable list of pending field-level findings produced by the editor validation.
     * Entries are replaced on the JavaFX Application Thread after each validation run.
     * Later GUI layers can read and render these findings directly below the affected fields.
     * Package-private for test inspection.
     */
    final List<GuiFieldFinding> pendingFieldFindings = new ArrayList<>();

    /**
     * The most recent editor validation result. Holds the combined messages and field findings
     * from the last completed validation run. Never {@code null}; starts as an empty result.
     * Package-private for test inspection.
     */
    GuiEditorValidationResult lastValidationResult = GuiEditorValidationResult.empty();

    private final EditorConfigurationValidator editorValidator = new EditorConfigurationValidator();
    private final ApiKeyResolutionPort apiKeyResolutionPort;

    /**
     * Launcher used by the processing-run tab to execute a regular batch run against the
     * saved configuration file. Supplied by Bootstrap via the startup context.
     */
    private final GuiBatchRunLauncher batchRunLauncher;

    /**
     * Launcher used by the processing-run tab to execute a targeted mini-run for a
     * selected set of documents. Supplied by Bootstrap via the startup context.
     */
    private final GuiMiniRunLauncher miniRunLauncher;

    /**
     * Port used by the processing-run tab to reset the persistence status of selected
     * documents without triggering a reprocessing run. Supplied by Bootstrap via the
     * startup context.
     */
    private final GuiResetDocumentStatusPort resetDocumentStatusPort;

    /**
     * Port used by the processing-run tab to rename a target file manually.
     * Supplied by Bootstrap via the startup context.
     */
    private final GuiManualFileRenamePort manualFileRenamePort;

    /**
     * Port used by the processing-run tab to copy a source file to the target folder for
     * documents that have not yet been successfully processed (FAILED- oder SKIPPED-Status).
     * Supplied by Bootstrap via the startup context.
     */
    private final GuiManualFileCopyPort manualFileCopyPort;

    /**
     * Port used by the processing-run coordinator to resolve the historical processing context
     * for skipped documents. Supplied by Bootstrap via the startup context.
     */
    private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;

    /**
     * Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort}
     * erzeugt. Wird verwendet, wenn eine neue Konfiguration geladen oder gespeichert wird,
     * um den {@link GuiPromptEditorTab} mit einem aktualisierten Port zu versorgen.
     * Supplied by Bootstrap via the startup context.
     */
    private final GuiPromptEditorPortFactory promptEditorPortFactory;

    /**
     * Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt
     * „Datenbank → Neue Datenbank anlegen…" ausgelöst.
     */
    private final GuiCreateNewDatabasePort createNewDatabasePort;

    /**
     * Callback, der nach jedem erfolgreichen Datei-Öffnen auf dem Hintergrund-Thread
     * aufgerufen wird, um den Bootstrap-seitigen Anwendungskontext und den Scheduler
     * zu initialisieren.
     */
    private final GuiApplicationContextInitializer applicationContextInitializer;

    /**
     * Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange
     * dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen
     * der GUI gesperrt (vgl. {@link #applyDbBusyLock()}).
     * <p>
     * Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt
     * über {@code disableProperty().bind(...)} auswerten kann.
     */
    private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation =
            new javafx.beans.property.SimpleBooleanProperty(false);

    /**
     * Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht
     * mehrere DB-Anlagen gleichzeitig laufen können.
     */
    private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> {
        Thread thread = new Thread(runnable, "gui-create-new-database");
        thread.setDaemon(true);
        return thread;
    });

    /**
     * Second main tab of the window that drives the live processing-run view. Created
     * during workspace construction and wired into the shared {@link #tabPane} alongside
     * the existing configuration tab.
     */
    private final GuiBatchRunTab batchRunTab;

    /**
     * Dritter Haupt-Tab: Scheduler-Steuerung. Wird während der Workspace-Konstruktion
     * erstellt und in den {@link #tabPane} eingehängt.
     */
    private final GuiSchedulerTab schedulerTab;

    /**
     * Vierter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
     * erstellt und in den {@link #tabPane} eingehängt.
     */
    private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;

    /**
     * Fünfter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
     * und in den {@link #tabPane} eingehängt.
     */
    private final GuiPromptEditorTab promptEditorTab;

    /**
     * Sechster Haupt-Tab: Modell-Preise. Verwaltet die persistierten
     * {@code model_price}-Eintraege fuer das Token-/Kosten-Tracking.
     */
    private final de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab modelPricesTab;

    /**
     * Optionaler Bridge-Port zur Abfrage der Modell-Preise.
     *
     * <p>Wird beim Speichern der Konfiguration zur Pruefung herangezogen,
     * ob das aktuell gewaehlte Modell einen Preis-Eintrag besitzt. Fehlt der
     * Eintrag, wird eine deutsche Warnung angezeigt; das Speichern bleibt
     * trotzdem erlaubt.
     */
    private final java.util.Optional<de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort> modelPricePortForConfig;

    /**
     * Hint banner shown at the top of the configuration tab while a processing run or
     * the automatic scheduler is active. Visible + managed state are controlled by
     * {@link #applyConfigTabLockState()}.
     */
    final Label configurationLockBanner = new Label(
            "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");

    /**
     * {@code true} while the automatic scheduler is in any non-{@code STOPPED} state.
     * Updated by {@link #updateLockState(SchedulerStatus)} from the 1 Hz refresh timeline.
     */
    private boolean schedulerLockActive = false;

    /**
     * Reference to the configuration tab so the running-state listener can disable its
     * content while a batch run is active.
     */
    Tab configurationTab;

    /**
     * Creates a new workspace with the unloaded start state.
     *
     * @param startupNotice optional startup notice to show in the header area
     */
    public GuiConfigurationEditorWorkspace(Optional<String> startupNotice) {
        this(GuiStartupContext.blank(startupNotice));
    }

    /**
     * Creates a new workspace with the supplied startup context.
     *
     * @param startupContext startup data from Bootstrap; must not be {@code null}
     */
    public GuiConfigurationEditorWorkspace(GuiStartupContext startupContext) {
        GuiStartupContext effectiveContext = startupContext == null
                ? GuiStartupContext.blank(Optional.empty())
                : startupContext;
        this.configurationFileLoader = effectiveContext.configurationFileLoader();
        this.configurationFileWriter = effectiveContext.configurationFileWriter();
        this.editorState = effectiveContext.initialState();
        this.welcomeGuidanceVisible = false;
        this.apiKeyResolutionPort = effectiveContext.apiKeyResolutionPort();

        this.modelCatalogCoordinator = new GuiModelCatalogCoordinator(
                effectiveContext.modelCatalogPort(), pendingMessages);
        this.modelCatalogCoordinator.postResultCallback = this::refreshAfterValidation;

        CorrectionExecutionService correctionService = effectiveContext.correctionExecutionService();
        this.correctionDialogCoordinator = new GuiCorrectionDialogCoordinator(
                correctionService,
                pendingMessages,
                ignored -> refreshAfterValidation());

        this.technicalTestCoordinator = new GuiTechnicalTestCoordinator(
                effectiveContext.technicalTestOrchestrator(),
                this::buildValidationInput,
                () -> editorState.loadedFileSnapshot()
                        .map(snapshot -> snapshot.filePath().toString())
                        .orElse(""),
                () -> editorState.values().logDirectory(),
                pendingMessages,
                report -> {
                    technicalTestsButton.setDisable(false);
                    refreshAfterValidation();
                    correctionDialogCoordinator.offerCorrections(report);
                });

        this.unsavedChangesGuard = new GuiUnsavedChangesGuard(
                triggerLabel -> showUnsavedChangesDialog(triggerLabel));

        this.batchRunLauncher = effectiveContext.batchRunLauncher();
        this.miniRunLauncher = effectiveContext.miniRunLauncher();
        this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
        this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
        this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
        this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
        this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
        this.createNewDatabasePort = effectiveContext.createNewDatabasePort();
        this.applicationContextInitializer = effectiveContext.applicationContextInitializer();
        this.batchRunTab = new GuiBatchRunTab(
                () -> this.batchRunLauncher,
                () -> this.miniRunLauncher,
                () -> this.resetDocumentStatusPort,
                this::loadedConfigurationPath,
                this::isSavedConfigurationReady,
                this::applyBatchRunLockState,
                () -> this.manualFileRenamePort,
                () -> this.manualFileCopyPort,
                () -> this.historicalDocumentContextPort,
                this::editorSourceFolder,
                this::editorTargetFolder,
                effectiveContext.configurationFileLockPort());

        this.schedulerTab = new GuiSchedulerTab(
                effectiveContext.schedulerControlUseCase(),
                () -> editorState.isDirty());

        this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
                effectiveContext.historyOverviewPort(),
                effectiveContext.historyDetailsPort(),
                effectiveContext.historyResetDocumentStatusPort(),
                effectiveContext.deleteDocumentHistoryPort(),
                this.batchRunTab::isRunning,
                this::loadedConfigurationPath);

        // Aktionsbuttons im Verlauf-Tab reaktivieren, sobald der Lauf beendet ist
        this.batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
            if (!running) {
                Platform.runLater(this.historyTab::notifyRunEnded);
            }
        });

        String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
        int maxTitleLength;
        try {
            maxTitleLength = Integer.parseInt(
                    effectiveContext.initialState().values().maxTitleLength().trim());
        } catch (NumberFormatException e) {
            maxTitleLength = 60;
        }
        this.promptEditorTab = new GuiPromptEditorTab(
                effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);

        this.modelPricePortForConfig = effectiveContext.modelPriceManagementPort();
        this.modelPricesTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab(
                this.modelPricePortForConfig.orElse(null),
                () -> java.util.Optional.ofNullable(loadedConfigurationPath()));

        configureRoot();
        configureHeader(effectiveContext.startupNotice());
        configureTabs();
        configureActionBar();
        configureActions();
        refreshView();
        applyBatchRunLockState();
    }

    /**
     * Returns the processing-run tab; package-private so smoke tests can drive start/cancel
     * actions and read the result table.
     */
    GuiBatchRunTab batchRunTab() {
        return batchRunTab;
    }

    /**
     * Returns the port for manual target-file renaming.
     * <p>
     * Supplied by Bootstrap. The processing-run tab can retrieve this port to delegate
     * manual rename actions without holding a direct reference to Bootstrap internals.
     *
     * @return the {@link GuiManualFileRenamePort}; never {@code null}
     */
    public GuiManualFileRenamePort manualFileRenamePort() {
        return manualFileRenamePort;
    }

    /**
     * Liefert den Port für das manuelle Kopieren der Quelldatei eines bislang nicht
     * erfolgreich verarbeiteten Dokuments ins Zielverzeichnis.
     * <p>
     * Wird von Bootstrap bereitgestellt. Der Verarbeitungslauf-Tab nutzt diesen Port,
     * wenn der Benutzer für ein FAILED- oder SKIPPED-Dokument einen manuellen
     * Zieldateinamen bestätigt.
     *
     * @return den {@link GuiManualFileCopyPort}; nie {@code null}
     */
    public GuiManualFileCopyPort manualFileCopyPort() {
        return manualFileCopyPort;
    }

    /**
     * Returns the currently loaded configuration file path, or {@code null} when the
     * editor has never loaded a file from disk. The processing-run tab uses this value to
     * derive the path a run should execute against.
     */
    private Path loadedConfigurationPath() {
        return editorState.loadedFileSnapshot()
                .map(GuiConfigurationFileSnapshot::filePath)
                .orElse(null);
    }

    /**
     * Returns whether the editor currently has a saved configuration that can be used as
     * the source of a processing run.
     * <p>
     * A configuration is considered run-ready when the editor was loaded from disk (i.e.
     * a file snapshot exists). Unsaved editor changes are intentionally ignored — the
     * run always uses the last saved state of the {@code .properties} file as required by
     * the specification.
     */
    private boolean isSavedConfigurationReady() {
        return editorState.hasLoadedFileSnapshot();
    }

    /**
     * Liefert den im Editor eingestellten Quellordner als {@link Path}, sofern ein
     * nicht-leerer Wert vorliegt. Wird vom Verarbeitungslauf-Tab genutzt um die
     * Quelldatei für die PDF-Vorschau zu lokalisieren.
     *
     * @return den Quellordner-Pfad oder ein leeres Optional
     */
    private java.util.Optional<Path> editorSourceFolder() {
        String raw = editorState.values().sourceFolder();
        if (raw == null || raw.isBlank()) {
            return java.util.Optional.empty();
        }
        try {
            return java.util.Optional.of(Path.of(raw));
        } catch (Exception e) {
            return java.util.Optional.empty();
        }
    }

    /**
     * Liefert den im Editor eingestellten Zielordner als Pfad-String, sofern ein
     * nicht-leerer Wert vorliegt. Wird vom Dateiname-Editor für die Pfadlängenprüfung
     * genutzt.
     *
     * @return den Zielordner-Pfad-String oder ein leeres Optional
     */
    private java.util.Optional<String> editorTargetFolder() {
        String raw = editorState.values().targetFolder();
        if (raw == null || raw.isBlank()) {
            return java.util.Optional.empty();
        }
        return java.util.Optional.of(raw);
    }

    /**
     * Applies the "batch run active" UI lock state to the configuration tab.
     * <p>
     * Delegates to {@link #applyConfigTabLockState()} so that both the batch-run lock
     * and the scheduler lock are evaluated together. Called whenever the batch-run
     * running state changes.
     */
    void applyBatchRunLockState() {
        applyConfigTabLockState();
    }

    /**
     * Evaluates the combined lock state (batch run active <em>or</em> scheduler active)
     * and applies it to the configuration tab.
     * <p>
     * When either source is locked the banner is shown, all input sections are disabled
     * and the action buttons (Neu, Öffnen, Speichern, Speichern unter) are disabled.
     * The banner text describes the dominant lock source.
     */
    private void applyConfigTabLockState() {
        boolean batchRunning = batchRunTab != null && batchRunTab.isRunning();
        boolean locked = batchRunning || schedulerLockActive;

        String bannerText = schedulerLockActive
                ? "⚠ Konfiguration gesperrt – Scheduler läuft (oder Lauf aktiv)."
                        + " Scheduler beenden bzw. Lauf abwarten um Änderungen vorzunehmen."
                : "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar";

        configurationLockBanner.setText(bannerText);
        configurationLockBanner.setVisible(locked);
        configurationLockBanner.setManaged(locked);
        sectionsBox.setDisable(locked);
        newButton.setDisable(locked);
        openButton.setDisable(locked);
        saveButton.setDisable(locked);
        saveAsButton.setDisable(locked);
    }

    /**
     * Installs the window-close request handler on the given primary stage.
     * <p>
     * When the editor is dirty the handler shows the unsaved-changes protection dialog and
     * consumes the close event if the user chooses to cancel or when saving fails. If the user
     * chooses to save, the close is deferred until the background save completes successfully,
     * at which point {@link Stage#close()} is called again on the FX Application Thread.
     *
     * @param stage the primary stage; must not be {@code null}
     */
    public void installCloseRequestHandler(Stage stage) {
        stage.setOnCloseRequest(event -> {
            if (batchRunTab != null && batchRunTab.isRunning()) {
                event.consume();
                handleCloseWhileRunRunning(stage);
                return;
            }
            // Dateiname-Dirty-State im Verarbeitungslauf-Tab prüfen
            if (batchRunTab != null && batchRunTab.hasUnsavedFilenameEdits()) {
                boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
                if (!shouldDiscard) {
                    event.consume();
                    return;
                }
            }
            if (!editorState.isDirty()) {
                return;
            }
            event.consume();
            unsavedChangesGuard.askAndProceed(
                    "Schließen",
                    () -> {
                        LOG.info("GUI-Editor: Fenster wird nach Verwerfen der Änderungen geschlossen.");
                        stage.close();
                    },
                    () -> performSaveBeforeAction(stage::close));
        });
    }

    /**
     * Supplier for the "Lauf läuft noch" dialog invoked by
     * {@link #handleCloseWhileRunRunning(Stage)}. Package-private so tests can substitute
     * a deterministic choice without showing a native dialog. The default implementation
     * displays a blocking confirmation alert with the two options mandated by the spec.
     */
    Supplier<CloseWhileRunningChoice> closeWhileRunningDialog = this::showCloseWhileRunningDialog;

    /**
     * Distinct outcomes of the "Lauf läuft noch" dialog.
     */
    enum CloseWhileRunningChoice {
        /** User chose "Nicht schließen"; the run continues and the window stays open. */
        KEEP_OPEN,
        /** User chose "Lauf beenden und schließen"; a soft-stop is requested. */
        CANCEL_AND_CLOSE
    }

    private void handleCloseWhileRunRunning(Stage stage) {
        CloseWhileRunningChoice choice;
        try {
            choice = closeWhileRunningDialog.get();
        } catch (RuntimeException e) {
            LOG.warn("GUI-Editor: Fehler im Schließen-Dialog: {}", e.getMessage(), e);
            return;
        }
        if (choice == CloseWhileRunningChoice.CANCEL_AND_CLOSE) {
            LOG.info("GUI-Editor: Soft-Stop angefordert; Fenster schließt nach Laufende.");
            batchRunTab.requestCancellation();
            batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
                if (!running) {
                    Platform.runLater(stage::close);
                }
            });
        } else {
            LOG.info("GUI-Editor: Schließen abgebrochen; Verarbeitungslauf läuft weiter.");
        }
    }

    private CloseWhileRunningChoice showCloseWhileRunningDialog() {
        Alert dialog = new Alert(Alert.AlertType.CONFIRMATION);
        dialog.setTitle("Verarbeitungslauf läuft");
        dialog.setHeaderText("Es läuft aktuell ein Verarbeitungslauf.");
        dialog.setContentText("Was soll geschehen?");
        ButtonType keepOpen = new ButtonType("Nicht schließen");
        ButtonType cancelAndClose = new ButtonType("Lauf beenden und schließen");
        dialog.getButtonTypes().setAll(cancelAndClose, keepOpen);
        Optional<ButtonType> result = dialog.showAndWait();
        return result.filter(cancelAndClose::equals).isPresent()
                ? CloseWhileRunningChoice.CANCEL_AND_CLOSE
                : CloseWhileRunningChoice.KEEP_OPEN;
    }

    /**
     * Returns the root node used by the JavaFX scene.
     *
     * @return the root pane for the workspace
     */
    public Parent root() {
        return root;
    }

    /**
     * Returns the currently active editor state.
     *
     * @return the current editor state
     */
    public GuiConfigurationEditorState editorState() {
        return editorState;
    }

    /**
     * Returns the currently displayed configuration-path text.
     *
     * @return the header text for the loaded configuration path
     */
    public String configurationPathText() {
        return configurationPathValueLabel.getText();
    }

    /**
     * Returns the welcome text shown in the editor start state.
     *
     * @return the German welcome text
     */
    public String welcomeText() {
        return welcomeTextLabel.getText();
    }

    /**
     * Returns whether the welcome guidance is currently visible.
     *
     * @return {@code true} when the welcome text is shown, otherwise {@code false}
     */
    public boolean isWelcomeGuidanceVisible() {
        return welcomeGuidanceVisible;
    }

    /**
     * Returns the "Neu" action button.
     *
     * @return the button used to switch to the standard template
     */
    public Button newButton() {
        return newButton;
    }

    /**
     * Returns the "Öffnen" action button.
     *
     * @return the button used for the open-file flow
     */
    public Button openButton() {
        return openButton;
    }

    /**
     * Returns the "Speichern" action button.
     *
     * @return the button used for future save wiring
     */
    public Button saveButton() {
        return saveButton;
    }

    /**
     * Returns the "Speichern unter" action button.
     *
     * @return the button used for future save-as wiring
     */
    public Button saveAsButton() {
        return saveAsButton;
    }

    /**
     * Returns the tab pane containing the single editor tab.
     *
     * @return the tab pane shown in the center area
     */
    public TabPane tabPane() {
        return tabPane;
    }

    /**
     * Returns the most recent editor validation result.
     * <p>
     * The result is updated after every call to {@link #applyEditorState} and after every
     * field change via {@link #updateValues}. The initial value before any configuration is
     * loaded is an empty result with no findings.
     * <p>
     * This method is safe to call from any thread; the result object itself is immutable.
     *
     * @return the most recent validation result; never {@code null}
     */
    public GuiEditorValidationResult lastValidationResult() {
        return lastValidationResult;
    }

    /**
     * Returns the fixed section titles shown inside the single configuration tab.
     *
     * @return the section titles in visual order
     */
    List<String> sectionTitles() {
        return List.of("Pfade", "Provider", "Verarbeitungslimits", "Tests", "Meldungen");
    }

    /**
     * Handles the explicit "Neu" action.
     * <p>
     * The workspace switches to the standard template and hides the welcome guidance.
     */
    public void requestNewConfiguration() {
        if (editorState.isDirty()) {
            unsavedChangesGuard.askAndProceed(
                    "Neu",
                    this::doApplyNewConfiguration,
                    () -> performSaveBeforeAction(this::doApplyNewConfiguration));
        } else {
            doApplyNewConfiguration();
        }
    }

    private void doApplyNewConfiguration() {
        LOG.info("GUI-Editor: Neue Standardvorlage wird angezeigt.");
        applyEditorState(GuiConfigurationTemplateFactory.createStandardTemplate());
    }

    /**
     * Handles the explicit "Öffnen" action.
     * <p>
     * The file chooser is native to the platform and filters for {@code *.properties} files.
     */
    public void requestOpenConfiguration() {
        if (editorState.isDirty()) {
            unsavedChangesGuard.askAndProceed(
                    "Öffnen",
                    this::doOpenConfigurationDialog,
                    () -> performSaveBeforeAction(this::doOpenConfigurationDialog));
            return;
        }
        doOpenConfigurationDialog();
    }

    private void doOpenConfigurationDialog() {
        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        FileChooser fileChooser = new FileChooser();
        fileChooser.setTitle("Konfiguration öffnen");
        fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
        if (owner != null && editorState.hasLoadedFileSnapshot()) {
            Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
            Path parent = currentPath.getParent();
            if (parent != null) {
                fileChooser.setInitialDirectory(parent.toFile());
            }
        }

        java.io.File selectedFile = fileChooser.showOpenDialog(owner);
        if (selectedFile != null) {
            openConfigurationFile(selectedFile.toPath());
        }
    }

    /**
     * Opens the selected configuration file on a background thread.
     *
     * @param configFilePath the selected file path; must not be {@code null}
     */
    void openConfigurationFile(Path configFilePath) {
        if (configurationFileLoader == null) {
            showError("Öffnen ist derzeit nicht verfügbar.");
            return;
        }

        // Lade-Auftrag wird seriell ueber den Single-Thread-Executor eingereicht, um
        // Race Conditions durch gleichzeitig laufende Lade-Threads zu vermeiden.
        configLoaderExecutor.submit(() -> {
            try {
                GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
                // Speichern des Pfads als letzte geladene Konfiguration
                saveLastConfigurationPath(configFilePath);
                // Anwendungskontext und Scheduler initialisieren; Ergebnis auf dem FX-Thread auswerten.
                GuiApplicationContextInitializer.InitResult initResult =
                        applicationContextInitializer.initialize(configFilePath);
                Platform.runLater(() -> {
                    applyEditorState(loadedState);
                    initResult.schedulerControlUseCase().ifPresent(schedulerTab::onSchedulerAvailable);
                });
            } catch (Exception exception) {
                Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
                        + safeMessage(exception)));
            }
        });
    }

    /**
     * Handles the explicit "Speichern" action.
     * <p>
     * When the editor has a loaded file snapshot, the file is written directly to the same
     * path. When no file has been loaded yet (new configuration), the action delegates to
     * "Speichern unter" to let the user choose a target path.
     */
    public void requestSaveConfiguration() {
        warnIfActiveModelHasNoPriceEntry();
        if (editorState.isNewConfiguration()) {
            requestSaveConfigurationAs();
            return;
        }
        Path targetPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
        saveToPath(targetPath);
    }

    /**
     * Prueft, ob das aktuell ausgewaehlte Modell einen Preis-Eintrag hat.
     *
     * <p>Liegt kein Eintrag vor, wird eine deutsche Warnung im zentralen
     * Meldungsbereich erzeugt. Das Speichern selbst wird durch diese Pruefung
     * nicht blockiert; sie dient ausschlie&szlig;lich dem Hinweis, da&szlig;
     * Token-Tracking ohne Preis-Snapshot fortgesetzt wird.
     */
    private void warnIfActiveModelHasNoPriceEntry() {
        if (modelPricePortForConfig.isEmpty()) {
            return;
        }
        Path configPath = loadedConfigurationPath();
        if (configPath == null) {
            return;
        }
        String activeProvider = editorState.values().activeProviderFamily();
        if (activeProvider == null || activeProvider.isBlank()) {
            return;
        }
        de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily family;
        try {
            family = de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily
                    .fromIdentifier(activeProvider).orElse(null);
        } catch (RuntimeException ex) {
            family = null;
        }
        if (family == null) {
            return;
        }
        de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState providerState =
                editorState.values().providerConfigurations().get(family);
        if (providerState == null) {
            return;
        }
        String modelName = providerState.model();
        if (modelName == null || modelName.isBlank()) {
            return;
        }
        try {
            java.util.Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView> view =
                    modelPricePortForConfig.get().findByProviderAndModelName(
                            configPath, family.getIdentifier(), modelName);
            if (view.isEmpty()) {
                LOG.warn("Kein Modell-Preis fuer Provider \"{}\" und Modell \"{}\" hinterlegt – "
                                + "Tokens werden erfasst, Kosten koennen jedoch nicht vollstaendig berechnet werden.",
                        family.getIdentifier(), modelName);
                showSensitiveMessage(
                        "Warnung: Fuer das aktuell gewaehlte Modell \"" + modelName
                                + "\" ist kein Preis hinterlegt. Tokens werden weiterhin erfasst, "
                                + "Kosten koennen jedoch nicht vollstaendig berechnet werden.");
            }
        } catch (RuntimeException ex) {
            LOG.warn("Pruefung auf Modell-Preis fehlgeschlagen: {}", ex.getMessage());
        }
    }

    /**
     * Zeigt eine deutsche Warnung als nicht-blockierenden Alert an.
     *
     * <p>Die Warnung erscheint nur wenn ein FX-Fenster aktiv ist; in
     * Headless-Smoke-Tests faellt die Methode auf einen Log-Eintrag zurueck.
     *
     * @param message Warntext
     */
    private void showSensitiveMessage(String message) {
        if (root.getScene() == null || root.getScene().getWindow() == null) {
            LOG.warn(message);
            return;
        }
        try {
            javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
                    javafx.scene.control.Alert.AlertType.WARNING, message,
                    javafx.scene.control.ButtonType.OK);
            alert.setHeaderText("Modell-Preis fehlt");
            alert.show();
        } catch (RuntimeException ex) {
            LOG.warn("Konnte Modell-Preis-Warnung nicht anzeigen: {} – Originalmeldung: {}",
                    ex.getMessage(), message);
        }
    }

    /**
     * Handles the explicit "Speichern unter" action.
     * <p>
     * Opens a native file chooser filtered to {@code *.properties} with a default suggestion
     * of {@code config/application.properties} relative to the working directory. When the
     * selected target file already exists, a background check is performed and the user is asked
     * to confirm before overwriting on the FX Application Thread.
     */
    public void requestSaveConfigurationAs() {
        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        FileChooser fileChooser = saveFileChooserFactory.get();
        fileChooser.setTitle("Konfiguration speichern");
        fileChooser.getExtensionFilters().add(
                new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));

        // Propose the default path relative to the working directory.
        Path proposedDir = DEFAULT_SAVE_PATH.getParent();
        if (proposedDir != null) {
            File proposedDirFile = proposedDir.toAbsolutePath().toFile();
            if (proposedDirFile.exists()) {
                fileChooser.setInitialDirectory(proposedDirFile);
            }
        }
        fileChooser.setInitialFileName(DEFAULT_SAVE_PATH.getFileName().toString());

        File selectedFile;
        try {
            selectedFile = saveDialogFunction.apply(fileChooser, owner);
        } catch (UnsupportedOperationException e) {
            // Native file dialogs are unavailable in headless/test environments.
            LOG.debug("GUI-Editor: Speichern-Dialog nicht verfügbar (headless).");
            return;
        }
        if (selectedFile == null) {
            return;
        }

        Path targetPath = selectedFile.toPath();
        checkExistsAndSave(targetPath, () -> { });
    }

    /**
     * Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der
     * Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist.
     *
     * @return aktueller DB-Busy-Zustand
     */
    public boolean isDbBusyForDatabaseCreation() {
        return dbBusyForDatabaseCreation.get();
    }

    /**
     * Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den
     * DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt
     * „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu
     * deaktivieren.
     *
     * @return read-only Property; nie {@code null}
     */
    public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() {
        return dbBusyForDatabaseCreation;
    }

    /**
     * Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den
     * Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der
     * Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während
     * eines laufenden Verarbeitungslaufs zu deaktivieren.
     *
     * @return read-only Property; nie {@code null}
     */
    public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() {
        return batchRunTab.runningProperty();
    }

    /**
     * Leitet einen aktuellen Scheduler-Status-Snapshot an alle betroffenen Tabs weiter.
     * <p>
     * Wird von der zentralen Status-Refresh-Timeline (1 Hz) auf dem JavaFX Application
     * Thread aufgerufen. Die Methode delegiert an:
     * <ul>
     *   <li>{@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab#updateSchedulerState}
     *       – Schaltflächen-Zustand im Verarbeitungslauf-Tab</li>
     *   <li>{@link GuiSchedulerTab#updateStatus} – Statusanzeige im Scheduler-Tab</li>
     *   <li>{@link #updateLockState} – Banner und Speichern-Button im Konfig-Tab</li>
     * </ul>
     *
     * @param status aktueller Scheduler-Status; darf nicht {@code null} sein
     */
    public void onSchedulerStatusRefresh(SchedulerStatus status) {
        batchRunTab.updateSchedulerState(status);
        schedulerTab.updateStatus(status);
        updateLockState(status);
    }

    /**
     * Liest den aktuellen Scheduler-Status vom verdrahteten Use Case und reicht ihn an
     * alle betroffenen Tabs weiter.
     * <p>
     * Im Gegensatz zu einem direkten Lesen am unveränderlichen
     * {@code GuiStartupContext} berücksichtigt diese Methode den nach einem
     * erfolgreichen Datei-Öffnen erst zur Laufzeit verdrahteten Use Case
     * (Auto-Load der zuletzt geladenen Konfiguration oder manuelles Öffnen).
     * Ist kein Use Case verdrahtet, ist der Aufruf ein No-op.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     */
    public void refreshSchedulerStatus() {
        schedulerTab.currentSchedulerUseCase()
                .ifPresent(uc -> onSchedulerStatusRefresh(uc.getStatus()));
    }

    /**
     * Prüft, ob der aktuell verdrahtete Scheduler-Use-Case in einem aktiven
     * Zustand (Zustand != {@code STOPPED}) ist.
     * <p>
     * Liest den Use Case dynamisch aus dem {@link GuiSchedulerTab}, damit auch
     * der nach erfolgreichem Datei-Öffnen erst zur Laufzeit verdrahtete Use Case
     * erfasst wird. Ist kein Use Case verdrahtet, wird {@code false} zurückgegeben.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @return {@code true}, wenn ein Use Case verdrahtet ist und sein Zustand
     *         als aktiv gilt; sonst {@code false}
     */
    public boolean isSchedulerActive() {
        return schedulerTab.currentSchedulerUseCase()
                .map(uc -> uc.getStatus().state().isActive())
                .orElse(false);
    }

    /**
     * Aktualisiert den Sperr-Zustand des Konfig-Tabs anhand des aktuellen Scheduler-Status.
     * <p>
     * Setzt {@link #schedulerLockActive} und ruft {@link #applyConfigTabLockState()} auf,
     * sodass Banner, Eingabefelder und Aktionsbuttons des Konfig-Tabs sofort in den
     * korrekten Zustand versetzt werden.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param status aktueller Scheduler-Status; darf nicht {@code null} sein
     */
    public void updateLockState(SchedulerStatus status) {
        schedulerLockActive = status.state().isActive();
        applyConfigTabLockState();
    }

    /**
     * Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…".
     * <p>
     * Öffnet einen FileChooser (Filter {@code *.db}, {@code *.sqlite}), prüft den Zielpfad
     * gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und
     * delegiert die eigentliche Anlage an
     * {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem
     * Hintergrund-Worker-Thread.
     * <p>
     * Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle
     * DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand
     * wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     */
    public void requestCreateNewDatabase() {
        if (dbBusyForDatabaseCreation.get()) {
            LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit – Klick ignoriert.");
            return;
        }
        if (batchRunTab != null && batchRunTab.isRunning()) {
            showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
            return;
        }

        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        FileChooser fileChooser = saveFileChooserFactory.get();
        fileChooser.setTitle("Neue Datenbank anlegen");
        fileChooser.getExtensionFilters().add(
                new FileChooser.ExtensionFilter("SQLite-Datenbank (*.db, *.sqlite)", "*.db", "*.sqlite"));

        // Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
        String currentSqlite = editorState.values().sqliteFile();
        String activeExtension = resolveDbExtension(currentSqlite);
        fileChooser.setInitialFileName("neue-datenbank" + activeExtension);
        if (currentSqlite != null && !currentSqlite.isBlank()) {
            try {
                Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
                if (proposedDir != null && proposedDir.toFile().isDirectory()) {
                    fileChooser.setInitialDirectory(proposedDir.toFile());
                }
            } catch (Exception ignore) {
                // bei ungültigem Pfad: kein Vorschlagsverzeichnis
            }
        }

        File selectedFile;
        try {
            selectedFile = saveDialogFunction.apply(fileChooser, owner);
        } catch (UnsupportedOperationException e) {
            LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
            return;
        }
        if (selectedFile == null) {
            return;
        }
        Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();

        // Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
        // die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
        if (java.nio.file.Files.exists(requestedTarget)) {
            ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
            ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
            Optional<ButtonType> choice = showConfirmation(
                    "Datei überschreiben?",
                    "Die Datei existiert bereits:\n" + requestedTarget
                            + "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
                    abbrechen,
                    ueberschreiben);
            if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
                LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
                return;
            }
        }

        startCreateNewDatabaseWorker(requestedTarget);
    }

    /**
     * Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
     * Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
     *
     * @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
     */
    private void startCreateNewDatabaseWorker(Path targetFile) {
        dbBusyForDatabaseCreation.set(true);
        applyDbBusyLock();
        showStatusMessage("Neue SQLite-Datenbank wird angelegt …");

        createNewDatabaseExecutor.submit(() -> {
            de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
                    .CreateNewDatabaseResult result;
            try {
                result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
            } catch (RuntimeException e) {
                LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
                        e.getMessage(), e);
                Platform.runLater(() -> {
                    dbBusyForDatabaseCreation.set(false);
                    applyDbBusyLock();
                    showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
                });
                return;
            }
            Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
        });
    }

    /**
     * Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
     * Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
     *
     * @param targetFile der vom Benutzer gewählte Zielpfad
     * @param result     das Ergebnis des Use-Cases
     */
    private void handleCreateNewDatabaseResult(
            Path targetFile,
            de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
                    .CreateNewDatabaseResult result) {
        try {
            switch (result) {
                case de.gecheckt.pdf.umbenenner.application.port.in
                        .CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
                    Path effectiveTarget = success.targetFile();
                    LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);

                    // Konfigurationsmodell aktualisieren → Dirty-State
                    GuiConfigurationValues updated = editorState.values()
                            .withSqliteFile(effectiveTarget.toString());
                    updateValues(updated);

                    // Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
                    if (historyTab != null) {
                        historyTab.reloadAfterDatabaseSwitch();
                    }

                    // Hinweismeldung im zentralen Meldungsbereich
                    pendingMessages.add(GuiMessageEntry.of(
                            GuiMessageSeverity.INFO,
                            "Neue Datenbank ist aktiv. Konfiguration speichern, damit "
                                    + "diese DB beim nächsten Start verwendet wird.",
                            "Datenbank-Anlage"));
                    refreshAfterValidation();
                    showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
                }
                case de.gecheckt.pdf.umbenenner.application.port.in
                        .CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
                    LOG.warn("GUI-Editor: Anlage abgelehnt – Zielpfad ist die aktuell aktive DB: {}",
                            same.targetFile());
                    showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
                            + "Bitte einen anderen Pfad wählen.");
                }
                case de.gecheckt.pdf.umbenenner.application.port.in
                        .CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
                    LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
                            failure.phase(), failure.message());
                    showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
                }
            }
        } finally {
            dbBusyForDatabaseCreation.set(false);
            applyDbBusyLock();
        }
    }

    /**
     * Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
     * <p>
     * Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
     * Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
     * Bootstrap und greifen automatisch den DB-Override des
     * {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
     */
    private void applyDbBusyLock() {
        if (historyTab != null) {
            historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
        }
    }

    /**
     * Checks on a background worker thread whether the target path already exists and, if so,
     * asks the user to confirm overwriting on the FX Application Thread before writing.
     * When the file does not exist the save proceeds directly without a confirmation dialog.
     * <p>
     * The path-existence check is always performed on a background worker thread. UI updates
     * and the optional overwrite confirmation always run on the FX Application Thread via
     * {@link Platform#runLater}. Must never be called from the FX Application Thread itself
     * with the intention of blocking on the result.
     *
     * @param targetPath     the file path to check and write to; must not be {@code null}
     * @param followUpAction the action to run on the FX Application Thread after a successful save
     */
    private void checkExistsAndSave(Path targetPath, Runnable followUpAction) {
        Runnable checkTask = () -> {
            boolean exists = java.nio.file.Files.exists(targetPath);
            Platform.runLater(() -> {
                if (exists) {
                    Optional<ButtonType> result = overwriteConfirmationSupplier != null
                            ? overwriteConfirmationSupplier.get()
                            : showConfirmation(
                                    "Datei überschreiben?",
                                    "Die Datei existiert bereits:\n" + targetPath.toAbsolutePath()
                                            + "\n\nSoll die Datei überschrieben werden?",
                                    ButtonType.YES, ButtonType.NO);
                    if (result.isEmpty() || result.get() != ButtonType.YES) {
                        return;
                    }
                }
                saveToPathAndThen(targetPath, followUpAction);
            });
        };
        Thread checker = pathCheckerThreadFactory != null
                ? pathCheckerThreadFactory.apply(checkTask)
                : new Thread(checkTask, "gui-path-checker");
        checker.setDaemon(true);
        checker.start();
    }

    /**
     * Carries the complete post-save state built on the worker thread, ready for
     * the FX Application Thread to apply without any further blocking I/O.
     *
     * @param saveResult the enriched write result; never {@code null}
     * @param snapshot   the file snapshot reconstructed from the written file; never {@code null}
     * @param newState   the fully assembled editor state to apply; never {@code null}
     */
    private record SaveCompletion(
            GuiConfigurationSaveResult saveResult,
            GuiConfigurationFileSnapshot snapshot,
            GuiConfigurationEditorState newState) {
    }

    /**
     * Performs the actual file write on a background thread for the given target path.
     * Delegates to {@link #saveToPathAndThen(Path, Runnable)} with a no-op follow-up.
     *
     * @param targetPath the file path to write to; must not be {@code null}
     */
    void saveToPath(Path targetPath) {
        saveToPathAndThen(targetPath, () -> { });
    }

    /**
     * Performs the actual file write on a background thread and runs the follow-up action on
     * the FX Application Thread after a successful write.
     *
     * @param targetPath     the file path to write to; must not be {@code null}
     * @param followUpAction the action to run after a successful save; must not be {@code null}
     */
    private void saveToPathAndThen(Path targetPath, Runnable followUpAction) {
        GuiApiKeyMerger.MergeResult mergeResult = GuiApiKeyMerger.merge(editorState);
        GuiConfigurationValues valuesToWrite = mergeResult.values();
        String preservedProviderIdentifier = mergeResult.preservedProviderIdentifier();
        GuiConfigurationEditorState stateAtSaveTime = this.editorState;

        Thread worker = new Thread(() -> {
            try {
                GuiConfigurationSaveResult writeResult = configurationFileWriter.write(valuesToWrite, targetPath);
                GuiConfigurationSaveResult result = (preservedProviderIdentifier != null)
                        ? GuiConfigurationSaveResult.savedWithPreservedKey(
                                writeResult.savedPath(), preservedProviderIdentifier)
                        : writeResult;

                java.util.Properties savedProperties = new java.util.Properties();
                try {
                    String fileContent = java.nio.file.Files.readString(result.savedPath(),
                            java.nio.charset.StandardCharsets.UTF_8);
                    savedProperties.load(new java.io.StringReader(fileContent));
                } catch (java.io.IOException reloadException) {
                    LOG.warn("GUI-Editor: Snapshot-Reload nach Speichern fehlgeschlagen (best-effort): {}",
                            safeMessage(reloadException));
                }

                GuiConfigurationFileSnapshot newSnapshot = new GuiConfigurationFileSnapshot(
                        result.savedPath(), savedProperties);
                GuiConfigurationEditorState newState = new GuiConfigurationEditorState(
                        Optional.of(newSnapshot),
                        stateAtSaveTime.values(),
                        stateAtSaveTime.values(),
                        stateAtSaveTime.pendingMigrationMessage().isPresent()
                                ? stateAtSaveTime.pendingMigrationMessage()
                                : Optional.empty());

                SaveCompletion completion = new SaveCompletion(result, newSnapshot, newState);
                Platform.runLater(() -> {
                    handleSaveSuccess(completion);
                    followUpAction.run();
                });
            } catch (Exception exception) {
                Platform.runLater(() -> showError("Konfiguration konnte nicht gespeichert werden: "
                        + safeMessage(exception)));
            }
        }, "gui-config-writer");
        worker.setDaemon(true);
        worker.start();
    }

    /**
     * Updates the editor state and header after a successful file write.
     * <p>
     * This method runs exclusively on the FX Application Thread. All blocking I/O has
     * already been performed on the worker thread; this method only assigns the prepared
     * state and triggers UI updates.
     *
     * @param completion the fully assembled post-save data; must not be {@code null}
     */
    private void handleSaveSuccess(SaveCompletion completion) {
        GuiConfigurationSaveResult result = completion.saveResult();
        LOG.info("GUI-Editor: Konfiguration erfolgreich gespeichert unter: {}",
                result.savedPath().toAbsolutePath());

        this.editorState = completion.newState();
        refreshHeader();
        // Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
        statusBarStateListener.accept(this.editorState);
        // Prompt-Tab über neuen Prompt-Pfad informieren (kann sich durch Speichern geändert haben)
        notifyPromptTabConfigChanged(this.editorState);

        if (result.hasApiKeyPreservationNote()) {
            LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
                    + "bestehender Wert bleibt erhalten).", result.apiKeyPreservedForProvider());
            // Preservation note is stored for later warning display by future validation layers.
        }
    }

    /**
     * Saves the current editor state and runs the given follow-up action after a successful write.
     * When no file path is known yet, the "Speichern unter" dialog is shown first.
     *
     * @param followUpAction action to run on the FX Application Thread after a successful save
     */
    private void performSaveBeforeAction(Runnable followUpAction) {
        if (editorState.isNewConfiguration()) {
            requestSaveConfigurationAsAndThen(followUpAction);
            return;
        }
        saveToPathAndThen(editorState.loadedFileSnapshot().orElseThrow().filePath(), followUpAction);
    }

    /**
     * Opens the "Speichern unter" dialog and runs the follow-up action after a successful write.
     * If the user cancels the dialog the follow-up is not executed. The existence check for the
     * target file is performed on a background worker thread.
     *
     * @param followUpAction the action to run after a successful save
     */
    private void requestSaveConfigurationAsAndThen(Runnable followUpAction) {
        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        FileChooser fileChooser = saveFileChooserFactory.get();
        fileChooser.setTitle("Konfiguration speichern");
        fileChooser.getExtensionFilters().add(
                new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
        java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile();
        if (proposedDirFile.exists()) {
            fileChooser.setInitialDirectory(proposedDirFile);
        }
        fileChooser.setInitialFileName(DEFAULT_SAVE_PATH.getFileName().toString());
        java.io.File selectedFile;
        try {
            selectedFile = saveDialogFunction.apply(fileChooser, owner);
        } catch (UnsupportedOperationException e) {
            LOG.debug("GUI-Editor: Speichern-Dialog nicht verfügbar (headless).");
            return;
        }
        if (selectedFile == null) {
            return;
        }
        Path targetPath = selectedFile.toPath();
        checkExistsAndSave(targetPath, followUpAction);
    }

    /**
     * Shows the three-option unsaved-changes protection dialog and returns the user choice.
     * Must be called on the FX Application Thread.
     *
     * @param triggerLabel a short label for the requested action; must not be {@code null}
     * @return the user choice; never {@code null}
     */
    private GuiUnsavedChangesGuard.Choice showUnsavedChangesDialog(String triggerLabel) {
        ButtonType saveBt = new ButtonType("Speichern");
        ButtonType discardBt = new ButtonType("Verwerfen");
        ButtonType cancelBt = new ButtonType("Abbrechen",
                javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);

        Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
        alert.setTitle("Ungespeicherte Änderungen");
        alert.setHeaderText("Es liegen ungespeicherte Änderungen vor.");
        alert.setContentText("Wie möchten Sie fortfahren?");
        alert.getButtonTypes().setAll(saveBt, discardBt, cancelBt);

        ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(cancelBt)).setDefaultButton(true);
        ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(saveBt)).setDefaultButton(false);
        ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(discardBt)).setDefaultButton(false);

        Optional<ButtonType> result = alert.showAndWait();
        if (result.isEmpty() || result.get() == cancelBt) {
            return GuiUnsavedChangesGuard.Choice.CANCEL;
        }
        if (result.get() == saveBt) {
            return GuiUnsavedChangesGuard.Choice.SAVE;
        }
        return GuiUnsavedChangesGuard.Choice.DISCARD;
    }

    private void applyEditorState(GuiConfigurationEditorState newState) {
        this.editorState = newState;
        this.welcomeGuidanceVisible = false;
        statusLabel.setVisible(false);
        statusLabel.setManaged(false);
        pendingMessages.clear();
        refreshView();
        runEditorValidation();
        // Statuszeile über den neuen Zustand informieren
        statusBarStateListener.accept(newState);
        // Prompt-Tab mit neuem Pfad und Port versorgen
        notifyPromptTabConfigChanged(newState);
        // Modell-Preise-Tab ueber neue Konfiguration informieren (aktiviert die Buttons)
        modelPricesTab.notifyConfigurationChanged();
    }

    /**
     * Benachrichtigt den Prompt-Editor-Tab über eine geänderte Konfiguration.
     * <p>
     * Liest {@code prompt.template.file} und {@code max.title.length} aus dem neuen
     * Zustand, erzeugt über die Factory einen passenden Port und übergibt beides an den
     * Tab. Ist der Prompt-Pfad leer, wird ein No-Op-Port verwendet.
     * <p>
     * Muss auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param state der neue Editor-Zustand; darf nicht {@code null} sein
     */
    private void notifyPromptTabConfigChanged(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState state) {
        String promptPath = state.values().promptTemplateFile();
        int maxTitle;
        try {
            maxTitle = Integer.parseInt(state.values().maxTitleLength().trim());
        } catch (NumberFormatException e) {
            maxTitle = 60;
        }
        GuiPromptEditorPort newPort = (promptPath != null && !promptPath.isBlank())
                ? promptEditorPortFactory.create(promptPath)
                : noOpPromptEditorPort();
        promptEditorTab.notifyConfigurationChanged(
                newPort,
                promptPath != null ? promptPath : "",
                maxTitle);
    }

    private static GuiPromptEditorPort noOpPromptEditorPort() {
        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_PATH", NO_PROMPT_PATH_MSG);
            }

            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
                return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
                        NO_PROMPT_PATH_MSG, 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, NO_PROMPT_PATH_MSG);
            }
        };
    }

    private void configureRoot() {
        root.getStyleClass().add("gui-editor-root");
        root.setPadding(new Insets(16));
    }

    private void configureHeader(Optional<String> startupNotice) {
        Label titleLabel = new Label("PDF-Umbenenner");
        titleLabel.setStyle("-fx-font-size: 24px; -fx-font-weight: bold;");

        Label subtitleLabel = new Label("Konfigurationseditor");
        subtitleLabel.setStyle("-fx-font-size: 12px; -fx-text-fill: #666666;");

        VBox titleBox = new VBox(2, titleLabel, subtitleLabel);

        Label pathCaption = new Label("Konfigurationspfad:");
        pathCaption.setStyle("-fx-font-weight: bold;");
        dirtyMarkerLabel.setStyle("-fx-text-fill: #8b4500; -fx-font-style: italic;");
        dirtyMarkerLabel.setVisible(false);
        dirtyMarkerLabel.setManaged(false);

        HBox pathRow = new HBox(8, pathCaption, configurationPathValueLabel, dirtyMarkerLabel);
        pathRow.setAlignment(Pos.CENTER_LEFT);

        statusLabel.setWrapText(true);
        statusLabel.setVisible(false);
        statusLabel.setManaged(false);
        statusLabel.setStyle(
                "-fx-padding: 8px 10px; -fx-background-color: #fff2cc; -fx-text-fill: #5c3b00; -fx-border-color: #e0b95b;");

        VBox headerBox = new VBox(8, titleBox, pathRow, statusLabel, new Separator());
        headerBox.setPadding(new Insets(0, 0, 16, 0));
        root.setTop(headerBox);

        startupNotice.ifPresent(this::showStatusMessage);
    }

    private void configureTabs() {
        Tab editorTab = new Tab("Konfiguration");
        editorTab.setClosable(false);
        configurationTab = editorTab;

        sectionsBox.setSpacing(4);
        sectionsBox.setFillWidth(true);

        configurationLockBanner.setId("configuration-lock-banner");
        configurationLockBanner.setStyle(
                "-fx-font-weight: bold; -fx-text-fill: #b45309; -fx-padding: 8 12 8 12;"
                        + " -fx-background-color: #fef3c7; -fx-background-radius: 4;");
        configurationLockBanner.setMaxWidth(Double.MAX_VALUE);
        configurationLockBanner.setVisible(false);
        configurationLockBanner.setManaged(false);

        // Kompakter Abstand (4px) zwischen Lock-Banner und Sektionen
        VBox tabContent = new VBox(4, configurationLockBanner, sectionsBox);
        VBox.setVgrow(sectionsBox, Priority.ALWAYS);

        ScrollPane scrollPane = new ScrollPane(tabContent);
        scrollPane.setFitToWidth(true);
        // FitToHeight sorgt dafür, dass tabContent den sichtbaren Viewport ausfüllt;
        // nur so kann VBox.setVgrow auf einzelnen Sektionen (z.B. Meldungen) wirken.
        scrollPane.setFitToHeight(true);
        scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
        scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
        scrollPane.setPadding(new Insets(0));
        editorTab.setContent(scrollPane);

        tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), schedulerTab.tab(),
                historyTab.tab(), promptEditorTab.tab(), modelPricesTab.tab());
        root.setCenter(tabPane);

        // Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
        // der Dateiname-Editor ungespeicherte Änderungen hat.
        // Gleiches gilt für den Prompt-Tab.
        tabPane.getSelectionModel().selectedItemProperty().addListener(
                (obs, oldTab, newTab) -> handleTabSwitch(oldTab, newTab));
    }

    private void handleTabSwitch(javafx.scene.control.Tab oldTab, javafx.scene.control.Tab newTab) {
        if (oldTab == null || newTab == null) {
            return;
        }
        if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
            boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
            if (!shouldDiscard) {
                Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
            }
        } else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
            boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
            if (!shouldDiscard) {
                Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
            } else {
                promptEditorTab.discardChanges();
            }
        }
    }

    private void configureActionBar() {
        // Tooltips für Toolbar-Buttons gemäß Spezifikation
        applyTooltip(newButton, GuiTooltipTexts.TOOLBAR_NEU);
        applyTooltip(openButton, GuiTooltipTexts.TOOLBAR_OEFFNEN);
        applyTooltip(saveButton, GuiTooltipTexts.TOOLBAR_SPEICHERN);
        applyTooltip(saveAsButton, GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);

        HBox actionBar = new HBox(10, newButton, openButton, saveButton, saveAsButton);
        actionBar.setAlignment(Pos.CENTER_LEFT);
        actionBar.setPadding(new Insets(16, 0, 0, 0));
        root.setBottom(actionBar);
    }

    private void configureActions() {
        newButton.setOnAction(event -> requestNewConfiguration());
        openButton.setOnAction(event -> requestOpenConfiguration());
        saveButton.setOnAction(event -> requestSaveConfiguration());
        saveAsButton.setOnAction(event -> requestSaveConfigurationAs());
    }

    private void refreshView() {
        refreshHeader();
        refreshSections();
    }

    private void refreshHeader() {
        configurationPathValueLabel.setText(editorState.configurationPathText());

        boolean dirty = editorState.changeState() == GuiChangeState.DIRTY;
        dirtyMarkerLabel.setVisible(dirty);
        dirtyMarkerLabel.setManaged(dirty);

        // Tab-Titel mit Dirty-Indikator aktualisieren (UX-Konsistenz zum Prompt-Tab)
        if (configurationTab != null) {
            configurationTab.setText(dirty ? "* Konfiguration" : "Konfiguration");
        }

        titleUpdateListener.accept(GuiWindowTitleFormatter.format(editorState));
    }

    private void refreshSections() {
        // Clear per-provider and per-field registries before rebuilding; new instances are
        // registered inside the section builders below.
        modelFieldContainers.clear();
        fieldErrorLabels.clear();
        apiKeyOriginLabels.clear();
        sectionsBox.getChildren().setAll(
                createWelcomeCard(),
                createPathsSection(),
                createProviderSection(),
                createProcessingLimitsSection(),
                createTestsSection(),
                createMessagesSection());
    }

    // =========================================================================
    // Welcome card
    // =========================================================================

    private Node createWelcomeCard() {
        VBox card = createCardContainer();
        welcomeTitleLabel.setStyle("-fx-font-size: 16px; -fx-font-weight: bold;");
        welcomeTextLabel.setWrapText(true);
        welcomeTextLabel.setStyle("-fx-font-size: 13px;");

        card.getChildren().addAll(welcomeTitleLabel, welcomeTextLabel);
        card.setVisible(welcomeGuidanceVisible);
        card.setManaged(welcomeGuidanceVisible);
        return card;
    }

    // =========================================================================
    // Pfade section
    // =========================================================================

    /**
     * Builds the "Pfade" section with editable text fields and folder/file picker buttons.
     * <p>
     * Quellordner and Zielordner use a {@link DirectoryChooser}. SQLite-Datei and Prompt-Datei
     * use a {@link FileChooser} with appropriate extension filters. All pickers preserve
     * Windows-style paths (including mapped drive letters such as {@code S:\}) unchanged.
     * <p>
     * Each path field registers a field-level error slot in {@link #fieldErrorLabels} keyed by
     * the corresponding property key. The slots are populated and shown or hidden by
     * {@link #refreshFieldErrorLabels()}.
     *
     * @return the card node for the "Pfade" section
     */
    private Node createPathsSection() {
        VBox card = createCardContainer();
        card.getChildren().add(sectionTitle("Pfade"));

        // Kompakteres 2-Spalten-Layout für Pfade-Felder
        VBox mainRows = new VBox(4);

        // Quellordner + Zielordner nebeneinander (2-Spalten-Layout)
        TextField sourceFolderField = boundTextField(
                editorState.values().sourceFolder(),
                val -> updateValues(editorState.values().withSourceFolder(val)));
        applyTooltip(sourceFolderField, GuiTooltipTexts.PFADE_QUELLORDNER);
        Label sourceFolderErrorLabel = createFieldErrorLabel();
        fieldErrorLabels.put("source.folder", sourceFolderErrorLabel);

        TextField targetFolderField = boundTextField(
                editorState.values().targetFolder(),
                val -> updateValues(editorState.values().withTargetFolder(val)));
        applyTooltip(targetFolderField, GuiTooltipTexts.PFADE_ZIELORDNER);
        Label targetFolderErrorLabel = createFieldErrorLabel();
        fieldErrorLabels.put("target.folder", targetFolderErrorLabel);

        HBox folderRow = buildTwoPathFieldsRow(
                "Quellordner:", sourceFolderField, sourceFolderErrorLabel, () -> {
                    String picked = pickDirectory("Quellordner auswählen", sourceFolderField.getText());
                    if (picked != null) {
                        sourceFolderField.setText(picked);
                        updateValues(editorState.values().withSourceFolder(picked));
                    }
                },
                "Zielordner:", targetFolderField, targetFolderErrorLabel, () -> {
                    String picked = pickDirectory("Zielordner auswählen", targetFolderField.getText());
                    if (picked != null) {
                        targetFolderField.setText(picked);
                        updateValues(editorState.values().withTargetFolder(picked));
                    }
                });
        mainRows.getChildren().add(folderRow);

        // SQLite-Datei + Prompt-Datei nebeneinander (2-Spalten-Layout)
        TextField sqliteField = boundTextField(
                editorState.values().sqliteFile(),
                val -> updateValues(editorState.values().withSqliteFile(val)));
        applyTooltip(sqliteField, GuiTooltipTexts.PFADE_SQLITE);
        Label sqliteErrorLabel = createFieldErrorLabel();
        fieldErrorLabels.put("sqlite.file", sqliteErrorLabel);

        TextField promptField = boundTextField(
                editorState.values().promptTemplateFile(),
                val -> updateValues(editorState.values().withPromptTemplateFile(val)));
        applyTooltip(promptField, GuiTooltipTexts.PFADE_PROMPT);
        Label promptErrorLabel = createFieldErrorLabel();
        fieldErrorLabels.put("prompt.template.file", promptErrorLabel);

        HBox fileRow = buildTwoPathFieldsRow(
                "SQLite-Datei:", sqliteField, sqliteErrorLabel, () -> {
                    String picked = pickFile("SQLite-Datei auswählen", sqliteField.getText(),
                            new FileChooser.ExtensionFilter("SQLite-Dateien", "*.db", "*.sqlite", "*.sqlite3"),
                            new FileChooser.ExtensionFilter("Alle Dateien (*.*)", "*.*"));
                    if (picked != null) {
                        sqliteField.setText(picked);
                        updateValues(editorState.values().withSqliteFile(picked));
                    }
                },
                "Prompt-Datei:", promptField, promptErrorLabel, () -> {
                    String picked = pickFile("Prompt-Datei auswählen", promptField.getText(),
                            new FileChooser.ExtensionFilter("Textdateien", "*.txt", "*.md"),
                            new FileChooser.ExtensionFilter("Alle Dateien (*.*)", "*.*"));
                    if (picked != null) {
                        promptField.setText(picked);
                        updateValues(editorState.values().withPromptTemplateFile(picked));
                    }
                });
        mainRows.getChildren().add(fileRow);

        card.getChildren().add(mainRows);

        // Optionale Felder in ausklappbare TitledPane packen
        TextField lockField = boundTextField(
                editorState.values().runtimeLockFile(),
                val -> updateValues(editorState.values().withRuntimeLockFile(val)));
        applyTooltip(lockField, GuiTooltipTexts.PFADE_LOCK_DATEI);

        TextField logDirField = boundTextField(
                editorState.values().logDirectory(),
                val -> updateValues(editorState.values().withLogDirectory(val)));
        applyTooltip(logDirField, GuiTooltipTexts.PFADE_LOG_VERZEICHNIS);

        VBox optionalContent = new VBox(4);
        optionalContent.setPadding(new Insets(6, 0, 0, 0));
        optionalContent.getChildren().add(buildSimpleFieldRow("Lock-Datei:", lockField, null));
        optionalContent.getChildren().add(buildSimpleFieldRow("Log-Verzeichnis:", logDirField, null));

        TitledPane optionalPane = new TitledPane("Weitere Optionen (Click zum Aufklappen)", optionalContent);
        optionalPane.setCollapsible(true);
        optionalPane.setExpanded(false);
        optionalPane.setStyle("-fx-padding: 2px;");

        card.getChildren().add(optionalPane);

        return card;
    }

    // =========================================================================
    // Provider section
    // =========================================================================

    /**
     * Builds the "Provider" section with a non-editable {@link ComboBox} for provider selection,
     * a "Modelle neu laden" button and a container that shows exactly one provider block at a time.
     * <p>
     * Only the block for the currently active provider is visible. The block for the hidden
     * provider is kept in the scene graph with {@code setVisible(false)} and
     * {@code setManaged(false)} so that no blank space appears. The hidden provider's field
     * values are preserved in {@link #visibleProviderSection} and are not lost when switching.
     * <p>
     * The active provider is resolved from {@code editorState.values().activeProviderFamily()}.
     * When the stored identifier does not match any known provider the first provider in
     * alphabetical enum order (Claude) is used as a safe default, consistent with the
     * behaviour of the standard template.
     * <p>
     * When the provider ComboBox selection changes, a model catalogue retrieval is triggered
     * automatically via {@link GuiModelCatalogCoordinator#triggerModelRetrieval}. The "Modelle
     * neu laden" button triggers the same retrieval path on demand.
     *
     * @return the card node for the "Provider" section
     */
    private Node createProviderSection() {
        VBox card = createCardContainer();
        card.getChildren().add(sectionTitle("Provider"));

        // --- Determine which provider to make visible initially ---
        AiProviderFamily initialProvider = resolveActiveProvider(editorState.values().activeProviderFamily());

        // --- ComboBox for provider selection ---
        ComboBox<AiProviderFamily> providerComboBox = new ComboBox<>();
        providerComboBox.setEditable(false);
        providerComboBox.setConverter(new AiProviderFamilyStringConverter());
        providerComboBox.getItems().addAll(AiProviderFamily.CLAUDE, AiProviderFamily.OPENAI_COMPATIBLE);
        providerComboBox.setValue(initialProvider);
        applyTooltip(providerComboBox, GuiTooltipTexts.PROVIDER_COMBOBOX);

        // --- "Modelle neu laden" button ---
        Button reloadModelsButton = new Button("Modelle neu laden");
        reloadModelsButton.setId("modelle-neu-laden-button");
        reloadModelsButton.setOnAction(event -> triggerModelRetrievalForCurrentProvider(providerComboBox));
        applyTooltip(reloadModelsButton, GuiTooltipTexts.PROVIDER_MODELLE_NEU_LADEN);

        HBox comboRow = new HBox(8, providerComboBox, reloadModelsButton);
        comboRow.setAlignment(Pos.CENTER_LEFT);

        Label activeProviderError = createFieldErrorLabel();
        fieldErrorLabels.put("ai.provider.active", activeProviderError);

        GridPane comboGrid = createFieldGrid();
        comboGrid.add(new Label("Aktiver Provider:"), 0, 0);
        comboGrid.add(comboRow, 1, 0);
        card.getChildren().addAll(comboGrid, activeProviderError);

        // --- Build both provider blocks ---
        VBox claudeBlock = createProviderBlock("Claude", AiProviderFamily.CLAUDE);
        VBox openaiBlock = createProviderBlock("OpenAI-kompatibel", AiProviderFamily.OPENAI_COMPATIBLE);

        // Apply initial visibility: only the active provider block is shown.
        applyProviderBlockVisibility(claudeBlock, openaiBlock, initialProvider);

        // --- Record the initial visible-provider section snapshot ---
        visibleProviderSection = buildVisibleProviderSection(initialProvider);

        // Container holds both blocks; layout renders only the managed/visible one.
        VBox providerBlockContainer = new VBox(0, claudeBlock, openaiBlock);

        card.getChildren().add(providerBlockContainer);

        // --- Wire the ComboBox selection change to the switch logic ---
        providerComboBox.valueProperty().addListener((obs, oldFamily, newFamily) -> {
            if (newFamily == null || newFamily.equals(oldFamily)) {
                return;
            }
            handleProviderSwitch(newFamily, claudeBlock, openaiBlock);
            // Trigger automatic model retrieval for the newly selected provider.
            triggerModelRetrievalForCurrentProvider(providerComboBox);
        });

        return card;
    }

    /**
     * Triggers a model catalogue retrieval for the provider currently shown in the given
     * ComboBox. The retrieval reads the current provider state from the editor state, so it
     * operates on live (potentially unsaved) field values.
     * <p>
     * Must be called on the JavaFX Application Thread.
     *
     * @param providerComboBox the provider selection ComboBox; must not be {@code null}
     */
    private void triggerModelRetrievalForCurrentProvider(ComboBox<AiProviderFamily> providerComboBox) {
        AiProviderFamily family = providerComboBox.getValue();
        if (family == null) {
            return;
        }
        GuiProviderConfigurationState providerState = Optional.ofNullable(
                editorState.values().providerConfiguration(family))
                .orElse(GuiProviderConfigurationState.blank());
        modelCatalogCoordinator.triggerModelRetrieval(family, providerState);
    }

    /**
     * Resolves the active provider family from a raw configuration identifier string.
     * <p>
     * When the identifier matches a known provider family it is returned directly. When the
     * identifier is blank, unknown or {@code null}, Claude is returned as the safe default
     * (alphabetically first and the standard-template default).
     *
     * @param activeProviderIdentifier raw value of {@code ai.provider.active}; may be blank
     * @return the resolved provider family; never {@code null}
     */
    private static AiProviderFamily resolveActiveProvider(String activeProviderIdentifier) {
        return AiProviderFamily.fromIdentifier(activeProviderIdentifier)
                .orElse(AiProviderFamily.CLAUDE);
    }

    /**
     * Builds a {@link GuiVisibleProviderSection} snapshot from the current editor state for
     * the given visible provider. The hidden provider is the other known family.
     *
     * @param visibleProvider the provider whose block is currently shown; must not be {@code null}
     * @return a fresh snapshot reflecting the current editor state; never {@code null}
     */
    private GuiVisibleProviderSection buildVisibleProviderSection(AiProviderFamily visibleProvider) {
        AiProviderFamily hiddenProvider = (visibleProvider == AiProviderFamily.CLAUDE)
                ? AiProviderFamily.OPENAI_COMPATIBLE
                : AiProviderFamily.CLAUDE;
        GuiProviderConfigurationState visibleState = Optional.ofNullable(
                editorState.values().providerConfiguration(visibleProvider))
                .orElse(GuiProviderConfigurationState.blank());
        GuiProviderConfigurationState hiddenState = Optional.ofNullable(
                editorState.values().providerConfiguration(hiddenProvider))
                .orElse(GuiProviderConfigurationState.blank());
        return new GuiVisibleProviderSection(visibleProvider, visibleState, hiddenProvider, hiddenState);
    }

    /**
     * Sets the visibility of the two provider blocks so that exactly the requested provider's
     * block is visible while the other is hidden and unmanaged.
     *
     * @param claudeBlock    the Claude provider block node; must not be {@code null}
     * @param openaiBlock    the OpenAI-compatible provider block node; must not be {@code null}
     * @param visibleProvider the provider whose block should be shown; must not be {@code null}
     */
    private static void applyProviderBlockVisibility(
            VBox claudeBlock, VBox openaiBlock, AiProviderFamily visibleProvider) {
        boolean claudeVisible = (visibleProvider == AiProviderFamily.CLAUDE);
        claudeBlock.setVisible(claudeVisible);
        claudeBlock.setManaged(claudeVisible);
        openaiBlock.setVisible(!claudeVisible);
        openaiBlock.setManaged(!claudeVisible);
    }

    /**
     * Handles a provider-switch event triggered by the provider {@link ComboBox}.
     * <p>
     * Sequence of operations on provider switch:
     * <ol>
     *   <li>The {@link #visibleProviderSection} snapshot is switched via
     *       {@link GuiVisibleProviderSection#switchProvider()} so both provider states are
     *       preserved without any data loss.</li>
     *   <li>The {@code ai.provider.active} value in the editor state is updated to the
     *       new provider's identifier.</li>
     *   <li>Block visibility is updated: the newly active block becomes visible and managed,
     *       the previously active block becomes invisible and unmanaged.</li>
     * </ol>
     * <p>
     * The field values of the previously visible provider are already held in the editor
     * state (they are updated on every keystroke via the field listeners), so no explicit
     * read-back from the UI is needed here.
     *
     * @param newProvider the provider selected by the user; must not be {@code null}
     * @param claudeBlock the Claude provider block node; must not be {@code null}
     * @param openaiBlock the OpenAI-compatible provider block node; must not be {@code null}
     */
    private void handleProviderSwitch(AiProviderFamily newProvider, VBox claudeBlock, VBox openaiBlock) {
        // Switch the visible-provider section snapshot.
        if (visibleProviderSection != null) {
            visibleProviderSection = visibleProviderSection.switchProvider();
        } else {
            visibleProviderSection = buildVisibleProviderSection(newProvider);
        }

        // Update ai.provider.active in the editor state.
        updateValues(editorState.values().withActiveProviderFamily(newProvider.getIdentifier()));

        // Apply visibility to the block nodes.
        applyProviderBlockVisibility(claudeBlock, openaiBlock, newProvider);

        LOG.debug("GUI-Editor: Providerwechsel zu '{}'.", newProvider.getIdentifier());
    }

    /**
     * Builds one provider configuration block for the given provider family.
     * <p>
     * The block contains editable fields for base URL, timeout and API key, as well as a
     * {@link GuiModelFieldContainer} for the model field. Field-level error slots are registered
     * in {@link #fieldErrorLabels} keyed by their property key (e.g.
     * {@code "ai.provider.claude.model"}). An API-key origin label is registered in
     * {@link #apiKeyOriginLabels} for display of the effective key source.
     *
     * @param displayName the human-readable label shown as the block title
     * @param family      the provider family this block represents
     * @return the provider block as a styled card node
     */
    /**
     * Erstellt einen kompakten Provider-Konfigurationsblock.
     * Basis-URL und Timeout stehen nebeneinander (4-Spalten-Layout, Timeout schmal rechts).
     * Modell und API-Key belegen jeweils eine eigene Zeile über volle Breite.
     */
    private VBox createProviderBlock(String displayName, AiProviderFamily family) {
        String ns = "ai.provider." + family.getIdentifier() + ".";

        // Kompaktere Provider-Box: VBox-Spacing 2 für engere Feldabstände, unteres Padding 4px
        VBox block = new VBox(2);
        block.setStyle(
                "-fx-padding: 8px 8px 4px 8px; -fx-border-color: #c8c8c8; -fx-border-radius: 6px;"
                        + " -fx-background-radius: 6px; -fx-background-color: #f9f9f9;");

        Label title = new Label(displayName);
        title.setStyle("-fx-font-weight: bold;");
        block.getChildren().add(title);

        GuiProviderConfigurationState pState = Optional.ofNullable(
                editorState.values().providerConfiguration(family))
                .orElse(GuiProviderConfigurationState.blank());

        // 4-Spalten-Layout: [label-links][feld-links(breit)][label-rechts][feld-rechts(schmal)]
        // Erste Zeile: Basis-URL (breit) + Timeout (schmal) nebeneinander
        // Weitere Zeilen: Modell und API-Key über volle Breite (Spalten 1–3)
        GridPane fieldGrid = new GridPane();
        fieldGrid.setHgap(8);
        fieldGrid.setVgap(4);

        // Spalte 0: Label links (fest)
        javafx.scene.layout.ColumnConstraints colLabel1 = new javafx.scene.layout.ColumnConstraints();
        colLabel1.setMinWidth(75);
        colLabel1.setPrefWidth(90);
        colLabel1.setHgrow(Priority.NEVER);
        // Spalte 1: Basis-URL-Feld (breit, wächst)
        javafx.scene.layout.ColumnConstraints colField1 = new javafx.scene.layout.ColumnConstraints();
        colField1.setHgrow(Priority.ALWAYS);
        // Spalte 2: Timeout-Label (fest)
        javafx.scene.layout.ColumnConstraints colLabel2 = new javafx.scene.layout.ColumnConstraints();
        colLabel2.setMinWidth(80);
        colLabel2.setPrefWidth(95);
        colLabel2.setHgrow(Priority.NEVER);
        // Spalte 3: Timeout-Feld (schmal, fest)
        javafx.scene.layout.ColumnConstraints colField2 = new javafx.scene.layout.ColumnConstraints();
        colField2.setMinWidth(65);
        colField2.setPrefWidth(80);
        colField2.setHgrow(Priority.NEVER);
        fieldGrid.getColumnConstraints().addAll(colLabel1, colField1, colLabel2, colField2);

        int gridRow = 0;

        // --- Zeile 1: Basis-URL (links, breit) + Timeout (rechts, schmal) ---
        TextField baseUrlField = boundTextField(pState.baseUrl(),
                val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
                        val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey())));
        Label baseUrlError = createFieldErrorLabel();
        fieldErrorLabels.put(ns + "baseUrl", baseUrlError);
        applyTooltip(baseUrlField, GuiTooltipTexts.PROVIDER_BASIS_URL);
        HBox baseUrlBox = new HBox(4, baseUrlField);
        HBox.setHgrow(baseUrlField, Priority.ALWAYS);
        fieldGrid.add(new Label("Basis-URL:"), 0, gridRow);
        fieldGrid.add(baseUrlBox, 1, gridRow);

        TextField timeoutField = boundTextField(pState.timeoutSeconds(),
                val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
                        pState2.baseUrl(), pState2.model(), val, pState2.apiKey())));
        applyTooltip(timeoutField, GuiTooltipTexts.PROVIDER_TIMEOUT);
        Label timeoutError = createFieldErrorLabel();
        fieldErrorLabels.put(ns + "timeoutSeconds", timeoutError);
        fieldGrid.add(new Label("Timeout (Sek.):"), 2, gridRow);
        fieldGrid.add(timeoutField, 3, gridRow);
        gridRow++;

        // Fehlerzeile für Basis-URL und/oder Timeout (gemeinsame Zeile).
        // Platzhalter in Spalte 0/2 werden auf managed=false gesetzt, damit die Zeile
        // kollabiert wenn das eigentliche Fehler-Label in Spalte 1/3 ebenfalls unmanaged ist.
        if (baseUrlError != null || timeoutError != null) {
            if (baseUrlError != null) {
                Label spacerBaseUrl = new Label();
                spacerBaseUrl.setManaged(false);
                spacerBaseUrl.setVisible(false);
                fieldGrid.add(spacerBaseUrl, 0, gridRow);
                fieldGrid.add(baseUrlError, 1, gridRow);
            }
            if (timeoutError != null) {
                Label spacerTimeout = new Label();
                spacerTimeout.setManaged(false);
                spacerTimeout.setVisible(false);
                fieldGrid.add(spacerTimeout, 2, gridRow);
                fieldGrid.add(timeoutError, 3, gridRow);
            }
            gridRow++;
        }

        // --- Zeile 2: Modell (volle Breite, Spalten 1–3) ---
        GuiModelFieldContainer modelContainer = new GuiModelFieldContainer(
                pState.model(),
                val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
                        pState2.baseUrl(), val, pState2.timeoutSeconds(), pState2.apiKey())));
        modelContainer.applyTooltip(GuiTooltipTexts.PROVIDER_MODELL);
        modelFieldContainers.put(family, modelContainer);
        modelCatalogCoordinator.registerFieldContainer(family, modelContainer);
        Label modelError = createFieldErrorLabel();
        fieldErrorLabels.put(ns + "model", modelError);
        Node modelNode = modelContainer.asNode();
        fieldGrid.add(new Label("Modell:"), 0, gridRow);
        fieldGrid.add(modelNode, 1, gridRow);
        GridPane.setColumnSpan(modelNode, 3);
        gridRow++;
        if (modelError != null) {
            // Platzhalter auf managed=false – Zeile kollabiert wenn modelError nicht sichtbar ist.
            Label spacerModel = new Label();
            spacerModel.setManaged(false);
            spacerModel.setVisible(false);
            fieldGrid.add(spacerModel, 0, gridRow);
            fieldGrid.add(modelError, 1, gridRow);
            GridPane.setColumnSpan(modelError, 3);
            gridRow++;
        }

        // --- Zeile 3: API-Key (volle Breite, Spalten 1–3) ---
        TextField apiKeyField = boundTextField(pState.apiKey().propertyValue(),
                val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
                        pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(),
                        GuiProviderApiKeyState.unresolved(val))));
        applyTooltip(apiKeyField, GuiTooltipTexts.PROVIDER_API_KEY);
        Label apiKeyError = createFieldErrorLabel();
        fieldErrorLabels.put(ns + "apiKey", apiKeyError);
        Label apiKeyOriginLabel = createApiKeyOriginLabel();
        apiKeyOriginLabels.put(family, apiKeyOriginLabel);
        HBox apiKeyBox = new HBox(4, apiKeyField);
        HBox.setHgrow(apiKeyField, Priority.ALWAYS);
        fieldGrid.add(new Label("API-Key:"), 0, gridRow);
        fieldGrid.add(apiKeyBox, 1, gridRow);
        GridPane.setColumnSpan(apiKeyBox, 3);
        gridRow++;
        if (apiKeyError != null) {
            // Platzhalter auf managed=false – Zeile kollabiert wenn apiKeyError nicht sichtbar ist.
            Label spacerApiKey = new Label();
            spacerApiKey.setManaged(false);
            spacerApiKey.setVisible(false);
            fieldGrid.add(spacerApiKey, 0, gridRow);
            fieldGrid.add(apiKeyError, 1, gridRow);
            GridPane.setColumnSpan(apiKeyError, 3);
            gridRow++;
        }
        // Platzhalter auf managed=false – Zeile kollabiert wenn apiKeyOriginLabel nicht sichtbar ist.
        Label spacerOrigin = new Label();
        spacerOrigin.setManaged(false);
        spacerOrigin.setVisible(false);
        fieldGrid.add(spacerOrigin, 0, gridRow);
        fieldGrid.add(apiKeyOriginLabel, 1, gridRow);
        GridPane.setColumnSpan(apiKeyOriginLabel, 3);

        block.getChildren().add(fieldGrid);
        return block;
    }

    // =========================================================================
    // Verarbeitungslimits section
    // =========================================================================

    /**
     * Erstellt den "Verarbeitungslimits"-Bereich mit kompaktem 2-Spalten-Layout.
     * Felder werden paarweise nebeneinander angeordnet für bessere Platznutzung.
     *
     * @return das Card-Element für den "Verarbeitungslimits"-Bereich
     */
    private Node createProcessingLimitsSection() {
        VBox card = createCardContainer();
        card.getChildren().add(sectionTitle("Verarbeitungslimits"));

        // 2-Spalten-GridPane für kompaktes Layout
        GridPane grid = new GridPane();
        grid.setHgap(12);
        grid.setVgap(4);

        // Konfiguriere 2 Spalten nebeneinander
        javafx.scene.layout.ColumnConstraints col1 = new javafx.scene.layout.ColumnConstraints();
        col1.setMinWidth(160);
        col1.setPrefWidth(180);
        col1.setHgrow(Priority.ALWAYS);
        javafx.scene.layout.ColumnConstraints col2 = new javafx.scene.layout.ColumnConstraints();
        col2.setMinWidth(160);
        col2.setPrefWidth(180);
        col2.setHgrow(Priority.ALWAYS);
        grid.getColumnConstraints().addAll(col1, col2);

        int row = 0;

        // Zeile 1: Maximale Seitenzahl (links) + Maximale Zeichenzahl (rechts)
        TextField maxPagesField = boundTextField(
                editorState.values().maxPages(),
                val -> updateValues(editorState.values().withMaxPages(val)));
        applyTooltip(maxPagesField, GuiTooltipTexts.LIMITS_MAX_PAGES);
        grid.add(new Label("Max. Seiten:"), 0, row);
        grid.add(maxPagesField, 1, row);

        TextField maxCharsField = boundTextField(
                editorState.values().maxTextCharacters(),
                val -> updateValues(editorState.values().withMaxTextCharacters(val)));
        applyTooltip(maxCharsField, GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
        grid.add(new Label("Max. Zeichen:"), 2, row);
        grid.add(maxCharsField, 3, row);
        row++;

        // Zeile 2: Max. Titellänge (links) + Max. transiente Retries (rechts)
        TextField maxTitleLengthField = boundTextField(
                editorState.values().maxTitleLength(),
                val -> updateValues(editorState.values().withMaxTitleLength(val)));
        applyTooltip(maxTitleLengthField, GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
        grid.add(new Label("Max. Titellänge:"), 0, row);
        grid.add(maxTitleLengthField, 1, row);

        TextField maxRetriesField = boundTextField(
                editorState.values().maxRetriesTransient(),
                val -> updateValues(editorState.values().withMaxRetriesTransient(val)));
        applyTooltip(maxRetriesField, GuiTooltipTexts.LIMITS_MAX_RETRIES);
        grid.add(new Label("Max. Retries:"), 2, row);
        grid.add(maxRetriesField, 3, row);
        row++;

        // Zeile 3: Log-Level (links) + Sensible KI-Ausgabe (rechts)
        TextField logLevelField = boundTextField(
                editorState.values().logLevel(),
                val -> updateValues(editorState.values().withLogLevel(val)));
        applyTooltip(logLevelField, GuiTooltipTexts.LIMITS_LOG_LEVEL);
        grid.add(new Label("Log-Level:"), 0, row);
        grid.add(logLevelField, 1, row);

        // log.ai.sensitive als Checkbox
        boolean sensitive = Boolean.parseBoolean(editorState.values().logAiSensitive());
        CheckBox sensitiveCheck = new CheckBox("Sensible KI-Ausgabe");
        sensitiveCheck.setSelected(sensitive);
        sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) ->
                updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal))));
        applyTooltip(sensitiveCheck, GuiTooltipTexts.LIMITS_SENSIBLE_KI_AUSGABE);
        grid.add(new Label(), 2, row);
        grid.add(sensitiveCheck, 3, row);

        card.getChildren().add(grid);
        return card;
    }

    // =========================================================================
    // Tests / Meldungen sections (structural placeholders)
    // =========================================================================

    /**
     * Builds the "Tests" section containing the explicit validation action button.
     * <p>
     * The "Validieren" button triggers a full in-memory validation of the current editor
     * state without writing anything to disk. It produces the same set of local findings
     * as the automatic background validation, but additionally appends an INFO message to
     * the central message area that confirms the action was executed and reports the number
     * of findings found.
     * <p>
     * Later technical overall-check actions (e.g. provider endpoint tests, path checks) will
     * be added to this section as separate buttons in subsequent iterations. The validation
     * action itself remains local and non-writing at all times.
     *
     * @return the card node for the "Tests" section
     */
    private Node createTestsSection() {
        VBox card = createCardContainer();

        validateButton.setId("validate-button");
        validateButton.setOnAction(e -> runValidationAction());
        applyTooltip(validateButton, GuiTooltipTexts.TOOLBAR_VALIDIEREN);

        technicalTestsButton.setId("technical-tests-button");
        technicalTestsButton.setOnAction(e -> runTechnicalTestsAction());
        applyTooltip(technicalTestsButton, GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);

        HBox buttonRow = new HBox(8, validateButton, technicalTestsButton);
        buttonRow.setAlignment(Pos.CENTER_LEFT);

        card.getChildren().addAll(
                sectionTitle("Tests"),
                buttonRow);
        return card;
    }

    /**
     * Executes the "Technische Tests ausführen" action triggered by the user.
     * <p>
     * Disables the button for the duration of the test run to prevent concurrent starts.
     * Delegates to {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()}, which
     * reads the current editor state, starts a background worker thread, and re-enables
     * the button after the result has been applied to the message area.
     * <p>
     * This method does not write anything to disk and does not execute any corrective actions.
     * Must be called on the FX Application Thread.
     */
    private void runTechnicalTestsAction() {
        LOG.info("Aktion Technische Tests ausführen gestartet.");
        // Hinweis: pendingMessages.clear() erfolgt jetzt im Coordinator selbst,
        // damit clear() und Worker-Start auf demselben Thread (FX) liegen.
        technicalTestsButton.setDisable(true);
        technicalTestCoordinator.triggerTechnicalTests();
    }

    /**
     * Builds the "Meldungen" section containing the central message area.
     * <p>
     * The visible component is a {@link ListView} whose cells render each message entry as a
     * coloured {@link TextFlow} row (severity prefix in the defined CSS colour, body text in
     * black). Multiple rows can be selected with mouse or keyboard; Ctrl+C copies all selected
     * rows as plain text to the system clipboard. The {@link #messagesAreaBox} VBox is populated
     * in parallel but not added to the scene graph; it exists solely as a test-inspection handle.
     *
     * @return the card node for the "Meldungen" section
     */
    private Node createMessagesSection() {
        VBox card = createCardContainer();
        card.getChildren().add(sectionTitle("Meldungen"));

        messagesListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        messagesListView.setMinHeight(60);
        messagesListView.setPrefHeight(60);
        // Kein MaxHeight-Limit – ListView dehnt sich vertikal aus wenn Platz vorhanden ist.
        VBox.setVgrow(messagesListView, Priority.ALWAYS);
        messagesListView.setStyle("-fx-border-color: #d8d8d8;");
        Label placeholder = new Label("Keine Meldungen vorhanden.");
        placeholder.setStyle("-fx-text-fill: #888888;");
        messagesListView.setPlaceholder(placeholder);

        messagesListView.setCellFactory(lv -> new ListCell<>() {
            @Override
            protected void updateItem(GuiMessageEntry entry, boolean empty) {
                super.updateItem(entry, empty);
                if (empty || entry == null) {
                    setGraphic(null);
                    setText(null);
                } else {
                    String ts = formatTimestamp(entry.timestamp());
                    String sl = entry.severity().getPrefixLabel();
                    Text prefix = new Text(ts + " " + sl + " ");
                    prefix.setStyle("-fx-fill: " + entry.severity().getPrefixCssColour()
                            + "; -fx-font-weight: bold;");
                    Text body = new Text(entry.text());
                    body.setStyle("-fx-fill: black;");
                    TextFlow flow = new TextFlow(prefix, body);
                    setGraphic(flow);
                    setText(null);
                }
            }
        });

        messagesListView.setOnKeyPressed(e -> {
            if (new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_DOWN).match(e)) {
                copySelectedMessagesToClipboard();
            }
        });

        MenuItem copySelected = new MenuItem("Meldung kopieren");
        copySelected.setOnAction(e -> copySelectedMessagesToClipboard());
        MenuItem copyAll = new MenuItem("Alle Meldungen kopieren");
        copyAll.setOnAction(e -> copyAllMessagesToClipboard());
        ContextMenu contextMenu = new ContextMenu(copySelected, copyAll);
        contextMenu.setOnShowing(e -> copySelected.setDisable(
                messagesListView.getSelectionModel().getSelectedItems().isEmpty()));
        messagesListView.setContextMenu(contextMenu);

        card.getChildren().add(messagesListView);

        clearMessagesButton.setOnAction(e -> clearMessages());
        applyTooltip(clearMessagesButton, GuiTooltipTexts.TOOLBAR_MELDUNGEN_LEEREN);
        HBox clearButtonRow = new HBox(clearMessagesButton);
        clearButtonRow.setAlignment(Pos.CENTER_LEFT);
        card.getChildren().add(clearButtonRow);

        // Populate immediately so the area is not blank before the first validation run.
        refreshMessagesArea();
        // Meldungsbereich füllt den verbleibenden vertikalen Platz in sectionsBox.
        VBox.setVgrow(card, Priority.ALWAYS);
        return card;
    }

    // =========================================================================
    // Editor-state updater helpers
    // =========================================================================

    /**
     * Applies new general values to the editor state and triggers a revalidation.
     * <p>
     * Must be called on the FX Application Thread.
     *
     * @param newValues the updated configuration values; must not be {@code null}
     */
    private void updateValues(GuiConfigurationValues newValues) {
        this.editorState = editorState.withValues(newValues);
        refreshHeader();
        runEditorValidation();
    }

    /**
     * Applies a mutation to the provider-specific configuration for the given family.
     * <p>
     * Must be called on the FX Application Thread.
     *
     * @param family  the provider family to update; must not be {@code null}
     * @param updater a function that receives the current provider state and returns the updated one
     */
    private void updateProviderField(AiProviderFamily family,
                                     java.util.function.Function<GuiProviderConfigurationState,
                                             GuiProviderConfigurationState> updater) {
        GuiProviderConfigurationState current = Optional.ofNullable(
                editorState.values().providerConfiguration(family))
                .orElse(GuiProviderConfigurationState.blank());
        GuiConfigurationValues updated = editorState.values()
                .withProviderConfiguration(family, updater.apply(current));
        updateValues(updated);
    }

    // =========================================================================
    // Editor validation
    // =========================================================================

    /**
     * Builds an {@link EditorValidationInput} from the current editor state.
     * <p>
     * Reads the active provider family and per-provider fields from the current
     * {@link GuiConfigurationValues}, resolves API key descriptors via the injected
     * {@link ApiKeyResolutionPort}, and returns the assembled input. This method is
     * pure in-memory (no I/O) and may be called on the JavaFX Application Thread.
     * <p>
     * This method is used both by the synchronous local validation and by the asynchronous
     * technical overall test to extract the current GUI state before handing it to a
     * background worker thread.
     * <p>
     * Must be called on the FX Application Thread.
     *
     * @return assembled validation input representing the current editor state; never {@code null}
     */
    EditorValidationInput buildValidationInput() {
        GuiConfigurationValues values = editorState.values();
        GuiProviderConfigurationState claudeState = Optional.ofNullable(
                values.providerConfiguration(AiProviderFamily.CLAUDE))
                .orElse(GuiProviderConfigurationState.blank());
        GuiProviderConfigurationState openaiState = Optional.ofNullable(
                values.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE))
                .orElse(GuiProviderConfigurationState.blank());

        EffectiveApiKeyDescriptor claudeKeyDescriptor =
                apiKeyResolutionPort.resolve(AiProviderFamily.CLAUDE,
                        claudeState.apiKey().propertyValue());
        EffectiveApiKeyDescriptor openaiKeyDescriptor =
                apiKeyResolutionPort.resolve(AiProviderFamily.OPENAI_COMPATIBLE,
                        openaiState.apiKey().propertyValue());

        return new EditorValidationInput(
                values.activeProviderFamily(),
                values.sourceFolder(),
                values.targetFolder(),
                values.sqliteFile(),
                values.promptTemplateFile(),
                values.maxRetriesTransient(),
                values.maxPages(),
                values.maxTextCharacters(),
                values.maxTitleLength(),
                claudeState.baseUrl(),
                claudeState.model(),
                claudeState.timeoutSeconds(),
                claudeKeyDescriptor,
                claudeState.apiKey().propertyValue(),
                openaiState.baseUrl(),
                openaiState.model(),
                openaiState.timeoutSeconds(),
                openaiKeyDescriptor,
                openaiState.apiKey().propertyValue());
    }

    /**
     * Runs the editor validation against the current editor state and stores the result
     * in {@link #lastValidationResult}, {@link #pendingMessages} and {@link #pendingFieldFindings}.
     * <p>
     * This method is pure in-memory (no I/O) and may be called on the JavaFX Application Thread.
     * Path existence checks, SQLite roundtrips and network calls are explicitly excluded from
     * this validation and belong to later technical overall checks.
     * <p>
     * Must be called on the FX Application Thread.
     */
    private void runEditorValidation() {
        EditorValidationInput input = buildValidationInput();
        EditorValidationReport report = editorValidator.validate(input);

        // Translate to GUI types
        List<GuiMessageEntry> messages = new ArrayList<>();
        List<GuiFieldFinding> fieldFindings = new ArrayList<>();

        for (EditorValidationFinding finding : report.findings()) {
            GuiMessageSeverity severity = toGuiSeverity(finding.severity());
            messages.add(GuiMessageEntry.of(severity, finding.message(), OPERATION_VALIDATE));
            if (finding.hasFieldKey()) {
                fieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
                        severity, finding.message()));
            }
        }

        // Replace validation-related entries; preserve model-catalog messages (from coordinator)
        pendingMessages.removeIf(m -> m.source().isPresent()
                && OPERATION_VALIDATE.equals(m.source().get()));
        pendingMessages.addAll(messages);

        pendingFieldFindings.clear();
        pendingFieldFindings.addAll(fieldFindings);

        lastValidationResult = new GuiEditorValidationResult(
                List.copyOf(pendingMessages),
                List.copyOf(pendingFieldFindings),
                java.time.Instant.now());

        refreshAfterValidation();
    }

    /**
     * Executes the explicit "Validieren" action triggered by the user.
     * <p>
     * This method re-runs the same in-memory validation logic as the automatic background
     * check ({@link #runEditorValidation()}) against the current editor state, without
     * writing anything to disk or making any network or filesystem calls. It is therefore
     * safe to call on the FX Application Thread at any time.
     * <p>
     * In addition to updating the field-level findings shown by the automatic validation,
     * this action appends a dedicated INFO confirmation message plus one message per concrete
     * finding to the central message area. These entries accumulate across repeated clicks
     * so the user can compare successive runs.
     * <p>
     * Differences from the automatic background validation:
     * <ul>
     *   <li>This method is user-initiated and always produces the confirmation INFO message.</li>
     *   <li>The automatic validation runs silently on field changes and file loads.</li>
     *   <li>Remote checks (provider endpoint, API key acceptance, model list) are not part of
     *       this action; they belong to the technical overall-check action.</li>
     * </ul>
     * <p>
     * Must be called on the FX Application Thread.
     */
    private void runValidationAction() {
        LOG.info("Aktion Validieren ausgeführt.");
        pendingMessages.clear();

        EditorValidationInput input = buildValidationInput();
        EditorValidationReport report = editorValidator.validate(input);

        // Update field-level findings (drives red labels under problematic inputs).
        pendingFieldFindings.clear();
        for (EditorValidationFinding finding : report.findings()) {
            if (finding.hasFieldKey()) {
                pendingFieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
                        toGuiSeverity(finding.severity()), finding.message()));
            }
        }

        // Drop silent auto-validation entries so the central message area is not flooded
        // by keystroke-level background checks; explicit action entries always accumulate.
        pendingMessages.removeIf(m -> m.source().isPresent()
                && OPERATION_VALIDATE.equals(m.source().get()));

        // Append a timestamped confirmation plus each concrete finding as its own entry.
        int findingCount = report.findings().size();
        String confirmationText = findingCount == 0
                ? "Aktion Validieren wurde ausgeführt. Keine Befunde."
                : "Aktion Validieren wurde ausgeführt. "
                        + findingCount + " Befund" + (findingCount == 1 ? "" : "e") + " gefunden:";
        pendingMessages.add(GuiMessageEntry.of(
                GuiMessageSeverity.INFO, confirmationText, "Validierung-Aktion"));
        for (EditorValidationFinding finding : report.findings()) {
            pendingMessages.add(GuiMessageEntry.of(
                    toGuiSeverity(finding.severity()), finding.message(), "Validierung-Aktion"));
        }

        lastValidationResult = new GuiEditorValidationResult(
                List.copyOf(pendingMessages),
                List.copyOf(pendingFieldFindings),
                java.time.Instant.now());

        refreshAfterValidation();
    }

    /**
     * Maps an {@link EditorValidationSeverity} to the corresponding {@link GuiMessageSeverity}.
     *
     * @param severity the application-layer severity; must not be {@code null}
     * @return the corresponding GUI severity; never {@code null}
     */
    private static GuiMessageSeverity toGuiSeverity(EditorValidationSeverity severity) {
        return switch (severity) {
            case ERROR -> GuiMessageSeverity.ERROR;
            case WARNING -> GuiMessageSeverity.WARNING;
            case HINT -> GuiMessageSeverity.HINT;
            case INFO -> GuiMessageSeverity.INFO;
        };
    }

    /**
     * Central refresh method called after every validation run and after every model-catalogue
     * result arrives. Updates the central message area, the field-level error labels and the
     * API-key origin labels in one consistent pass.
     * <p>
     * Must be called on the JavaFX Application Thread.
     */
    private void refreshAfterValidation() {
        refreshMessagesArea();
        refreshFieldErrorLabels();
        refreshApiKeyOriginLabels();
    }

    // =========================================================================
    // Message area rendering
    // =========================================================================

    /**
     * Rebuilds both {@link #messagesAreaBox} (off-screen, for test assertions) and
     * {@link #messagesTextArea} (the visible, user-selectable display) from the current
     * {@link #pendingMessages} list.
     * <p>
     * {@code messagesAreaBox} is populated with one {@link TextFlow} per message entry so that
     * existing smoke tests can inspect the node structure. {@code messagesTextArea} receives the
     * same content as plain text so that the user can select, copy and paste message lines.
     * <p>
     * Must be called on the JavaFX Application Thread.
     */
    void refreshMessagesArea() {
        messagesAreaBox.getChildren().clear();
        messagesListItems.setAll(pendingMessages);
        if (pendingMessages.isEmpty()) {
            Text placeholder = new Text("Keine Meldungen vorhanden.");
            placeholder.setStyle("-fx-fill: #888888;");
            messagesAreaBox.getChildren().add(new TextFlow(placeholder));
            return;
        }
        for (GuiMessageEntry entry : pendingMessages) {
            String timestampLabel = formatTimestamp(entry.timestamp());
            String severityLabel = entry.severity().getPrefixLabel();
            Text prefix = new Text(timestampLabel + " " + severityLabel + " ");
            prefix.setStyle("-fx-fill: " + entry.severity().getPrefixCssColour() + ";"
                    + " -fx-font-weight: bold;");
            Text body = new Text(entry.text());
            body.setStyle("-fx-fill: black;");
            TextFlow row = new TextFlow(prefix, body);
            row.setStyle("-fx-padding: 1px 4px;");
            messagesAreaBox.getChildren().add(row);
        }
    }

    /**
     * Removes all entries from the central message area.
     * <p>
     * Clears {@link #pendingMessages} and refreshes the visible message area.
     * Must be called on the JavaFX Application Thread.
     */
    void clearMessages() {
        pendingMessages.clear();
        refreshMessagesArea();
    }

    /**
     * Formats a message timestamp as {@code [HH:mm:ss]} in the system default zone.
     *
     * @param instant the source instant; must not be {@code null}
     * @return the formatted prefix; never {@code null}
     */
    /**
     * Copies all current message entries to the system clipboard in display order.
     * Invoked by the "Alle Meldungen kopieren" context menu action.
     */
    private void copyAllMessagesToClipboard() {
        if (pendingMessages.isEmpty()) {
            return;
        }
        StringBuilder sb = new StringBuilder();
        for (GuiMessageEntry entry : pendingMessages) {
            if (!sb.isEmpty()) {
                sb.append(System.lineSeparator());
            }
            sb.append(formatTimestamp(entry.timestamp()))
                    .append(' ')
                    .append(entry.severity().getPrefixLabel())
                    .append(' ')
                    .append(entry.text());
        }
        ClipboardContent content = new ClipboardContent();
        content.putString(sb.toString());
        Clipboard.getSystemClipboard().setContent(content);
    }

    /**
     * Copies the full text of all currently selected rows in {@link #messagesListView} to the
     * system clipboard. Each row is written as one line in display order (timestamp, severity
     * label, body text). Invoked by the Ctrl+C key handler and the context menu.
     */
    private void copySelectedMessagesToClipboard() {
        List<GuiMessageEntry> selected =
                messagesListView.getSelectionModel().getSelectedItems();
        if (selected.isEmpty()) {
            return;
        }
        StringBuilder sb = new StringBuilder();
        for (GuiMessageEntry entry : selected) {
            if (!sb.isEmpty()) {
                sb.append(System.lineSeparator());
            }
            sb.append(formatTimestamp(entry.timestamp()))
                    .append(' ')
                    .append(entry.severity().getPrefixLabel())
                    .append(' ')
                    .append(entry.text());
        }
        ClipboardContent content = new ClipboardContent();
        content.putString(sb.toString());
        Clipboard.getSystemClipboard().setContent(content);
    }

    private static String formatTimestamp(java.time.Instant instant) {
        return "[" + TIMESTAMP_FORMATTER.format(instant) + "]";
    }

    private static final java.time.format.DateTimeFormatter TIMESTAMP_FORMATTER =
            java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")
                    .withZone(java.time.ZoneId.systemDefault());

    // =========================================================================
    // Field-level error label rendering
    // =========================================================================

    /**
     * Updates each field-level error label in {@link #fieldErrorLabels} from the current
     * {@link #pendingFieldFindings} list.
     * <p>
     * For each registered field key, the most severe finding of severity {@link GuiMessageSeverity#ERROR}
     * or {@link GuiMessageSeverity#WARNING} is selected and its text is shown in the label.
     * Labels for fields without any relevant finding are hidden and unmanaged so they consume no
     * layout space.
     * <p>
     * Must be called on the JavaFX Application Thread.
     */
    void refreshFieldErrorLabels() {
        for (Map.Entry<String, Label> entry : fieldErrorLabels.entrySet()) {
            String key = entry.getKey();
            Label label = entry.getValue();
            pendingFieldFindings.stream()
                    .filter(f -> key.equals(f.fieldKey()))
                    .filter(f -> f.severity() == GuiMessageSeverity.ERROR
                            || f.severity() == GuiMessageSeverity.WARNING)
                    .findFirst()
                    .ifPresentOrElse(
                            finding -> {
                                label.setText(finding.text());
                                label.setVisible(true);
                                label.setManaged(true);
                            },
                            () -> {
                                label.setText("");
                                label.setVisible(false);
                                label.setManaged(false);
                            });
        }
    }

    // =========================================================================
    // API-key origin label rendering
    // =========================================================================

    /**
     * Updates the API-key origin labels below each provider's API-key field.
     * <p>
     * The label is shown exclusively when the effective key comes from an environment variable
     * (INFO finding). In all other cases the label is hidden:
     * <ul>
     *   <li>Key from properties file: label hidden (no action required).</li>
     *   <li>Key absent (WARNING finding): label hidden — the field-error label registered in
     *       {@link #fieldErrorLabels} already displays this finding directly below the field;
     *       showing it a second time in the origin label would produce a visible duplicate.</li>
     * </ul>
     * <p>
     * The origin information is derived from the pending field findings produced by the validator,
     * which already resolved the API-key precedence for both providers.
     * <p>
     * Must be called on the JavaFX Application Thread.
     */
    void refreshApiKeyOriginLabels() {
        for (Map.Entry<AiProviderFamily, Label> entry : apiKeyOriginLabels.entrySet()) {
            AiProviderFamily family = entry.getKey();
            Label label = entry.getValue();
            String apiKeyField = "ai.provider." + family.getIdentifier() + ".apiKey";

            // Look for an INFO finding about ENV-variable origin for this provider.
            // WARNING/ERROR findings (missing key) are intentionally excluded here:
            // those are already shown by the field-error label registered in fieldErrorLabels,
            // and repeating them in the origin label would cause the same text to appear twice
            // in close proximity below the API-key field.
            Optional<GuiFieldFinding> envFinding = pendingFieldFindings.stream()
                    .filter(f -> apiKeyField.equals(f.fieldKey()))
                    .filter(f -> f.severity() == GuiMessageSeverity.INFO)
                    .findFirst();

            if (envFinding.isPresent()) {
                label.setText(envFinding.get().text());
                label.setStyle(STYLE_ORIGIN_INFO);
                label.setVisible(true);
                label.setManaged(true);
            } else {
                label.setText("");
                label.setVisible(false);
                label.setManaged(false);
            }
        }
    }

    private static final String STYLE_ORIGIN_INFO =
            "-fx-font-size: 11px; -fx-text-fill: #1565c0; -fx-padding: 0 0 4px 0;";

    // =========================================================================
    // Path picker helpers
    // =========================================================================

    /**
     * Opens a directory-picker dialog using the injectable {@link #directoryPickerDialog} hook.
     * <p>
     * In production the hook delegates to a native {@link DirectoryChooser}. In tests the hook
     * can be replaced with a lambda that returns a fixed string. Windows mapped drive letters
     * (e.g. {@code S:\data}) are accepted and returned without modification.
     *
     * @param title       the dialog title
     * @param initialPath the pre-selected path text; may be empty or {@code null}
     * @return the selected absolute path string, or {@code null} when the dialog was cancelled
     */
    private String pickDirectory(String title, String initialPath) {
        return directoryPickerDialog.apply(title, initialPath);
    }

    /**
     * Oeffnet einen Datei-Auswaehldialog ueber den injizierbaren {@link #filePickerDialog}-Hook.
     * <p>
     * In der Produktion delegiert der Hook an einen nativen {@link FileChooser} und wendet
     * die uebergebenen Filter an. In Tests kann der Hook durch eine Lambda-Implementierung
     * ersetzt werden, die einen festen Pfad zurueckgibt. Windows-Laufwerksbuchstaben
     * (z.&nbsp;B. {@code S:\}) werden unveraendert weitergegeben.
     *
     * @param title       der Titel des Datei-Dialogs
     * @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
     * @param filters     Dateitypfilter, die im nativen Dialog angezeigt werden
     * @return den ausgewaehlten absoluten Pfad, oder {@code null} wenn der Dialog abgebrochen wurde
     */
    private String pickFile(String title, String initialPath,
                            FileChooser.ExtensionFilter... filters) {
        return filePickerDialog.pick(title, initialPath, List.of(filters));
    }

    /**
     * Default native directory-chooser implementation used by {@link #directoryPickerDialog}.
     *
     * @param title       the dialog title
     * @param initialPath the initial path hint; may be empty or {@code null}
     * @return the selected absolute path string, or {@code null} when cancelled or unavailable
     */
    private String showNativeDirectoryChooser(String title, String initialPath) {
        DirectoryChooser chooser = new DirectoryChooser();
        chooser.setTitle(title);
        setInitialPath(chooser, initialPath);
        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        try {
            File selected = chooser.showDialog(owner);
            return selected == null ? null : selected.getAbsolutePath();
        } catch (UnsupportedOperationException e) {
            LOG.debug("GUI-Editor: Ordner-Dialog nicht verf\u00fcgbar (headless).");
            return null;
        }
    }

    /**
     * Standardimplementierung des nativen Datei-Auswaehldialogs fuer {@link #filePickerDialog}.
     * <p>
     * Wendet alle uebergebenen Dateitypfilter auf den {@link FileChooser} an, bevor der Dialog
     * geoeffnet wird.
     *
     * @param title       der Titel des Datei-Dialogs
     * @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
     * @param filters     anzuwendende Dateitypfilter; darf leer, aber nicht {@code null} sein
     * @return den ausgewaehlten absoluten Pfad, oder {@code null} wenn abgebrochen oder nicht verfuegbar
     */
    private String showNativeFileChooser(String title, String initialPath,
                                         List<FileChooser.ExtensionFilter> filters) {
        FileChooser chooser = new FileChooser();
        chooser.setTitle(title);
        setInitialPathForFileChooser(chooser, initialPath);
        if (!filters.isEmpty()) {
            chooser.getExtensionFilters().addAll(filters);
        }
        Window owner = root.getScene() == null ? null : root.getScene().getWindow();
        try {
            File selected = chooser.showOpenDialog(owner);
            return selected == null ? null : selected.getAbsolutePath();
        } catch (UnsupportedOperationException e) {
            LOG.debug("GUI-Editor: Datei-Dialog nicht verfuegbar (headless).");
            return null;
        }
    }

    /**
     * Sets the initial directory for a {@link DirectoryChooser} based on the given path string.
     * <p>
     * Only absolute paths that point to an existing directory are used. Relative or non-existent
     * paths are silently ignored. The original path text is never transformed or modified.
     *
     * @param chooser     the directory chooser to configure; must not be {@code null}
     * @param initialPath the candidate initial path; may be empty or {@code null}
     */
    private static void setInitialPath(DirectoryChooser chooser, String initialPath) {
        if (initialPath == null || initialPath.isBlank()) {
            return;
        }
        try {
            Path p = Paths.get(initialPath);
            File f = p.toAbsolutePath().toFile();
            if (f.isDirectory()) {
                chooser.setInitialDirectory(f);
            } else if (f.getParentFile() != null && f.getParentFile().isDirectory()) {
                chooser.setInitialDirectory(f.getParentFile());
            }
        } catch (Exception ignored) {
            // Malformed path: silently skip the initial-directory hint.
        }
    }

    /**
     * Sets the initial directory and file name for a {@link FileChooser} based on the given path.
     * <p>
     * Only existing parent directories are used. The original path text is never modified.
     *
     * @param chooser     the file chooser to configure; must not be {@code null}
     * @param initialPath the candidate initial path; may be empty or {@code null}
     */
    private static void setInitialPathForFileChooser(FileChooser chooser, String initialPath) {
        if (initialPath == null || initialPath.isBlank()) {
            return;
        }
        try {
            Path p = Paths.get(initialPath);
            File f = p.toAbsolutePath().toFile();
            if (f.isFile()) {
                chooser.setInitialDirectory(f.getParentFile());
                chooser.setInitialFileName(f.getName());
            } else if (f.getParentFile() != null && f.getParentFile().isDirectory()) {
                chooser.setInitialDirectory(f.getParentFile());
            }
        } catch (Exception ignored) {
            // Malformed path: silently skip the initial-directory hint.
        }
    }

    // =========================================================================
    // Layout helpers
    // =========================================================================

    /**
     * Creates a hidden, small red error label used for field-level validation feedback.
     * <p>
     * The label starts invisible and unmanaged so it consumes no layout space until a finding
     * is attached by {@link #refreshFieldErrorLabels()}.
     *
     * @return a pre-configured error label; never {@code null}
     */
    private static Label createFieldErrorLabel() {
        Label label = new Label();
        label.setWrapText(true);
        label.setStyle("-fx-font-size: 11px; -fx-text-fill: #b71c1c; -fx-padding: 0 0 4px 2px;");
        label.setVisible(false);
        label.setManaged(false);
        return label;
    }

    /**
     * Creates a hidden informational label used to display the effective API-key origin.
     * <p>
     * Starts invisible and unmanaged; styled and shown by {@link #refreshApiKeyOriginLabels()}.
     *
     * @return a pre-configured origin label; never {@code null}
     */
    private static Label createApiKeyOriginLabel() {
        Label label = new Label();
        label.setWrapText(true);
        label.setStyle(STYLE_ORIGIN_INFO);
        label.setVisible(false);
        label.setManaged(false);
        return label;
    }

    /**
     * Baut zwei Pfad-Felder nebeneinander in einer HBox. Jedes Feld hat ein Label und einen Picker-Button.
     * Error-Labels werden untereinander angezeigt.
     *
     * @param label1Text   das Label des linken Felds
     * @param field1       das TextFeld links
     * @param errorLabel1  das Error-Label des linken Felds oder null
     * @param onPick1      Action für den Picker-Button des linken Felds
     * @param label2Text   das Label des rechten Felds
     * @param field2       das TextFeld rechts
     * @param errorLabel2  das Error-Label des rechten Felds oder null
     * @param onPick2      Action für den Picker-Button des rechten Felds
     * @return eine HBox mit den zwei nebeneinander angeordneten Pfad-Feldern
     */
    private static HBox buildTwoPathFieldsRow(
            String label1Text, TextField field1, Label errorLabel1, Runnable onPick1,
            String label2Text, TextField field2, Label errorLabel2, Runnable onPick2) {

        // Linkes Feld mit Label und Picker-Button
        Label label1 = new Label(label1Text);
        Button pickButton1 = new Button("…");
        pickButton1.setOnAction(e -> onPick1.run());
        pickButton1.setMinWidth(32);
        applyTooltip(pickButton1, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
        HBox fieldBox1 = new HBox(4, field1, pickButton1);
        HBox.setHgrow(field1, Priority.ALWAYS);
        fieldBox1.setAlignment(Pos.CENTER_LEFT);

        GridPane grid1 = new GridPane();
        grid1.setHgap(12);
        grid1.setVgap(0);
        javafx.scene.layout.ColumnConstraints labelCol1 = new javafx.scene.layout.ColumnConstraints();
        labelCol1.setMinWidth(100);
        labelCol1.setPrefWidth(120);
        javafx.scene.layout.ColumnConstraints fieldCol1 = new javafx.scene.layout.ColumnConstraints();
        fieldCol1.setFillWidth(true);
        fieldCol1.setHgrow(Priority.ALWAYS);
        grid1.getColumnConstraints().addAll(labelCol1, fieldCol1);
        grid1.add(label1, 0, 0);
        grid1.add(fieldBox1, 1, 0);

        VBox leftSlot = new VBox(0, grid1);
        if (errorLabel1 != null) {
            leftSlot.getChildren().add(errorLabel1);
        }

        // Rechtes Feld mit Label und Picker-Button
        Label label2 = new Label(label2Text);
        Button pickButton2 = new Button("…");
        pickButton2.setOnAction(e -> onPick2.run());
        pickButton2.setMinWidth(32);
        applyTooltip(pickButton2, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
        HBox fieldBox2 = new HBox(4, field2, pickButton2);
        HBox.setHgrow(field2, Priority.ALWAYS);
        fieldBox2.setAlignment(Pos.CENTER_LEFT);

        GridPane grid2 = new GridPane();
        grid2.setHgap(12);
        grid2.setVgap(0);
        javafx.scene.layout.ColumnConstraints labelCol2 = new javafx.scene.layout.ColumnConstraints();
        labelCol2.setMinWidth(100);
        labelCol2.setPrefWidth(120);
        javafx.scene.layout.ColumnConstraints fieldCol2 = new javafx.scene.layout.ColumnConstraints();
        fieldCol2.setFillWidth(true);
        fieldCol2.setHgrow(Priority.ALWAYS);
        grid2.getColumnConstraints().addAll(labelCol2, fieldCol2);
        grid2.add(label2, 0, 0);
        grid2.add(fieldBox2, 1, 0);

        VBox rightSlot = new VBox(0, grid2);
        if (errorLabel2 != null) {
            rightSlot.getChildren().add(errorLabel2);
        }

        // Beide Felder nebeneinander in einer HBox
        HBox containerRow = new HBox(12, leftSlot, rightSlot);
        HBox.setHgrow(leftSlot, Priority.ALWAYS);
        HBox.setHgrow(rightSlot, Priority.ALWAYS);
        containerRow.setPadding(new Insets(0, 0, 0, 0));

        return containerRow;
    }

    /**
     * Builds a labelled simple-field row (no picker button) as a {@link VBox} containing a grid
     * row with the label and text field, plus an optional error-slot label directly beneath.
     *
     * @param labelText  the row label text; must not be {@code null}
     * @param field      the text field; must not be {@code null}
     * @param errorLabel the field-level error label, or {@code null} for no error slot
     * @return a VBox containing the labelled row and optional error slot
     */
    private static VBox buildSimpleFieldRow(String labelText, TextField field, Label errorLabel) {
        GridPane grid = new GridPane();
        grid.setHgap(12);
        grid.setVgap(0);
        javafx.scene.layout.ColumnConstraints labelCol = new javafx.scene.layout.ColumnConstraints();
        labelCol.setMinWidth(180);
        labelCol.setPrefWidth(200);
        javafx.scene.layout.ColumnConstraints fieldCol = new javafx.scene.layout.ColumnConstraints();
        fieldCol.setFillWidth(true);
        fieldCol.setHgrow(Priority.ALWAYS);
        grid.getColumnConstraints().addAll(labelCol, fieldCol);
        grid.add(new Label(labelText), 0, 0);
        grid.add(field, 1, 0);
        grid.setPadding(new Insets(4, 0, 0, 0));

        VBox slot = new VBox(0, grid);
        if (errorLabel != null) {
            slot.getChildren().add(errorLabel);
        }
        return slot;
    }

    /**
     * Creates a text field pre-populated from the editor state that updates the state on every
     * text change.
     * <p>
     * Changes are only propagated when the new text differs from the current field text to avoid
     * redundant state updates during programmatic population.
     *
     * @param initialValue  the initial text; never {@code null}
     * @param onValueChange callback that receives the new text on every change; must not be
     *                      {@code null}
     * @return the configured text field
     */
    private static TextField boundTextField(String initialValue, Consumer<String> onValueChange) {
        TextField field = new TextField(initialValue == null ? "" : initialValue);
        field.textProperty().addListener((obs, oldText, newText) -> {
            if (!newText.equals(oldText)) {
                onValueChange.accept(newText);
            }
        });
        return field;
    }

    /**
     * Erstellt ein GridPane für Konfigurationsfelder mit kompaktem vertikalen Abstand.
     */
    private GridPane createFieldGrid() {
        GridPane grid = new GridPane();
        grid.setHgap(12);
        grid.setVgap(4);
        javafx.scene.layout.ColumnConstraints labelCol = new javafx.scene.layout.ColumnConstraints();
        labelCol.setMinWidth(180);
        labelCol.setPrefWidth(200);
        javafx.scene.layout.ColumnConstraints fieldCol = new javafx.scene.layout.ColumnConstraints();
        fieldCol.setFillWidth(true);
        fieldCol.setHgrow(Priority.ALWAYS);
        grid.getColumnConstraints().addAll(labelCol, fieldCol);
        return grid;
    }

    /**
     * Erstellt einen Card-Container mit kompaktem Layout für Konfigurationsbereiche.
     * Spacing und Padding sind reduziert für ein platzsparendes Design.
     */
    private VBox createCardContainer() {
        VBox card = new VBox(4);
        card.setStyle(
                "-fx-padding: 8px; -fx-border-color: #d8d8d8; -fx-border-radius: 8px; -fx-background-radius: 8px; -fx-background-color: white;");
        card.setMaxWidth(Double.MAX_VALUE);
        VBox.setVgrow(card, Priority.NEVER);
        return card;
    }

    private Label sectionTitle(String title) {
        Label label = new Label(title);
        label.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;");
        return label;
    }

    private void showStatusMessage(String message) {
        statusLabel.setText(message);
        statusLabel.setVisible(true);
        statusLabel.setManaged(true);
    }

    private void showError(String message) {
        LOG.warn("GUI-Editor: {}", message);
        showStatusMessage(message);
        Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
        alert.setHeaderText("Fehler");
        alert.show();
    }

    /**
     * Shows a blocking confirmation dialog and returns the button the user chose.
     *
     * @param title   the dialog title; must not be {@code null}
     * @param message the dialog body text; must not be {@code null}
     * @param buttons the available button types; must not be empty
     * @return the chosen button type, or empty when the dialog was dismissed without a choice
     */
    private Optional<ButtonType> showConfirmation(String title, String message, ButtonType... buttons) {
        Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, buttons);
        alert.setTitle(title);
        alert.setHeaderText(null);
        return alert.showAndWait();
    }

    private String safeMessage(Throwable exception) {
        return exception == null ? "unbekannter Fehler" : exception.getMessage() == null
                ? exception.getClass().getSimpleName()
                : exception.getMessage();
    }

    /**
     * Ermittelt die Dateiendung der aktiven SQLite-Konfigurationsdatei.
     * Gibt {@code ".db"} zurück, wenn der Pfad auf {@code .db} endet, sonst {@code ".sqlite"}.
     *
     * @param sqliteFile konfigurierter SQLite-Pfad, darf {@code null} oder leer sein
     * @return {@code ".db"} oder {@code ".sqlite"}
     */
    private static String resolveDbExtension(String sqliteFile) {
        if (sqliteFile != null && sqliteFile.toLowerCase().endsWith(".db")) {
            return ".db";
        }
        return ".sqlite";
    }

    /**
     * Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
     * <p>
     * Alle Tooltips in dieser Klasse werden über diese Methode gesetzt, damit ein konsistentes
     * Erscheinungsbild gewährleistet ist. Darf nur auf dem JavaFX Application Thread aufgerufen werden.
     *
     * @param control der Button oder ein anderes {@link javafx.scene.control.Control}, das den
     *                Tooltip erhalten soll; darf nicht {@code null} sein
     * @param text    der Tooltip-Text; darf nicht leer sein
     */
    private static void applyTooltip(javafx.scene.control.Control control, String text) {
        Tooltip tooltip = new Tooltip(text);
        tooltip.setShowDelay(Duration.millis(300));
        control.setTooltip(tooltip);
    }

    /**
     * Speichert den Pfad einer gerade geladenen Konfigurationsdatei.
     * Der Pfad wird in den Java Preferences gespeichert und beim nächsten Start
     * automatisch geladen.
     *
     * @param configFilePath der zu speichernde Dateipfad; darf nicht null sein
     */
    private void saveLastConfigurationPath(Path configFilePath) {
        try {
            Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
            prefs.put("lastConfigPath", configFilePath.toAbsolutePath().toString());
            LOG.debug("GUI-Editor: Letzter Konfigurationspfad gespeichert: {}", configFilePath);
        } catch (Exception exception) {
            LOG.warn("GUI-Editor: Fehler beim Speichern des letzten Konfigurationspfads", exception);
        }
    }

    /**
     * Lädt die zuletzt geladene Konfigurationsdatei automatisch beim Start.
     * Falls keine Konfiguration zuvor geladen wurde oder der gespeicherte Pfad
     * nicht mehr existiert, erfolgt ein normaler Start ohne Fehlermeldung.
     */
    public void autoLoadLastConfiguration() {
        try {
            Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
            String lastPath = prefs.get("lastConfigPath", null);

            if (lastPath == null) {
                LOG.debug("GUI-Editor: Keine letzte Konfiguration zum Laden vorhanden.");
                return;
            }

            File lastFile = new File(lastPath);
            if (!lastFile.exists() || !lastFile.isFile()) {
                LOG.debug("GUI-Editor: Gespeicherter Konfigurationspfad existiert nicht mehr: {}", lastPath);
                return;
            }

            LOG.info("GUI-Editor: Letzte Konfiguration wird automatisch geladen: {}", lastPath);
            openConfigurationFile(lastFile.toPath());
        } catch (Exception exception) {
            LOG.warn("GUI-Editor: Fehler beim automatischen Laden der letzten Konfiguration", exception);
        }
    }
}
