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

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

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

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

import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;

/**
 * Smoke tests for the automatic model catalogue retrieval, the "Modelle neu laden" button,
 * and the ComboBox/TextField switching behaviour in the provider section of the editor workspace.
 *
 * <p>All tests run on the JavaFX Application Thread under Monocle headless. The model catalogue
 * port is replaced with a synchronous stub so no real HTTP calls are made and the tests are
 * fully deterministic.
 *
 * <p>The coordinator's thread factory and result-delivery mechanism are both replaced with
 * synchronous implementations so retrieval and result application happen inline on the calling
 * thread (the FX thread in these tests). This avoids any async boundary and makes assertions
 * immediately consistent after each trigger call.
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiModelCatalogSmokeTest {

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

    // =========================================================================
    // JavaFX Platform lifecycle
    // =========================================================================

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

    // =========================================================================
    // Test: Success result → ComboBox shown, first model pre-selected
    // =========================================================================

    /**
     * When the model catalogue port returns a {@link ModelCatalogResult.Success}, the provider
     * block's model field must switch to a non-editable ComboBox pre-selecting the first model.
     */
    @Test
    @Order(1)
    void successResult_comboBoxIsShownWithFirstModelSelected() throws Exception {
        List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container, "Claude model field container must be present");
            assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
                    "Source must be LIST_REMOTE_SUCCESS after successful retrieval");
            assertEquals("claude-3-5-sonnet", container.currentModelValue(),
                    "First model must be pre-selected");
        });
    }

    // =========================================================================
    // Test: EmptyList result → TextField shown
    // =========================================================================

    /**
     * When the model catalogue port returns {@link ModelCatalogResult.EmptyList}, the provider
     * block's model field must show the manual text field.
     */
    @Test
    @Order(2)
    void emptyListResult_textFieldIsShown() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container, "Claude model field container must be present");
            assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
                    "Source must be LIST_UNAVAILABLE_MANUAL_INPUT for EmptyList result");
        });
    }

    // =========================================================================
    // Test: IncompleteConfiguration result → TextField shown
    // =========================================================================

    /**
     * When the model catalogue port returns {@link ModelCatalogResult.IncompleteConfiguration},
     * the provider block's model field must show the manual text field.
     */
    @Test
    @Order(3)
    void incompleteConfigResult_textFieldIsShown() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "API-Key fehlt.");

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container, "Claude model field container must be present");
            assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
                    "Source must be LIST_UNAVAILABLE_MANUAL_INPUT for IncompleteConfiguration");
        });
    }

    // =========================================================================
    // Test: TechnicalFailure result → TextField shown with FAILED state
    // =========================================================================

    /**
     * When the model catalogue port returns {@link ModelCatalogResult.TechnicalFailure},
     * the provider block's model field must show the manual text field in the failed state.
     */
    @Test
    @Order(4)
    void technicalFailureResult_textFieldIsShownWithFailedState() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "HTTP_ERROR",
                        "Status 503");

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container, "Claude model field container must be present");
            assertEquals(GuiModelSource.LIST_FAILED_MANUAL_INPUT, container.currentSource(),
                    "Source must be LIST_FAILED_MANUAL_INPUT for TechnicalFailure");
        });
    }

    // =========================================================================
    // Test: Manual value discarded when not in new list
    // =========================================================================

    /**
     * When a manual model name is present in the text field and a subsequent successful
     * retrieval returns a list that does NOT contain that name, the value must be discarded
     * and the first item in the new list must be selected.
     */
    @Test
    @Order(5)
    void successResult_manualValueDiscardedWhenNotInList() throws Exception {
        List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            // Pre-set a manual value not present in the incoming list.
            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container);
            container.setTextFieldValue("my-custom-model");

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
                    "Source must be LIST_REMOTE_SUCCESS after successful retrieval");
            assertEquals("claude-3-5-sonnet", container.currentModelValue(),
                    "Manual value not in list must be discarded; first list item selected");
        });
    }

    // =========================================================================
    // Test: Manual value preserved when present in new list
    // =========================================================================

    /**
     * When a manual model name is present in the text field and a subsequent successful
     * retrieval returns a list that DOES contain that name, the selection must be preserved.
     */
    @Test
    @Order(6)
    void successResult_manualValuePreservedWhenInList() throws Exception {
        List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container);
            // Pre-set a value that IS in the incoming list.
            container.setTextFieldValue("claude-3-haiku");

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
                    "Source must be LIST_REMOTE_SUCCESS");
            assertEquals("claude-3-haiku", container.currentModelValue(),
                    "Manual value present in list must be preserved as selection");
        });
    }

    // =========================================================================
    // Test: Provider switch triggers automatic model retrieval
    // =========================================================================

    /**
     * Switching the provider ComboBox must automatically trigger a model retrieval for the
     * newly selected provider without requiring the user to press "Modelle neu laden".
     */
    @Test
    @Order(7)
    void providerSwitch_triggersAutomaticModelRetrieval() throws Exception {
        List<String> openAiModels = List.of("gpt-4o", "gpt-4-turbo");
        AiModelCatalogPort stub = req -> {
            if (AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier().equals(req.providerIdentifier())) {
                return new ModelCatalogResult.Success(req.providerIdentifier(), openAiModels,
                        Instant.now());
            }
            return new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
                    "Test-Stub: kein Claude-Abruf in diesem Test.");
        };

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            // Switch the provider ComboBox from Claude to OpenAI; the listener auto-triggers retrieval.
            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            assertNotNull(comboBox, "Provider ComboBox must be present");
            comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
            // Because resultDelivery is synchronous, retrieval and result application happened inline.

            GuiModelFieldContainer openAiContainer =
                    ws.modelFieldContainers.get(AiProviderFamily.OPENAI_COMPATIBLE);
            assertNotNull(openAiContainer, "OpenAI model field container must be present");
            assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, openAiContainer.currentSource(),
                    "OpenAI model source must be LIST_REMOTE_SUCCESS after automatic retrieval on switch");
            assertEquals("gpt-4o", openAiContainer.currentModelValue(),
                    "First OpenAI model must be pre-selected after automatic retrieval");
        });
    }

    // =========================================================================
    // Test: "Modelle neu laden" button triggers retrieval
    // =========================================================================

    /**
     * Pressing the "Modelle neu laden" button must trigger the same retrieval path as the
     * automatic trigger on provider switch.
     */
    @Test
    @Order(8)
    void reloadModelsButton_triggersModelRetrieval() throws Exception {
        List<String> models = List.of("claude-opus-4");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            Button reloadButton = findNodeById(ws, "modelle-neu-laden-button", Button.class);
            assertNotNull(reloadButton, "\"Modelle neu laden\" button must be present in the scene graph");

            reloadButton.fire();
            // Because resultDelivery is synchronous, result is applied immediately.

            GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
            assertNotNull(container, "Claude model field container must be present");
            assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
                    "Source must be LIST_REMOTE_SUCCESS after pressing \"Modelle neu laden\"");
            assertEquals("claude-opus-4", container.currentModelValue(),
                    "Model returned by stub must be selected after reload");
        });
    }

    // =========================================================================
    // Test: pendingMessages list receives entry after each retrieval
    // =========================================================================

    /**
     * After each model catalogue retrieval a {@link GuiMessageEntry} must be appended to
     * {@link GuiConfigurationEditorWorkspace#pendingMessages}, regardless of the result type.
     */
    @Test
    @Order(9)
    void pendingMessages_entryAppendedAfterEachRetrieval() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "TIMEOUT",
                        "Zeitüberschreitung");

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            int before = ws.pendingMessages.size();
            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertEquals(before + 1, ws.pendingMessages.size(),
                    "Exactly one message entry must be appended after retrieval");
            GuiMessageEntry entry = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
            assertEquals(GuiMessageSeverity.ERROR, entry.severity(),
                    "TechnicalFailure must produce an ERROR message entry");
            assertTrue(entry.source().isPresent(), "Message must have a source label");
            assertEquals("Modellabruf", entry.source().get(),
                    "Message source must be \"Modellabruf\"");
        });
    }

    // =========================================================================
    // Test: Success pendingMessage has INFO severity
    // =========================================================================

    /**
     * A successful model list retrieval must append a message entry with {@link GuiMessageSeverity#INFO}.
     */
    @Test
    @Order(10)
    void pendingMessages_successProducesInfoEntry() throws Exception {
        List<String> models = List.of("claude-3-5-sonnet");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
            GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
            assertEquals(GuiMessageSeverity.INFO, last.severity(),
                    "Successful retrieval must produce an INFO message");
        });
    }

    // =========================================================================
    // Test: IncompleteConfiguration pendingMessage has WARNING severity
    // =========================================================================

    /**
     * An {@link ModelCatalogResult.IncompleteConfiguration} result must append a
     * {@link GuiMessageSeverity#WARNING} entry.
     */
    @Test
    @Order(11)
    void pendingMessages_incompleteConfigProducesWarningEntry() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
                        "Kein API-Key.");

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
            GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
            assertEquals(GuiMessageSeverity.WARNING, last.severity(),
                    "IncompleteConfiguration must produce a WARNING message");
        });
    }

    // =========================================================================
    // Test: EmptyList pendingMessage has HINT severity
    // =========================================================================

    /**
     * An {@link ModelCatalogResult.EmptyList} result must append a
     * {@link GuiMessageSeverity#HINT} entry.
     */
    @Test
    @Order(12)
    void pendingMessages_emptyListProducesHintEntry() throws Exception {
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
            GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
            assertEquals(GuiMessageSeverity.HINT, last.severity(),
                    "EmptyList must produce a HINT message");
        });
    }

    // =========================================================================
    // Test: repeated retrieval replaces previous message entry, not accumulates
    // =========================================================================

    /**
     * Triggering model retrieval twice must not accumulate two "Modellabruf" entries in
     * {@code pendingMessages}. The second trigger must replace the entry from the first trigger
     * so that exactly one entry with source "Modellabruf" is present after both calls.
     * <p>
     * This verifies the fix that removes old "Modellabruf" entries at the start of
     * {@code applyResult} before appending the new one.
     */
    @Test
    @Order(13)
    void pendingMessages_repeatedRetrieval_replacesNotAccumulates() throws Exception {
        List<String> models = List.of("claude-3-5-sonnet");
        AiModelCatalogPort stub = req ->
                new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());

        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
            ws.requestNewConfiguration();

            // Trigger retrieval twice for the same provider.
            triggerRetrieval(ws, AiProviderFamily.CLAUDE);
            triggerRetrieval(ws, AiProviderFamily.CLAUDE);

            long modellabrufCount = ws.pendingMessages.stream()
                    .filter(m -> m.source().isPresent()
                            && "Modellabruf".equals(m.source().get()))
                    .count();
            assertEquals(1L, modellabrufCount,
                    "After two retrieval triggers, exactly one 'Modellabruf' entry must remain in"
                    + " pendingMessages (replace semantics, not accumulate)");
        });
    }

    // =========================================================================
    // Helpers: workspace creation with stub
    // =========================================================================

    /**
     * Creates a workspace whose model catalogue coordinator is backed by the given stub port.
     * Both the thread factory and result delivery are replaced with synchronous implementations
     * so retrieval and result application happen inline without any async boundary.
     *
     * @param stub the stub port returning deterministic results; must not be {@code null}
     * @return a workspace ready for testing; never {@code null}
     */
    private static GuiConfigurationEditorWorkspace createWorkspaceWithStub(AiModelCatalogPort stub) {
        GuiStartupContext ctx = new GuiStartupContext(
                de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
                        .createBlankStartState(),
                Optional.empty(),
                path -> de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
                        .createBlankStartState(),
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                stub,
                (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"); }
                        }));
        GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
        // Synchronous thread factory: run the task directly instead of starting an OS thread.
        ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(task, "gui-model-catalog-test") {
            @Override
            public synchronized void start() {
                run(); // run synchronously on the calling thread
            }
        };
        // Synchronous result delivery: execute the callback directly instead of via Platform.runLater.
        ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
        return ws;
    }

    /**
     * Triggers model retrieval for the given family using the current editor state.
     * Because the coordinator uses synchronous delivery, the result is applied immediately.
     *
     * @param ws     the workspace to trigger retrieval on; must not be {@code null}
     * @param family the provider family to retrieve models for; must not be {@code null}
     */
    private static void triggerRetrieval(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
        de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState pState =
                Optional.ofNullable(ws.editorState().values().providerConfiguration(family))
                        .orElse(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState
                                .blank());
        ws.modelCatalogCoordinator.triggerModelRetrieval(family, pState);
    }

    // =========================================================================
    // Helpers: scene graph traversal
    // =========================================================================

    @SuppressWarnings("unchecked")
    private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
        return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
    }

    @SuppressWarnings("unchecked")
    private static <T extends Node> T findNodeById(GuiConfigurationEditorWorkspace ws,
                                                   String id, Class<T> type) {
        return (T) findNodeByIdDeep(ws.tabPane, id);
    }

    private static Node findNodeByIdDeep(Node root, String id) {
        if (id.equals(root.getId())) {
            return root;
        }
        if (root instanceof javafx.scene.control.ScrollPane sp) {
            Node content = sp.getContent();
            if (content != null) {
                Node found = findNodeByIdDeep(content, id);
                if (found != null) return found;
            }
        } else if (root instanceof javafx.scene.control.TabPane tabPane) {
            for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
                if (tab.getContent() != null) {
                    Node found = findNodeByIdDeep(tab.getContent(), id);
                    if (found != null) return found;
                }
            }
        } else if (root instanceof javafx.scene.Parent parent) {
            for (Node child : parent.getChildrenUnmodifiable()) {
                Node found = findNodeByIdDeep(child, id);
                if (found != null) return found;
            }
        }
        return null;
    }

    private static Node findNodeDeep(Node root, Class<?> nodeType) {
        if (nodeType.isInstance(root)) {
            return root;
        }
        if (root instanceof javafx.scene.control.ScrollPane sp) {
            Node content = sp.getContent();
            if (content != null) {
                Node found = findNodeDeep(content, nodeType);
                if (found != null) return found;
            }
        } else if (root instanceof javafx.scene.control.TabPane tabPane) {
            for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
                if (tab.getContent() != null) {
                    Node found = findNodeDeep(tab.getContent(), nodeType);
                    if (found != null) return found;
                }
            }
        } else if (root instanceof javafx.scene.Parent parent) {
            for (Node child : parent.getChildrenUnmodifiable()) {
                Node found = findNodeDeep(child, nodeType);
                if (found != null) return found;
            }
        }
        return null;
    }

    // =========================================================================
    // Threading helper
    // =========================================================================

    private static void runOnFx(ThrowingRunnable task) throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Throwable> error = new AtomicReference<>();
        Platform.runLater(() -> {
            try {
                task.run();
            } catch (Throwable t) {
                error.set(t);
            } finally {
                latch.countDown();
            }
        });
        assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX task must complete within timeout");
        if (error.get() != null) {
            Throwable t = error.get();
            if (t instanceof Exception e) {
                throw e;
            }
            throw new AssertionError("Unexpected error on FX thread", t);
        }
    }

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