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.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.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.layout.VBox;

/**
 * Smoke tests for the provider selection ComboBox, provider block visibility management
 * and state preservation on provider switch.
 *
 * <p>All tests run on the JavaFX Application Thread under Monocle headless. The tests verify:
 * <ul>
 *   <li>Initial ComboBox selection matches the active provider from the editor state.</li>
 *   <li>Only the active provider block is visible; the other is not visible and not managed.</li>
 *   <li>After a provider switch the previously hidden provider's data is still intact in the
 *       editor state (no data loss on switch).</li>
 *   <li>After a provider switch the {@code ai.provider.active} value is updated correctly.</li>
 *   <li>The provider ComboBox is not editable.</li>
 *   <li>{@link GuiVisibleProviderSection} correctly reflects the visible/hidden split.</li>
 * </ul>
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiProviderSelectionSmokeTest {

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

    // =========================================================================
    // Initial state: ComboBox selects the active provider
    // =========================================================================

    /**
     * After loading the standard template (active provider: Claude) the provider ComboBox
     * must pre-select Claude and the Claude block must be visible.
     */
    @Test
    @Order(1)
    void afterNew_comboBoxSelectsClaudeAndClaudeBlockIsVisible() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Standard template uses Claude as active provider.
            assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
                    ws.editorState().values().activeProviderFamily(),
                    "Precondition: standard template active provider must be Claude");

            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            assertNotNull(comboBox, "Provider ComboBox must be present in the section");
            assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
                    "ComboBox must pre-select Claude when active provider is Claude");

            // Exactly one block must be visible.
            VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
            VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
            assertNotNull(claudeBlock, "Claude block must exist in the section");
            assertNotNull(openaiBlock, "OpenAI block must exist in the section");

            assertTrue(claudeBlock.isVisible(), "Claude block must be visible");
            assertTrue(claudeBlock.isManaged(), "Claude block must be managed");
            assertFalse(openaiBlock.isVisible(), "OpenAI block must not be visible");
            assertFalse(openaiBlock.isManaged(), "OpenAI block must not be managed");
        });
    }

    // =========================================================================
    // ComboBox is not editable
    // =========================================================================

    /**
     * The provider ComboBox must not be editable so the user cannot type arbitrary text
     * into the selection field.
     */
    @Test
    @Order(2)
    void providerComboBox_isNotEditable() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            assertNotNull(comboBox, "Provider ComboBox must be present");
            assertFalse(comboBox.isEditable(), "Provider ComboBox must not be editable");
        });
    }

    // =========================================================================
    // Provider switch: visibility toggles correctly
    // =========================================================================

    /**
     * After switching from Claude to OpenAI-kompatibel the OpenAI block must become visible
     * and managed, and the Claude block must become invisible and unmanaged.
     */
    @Test
    @Order(3)
    void switchToOpenAi_openAiBlockBecomesVisibleClaudeBlockHides() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            assertNotNull(comboBox, "Provider ComboBox must be present");
            assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
                    "Precondition: Claude must be pre-selected");

            // Simulate user switching the ComboBox to OpenAI-compatible.
            comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);

            VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
            VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);

            assertFalse(claudeBlock.isVisible(), "Claude block must be hidden after switch to OpenAI");
            assertFalse(claudeBlock.isManaged(), "Claude block must be unmanaged after switch to OpenAI");
            assertTrue(openaiBlock.isVisible(), "OpenAI block must be visible after switch");
            assertTrue(openaiBlock.isManaged(), "OpenAI block must be managed after switch");
        });
    }

    // =========================================================================
    // Provider switch: ai.provider.active is updated
    // =========================================================================

    /**
     * After switching the provider ComboBox to OpenAI-compatible the {@code ai.provider.active}
     * value in the editor state must be updated to the OpenAI-compatible identifier.
     */
    @Test
    @Order(4)
    void switchToOpenAi_activeProviderValueUpdatedInEditorState() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);

            assertEquals(AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier(),
                    ws.editorState().values().activeProviderFamily(),
                    "ai.provider.active must reflect the newly selected provider");
        });
    }

    // =========================================================================
    // Provider switch: hidden provider data is preserved
    // =========================================================================

    /**
     * After switching the provider the previously hidden provider's configuration data must
     * remain intact in the editor state.
     * <p>
     * This test explicitly sets a distinct model name on the Claude provider, switches to
     * OpenAI-compatible, and then verifies the Claude model name is still present in the
     * editor state after the switch.
     */
    @Test
    @Order(5)
    void switchProvider_hiddenProviderDataIsPreserved() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            // Record the original OpenAI model from the standard template.
            String originalOpenAiModel = ws.editorState().values()
                    .providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();

            // Set a distinctive model name on the Claude provider (visible at this point).
            GuiProviderConfigurationState currentClaude =
                    ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE);
            String distinctiveClaudeModel = "claude-test-model-preserved";
            GuiProviderConfigurationState updatedClaude = new GuiProviderConfigurationState(
                    currentClaude.baseUrl(),
                    distinctiveClaudeModel,
                    currentClaude.timeoutSeconds(),
                    currentClaude.apiKey());
            ws.editorState = ws.editorState().withValues(
                    ws.editorState().values().withProviderConfiguration(AiProviderFamily.CLAUDE, updatedClaude));

            // Switch from Claude to OpenAI-compatible.
            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);

            // Claude's model must still be present in the editor state after the switch.
            String claudeModelAfterSwitch = ws.editorState().values()
                    .providerConfiguration(AiProviderFamily.CLAUDE).model();
            assertEquals(distinctiveClaudeModel, claudeModelAfterSwitch,
                    "Claude model must not be lost when switching to OpenAI-compatible");

            // OpenAI model must also be untouched.
            String openAiModelAfterSwitch = ws.editorState().values()
                    .providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();
            assertEquals(originalOpenAiModel, openAiModelAfterSwitch,
                    "OpenAI model must remain unchanged after switch");
        });
    }

    // =========================================================================
    // Provider switch: switch back restores first provider visibility
    // =========================================================================

    /**
     * Switching to OpenAI and then back to Claude must restore the Claude block as visible
     * and hide the OpenAI block again. The {@code ai.provider.active} value must reflect Claude.
     */
    @Test
    @Order(6)
    void switchBackToClaude_claudeBlockVisibleActiveProviderUpdated() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
            comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
            comboBox.setValue(AiProviderFamily.CLAUDE);

            VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
            VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);

            assertTrue(claudeBlock.isVisible(), "Claude block must be visible after switching back");
            assertTrue(claudeBlock.isManaged(), "Claude block must be managed after switching back");
            assertFalse(openaiBlock.isVisible(), "OpenAI block must be hidden after switching back");
            assertFalse(openaiBlock.isManaged(), "OpenAI block must be unmanaged after switching back");

            assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
                    ws.editorState().values().activeProviderFamily(),
                    "ai.provider.active must reflect Claude after switching back");
        });
    }

    // =========================================================================
    // visibleProviderSection reflects current state
    // =========================================================================

    /**
     * After loading the standard template {@link GuiConfigurationEditorWorkspace#visibleProviderSection}
     * must reflect Claude as the visible provider.
     */
    @Test
    @Order(7)
    void afterNew_visibleProviderSectionReflectsClaudeAsVisible() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            GuiVisibleProviderSection section = ws.visibleProviderSection;
            assertNotNull(section, "visibleProviderSection must not be null after loading a configuration");
            assertEquals(AiProviderFamily.CLAUDE, section.visibleProvider(),
                    "Visible provider in the section snapshot must be Claude");
            assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, section.hiddenProvider(),
                    "Hidden provider in the section snapshot must be OpenAI-compatible");
        });
    }

    // =========================================================================
    // Helper: find provider ComboBox and blocks inside the workspace
    // =========================================================================

    /**
     * Locates the provider {@link ComboBox} by traversing the workspace scene graph.
     * Uses a deep traversal that also visits {@link javafx.scene.control.ScrollPane} viewport
     * content and {@link javafx.scene.control.TabPane} tab content nodes, which are not
     * accessible via {@code getChildrenUnmodifiable()} on their parent containers.
     *
     * @param ws the workspace whose root is searched; must not be {@code null}
     * @return the ComboBox, or {@code null} when not found
     */
    @SuppressWarnings("unchecked")
    private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
        return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
    }

    /**
     * Locates the provider block {@link VBox} for the given family.
     * <p>
     * Provider blocks are identified by the presence of {@code -fx-border-color: #c8c8c8}
     * in their inline style. Claude is the first block, OpenAI-compatible the second.
     *
     * @param ws     the workspace to search; must not be {@code null}
     * @param family the provider family to locate; must not be {@code null}
     * @return the block VBox, or {@code null} when not found
     */
    private static VBox findProviderBlock(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
        java.util.List<VBox> blocks = collectProviderBlocks(ws.tabPane);
        if (family == AiProviderFamily.CLAUDE) {
            return blocks.isEmpty() ? null : blocks.get(0);
        } else {
            return blocks.size() < 2 ? null : blocks.get(1);
        }
    }

    /**
     * Collects all provider block VBoxes identified by the provider-block inline style.
     *
     * @param root the starting node; must not be {@code null}
     * @return ordered list of provider block VBoxes
     */
    private static java.util.List<VBox> collectProviderBlocks(Node root) {
        java.util.List<VBox> result = new java.util.ArrayList<>();
        collectProviderBlocksInto(root, result);
        return result;
    }

    private static void collectProviderBlocksInto(Node node, java.util.List<VBox> result) {
        if (node instanceof VBox vbox) {
            String style = vbox.getStyle();
            if (style != null && style.contains("-fx-border-color: #c8c8c8")) {
                result.add(vbox);
                // Do NOT recurse into provider blocks themselves to avoid nested matches.
                return;
            }
        }
        if (node instanceof javafx.scene.control.ScrollPane sp) {
            Node content = sp.getContent();
            if (content != null) {
                collectProviderBlocksInto(content, result);
            }
        } else if (node instanceof javafx.scene.control.TabPane tabPane) {
            for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
                if (tab.getContent() != null) {
                    collectProviderBlocksInto(tab.getContent(), result);
                }
            }
        } else if (node instanceof javafx.scene.Parent parent) {
            for (Node child : parent.getChildrenUnmodifiable()) {
                collectProviderBlocksInto(child, result);
            }
        }
    }

    /**
     * Finds the first node of the requested type using a deep traversal that visits
     * {@link javafx.scene.control.ScrollPane} and {@link javafx.scene.control.TabPane} content.
     *
     * @param root     the starting node; must not be {@code null}
     * @param nodeType the type to search for; must not be {@code null}
     * @return the first matching node, or {@code null} when not found
     */
    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;
    }
}
