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.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.Test;

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

/**
 * Monocle-based headless smoke tests for the explicit "Validieren" action.
 * <p>
 * Verifies that clicking the "Validieren" button in the "Tests" section executes
 * the in-memory validation against the current editor state, reports the findings
 * via the central message area, and neither writes any file nor triggers remote calls.
 *
 * <h2>Covered scenarios</h2>
 * <ul>
 *   <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and
 *       an INFO message reporting the finding count plus one entry per concrete finding.</li>
 *   <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and
 *       an INFO message reporting "Keine Befunde." or a zero count.</li>
 *   <li>Clicking "Validieren" twice appends a second action-confirmation INFO message
 *       (accumulation semantics; each click adds a fresh snapshot).</li>
 *   <li>Clicking "Validieren" does not trigger any file write (the writer stub records no
 *       calls).</li>
 *   <li>The button is findable by its CSS ID {@code validate-button}.</li>
 * </ul>
 *
 * <h2>Threading</h2>
 * All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
 * The Monocle headless configuration is activated by the Surefire JVM arguments.
 */
class GuiValidateActionSmokeTest {

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

    /** Source tag used by the validation action INFO message. */
    private static final String ACTION_SOURCE = "Validierung-Aktion";

    @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: button is findable by CSS ID
    // =========================================================================

    /**
     * Smoke test: after constructing a workspace, the "Validieren" button exists
     * and carries the CSS ID {@code validate-button}.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateButton_hasCssId() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws =
                    new GuiConfigurationEditorWorkspace(Optional.empty());
            Button btn = ws.validateButton;
            assertNotNull(btn, "validateButton must not be null");
            assertEquals("validate-button", btn.getId(),
                    "validateButton must carry CSS ID 'validate-button'");
        });
    }

    // =========================================================================
    // Scenario: incomplete configuration → ERROR findings + INFO message with count
    // =========================================================================

    /**
     * Smoke test: after clicking "Validieren" on a workspace whose editor state has
     * an empty active-provider value, the last validation result contains at least one
     * ERROR and the central message area contains one INFO confirmation with source
     * "Validierung-Aktion" plus one entry per concrete finding with the same source.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateAction_incompleteConfiguration_producesErrorsAndInfoMessage() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = buildWorkspace();

            // Force an incomplete state: replace the editor state with a truly empty one
            // (no active provider, no template values) so validation produces errors.
            ws.editorState = GuiConfigurationTemplateFactory.createEmptyStartState();

            ws.validateButton.fire();

            GuiEditorValidationResult result = ws.lastValidationResult();
            assertTrue(result.hasErrors(),
                    "Clicking Validieren on incomplete config must produce ERROR findings");

            List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
                    .filter(m -> m.source().isPresent()
                            && ACTION_SOURCE.equals(m.source().get()))
                    .toList();

            assertFalse(actionMessages.isEmpty(),
                    "At least one action message must be present");
            GuiMessageEntry confirmation = actionMessages.get(0);
            assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
                    "First action message must be the INFO confirmation");
            assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
                    "Action-confirmation message text must start with expected prefix");
            assertFalse(confirmation.text().contains("Keine Befunde"),
                    "With errors present the confirmation must NOT say 'Keine Befunde'");
            assertTrue(actionMessages.size() > 1,
                    "Each concrete finding must be listed as its own action message");
        });
    }

    // =========================================================================
    // Scenario: valid configuration → no ERRORs + INFO message "Keine Befunde."
    // =========================================================================

    /**
     * Smoke test: after clicking "Validieren" on a workspace with the standard template
     * values loaded, the last validation result contains no ERRORs and the action-confirmation
     * message either contains "Keine Befunde." or a zero finding count.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateAction_validTemplate_noErrorsAndNoBefundeMessage() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = buildWorkspace();
            ws.requestNewConfiguration();

            ws.validateButton.fire();

            GuiEditorValidationResult result = ws.lastValidationResult();
            assertFalse(result.hasErrors(),
                    "Clicking Validieren on standard template must produce no ERROR findings");

            List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
                    .filter(m -> m.source().isPresent()
                            && ACTION_SOURCE.equals(m.source().get()))
                    .toList();

            assertFalse(actionMessages.isEmpty(),
                    "At least one action message must be present");
            GuiMessageEntry confirmation = actionMessages.get(0);
            assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
                    "First action message must be the INFO confirmation");
            // Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0.
            assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
                    "Action-confirmation message text must start with expected prefix");
        });
    }

    // =========================================================================
    // Scenario: "Validieren" with unsaved (dirty) editor state — validates current content
    // =========================================================================

    /**
     * Smoke test: when the editor holds unsaved changes that introduce a validation error,
     * clicking "Validieren" must reflect the <em>current, modified (dirty)</em> editor state,
     * not the previously saved or baseline state.
     *
     * <p>The test loads the standard template (which has a valid active-provider value and
     * produces no errors), then directly overwrites the in-memory editor state with an
     * equivalent state whose active-provider value is cleared. This mimics a user editing a
     * field without saving. A subsequent click on "Validieren" must produce ERROR findings
     * that originate from the unsaved change — proving that validation is always based on the
     * current editor content.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateAction_withUnsavedDirtyChange_validatesCurrentState() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = buildWorkspace();

            // Load the template so the baseline is valid (no errors expected initially).
            ws.requestNewConfiguration();

            // Confirm: template produces no errors.
            ws.validateButton.fire();
            assertFalse(ws.lastValidationResult().hasErrors(),
                    "Standard template must produce no errors before the dirty change");

            // Simulate an unsaved edit: replace the editor state with a version that has
            // an empty active-provider value. The baseline remains the template values,
            // so isDirty() returns true. The workspace field is package-private.
            GuiConfigurationEditorState currentState = ws.editorState;
            GuiConfigurationEditorState dirtyState = currentState.withValues(
                    currentState.values().withActiveProviderFamily(""));
            ws.editorState = dirtyState;

            // Confirm the editor is now dirty (sanity check).
            assertTrue(ws.editorState.isDirty(),
                    "Editor state must be dirty after the unsaved change");

            // Click "Validieren" again — must validate the dirty (unsaved) state.
            ws.validateButton.fire();

            assertTrue(ws.lastValidationResult().hasErrors(),
                    "Validieren must produce ERROR findings reflecting the unsaved dirty state, "
                    + "not the previously clean baseline state");
        });
    }

    // =========================================================================
    // Scenario: clicking twice → exactly one message present (replace semantics)
    // =========================================================================

    /**
     * Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
     * INFO message in the message list. Each click clears the previous messages before
     * adding the new result, so messages from an earlier click do not accumulate.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateAction_clickedTwice_infoMessageAppearsTwice() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = buildWorkspace();

            ws.validateButton.fire();
            ws.validateButton.fire();

            long confirmationCount = ws.pendingMessages.stream()
                    .filter(m -> m.source().isPresent()
                            && ACTION_SOURCE.equals(m.source().get()))
                    .filter(m -> m.text().startsWith("Aktion Validieren wurde ausgeführt."))
                    .count();
            assertEquals(1, confirmationCount,
                    "After two clicks exactly one action-confirmation INFO message must be present"
                    + " (second click replaces messages from the first click)");
        });
    }

    // =========================================================================
    // Scenario: clicking Validieren does not trigger a file write
    // =========================================================================

    /**
     * Smoke test: clicking "Validieren" must not invoke the configuration file writer.
     * The writer stub records whether it was called; it must remain uncalled after
     * the button is fired.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    void validateAction_doesNotTriggerFileWrite() throws Exception {
        runOnFx(() -> {
            AtomicBoolean writerCalled = new AtomicBoolean(false);

            GuiConfigurationEditorState blankState =
                    GuiConfigurationEditorStateFactory.createBlankStartState();

            GuiStartupContext ctx = new GuiStartupContext(
                    blankState,
                    Optional.empty(),
                    path -> blankState,  // no-op loader
                    (values, path) -> {
                        writerCalled.set(true);
                        return GuiConfigurationSaveResult.saved(path);
                    },
                    req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
                            .ModelCatalogResult.IncompleteConfiguration(
                                    req.providerIdentifier(), "kein Port im Test"),
                    noOpApiKeyResolutionPort(),
                        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"),
                                noOpApiKeyResolutionPort()),
                        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"),
                                noOpApiKeyResolutionPort()),
                        () -> java.util.Optional.empty()),
                new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
                        new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                            @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
                        }));

            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
            ws.validateButton.fire();

            assertFalse(writerCalled.get(),
                    "Clicking Validieren must not invoke the file writer");
        });
    }

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

    /**
     * Returns a no-op {@link de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort}
     * that always reports {@code ABSENT}. Used for tests that do not exercise actual API-key
     * resolution.
     */
    private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
            noOpApiKeyResolutionPort() {
        return (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
                .EffectiveApiKeyDescriptor.absent();
    }

    /**
     * Builds a workspace with no-op loader/writer and absent API-key resolution,
     * suitable for in-memory validation tests.
     */
    private static GuiConfigurationEditorWorkspace buildWorkspace() {
        GuiConfigurationEditorState blankState =
                GuiConfigurationEditorStateFactory.createBlankStartState();

        GuiStartupContext ctx = new GuiStartupContext(
                blankState,
                Optional.empty(),
                path -> blankState,
                (values, path) -> GuiConfigurationSaveResult.saved(path),
                req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
                        .ModelCatalogResult.IncompleteConfiguration(
                                req.providerIdentifier(), "kein Port im Test"),
                noOpApiKeyResolutionPort(),
                        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"),
                                noOpApiKeyResolutionPort()),
                        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"),
                                noOpApiKeyResolutionPort()),
                        () -> 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"); }
                        }));

        return new GuiConfigurationEditorWorkspace(ctx);
    }

    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);
    }

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