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.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.GuiEditorValidationResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import javafx.application.Platform;

/**
 * Monocle-based headless smoke tests for the automatic editor validation.
 * <p>
 * These tests verify that the workspace triggers validation automatically when the editor
 * state changes (via {@code applyEditorState} and {@code updateValues}) and that the
 * {@link GuiEditorValidationResult} returned by {@code lastValidationResult()} reflects the
 * current editor state.
 *
 * <h2>Covered scenarios</h2>
 * <ul>
 *   <li>Opening an incomplete configuration (missing active provider) produces ERROR findings
 *       in {@code lastValidationResult} after the file is loaded.</li>
 *   <li>Opening an incomplete configuration populates {@code pendingFieldFindings} with a
 *       finding for {@code ai.provider.active}.</li>
 *   <li>After {@code requestNewConfiguration}: template values replace blank values, validation
 *       re-runs, {@code ai.provider.active} error disappears (valid provider in template);
 *       no WARNING for {@code max.text.characters} since default (1000) is non-critical.</li>
 *   <li>Changing a field via direct state update + re-applying state updates the validation
 *       result with new findings.</li>
 * </ul>
 *
 * <h2>Threading</h2>
 * <p>
 * All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
 * The {@code openConfigurationFile} method uses a background thread internally; tests that use
 * it await file-load completion via a polling helper before verifying results.
 * The Monocle headless configuration is activated by the Surefire JVM arguments.
 */
class GuiEditorValidationSmokeTest {

    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: opening an incomplete configuration produces ERROR findings
    // =========================================================================

    /**
     * Smoke test: when a properties file with an unknown (or empty) active-provider value is
     * opened via {@link GuiConfigurationEditorWorkspace#openConfigurationFile}, the workspace
     * calls {@code applyEditorState} after loading and runs validation automatically.
     * <p>
     * The resulting {@code lastValidationResult} must contain at least one ERROR because the
     * active-provider field is empty.
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void openingIncompleteConfiguration_validationRunsAndProducesErrors(@TempDir Path tempDir)
            throws Exception {
        // Write a properties file with an empty active provider.
        Path configFile = tempDir.resolve("incomplete.properties");
        writePropertiesFile(configFile, "" /* empty active provider */);

        GuiConfigurationFileLoader loader = buildSnapshotLoader();
        GuiConfigurationEditorState blankState =
                GuiConfigurationEditorStateFactory.createBlankStartState();
        GuiStartupContext ctx = new GuiStartupContext(
                blankState, Optional.empty(), loader,
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                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<>();

        // Create workspace and trigger file load on the FX thread.
        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
                wsRef.set(ws);
                ws.openConfigurationFile(configFile);
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });
        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup must complete within timeout");
        rethrow(error);

        // Wait for the background loader thread to apply the state.
        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);

        // Verify validation result on the FX thread.
        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                GuiEditorValidationResult result = ws.lastValidationResult();

                assertNotNull(result, "lastValidationResult must never be null");
                assertTrue(result.hasErrors(),
                        "Loading a config with empty active provider must produce ERROR findings");
                assertTrue(result.hasFieldFindingFor("ai.provider.active"),
                        "pendingFieldFindings must contain a finding for 'ai.provider.active'"
                        + " when the active provider is empty in the loaded file");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });

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

    // =========================================================================
    // Scenario: changing a field updates the validation result
    // =========================================================================

    /**
     * Smoke test: when the active provider is changed from a valid value to an empty string via
     * a direct state update followed by {@code requestNewConfiguration} (which calls
     * {@code applyEditorState} and triggers {@code runEditorValidation}), the
     * {@code lastValidationResult} is updated with findings that reflect the new state.
     * <p>
     * More concretely, this test demonstrates the field-change→re-validation flow by:
     * <ol>
     *   <li>Starting with the standard template (valid provider → no provider error).</li>
     *   <li>Loading a file that has an empty provider (produces a provider ERROR).</li>
     *   <li>Verifying that {@code lastValidationResult} changed from "no error" to "error" as
     *       the result of loading the file with invalid values.</li>
     * </ol>
     *
     * @param tempDir JUnit-provided temporary directory
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void changingField_revalidatesAndUpdatesLastValidationResult(@TempDir Path tempDir)
            throws Exception {
        Path invalidConfig = tempDir.resolve("invalid-provider.properties");
        writePropertiesFile(invalidConfig, "" /* empty active provider */);

        GuiConfigurationFileLoader loader = buildSnapshotLoader();
        GuiConfigurationEditorState blankState =
                GuiConfigurationEditorStateFactory.createBlankStartState();
        GuiStartupContext ctx = new GuiStartupContext(
                blankState, Optional.empty(), loader,
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                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(ctx);
                wsRef.set(ws);
                // Step 1: apply template – validation runs with valid values.
                ws.requestNewConfiguration();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });
        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Setup timeout");
        rethrow(error);

        // Confirm valid state after template.
        CountDownLatch checkValidLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                assertFalse(ws.lastValidationResult().hasFieldFindingFor("ai.provider.active"),
                        "After 'Neu' with valid template the active-provider field must have no error");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                checkValidLatch.countDown();
            }
        });
        assertTrue(checkValidLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Check timeout");
        rethrow(error);

        // Step 2: trigger field change by loading an invalid config file.
        CountDownLatch loadLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                wsRef.get().openConfigurationFile(invalidConfig);
            } catch (Throwable t) {
                error.set(t);
            } finally {
                loadLatch.countDown();
            }
        });
        assertTrue(loadLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Load trigger timeout");
        rethrow(error);

        // Wait for background loader.
        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);

        // Verify: invalid provider is now detected.
        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace ws = wsRef.get();
                GuiEditorValidationResult result = ws.lastValidationResult();
                assertTrue(result.hasErrors(),
                        "After loading a config with empty active provider, result must have errors");
                assertTrue(result.hasFieldFindingFor("ai.provider.active"),
                        "After loading invalid config, active-provider finding must be present");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });
        assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Verify timeout");
        rethrow(error);
    }

    // =========================================================================
    // Scenario: standard template validation – WARNING for max.text.characters
    // =========================================================================

    /**
     * Smoke test: after {@code requestNewConfiguration}, the standard template values are active
     * and validation runs. The template now uses {@code max.text.characters = 1000} (changed from
     * previous 5000) which is non-critical per spec. The template sets a valid active provider
     * → no ERROR for that field.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void requestNewConfiguration_triggersValidationAndLoadsTemplate()
            throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            GuiEditorValidationResult result = ws.lastValidationResult();
            assertNotNull(result, "lastValidationResult must not be null after 'Neu'");

            // Template has valid provider → no field finding for ai.provider.active.
            assertFalse(result.hasFieldFindingFor("ai.provider.active"),
                    "Standard template has a valid provider; 'ai.provider.active' must have"
                    + " no field finding");

            // Template max.text.characters = 1000 per standard default (non-critical threshold).
            // The validation loads and runs successfully.
            assertTrue(result != null,
                    "Validation result must exist after loading standard template");
        });
    }

    // =========================================================================
    // Scenario: pendingFieldFindings updated by applyEditorState
    // =========================================================================

    /**
     * Smoke test: after {@code requestNewConfiguration}, the {@code pendingFieldFindings} list is
     * updated and the template's valid provider is not flagged.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void requestNewConfiguration_pendingFieldFindings_noProviderError() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            assertNotNull(ws.pendingFieldFindings, "pendingFieldFindings must never be null");
            assertFalse(ws.pendingFieldFindings.stream()
                    .anyMatch(f -> "ai.provider.active".equals(f.fieldKey())),
                    "Standard template has a valid provider; no field finding expected for"
                    + " 'ai.provider.active'");
        });
    }

    // =========================================================================
    // Scenario: max.title.length – validation per value band
    // =========================================================================

    /**
     * Smoke test: when the standard template is applied and the title-length field is cleared
     * via the {@code withMaxTitleLength("")} copy, the local validation produces an ERROR
     * finding for {@code max.title.length}.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void emptyMaxTitleLength_producesFieldFindingError() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withMaxTitleLength(""));
            ws.validateButton.fire();

            assertNotNull(ws.lastValidationResult(),
                    "lastValidationResult must not be null after editing");
            assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Clearing max.title.length must produce a field finding");
            boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
                    .anyMatch(f -> "max.title.length".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertTrue(hasErrorForField,
                    "Empty max.title.length must be an ERROR for this field");
        });
    }

    /**
     * Smoke test: a too-small title-length value (below the minimum of 10) produces an ERROR
     * finding for the field.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void tooSmallMaxTitleLength_producesFieldFindingError() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withMaxTitleLength("5"));
            ws.validateButton.fire();

            assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Value below minimum must produce a field finding");
            boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
                    .anyMatch(f -> "max.title.length".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertTrue(hasErrorForField,
                    "Value below minimum must be an ERROR for this field");
        });
    }

    /**
     * Smoke test: a too-large title-length value (above the upper limit of 120) produces an ERROR
     * finding for the field.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void tooLargeMaxTitleLength_producesFieldFindingError() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withMaxTitleLength("200"));
            ws.validateButton.fire();

            assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Value above safe maximum must produce a field finding");
            boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
                    .anyMatch(f -> "max.title.length".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertTrue(hasErrorForField,
                    "Value above safe maximum must be an ERROR for this field");
        });
    }

    /**
     * Smoke test: a value in the lower warning band (10..39) produces a field finding that is
     * not marked as ERROR.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void lowWarnMaxTitleLength_producesWarningOnly() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withMaxTitleLength("15"));
            ws.validateButton.fire();

            assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Value in low warn band must produce a field finding");
            boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
                    .anyMatch(f -> "max.title.length".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertFalse(hasErrorForField,
                    "Value in low warn band must not produce an ERROR for this field");
        });
    }

    /**
     * Smoke test: a value in the upper warning band (100..120) produces a field finding that is
     * not marked as ERROR.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void highWarnMaxTitleLength_producesWarningOnly() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withMaxTitleLength("110"));
            ws.validateButton.fire();

            assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Value in high warn band must produce a field finding");
            boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
                    .anyMatch(f -> "max.title.length".equals(f.fieldKey())
                            && f.severity() == GuiMessageSeverity.ERROR);
            assertFalse(hasErrorForField,
                    "Value in high warn band must not produce an ERROR for this field");
        });
    }

    /**
     * Smoke test: the default template value of 60 produces no finding for the title-length field.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void defaultMaxTitleLength_producesNoFieldFinding() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            assertNotNull(ws.lastValidationResult(),
                    "lastValidationResult must not be null after 'Neu'");
            assertFalse(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
                    "Default value 60 must not produce a field finding");
        });
    }

    // =========================================================================
    // 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 writePropertiesFile(Path path, String activeProvider) 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=500\n"
                + "max.title.length=60\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;
    }
}
