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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.io.TempDir;

import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import javafx.application.Platform;
import javafx.scene.control.ButtonType;

/**
 * Regression smoke tests for the complete editor workflow.
 * <p>
 * Each test method covers one distinct user-visible flow at integration level. The individual
 * sub-behaviours (dirty-state derivation, guard dialog options, API-key merge) are already
 * covered by dedicated unit tests; this class focuses on the end-to-end flow across the
 * relevant subsystems.
 *
 * <h2>Covered flows</h2>
 * <ul>
 *   <li>GUI start without configuration: blank state, welcome text, no file snapshot.</li>
 *   <li>"Neu" with standard template: template values loaded, no file path, state clean.</li>
 *   <li>"Öffnen" existing file: file loaded via callback, fields filled, header shows path.</li>
 *   <li>"Speichern" on known path: normalized file written, dirty state cleared after write.</li>
 *   <li>"Speichern unter" first time: file created at chosen path, header updated.</li>
 *   <li>Overwrite dialog: existing target → dialog appears → yes overwrites, no aborts.</li>
 *   <li>Dirty-state marking: title prefix and header marker both appear after a field change.</li>
 *   <li>Unsaved-changes guard: dialog appears before "Neu"/"Öffnen"/"Schließen" when dirty.</li>
 * </ul>
 *
 * <h2>Threading and headless compatibility</h2>
 * <p>
 * All workspace interactions run on the FX Application Thread under Monocle headless. Native
 * file dialogs are replaced with injectable hook fields. Asynchronous background operations
 * are awaited via {@link CountDownLatch} and a polling helper.
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiEditorRegressionSmokeTest {

    private static final long FX_TIMEOUT_SECONDS = 10;
    private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);

    @BeforeAll
    static void setUpJavaFxPlatform() throws InterruptedException {
        Platform.setImplicitExit(false);
        CountDownLatch latch = new CountDownLatch(1);
        try {
            Platform.startup(() -> {
                PLATFORM_STARTED.set(true);
                latch.countDown();
            });
            assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                    "JavaFX Platform must start within timeout");
        } catch (IllegalStateException alreadyStarted) {
            CountDownLatch verifyLatch = new CountDownLatch(1);
            Platform.runLater(() -> {
                PLATFORM_STARTED.set(true);
                verifyLatch.countDown();
            });
            assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                    "Existing JavaFX Platform must be reachable within timeout");
        }
    }

    @AfterAll
    static void tearDownJavaFxPlatform() {
        // Shared platform – do not call Platform.exit().
    }

    // =========================================================================
    // Flow: GUI start without loaded configuration
    // =========================================================================

    /**
     * Regression: starting without a configuration immediately shows the standard template defaults.
     * <p>
     * The workspace must keep the welcome guidance hidden because the standard template values
     * are populated right away. The header path stays empty and no file snapshot is associated
     * with the editor state. "Neu" and "Öffnen" must be present.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(1)
    void guiStart_withoutConfig_showsStandardTemplateDefaultsAndExposesNeuAndOeffnenButtons()
            throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());

            assertFalse(ws.isWelcomeGuidanceVisible(),
                    "Welcome guidance must stay hidden because the standard template is shown immediately");
            assertEquals("", ws.configurationPathText(),
                    "Header path must be empty on default start");
            assertFalse(ws.editorState().hasLoadedFileSnapshot(),
                    "No file snapshot must exist on default start");
            assertFalse(ws.editorState().isDirty(),
                    "Default start state must not be dirty");
            assertEquals("Neu", ws.newButton().getText(),
                    "'Neu' button must be present");
            assertEquals("Öffnen", ws.openButton().getText(),
                    "'Öffnen' button must be present");
        });
    }

    // =========================================================================
    // Flow: "Neu" with standard template
    // =========================================================================

    /**
     * Regression: "Neu" reloads the standard template values, keeps the welcome guidance
     * hidden, and leaves the state clean with all template fields populated.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(2)
    void neu_withStandardTemplate_populatesFieldsAndKeepsWelcomeHidden() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());

            assertFalse(ws.isWelcomeGuidanceVisible(),
                    "Precondition: welcome must already be hidden because the start state shows defaults");

            ws.requestNewConfiguration();

            assertFalse(ws.isWelcomeGuidanceVisible(),
                    "Welcome guidance must remain hidden after 'Neu'");
            assertEquals("", ws.editorState().configurationPathText(),
                    "Path must remain empty after 'Neu' (no file saved yet)");
            assertFalse(ws.editorState().isDirty(),
                    "State must be clean right after 'Neu'");

            GuiConfigurationValues v = ws.editorState().values();
            assertEquals(GuiConfigurationTemplateFactory.createStandardValues().sourceFolder(),
                    v.sourceFolder(), "Source folder must match standard template default");
            assertEquals(GuiConfigurationTemplateFactory.createStandardValues().targetFolder(),
                    v.targetFolder(), "Target folder must match standard template default");
            assertEquals(GuiConfigurationTemplateFactory.createStandardValues().logLevel(),
                    v.logLevel(), "Log level must match standard template default");
        });
    }

    // =========================================================================
    // Flow: "Öffnen" existing .properties file via loader callback
    // =========================================================================

    /**
     * Regression: "Öffnen" via the file-loader callback populates the editor fields from
     * the file content and updates the header with the loaded path.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(3)
    void oeffnen_existingPropertiesFile_fillsFieldsAndUpdatesHeader(@TempDir Path tempDir)
            throws Exception {
        Path configFile = tempDir.resolve("open-test.properties");
        writeMinimalPropertiesFile(configFile, "./source-loaded", "./target-loaded", "claude");

        GuiConfigurationFileLoader loader = buildSnapshotLoader();
        GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
        GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
        GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter,
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
                                (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
                        new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
                                (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        AtomicReference<Throwable> error = new AtomicReference<>();

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
                wsRef.set(ws);
                // Load file directly via the package-private openConfigurationFile method
                // (mirrors what the "Öffnen" button does after the native dialog returns a file).
                ws.openConfigurationFile(configFile);
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete");
        rethrow(error);

        // Wait for the async loader to apply the state on the FX thread.
        waitFor(() -> {
            AtomicBoolean loaded = new AtomicBoolean(false);
            CountDownLatch check = new CountDownLatch(1);
            Platform.runLater(() -> {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
                    loaded.set(true);
                }
                check.countDown();
            });
            try {
                check.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return loaded.get();
        }, FX_TIMEOUT_SECONDS);

        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();

                assertEquals(configFile.toString(), ws.configurationPathText(),
                        "Header must show the path of the loaded configuration file");
                assertTrue(ws.editorState().hasLoadedFileSnapshot(),
                        "Editor must have a file snapshot after opening");
                assertEquals("./source-loaded", ws.editorState().values().sourceFolder(),
                        "Source folder must be populated from the opened file");
                assertEquals("./target-loaded", ws.editorState().values().targetFolder(),
                        "Target folder must be populated from the opened file");
                assertFalse(ws.editorState().isDirty(),
                        "State must be clean immediately after opening a file");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });

        assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Verify latch must complete");
        rethrow(error);
    }

    // =========================================================================
    // Flow: "Speichern" on a known path
    // =========================================================================

    /**
     * Regression: "Speichern" on a workspace with a known file path calls the writer with the
     * correct target path and clears the dirty state after the write succeeds.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(4)
    void speichern_onKnownPath_writesFileAndClearsDirtyState(@TempDir Path tempDir) throws Exception {
        Path targetPath = tempDir.resolve("save-test.properties");
        AtomicReference<Path> writtenPath = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        AtomicReference<Throwable> error = new AtomicReference<>();

        GuiConfigurationFileWriter capturingWriter = (values, path) -> {
            writtenPath.set(path);
            // Write an actual file so the post-save snapshot reload succeeds.
            try {
                Files.writeString(path,
                        "source.folder=" + values.sourceFolder() + "\n",
                        StandardCharsets.UTF_8);
            } catch (IOException ignored) {
            }
            return GuiConfigurationSaveResult.saved(path);
        };

        // Build a workspace that already has a loaded file at targetPath.
        GuiConfigurationEditorState stateWithFile =
                GuiConfigurationTemplateFactory.createStandardTemplate()
                        .withLoadedFileSnapshot(new GuiConfigurationFileSnapshot(
                                targetPath, new Properties()));

        GuiStartupContext context = new GuiStartupContext(
                stateWithFile,
                Optional.empty(),
                configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                capturingWriter,
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
                                (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
                        new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
                                (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
                wsRef.set(ws);

                // Make it dirty first.
                ws.editorState = ws.editorState().withValues(
                        ws.editorState().values().withSourceFolder("./dirty/source"));
                assertTrue(ws.editorState().isDirty(), "Precondition: must be dirty before save");

                ws.requestSaveConfiguration();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete");
        rethrow(error);

        // Wait for the background save worker to complete and apply the result.
        waitFor(() -> {
            AtomicBoolean saved = new AtomicBoolean(false);
            CountDownLatch check = new CountDownLatch(1);
            Platform.runLater(() -> {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                if (ws != null && !ws.editorState().isDirty()) {
                    saved.set(true);
                }
                check.countDown();
            });
            try {
                check.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return saved.get();
        }, FX_TIMEOUT_SECONDS);

        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                assertFalse(ws.editorState().isDirty(),
                        "State must be clean after a successful save");
                assertEquals(targetPath, writtenPath.get(),
                        "Writer must have been called with the known target path");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });

        assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Verify latch must complete");
        rethrow(error);
    }

    // =========================================================================
    // Flow: "Speichern unter" first time (new configuration)
    // =========================================================================

    /**
     * Regression: "Speichern unter" for a new, unsaved configuration creates the file at the
     * chosen path and updates the header. After the save the state is no longer in
     * new-configuration mode.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(5)
    void speichernUnter_firstTime_createsFileAndUpdatesHeader(@TempDir Path tempDir) throws Exception {
        Path targetPath = tempDir.resolve("save-as-test.properties");
        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        AtomicReference<Throwable> error = new AtomicReference<>();

        GuiConfigurationFileWriter capturingWriter = (values, path) -> {
            try {
                Files.writeString(path, "source.folder=" + values.sourceFolder() + "\n",
                        StandardCharsets.UTF_8);
            } catch (IOException ignored) {
            }
            return GuiConfigurationSaveResult.saved(path);
        };

        GuiStartupContext context = new GuiStartupContext(
                GuiConfigurationTemplateFactory.createStandardTemplate(),
                Optional.empty(),
                configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                capturingWriter,
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
                                (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
                        new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
                                (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
                wsRef.set(ws);

                assertTrue(ws.editorState().isNewConfiguration(),
                        "Precondition: editor must be in new-configuration state");

                // Inject a save-dialog function that returns the target path without a native dialog.
                ws.saveDialogFunction = (chooser, owner) -> targetPath.toFile();

                ws.requestSaveConfigurationAs();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete");
        rethrow(error);

        // Wait for background save worker → FX thread update.
        waitFor(() -> {
            AtomicBoolean saved = new AtomicBoolean(false);
            CountDownLatch check = new CountDownLatch(1);
            Platform.runLater(() -> {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                if (ws != null && !ws.editorState().isNewConfiguration()) {
                    saved.set(true);
                }
                check.countDown();
            });
            try {
                check.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return saved.get();
        }, FX_TIMEOUT_SECONDS);

        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                assertFalse(ws.editorState().isNewConfiguration(),
                        "After 'Speichern unter' the editor must no longer be in new-configuration state");
                assertEquals(targetPath.toString(), ws.configurationPathText(),
                        "Header must be updated with the path chosen in 'Speichern unter'");
                assertFalse(ws.editorState().isDirty(),
                        "State must be clean after successful 'Speichern unter'");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });

        assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Verify latch must complete");
        rethrow(error);
    }

    // =========================================================================
    // Flow: Overwrite dialog – YES path: writer is called
    // =========================================================================

    /**
     * Regression: when the chosen target file already exists and the user confirms the overwrite
     * dialog, the writer is called with the target path.
     * <p>
     * The file chooser and the confirmation dialog are replaced by injectable test hooks so the
     * test runs headless without native dialogs.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(6)
    void overwriteDialog_existingTarget_yesConfirmation_writerIsCalled(@TempDir Path tempDir)
            throws Exception {
        Path existingFile = tempDir.resolve("existing.properties");
        Files.writeString(existingFile, "source.folder=old\n", StandardCharsets.UTF_8);

        AtomicBoolean writerCalled = new AtomicBoolean(false);
        AtomicReference<Throwable> error = new AtomicReference<>();

        GuiConfigurationFileWriter capturingWriter = (values, path) -> {
            writerCalled.set(true);
            return GuiConfigurationSaveResult.saved(path);
        };

        GuiStartupContext ctx = new GuiStartupContext(
                GuiConfigurationTemplateFactory.createStandardTemplate(),
                Optional.empty(),
                configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                capturingWriter,
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
                                (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
                        new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
                                (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
                ws.saveDialogFunction = (chooser, owner) -> existingFile.toFile();
                ws.overwriteConfirmationSupplier = () -> Optional.of(ButtonType.YES);
                ws.requestSaveConfigurationAs();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete");
        rethrow(error);

        waitFor(() -> {
            AtomicBoolean done = new AtomicBoolean(false);
            CountDownLatch check = new CountDownLatch(1);
            Platform.runLater(() -> {
                done.set(writerCalled.get());
                check.countDown();
            });
            try {
                check.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return done.get();
        }, FX_TIMEOUT_SECONDS);

        assertTrue(writerCalled.get(),
                "Writer must be called when the user confirms the overwrite dialog");
    }

    // =========================================================================
    // Flow: Overwrite dialog – NO path: writer is NOT called
    // =========================================================================

    /**
     * Regression: when the chosen target file already exists and the user cancels the overwrite
     * dialog, the writer is not called.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(7)
    void overwriteDialog_existingTarget_noConfirmation_writerIsNotCalled(@TempDir Path tempDir)
            throws Exception {
        Path existingFile = tempDir.resolve("existing-no.properties");
        Files.writeString(existingFile, "source.folder=old\n", StandardCharsets.UTF_8);

        AtomicBoolean writerCalled = new AtomicBoolean(false);
        AtomicReference<Throwable> error = new AtomicReference<>();
        // Latch that fires once the FX thread has processed the dialog result (NO branch).
        CountDownLatch dialogProcessedLatch = new CountDownLatch(1);

        GuiConfigurationFileWriter trackingWriter = (values, path) -> {
            writerCalled.set(true);
            return GuiConfigurationSaveResult.saved(path);
        };

        GuiStartupContext ctx = new GuiStartupContext(
                GuiConfigurationTemplateFactory.createStandardTemplate(),
                Optional.empty(),
                configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                trackingWriter,
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
                                (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
                        new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
                            @Override public boolean isDirectoryReadable(String p) { return false; }
                            @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
                            @Override public boolean isFileReadable(String p) { return false; }
                            @Override public boolean isSqlitePathUsable(String p) { return false; }
                        },
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
                                req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
                                (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
                ws.saveDialogFunction = (chooser, owner) -> existingFile.toFile();
                // Stub: NO confirmation. Signal that the FX-thread dialog logic ran.
                ws.overwriteConfirmationSupplier = () -> {
                    dialogProcessedLatch.countDown();
                    return Optional.of(ButtonType.NO);
                };
                ws.requestSaveConfigurationAs();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete");
        rethrow(error);

        // Wait until the FX thread has run the dialog logic (the overwrite supplier was called).
        assertTrue(dialogProcessedLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Dialog-processed latch must fire within timeout");

        assertFalse(writerCalled.get(),
                "Writer must NOT be called when the user cancels the overwrite dialog");
    }

    // =========================================================================
    // Flow: Dirty-state marking (title prefix + header marker)
    // =========================================================================

    /**
     * Regression: after a field change the title-update listener receives a title with the
     * dirty prefix and the header dirty-marker label becomes visible and managed.
     * <p>
     * The dirty state is injected directly via the package-private {@code editorState} field.
     * The private {@code refreshHeader} method is then invoked via reflection to propagate the
     * new state to the UI elements without altering any other workspace state. This approach
     * avoids the need for a public or package-private refresh hook in production code while still
     * verifying the complete rendering pipeline end-to-end.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(8)
    void fieldChange_titleListenerReceivesDirtyPrefixAndHeaderIsMarked() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            assertFalse(ws.editorState().isDirty(),
                    "Precondition: state must be clean right after 'Neu'");
            assertFalse(ws.dirtyMarkerLabel.isVisible(),
                    "Precondition: dirty-marker label must be hidden in clean state");

            AtomicReference<String> lastTitle = new AtomicReference<>("");
            AtomicBoolean listenerFired = new AtomicBoolean(false);

            // Register the listener ONCE before injecting the dirty state.
            ws.titleUpdateListener = title -> {
                lastTitle.set(title);
                listenerFired.set(true);
            };

            // Inject dirty state via package-private field.
            GuiConfigurationValues dirty = ws.editorState().values()
                    .withSourceFolder("./field-changed-source");
            ws.editorState = ws.editorState().withValues(dirty);
            assertTrue(ws.editorState().isDirty(),
                    "Editor state must be dirty after field-value injection");

            // Trigger refreshHeader via reflection so the UI elements and the title listener
            // reflect the dirty state without altering any other workspace data.
            try {
                java.lang.reflect.Method m =
                        GuiConfigurationEditorWorkspace.class.getDeclaredMethod("refreshHeader");
                m.setAccessible(true);
                m.invoke(ws);
            } catch (Exception ex) {
                throw new AssertionError("Could not invoke refreshHeader via reflection", ex);
            }

            // Assertion A: dirty-marker label is both visible and managed in the header.
            assertTrue(ws.dirtyMarkerLabel.isVisible(),
                    "Header dirty-marker label must be visible after a field change");
            assertTrue(ws.dirtyMarkerLabel.isManaged(),
                    "Header dirty-marker label must be managed after a field change");

            // Assertion B: listener received a title that starts with the dirty prefix.
            assertTrue(listenerFired.get(),
                    "Title-update listener must have fired after refreshHeader");
            assertTrue(lastTitle.get().startsWith(GuiWindowTitleFormatter.DIRTY_PREFIX),
                    "Title received by listener must start with the dirty prefix \""
                            + GuiWindowTitleFormatter.DIRTY_PREFIX + "\" but was: \""
                            + lastTitle.get() + "\"");
        });
    }

    // =========================================================================
    // Flow: Unsaved-changes guard fires before "Neu" when dirty
    // =========================================================================

    /**
     * Regression: requesting "Neu" when the editor is dirty invokes the guard, and the cancel
     * outcome keeps the current state unchanged.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(9)
    void requestNeu_whenDirty_invokesGuardAndCancelKeepsState() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            GuiConfigurationValues dirty = ws.editorState().values()
                    .withSourceFolder("./dirty-source");
            ws.editorState = ws.editorState().withValues(dirty);
            assertTrue(ws.editorState().isDirty(), "Precondition: must be dirty");

            AtomicBoolean guardInvoked = new AtomicBoolean(false);
            ws.unsavedChangesGuard.setDialogSupplier(label -> {
                guardInvoked.set(true);
                return GuiUnsavedChangesGuard.Choice.CANCEL;
            });

            ws.requestNewConfiguration();

            assertTrue(guardInvoked.get(), "Guard must be invoked when dirty and 'Neu' requested");
            assertEquals("./dirty-source", ws.editorState().values().sourceFolder(),
                    "State must remain unchanged after Cancel");
        });
    }

    // =========================================================================
    // Flow: Unsaved-changes guard fires before "Öffnen" when dirty
    // =========================================================================

    /**
     * Regression: requesting "Öffnen" when the editor is dirty invokes the guard before
     * the file dialog.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(10)
    void requestOeffnen_whenDirty_invokesGuard() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withSourceFolder("./dirty"));
            assertTrue(ws.editorState().isDirty(), "Precondition: must be dirty");

            AtomicBoolean guardInvoked = new AtomicBoolean(false);
            ws.unsavedChangesGuard.setDialogSupplier(label -> {
                guardInvoked.set(true);
                return GuiUnsavedChangesGuard.Choice.CANCEL;
            });

            ws.requestOpenConfiguration();

            assertTrue(guardInvoked.get(),
                    "Guard must be invoked when dirty and 'Öffnen' is requested");
        });
    }

    // =========================================================================
    // Helpers
    // =========================================================================

    private static GuiConfigurationFileLoader buildSnapshotLoader() {
        return path -> {
            try {
                String content = Files.readString(path, StandardCharsets.UTF_8);
                Properties props = new Properties();
                props.load(new StringReader(content));
                GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
                return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
                        snapshot, Optional.empty());
            } catch (IOException e) {
                throw new GuiConfigurationLoadException("Failed to load " + path, e);
            }
        };
    }

    private static void writeMinimalPropertiesFile(Path path,
                                                    String sourceFolder,
                                                    String targetFolder,
                                                    String activeProvider) throws IOException {
        String content = "source.folder=" + sourceFolder + "\n"
                + "target.folder=" + targetFolder + "\n"
                + "ai.provider.active=" + activeProvider + "\n"
                + "sqlite.file=./work/test.db\n"
                + "max.retries.transient=3\n"
                + "max.pages=10\n"
                + "max.text.characters=1000\n"
                + "prompt.template.file=./config/prompt.txt\n";
        Files.writeString(path, content, StandardCharsets.UTF_8);
    }

    private static void runOnFx(ThrowingRunnable task) throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Throwable> error = new AtomicReference<>();
        Platform.runLater(() -> {
            try {
                task.run();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                latch.countDown();
            }
        });
        assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX task must complete within timeout");
        rethrow(error);
    }

    private static void rethrow(AtomicReference<Throwable> error) throws Exception {
        Throwable t = error.get();
        if (t == null) {
            return;
        }
        if (t instanceof Exception e) {
            throw e;
        }
        throw new AssertionError("Unexpected error", t);
    }

    private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
            throws InterruptedException {
        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
        while (System.nanoTime() < deadline) {
            if (condition.getAsBoolean()) {
                return;
            }
            Thread.sleep(20L);
        }
        throw new AssertionError("Condition did not become true within timeout");
    }

    @FunctionalInterface
    private interface ThrowingRunnable {
        void run() throws Exception;
    }
}
