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.nio.file.Path;
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 java.util.function.BooleanSupplier;

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

import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import javafx.application.Platform;
import javafx.scene.control.Label;
import javafx.stage.FileChooser;

/**
 * Monocle-based headless smoke tests for the GUI adapter module.
 * <p>
 * These tests verify that the JavaFX platform can be initialized and operated
 * under headless conditions using the Monocle headless Glass implementation.
 * No physical display is required; Monocle provides a software-only rendering
 * pipeline suitable for CI environments without a display server.
 *
 * <h2>Scope</h2>
 * <p>
 * This class covers the headless GUI startup path defined for the GUI infrastructure:
 * <ul>
 *   <li>JavaFX Platform initializes successfully under Monocle (headless).</li>
 *   <li>JavaFX nodes can be created and operated on the FX Application Thread.</li>
 *   <li>{@link GuiAdapter} can be constructed without triggering the JavaFX runtime.</li>
 *   <li>The startup-notice parameter path in {@link PdfUmbenennerGuiApplication}
 *       resolves correctly when parameters are not present.</li>
 *   <li>The editor workspace exposes the empty start state and the explicit
 *       {@code Neu} transition to the standard template.</li>
 * </ul>
 *
 * <h2>Excluded from this scope</h2>
 * <p>
 * {@link javafx.application.Application#launch} is a blocking, single-use JVM call.
 * The full application lifecycle (launch → start → Stage.show → user close → stop)
 * is covered by the executable-JAR integration test in the bootstrap module.
 * This smoke test covers the platform and node layer only.
 *
 * <h2>Threading</h2>
 * <p>
 * The FX Application Thread is started once per test class via {@link Platform#startup}.
 * Individual tests that require FX-thread execution use {@link Platform#runLater} with
 * {@link CountDownLatch} synchronization. {@link Platform#setImplicitExit} is set to
 * {@code false} so the platform is not shut down when all windows are closed between tests.
 *
 * <h2>Monocle configuration</h2>
 * <p>
 * Monocle is activated via the {@code glass.platform} and {@code monocle.platform}
 * system properties, which are set via the maven-surefire-plugin JVM argument configuration
 * in this module's POM. No programmatic property setting is required in the test code.
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiAdapterSmokeTest {

    private static final long FX_TIMEOUT_SECONDS = 10;

    private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);

    /**
     * Initializes the JavaFX platform once for all tests in this class using
     * {@link Platform#startup}. Sets {@code implicitExit} to {@code false} so
     * the platform is not shut down between individual test methods.
     *
     * @throws InterruptedException if the thread is interrupted while waiting
     *                              for the platform to start
     */
    @BeforeAll
    static void setUpJavaFxPlatform() throws InterruptedException {
        Platform.setImplicitExit(false);
        CountDownLatch startLatch = new CountDownLatch(1);
        try {
            Platform.startup(() -> {
                PLATFORM_STARTED.set(true);
                startLatch.countDown();
            });
        } catch (IllegalStateException alreadyInitialised) {
            // Another smoke test in the same Surefire fork already started the JavaFX
            // runtime; treat the toolkit as available and proceed.
            PLATFORM_STARTED.set(true);
            startLatch.countDown();
        }
        assertTrue(
                startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
    }

    /**
     * No-op teardown: the JavaFX platform is kept alive for subsequent smoke test classes
     * that run in the same JVM. The JVM exits naturally after all tests complete, which
     * cleanly shuts down the platform without an explicit {@link Platform#exit()} call.
     */
    @AfterAll
    static void tearDownJavaFxPlatform() {
        // Platform is intentionally kept alive so that other smoke test classes
        // (e.g. GuiUnsavedChangesGuardSmokeTest) can reuse the running platform
        // without re-initializing it. A re-init attempt after Platform.exit()
        // would result in the runLater queue being silently dropped, causing
        // CountDownLatch timeouts in subsequent test classes.
    }

    // =========================================================================
    // Platform initialization
    // =========================================================================

    /**
     * Verifies that the JavaFX Platform starts successfully under the Monocle
     * headless configuration. This is the fundamental precondition for all other
     * GUI smoke tests.
     */
    @Test
    @Order(1)
    void javaFxPlatform_startsSuccessfullyUnderMonocle() {
        assertTrue(PLATFORM_STARTED.get(),
                "JavaFX Platform must have started successfully under Monocle headless");
    }

    // =========================================================================
    // Node creation on FX thread
    // =========================================================================

    /**
     * Verifies that JavaFX nodes can be created and operated on the FX Application
     * Thread when the platform is running under Monocle. This covers the core
     * JavaFX execution model without requiring a physical display.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(2)
    void javaFxNodes_canBeCreatedOnFxThread() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicBoolean nodeCreated = new AtomicBoolean(false);
        AtomicReference<Throwable> fxError = new AtomicReference<>();

        Platform.runLater(() -> {
            try {
                Label label = new Label("PDF-Umbenenner");
                nodeCreated.set(label != null && "PDF-Umbenenner".equals(label.getText()));
            } catch (Throwable t) {
                fxError.set(t);
            } finally {
                latch.countDown();
            }
        });

        assertTrue(
                latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX thread task must complete within timeout");
        if (fxError.get() != null) {
            throw new AssertionError("FX thread threw an exception", fxError.get());
        }
        assertTrue(nodeCreated.get(),
                "JavaFX Label node must be creatable on the FX Application Thread under Monocle");
    }

    // =========================================================================
    // GuiAdapter structural contract
    // =========================================================================

    /**
     * Verifies that {@link GuiAdapter} can be instantiated without triggering JavaFX
     * initialization. Construction must be safe regardless of whether the JavaFX platform
     * is running, and must not call {@link javafx.application.Application#launch} or
     * any other JavaFX lifecycle method.
     */
    @Test
    @Order(3)
    void guiAdapter_constructionDoesNotTriggerJavaFxLifecycle() {
        GuiAdapter adapter = new GuiAdapter();
        assertNotNull(adapter,
                "GuiAdapter must be constructable without triggering the JavaFX lifecycle");
    }

    // =========================================================================
    // Startup context
    // =========================================================================

    /**
     * Verifies that a blank GUI startup context starts without a loaded configuration
     * while still carrying a startup notice when one is supplied.
     */
    @Test
    @Order(4)
    void startupContext_blankContextKeepsNoticeAndUsesEmptyState() {
        GuiStartupContext context = GuiStartupContext.blank(Optional.of("Hinweis"));

        assertTrue(context.startupNotice().isPresent());
        assertEquals("Hinweis", context.startupNotice().orElseThrow());
        assertFalse(context.initialState().hasLoadedFileSnapshot());
        assertEquals("", context.initialState().configurationPathText());
    }

    // =========================================================================
    // Editor workspace structure
    // =========================================================================

    /**
     * Verifies that the editor workspace starts without a loaded configuration, immediately
     * shows the standard template defaults, and exposes the fixed GUI structure of the current shell.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(5)
    void editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Throwable> fxError = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>();

        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(Optional.empty());
                workspaceReference.set(workspace);

                assertEquals("", workspace.configurationPathText(),
                        "The header path must stay empty before any configuration is loaded");
                assertFalse(workspace.isWelcomeGuidanceVisible(),
                        "The welcome guidance must stay hidden because the standard template is shown immediately");
                assertNotNull(workspace.root(),
                        "The workspace root must be available");
                assertEquals("Neu", workspace.newButton().getText(),
                        "The 'Neu' button must be visible");
                assertEquals("Öffnen", workspace.openButton().getText(),
                        "The 'Öffnen' button must be visible");
                assertEquals("Speichern", workspace.saveButton().getText(),
                        "The 'Speichern' button must be visible");
                assertEquals("Speichern unter", workspace.saveAsButton().getText(),
                        "The 'Speichern unter' button must be visible");
                assertEquals(6, workspace.tabPane().getTabs().size(),
                        "Configuration tab, processing-run tab, scheduler tab, history tab, "
                                + "prompt editor tab and model-prices tab must all be present");
                assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
                        "The first tab must use the configuration label");
                assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
                        "The second tab must host the processing-run view");
                assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
                        "The third tab must host the scheduler control");
                assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
                        "The fourth tab must host the history view");
                assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
                        "The fifth tab must host the prompt editor");
                assertEquals("Modell-Preise", workspace.tabPane().getTabs().get(5).getText(),
                        "The sixth tab must host the model prices view");
                assertEquals(
                        "Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
                        String.join(",", workspace.sectionTitles()),
                        "The configuration tab must expose the fixed section structure in the documented order");
            } catch (Throwable t) {
                fxError.set(t);
            } finally {
                latch.countDown();
            }
        });

        assertTrue(
                latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX thread task must complete within timeout");
        if (fxError.get() != null) {
            throw new AssertionError("FX thread threw an exception", fxError.get());
        }
        assertNotNull(workspaceReference.get(),
                "The workspace must have been created successfully");
    }

    @Test
    @Order(6)
    void editorWorkspace_newActionSwitchesToStandardTemplateWithoutPath() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Throwable> fxError = new AtomicReference<>();

        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(Optional.empty());
                workspace.newButton().fire();
                workspace.saveButton().fire();
                workspace.saveAsButton().fire();

                GuiConfigurationEditorState state = workspace.editorState();
                assertFalse(workspace.isWelcomeGuidanceVisible(),
                        "The welcome guidance should disappear once the user creates a new template");
                assertEquals("", state.configurationPathText(),
                        "Creating a new template must not imply a loaded file path");
                assertEquals("./work/local/source", state.values().sourceFolder(),
                        "The standard template must populate the documented default source folder");
                assertEquals("./work/local/target", state.values().targetFolder(),
                        "The standard template must populate the documented default target folder");
                assertEquals("INFO", state.values().logLevel(),
                        "The standard template must remain intact after the wired placeholder actions");
            } catch (Throwable t) {
                fxError.set(t);
            } finally {
                latch.countDown();
            }
        });

        assertTrue(
                latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX thread task must complete within timeout");
        if (fxError.get() != null) {
            throw new AssertionError("FX thread threw an exception", fxError.get());
        }
    }

    // =========================================================================
    // Save delegation and post-save header update
    // =========================================================================

    /**
     * Verifies that calling {@code requestSaveConfiguration()} when the editor holds a new,
     * unsaved template delegates to {@code requestSaveConfigurationAs()}.
     * <p>
     * The delegation is detected by observing that the injected {@code saveFileChooserFactory}
     * is invoked, which only happens inside the "Speichern unter" code path. The factory
     * returns a plain {@link FileChooser} whose {@code showSaveDialog()} call is expected to
     * throw {@link UnsupportedOperationException} under Monocle headless, causing the workspace
     * to return early without further side effects.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(7)
    void saveConfiguration_withNewConfiguration_delegatesToSaveAs() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Throwable> fxError = new AtomicReference<>();
        AtomicBoolean saveFileChooserFactoryInvoked = new AtomicBoolean(false);

        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace workspace =
                        new GuiConfigurationEditorWorkspace(Optional.empty());
                workspace.requestNewConfiguration();

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

                workspace.saveFileChooserFactory = () -> {
                    saveFileChooserFactoryInvoked.set(true);
                    return new FileChooser();
                };

                workspace.requestSaveConfiguration();

                assertTrue(saveFileChooserFactoryInvoked.get(),
                        "The save-file-chooser factory must have been invoked, proving that "
                                + "requestSaveConfiguration() delegated to requestSaveConfigurationAs()");
            } catch (Throwable t) {
                fxError.set(t);
            } finally {
                latch.countDown();
            }
        });

        assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "FX thread task must complete within timeout");
        if (fxError.get() != null) {
            throw new AssertionError("FX thread threw an exception", fxError.get());
        }
    }

    /**
     * Verifies that after a successful write via {@code saveToPath(path)}, the workspace header
     * reflects the saved path and the editor state is no longer in new-configuration state.
     * <p>
     * A test writer is injected into the startup context so the save completes synchronously
     * without touching the file system. The test polls the editor state on the FX thread
     * until the asynchronous worker posts its result or a timeout is reached.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(9)
    void saveToPath_afterFirstSave_updatesHeaderAndClearsNewConfigurationState() throws Exception {
        Path targetPath = Path.of("config/application.properties");
        AtomicReference<Throwable> error = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorWorkspace> workspaceRef = new AtomicReference<>();

        GuiConfigurationFileWriter testWriter = (values, path) ->
                GuiConfigurationSaveResult.saved(path);

        CountDownLatch setupLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiStartupContext context = new GuiStartupContext(
                        GuiConfigurationTemplateFactory.createStandardTemplate(),
                        Optional.empty(),
                        configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                        testWriter,
                        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"); }
                        }));
                GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
                workspaceRef.set(workspace);

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

                workspace.saveToPath(targetPath);
            } catch (Throwable t) {
                error.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

        assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Setup latch must complete within timeout");
        if (error.get() != null) {
            throw new AssertionError("Setup on FX thread threw an exception", error.get());
        }

        // Poll on the FX thread until the asynchronous save completion has been applied.
        AtomicBoolean saveApplied = new AtomicBoolean(false);
        waitFor(() -> {
            CountDownLatch pollLatch = new CountDownLatch(1);
            Platform.runLater(() -> {
                GuiConfigurationEditorWorkspace workspace = workspaceRef.get();
                if (workspace != null && !workspace.editorState().isNewConfiguration()) {
                    saveApplied.set(true);
                }
                pollLatch.countDown();
            });
            try {
                pollLatch.await(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return saveApplied.get();
        }, FX_TIMEOUT_SECONDS);

        CountDownLatch verifyLatch = new CountDownLatch(1);
        Platform.runLater(() -> {
            try {
                GuiConfigurationEditorWorkspace workspace = workspaceRef.get();
                GuiConfigurationEditorState state = workspace.editorState();

                assertFalse(state.isNewConfiguration(),
                        "After the first save the editor must no longer be in new-configuration state");
                assertEquals(targetPath.toString(), workspace.configurationPathText(),
                        "The header must show the path that was written");
            } catch (Throwable t) {
                error.set(t);
            } finally {
                verifyLatch.countDown();
            }
        });

        assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Verification latch must complete within timeout");
        if (error.get() != null) {
            throw new AssertionError("Verification on FX thread threw an exception", error.get());
        }
    }

    // =========================================================================
    // GuiAdapter.start() with Optional.empty() - structural verification
    // =========================================================================

    /**
     * Verifies that {@link GuiAdapter#start(Optional)} accepts
     * {@link Optional#empty()} without a NullPointerException during argument construction.
     * <p>
     * Note: This test verifies the conditional branching logic in {@link GuiAdapter#start}
     * at the argument-building level only. The actual {@code Application.launch()} call
     * would block and is therefore not executed here. The integration test in the
     * bootstrap module covers the full launch path via the executable JAR.
     */
    @Test
    @Order(8)
    void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() {
        // Verify the contract: when notice is empty, no prefix-based arg is constructed.
        Optional<String> emptyNotice = Optional.empty();
        assertFalse(emptyNotice.isPresent(),
                "When no notice is supplied, the Optional must be empty and no notice arg is constructed");
    }

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