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

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.Test;
import org.junit.jupiter.api.io.TempDir;

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.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import javafx.scene.control.Label;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;

/**
 * Monocle-based headless smoke tests for the central message area, field-level error labels
 * and API-key origin display introduced in the message-area integration step.
 *
 * <h2>Covered scenarios</h2>
 * <ul>
 *   <li>After opening an incomplete configuration, ERROR entries are visible in the central
 *       message area (non-zero child count in {@code messagesAreaBox}).</li>
 *   <li>The first child of an ERROR row is a coloured {@link Text} prefix node; the second
 *       child (body) carries black fill.</li>
 *   <li>After opening a configuration with a missing source folder, the field-level error label
 *       registered for {@code source.folder} is visible.</li>
 *   <li>After the standard template is applied via {@code requestNewConfiguration()}, the
 *       {@code source.folder} error label is hidden.</li>
 *   <li>The WARNING threshold for {@code max.text.characters} (1001–3000) appears in the
 *       central message area with a WARNING-coloured prefix.</li>
 *   <li>After synchronous model-catalogue retrieval, the central message area is updated via the
 *       post-result callback.</li>
 *   <li>When the API-key resolution port reports an ENV-variable origin for Claude, the
 *       api-key origin label is visible and references the variable name.</li>
 *   <li>The field-error label for {@code ai.provider.active} is registered and shown when the
 *       active provider is empty.</li>
 * </ul>
 *
 * <h2>Threading</h2>
 * All workspace interactions run on the JavaFX Application Thread via {@link Platform#runLater}.
 * Model-catalogue retrieval is made synchronous via the coordinator's injectable factories so no
 * real background threads are used and results are delivered inline.
 */
class GuiMessageAreaSmokeTest {

    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().
    }

    // =========================================================================
    // Scenario: central message area has ERROR entries for incomplete config
    // =========================================================================

    /**
     * Smoke test: after opening a properties file with an empty active-provider value, the
     * central {@code messagesAreaBox} contains at least one row, and at least one row has an
     * ERROR-coloured prefix node.
     */
    @Test
    void incompleteConfig_messagesAreaContainsErrorRow(@TempDir Path tempDir) throws Exception {
        Path configFile = tempDir.resolve("incomplete.properties");
        writePropertiesFile(configFile, "" /* empty active provider */, "500");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
                    "Central message area must not be empty after loading an incomplete configuration");

            boolean foundErrorRow = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .anyMatch(tf -> {
                        if (tf.getChildren().isEmpty()) {
                            return false;
                        }
                        Object first = tf.getChildren().get(0);
                        if (first instanceof Text t) {
                            return t.getStyle().contains(GuiMessageSeverity.ERROR.getPrefixCssColour());
                        }
                        return false;
                    });
            assertTrue(foundErrorRow,
                    "At least one TextFlow row must have an ERROR-coloured prefix node");
        });
    }

    /**
     * Smoke test: the body-text node of any message row must carry the black fill style.
     */
    @Test
    void messageRow_bodyTextIsBlack(@TempDir Path tempDir) throws Exception {
        Path configFile = tempDir.resolve("incomplete2.properties");
        writePropertiesFile(configFile, "" /* empty active provider */, "500");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            Optional<TextFlow> anyRow = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .filter(tf -> tf.getChildren().size() >= 2)
                    .findFirst();
            assertTrue(anyRow.isPresent(), "Expected at least one TextFlow with two children");
            Object bodyNode = anyRow.get().getChildren().get(1);
            assertTrue(bodyNode instanceof Text, "Second child of a message row must be a Text node");
            String bodyStyle = ((Text) bodyNode).getStyle();
            // Body must be explicitly styled black or have no colour override at all.
            assertTrue(bodyStyle.contains("black") || bodyStyle.contains("#000000")
                            || bodyStyle.contains("000"),
                    "Body text must be rendered in black; style: " + bodyStyle);
        });
    }

    // =========================================================================
    // Scenario: field-level error label for source.folder
    // =========================================================================

    /**
     * Smoke test: when a configuration with a blank source folder is opened, the field-error label
     * for {@code source.folder} is visible and non-blank.
     */
    @Test
    void blankSourceFolder_fieldErrorLabelVisible(@TempDir Path tempDir) throws Exception {
        Path configFile = tempDir.resolve("nosrc.properties");
        writePropertiesFileBlankSourceFolder(configFile);

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            Label errorLabel = ws.fieldErrorLabels.get("source.folder");
            assertNotNull(errorLabel,
                    "A field-error label must be registered for 'source.folder'");
            assertTrue(errorLabel.isVisible(),
                    "source.folder error label must be visible when the field is blank");
            assertFalse(errorLabel.getText().isBlank(),
                    "source.folder error label must carry a non-blank error text");
        });
    }

    /**
     * Smoke test: after applying the standard template via {@code requestNewConfiguration()}, the
     * source.folder error label is hidden because the template supplies a non-blank value.
     */
    @Test
    void standardTemplate_sourceFolderErrorLabelHidden() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            Label errorLabel = ws.fieldErrorLabels.get("source.folder");
            assertNotNull(errorLabel,
                    "A field-error label must be registered for 'source.folder' after 'Neu'");
            assertFalse(errorLabel.isVisible(),
                    "source.folder error label must be hidden when the template provides a non-blank value");
        });
    }

    // =========================================================================
    // Scenario: WARNING for max.text.characters between 1001 and 3000
    // =========================================================================

    /**
     * Smoke test: loading a config with {@code max.text.characters = 1500} (between 1001 and 3000)
     * must produce at least one WARNING entry in the central message area.
     */
    @Test
    void maxTextCharacters_warningThreshold_warningInMessages(@TempDir Path tempDir)
            throws Exception {
        Path configFile = tempDir.resolve("warning-chars.properties");
        writePropertiesFile(configFile, "claude", "1500");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .anyMatch(tf -> {
                        if (tf.getChildren().isEmpty()) {
                            return false;
                        }
                        Object first = tf.getChildren().get(0);
                        if (first instanceof Text t) {
                            return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
                        }
                        return false;
                    });
            assertTrue(hasWarning,
                    "max.text.characters=1500 must produce at least one WARNING row in the message area");
        });
    }

    // =========================================================================
    // Scenario: model-catalogue result updates the message area via postResultCallback
    // =========================================================================

    /**
     * Smoke test: after a synchronous (inline) model-catalogue retrieval that returns an
     * {@link ModelCatalogResult.IncompleteConfiguration} result, the central message area is
     * updated and contains the coordinator's message with source "Modellabruf".
     * <p>
     * Both the thread factory and the result-delivery mechanism are replaced with synchronous
     * implementations so the entire retrieval+delivery cycle completes within the FX thread call.
     */
    @Test
    void modelCatalogResult_updatesMessageArea() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Make retrieval fully synchronous: run the task inline and deliver result inline.
            ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
                @Override
                public void start() {
                    // Execute the task inline on the calling thread instead of starting a new thread.
                    this.run();
                }
            };
            ws.modelCatalogCoordinator.resultDelivery = Runnable::run;

            // Trigger retrieval for Claude — stub port returns IncompleteConfiguration.
            ws.modelCatalogCoordinator.triggerModelRetrieval(
                    AiProviderFamily.CLAUDE,
                    de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState.blank());

            // The post-result callback must have called refreshMessagesArea().
            assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
                    "messagesAreaBox must not be empty after model-catalogue result was applied");

            boolean hasModelCatalogEntry = ws.pendingMessages.stream()
                    .anyMatch(m -> m.source().isPresent()
                            && "Modellabruf".equals(m.source().get()));
            assertTrue(hasModelCatalogEntry,
                    "pendingMessages must contain at least one entry from source 'Modellabruf'"
                    + " after retrieval");
        });
    }

    // =========================================================================
    // Scenario: API-key ENV-variable origin label
    // =========================================================================

    /**
     * Smoke test: when the API-key resolution port reports that the Claude key comes from an
     * environment variable, the api-key origin label below the Claude API-key field is visible
     * and references the variable name or the concept of an environment variable.
     */
    @Test
    void apiKeyFromEnvVariable_originLabelVisible() throws Exception {
        GuiStartupContext ctx = new GuiStartupContext(
                GuiConfigurationEditorStateFactory.createBlankStartState(),
                Optional.empty(),
                path -> { throw new GuiConfigurationLoadException("not used in test", null); },
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                req -> new ModelCatalogResult.IncompleteConfiguration(
                        req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> {
                    if (family == AiProviderFamily.CLAUDE) {
                        return EffectiveApiKeyDescriptor.fromProviderEnvVar("CLAUDE_API_KEY");
                    }
                    return 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"); }
                        }));

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
            ws.requestNewConfiguration();

            Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
            assertNotNull(originLabel,
                    "An api-key origin label must be registered for AiProviderFamily.CLAUDE");
            assertTrue(originLabel.isVisible(),
                    "Claude api-key origin label must be visible when key comes from ENV-variable");
            String labelText = originLabel.getText();
            assertTrue(labelText.contains("CLAUDE_API_KEY")
                            || labelText.contains("Umgebungsvariable"),
                    "Claude api-key origin label must reference the ENV-variable name or type;"
                    + " got: " + labelText);
        });
    }

    // =========================================================================
    // Scenario: absent API key — origin label hidden, no duplicate in message area
    // =========================================================================

    /**
     * Smoke test: when no API key is configured and no environment variable provides one, the
     * api-key origin label below the Claude API-key field must be hidden (not visible).
     * <p>
     * The field-error label (registered in {@code fieldErrorLabels}) already shows the warning
     * below the input field; duplicating the same text in the origin label would cause the same
     * message to appear twice in close visual proximity. The origin label is therefore hidden
     * for the absent-key case.
     */
    @Test
    void apiKeyAbsent_originLabelHidden() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Standard template has active provider = Claude but no API key value.
            Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
            assertNotNull(originLabel,
                    "An api-key origin label must be registered for AiProviderFamily.CLAUDE");
            assertFalse(originLabel.isVisible(),
                    "Claude api-key origin label must be HIDDEN when no API key is configured "
                    + "— the field-error label already shows the warning, so the origin label "
                    + "must not repeat it");
        });
    }

    /**
     * Smoke test: when no API key is configured, the field-error label registered for
     * {@code ai.provider.claude.apiKey} must be visible and carry a non-blank warning text.
     * <p>
     * This is the single intended location for the missing-key warning below the input field.
     */
    @Test
    void apiKeyAbsent_fieldErrorLabelVisible() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            Label errorLabel = ws.fieldErrorLabels.get("ai.provider.claude.apiKey");
            assertNotNull(errorLabel,
                    "A field-error label must be registered for 'ai.provider.claude.apiKey'");
            assertTrue(errorLabel.isVisible(),
                    "api-key field-error label must be visible when no API key is configured");
            assertFalse(errorLabel.getText().isBlank(),
                    "api-key field-error label must carry a non-blank warning text");
        });
    }

    /**
     * Smoke test: when no API key is configured, the text of the missing-key warning must appear
     * exactly once in {@code pendingMessages}. Having the same text twice would mean two
     * validation sources independently write the same finding to the central message area.
     */
    @Test
    void apiKeyAbsent_noDuplicateMessageInPendingMessages() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Collect all message texts that contain the canonical "Kein API-Schlüssel" fragment.
            long count = ws.pendingMessages.stream()
                    .filter(m -> m.text().contains("API-Schlüssel") || m.text().contains("API-Key"))
                    .count();
            assertTrue(count <= 1,
                    "The missing-API-key finding must appear at most once in pendingMessages, "
                    + "but found " + count + " occurrences");
        });
    }

    // =========================================================================
    // Scenario: INFO-coloured prefix in the message area (model-catalogue success)
    // =========================================================================

    /**
     * Smoke test: after a successful model-catalogue retrieval (stub returns Success), the central
     * message area must contain at least one row whose prefix node carries the INFO colour.
     * <p>
     * This verifies that the INFO severity level is rendered with its defined CSS colour and not
     * accidentally displayed with the ERROR or WARNING colour.
     */
    @Test
    void successfulModelRetrieval_messagesAreaContainsInfoRow() throws Exception {
        runOnFx(() -> {
            GuiStartupContext ctx = new GuiStartupContext(
                    GuiConfigurationEditorStateFactory.createBlankStartState(),
                    Optional.empty(),
                    path -> { throw new GuiConfigurationLoadException("not used in test", null); },
                    (values, path) -> GuiConfigurationSaveResult.saved(path),
                    req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
                            .ModelCatalogResult.Success(
                                    req.providerIdentifier(),
                                    java.util.List.of("claude-3-5-sonnet"),
                                    java.time.Instant.now()),
                    (family, propertyValue) -> 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"); }
                        }));

            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
            // Make retrieval synchronous.
            ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
                @Override
                public void start() {
                    this.run();
                }
            };
            ws.modelCatalogCoordinator.resultDelivery = Runnable::run;

            ws.requestNewConfiguration();

            // Trigger retrieval so an INFO message is added.
            ws.modelCatalogCoordinator.triggerModelRetrieval(
                    AiProviderFamily.CLAUDE,
                    de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
                            .GuiProviderConfigurationState.blank());

            boolean foundInfoRow = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .anyMatch(tf -> {
                        if (tf.getChildren().isEmpty()) {
                            return false;
                        }
                        Object first = tf.getChildren().get(0);
                        if (first instanceof Text t) {
                            return t.getStyle().contains(GuiMessageSeverity.INFO.getPrefixCssColour());
                        }
                        return false;
                    });
            assertTrue(foundInfoRow,
                    "After successful model retrieval at least one TextFlow row must have an"
                    + " INFO-coloured prefix node");
        });
    }

    // =========================================================================
    // Scenario: HINT-coloured prefix in the message area (empty model list)
    // =========================================================================

    /**
     * Smoke test: after a model-catalogue retrieval that returns {@code EmptyList}, the central
     * message area must contain at least one row whose prefix node carries the HINT colour.
     * <p>
     * This verifies that the HINT severity level is correctly propagated from the coordinator to
     * the rendered message area.
     */
    @Test
    void emptyModelList_messagesAreaContainsHintRow() throws Exception {
        runOnFx(() -> {
            GuiStartupContext ctx = new GuiStartupContext(
                    GuiConfigurationEditorStateFactory.createBlankStartState(),
                    Optional.empty(),
                    path -> { throw new GuiConfigurationLoadException("not used in test", null); },
                    (values, path) -> GuiConfigurationSaveResult.saved(path),
                    req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
                            .ModelCatalogResult.EmptyList(
                                    req.providerIdentifier(), java.time.Instant.now()),
                    (family, propertyValue) -> 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"); }
                        }));

            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
            ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
                @Override
                public void start() {
                    this.run();
                }
            };
            ws.modelCatalogCoordinator.resultDelivery = Runnable::run;

            ws.requestNewConfiguration();

            ws.modelCatalogCoordinator.triggerModelRetrieval(
                    AiProviderFamily.CLAUDE,
                    de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
                            .GuiProviderConfigurationState.blank());

            boolean foundHintRow = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .anyMatch(tf -> {
                        if (tf.getChildren().isEmpty()) {
                            return false;
                        }
                        Object first = tf.getChildren().get(0);
                        if (first instanceof Text t) {
                            return t.getStyle().contains(GuiMessageSeverity.HINT.getPrefixCssColour());
                        }
                        return false;
                    });
            assertTrue(foundHintRow,
                    "After EmptyList model retrieval at least one TextFlow row must have a"
                    + " HINT-coloured prefix node");
        });
    }

    // =========================================================================
    // Scenario: strong WARNING for max.text.characters > 3000
    // =========================================================================

    /**
     * Smoke test: loading a config with {@code max.text.characters = 3001} (above the 3000 strong
     * warning threshold) must produce at least one WARNING entry in the central message area.
     * <p>
     * This verifies the upper threshold of the economic warning logic: values strictly above 3000
     * trigger the strong warning level.
     */
    @Test
    void maxTextCharacters_strongWarningThreshold_warningInMessages(@TempDir Path tempDir)
            throws Exception {
        Path configFile = tempDir.resolve("strong-warning-chars.properties");
        writePropertiesFile(configFile, "claude", "3001");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
                    .filter(n -> n instanceof TextFlow)
                    .map(n -> (TextFlow) n)
                    .anyMatch(tf -> {
                        if (tf.getChildren().isEmpty()) {
                            return false;
                        }
                        Object first = tf.getChildren().get(0);
                        if (first instanceof Text t) {
                            return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
                        }
                        return false;
                    });
            assertTrue(hasWarning,
                    "max.text.characters=3001 must produce at least one WARNING row in the"
                    + " message area (strong-warning threshold)");
        });
    }

    // =========================================================================
    // Scenario: max.pages > 100 produces no ERROR field-finding (HINT only)
    // =========================================================================

    /**
     * Smoke test: loading a config with {@code max.pages = 101} must not produce an ERROR
     * field-finding for the {@code max.pages} key. High page counts are treated as
     * plausibility/performance hints and must never block the configuration from being
     * considered operational from the editor's perspective.
     * <p>
     * This complements the unit-level validation tests by verifying the finding is correctly
     * mapped through the workspace pipeline and not accidentally escalated to ERROR.
     */
    @Test
    void maxPages_over100_noErrorFieldFinding(@TempDir Path tempDir)
            throws Exception {
        Path configFile = tempDir.resolve("highpages.properties");
        writePropertiesFileWithMaxPages(configFile, "claude", "500", "101");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            // The field-finding for max.pages must not be ERROR.
            boolean hasErrorFindingForMaxPages = ws.pendingFieldFindings.stream()
                    .anyMatch(f -> "max.pages".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertFalse(hasErrorFindingForMaxPages,
                    "max.pages=101 must not produce an ERROR field-finding; high page limits"
                    + " are treated as plausibility hints only");
        });
    }

    // =========================================================================
    // Scenario: message area is cleared when a new configuration is applied
    // =========================================================================

    /**
     * Smoke test: after "Neu" is triggered on a workspace that already has a message, the
     * central message area must no longer contain that pre-existing message entry.
     * <p>
     * This verifies that {@code applyEditorState} clears {@code pendingMessages} so that
     * messages from a previous configuration do not bleed into the freshly loaded one.
     */
    @Test
    void newConfiguration_clearsPreviousMessages() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            // Seed a message that must not survive a "Neu" action.
            ws.pendingMessages.add(
                    GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Fehler", "Test"));
            ws.refreshMessagesArea();
            assertFalse(ws.pendingMessages.isEmpty(),
                    "Pre-condition: pending messages must not be empty before 'Neu'");

            ws.requestNewConfiguration();

            assertFalse(
                    ws.pendingMessages.stream()
                            .anyMatch(m -> "Alter Fehler".equals(m.text())),
                    "The pre-existing message must have been removed after 'Neu'");
        });
    }

    // =========================================================================
    // Scenario: "Meldungen leeren" button clears the message area
    // =========================================================================

    /**
     * Smoke test: invoking {@code clearMessages()} removes all entries from
     * {@code pendingMessages} and causes the visible message area to show the
     * placeholder instead of previous entries.
     */
    @Test
    void clearMessages_removesAllEntries() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Seed some messages.
            ws.pendingMessages.add(
                    GuiMessageEntry.of(GuiMessageSeverity.INFO, "Info-Meldung", "Test"));
            ws.pendingMessages.add(
                    GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Warnung", "Test"));
            ws.refreshMessagesArea();
            assertFalse(ws.pendingMessages.isEmpty(),
                    "Pre-condition: pending messages must not be empty before clearing");

            ws.clearMessages();

            assertTrue(ws.pendingMessages.isEmpty(),
                    "pendingMessages must be empty after clearMessages()");
            assertTrue(ws.messagesAreaBox.getChildren().isEmpty()
                            || ws.messagesAreaBox.getChildren().stream()
                            .noneMatch(n -> n instanceof javafx.scene.text.TextFlow tf
                                    && tf.getChildren().stream()
                                    .anyMatch(c -> c instanceof javafx.scene.text.Text t
                                            && (t.getText().contains("Info-Meldung")
                                            || t.getText().contains("Warnung")))),
                    "messagesAreaBox must not contain the cleared message texts after clearMessages()");
        });
    }

    // =========================================================================
    // Scenario: "Validieren" clears previous messages before showing results
    // =========================================================================

    /**
     * Smoke test: invoking {@code runValidationAction()} via the validate button removes
     * pre-existing messages so that results from a previous action do not accumulate.
     */
    @Test
    void validationAction_clearsPreviousMessages() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Seed a stale message that must not survive the validation action.
            ws.pendingMessages.add(
                    GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Befund", "Test"));
            assertFalse(ws.pendingMessages.isEmpty(),
                    "Pre-condition: pending messages must not be empty before Validieren");

            ws.validateButton.fire();

            assertFalse(
                    ws.pendingMessages.stream()
                            .anyMatch(m -> "Alter Befund".equals(m.text())),
                    "The stale message must have been removed after Validieren");
        });
    }

    // =========================================================================
    // Scenario: "Technische Tests ausführen" clears previous messages before starting
    // =========================================================================

    /**
     * Smoke test: invoking the technical-tests action removes pre-existing messages so that
     * results from a previous action do not accumulate.
     * <p>
     * The clear happens synchronously on the FX thread before the background worker starts.
     * The thread factory is replaced with a no-op so no background thread is actually
     * started, which prevents native dialog calls that are not supported under Monocle.
     */
    @Test
    void technicalTestsAction_clearsPreviousMessages() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Replace thread factory with a no-op so no background work runs in Monocle.
            ws.technicalTestCoordinator.testThreadFactory = task -> new Thread(() -> { }) {
                @Override
                public void start() {
                    // Do not start — we only verify the synchronous clear, not the test result.
                }
            };

            ws.pendingMessages.add(
                    GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Alte Warnung", "Test"));
            assertFalse(ws.pendingMessages.isEmpty(),
                    "Pre-condition: pending messages must not be empty before technical tests");

            ws.technicalTestsButton.fire();

            assertFalse(
                    ws.pendingMessages.stream()
                            .anyMatch(m -> "Alte Warnung".equals(m.text())),
                    "The stale message must have been removed after Technische Tests ausführen");
        });
    }

    // =========================================================================
    // Scenario: ai.provider.active field-error label is registered and shown
    // =========================================================================

    /**
     * Smoke test: when the active provider is empty, the field-error label for
     * {@code ai.provider.active} must be registered and visible.
     */
    @Test
    void incompleteConfig_activeProviderFieldErrorLabelVisible(@TempDir Path tempDir)
            throws Exception {
        Path configFile = tempDir.resolve("noprovider.properties");
        writePropertiesFile(configFile, "" /* empty */, "500");

        AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
        openConfigAndWait(configFile, wsRef);

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = wsRef.get();
            Label errorLabel = ws.fieldErrorLabels.get("ai.provider.active");
            assertNotNull(errorLabel,
                    "A field-error label must be registered for 'ai.provider.active'");
            assertTrue(errorLabel.isVisible(),
                    "'ai.provider.active' error label must be visible when provider is empty");
        });
    }

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

    /**
     * Opens {@code configFile} in a freshly created workspace and waits for the background loader
     * to complete. The workspace reference is stored in {@code wsRef}.
     */
    private static void openConfigAndWait(Path configFile,
                                          AtomicReference<GuiConfigurationEditorWorkspace> wsRef)
            throws Exception {
        GuiConfigurationFileLoader loader = buildSnapshotLoader();
        GuiStartupContext ctx = new GuiStartupContext(
                GuiConfigurationEditorStateFactory.createBlankStartState(),
                Optional.empty(),
                loader,
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                req -> new ModelCatalogResult.IncompleteConfiguration(
                        req.providerIdentifier(), "kein Port im Test"),
                (family, propertyValue) -> 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);
                wsRef.set(ws);
                ws.openConfigurationFile(configFile);
            } finally {
                setupLatch.countDown();
            }
        });
        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup must complete within timeout");

        waitFor(() -> {
            AtomicBoolean ready = new AtomicBoolean(false);
            CountDownLatch check = new CountDownLatch(1);
            Platform.runLater(() -> {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
                    ready.set(true);
                }
                check.countDown();
            });
            try {
                check.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return ready.get();
        }, FX_TIMEOUT_SECONDS);
    }

    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 writePropertiesFile(Path path, String activeProvider,
                                             String maxTextCharacters) throws IOException {
        String content = "source.folder=./work/source\n"
                + "target.folder=./work/target\n"
                + "ai.provider.active=" + activeProvider + "\n"
                + "sqlite.file=./work/test.db\n"
                + "max.retries.transient=3\n"
                + "max.pages=10\n"
                + "max.text.characters=" + maxTextCharacters + "\n"
                + "prompt.template.file=./config/prompt.txt\n";
        Files.writeString(path, content, StandardCharsets.UTF_8);
    }

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

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

    private static void runOnFx(ThrowingRunnable task) throws Exception {
        AtomicReference<Throwable> error = new AtomicReference<>();
        CountDownLatch latch = new CountDownLatch(1);
        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 ex) {
            throw ex;
        }
        throw new AssertionError("Unexpected error", t);
    }

    private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
            throws InterruptedException {
        long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
        while (!condition.getAsBoolean()) {
            assertTrue(System.currentTimeMillis() < deadline,
                    "Condition was not met within the timeout");
            Thread.sleep(50);
        }
    }

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