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.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.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;

/**
 * Smoke tests for the field-to-state bidirectional binding, path-picker hooks and the
 * threading contract for the overwrite-existence check introduced by the full editor surface.
 *
 * <p>All tests run on the FX Application Thread under Monocle headless. Native dialog calls
 * are intercepted via the injectable hook fields on the workspace.
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiEditorFieldBindingTest {

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

    // =========================================================================
    // Workspace initialises with standard template values
    // =========================================================================

    /**
     * Verifies that after "Neu" the editor state reflects the standard template defaults
     * for all major fields.
     */
    @Test
    @Order(1)
    void afterNew_editorStateContainsTemplateDefaults() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            GuiConfigurationValues v = ws.editorState().values();
            assertEquals("./work/local/source", v.sourceFolder(),
                    "Source folder must match the standard template default");
            assertEquals("./work/local/target", v.targetFolder(),
                    "Target folder must match the standard template default");
            assertEquals("./work/local/pdf-umbenenner.db", v.sqliteFile(),
                    "SQLite file must match the standard template default");
            assertEquals("./config/prompts/template.txt", v.promptTemplateFile(),
                    "Prompt file must match the standard template default");
            assertEquals("3", v.maxRetriesTransient(),
                    "Max retries must match the standard template default");
            assertEquals("10", v.maxPages(),
                    "Max pages must match the standard template default");
            assertEquals("1000", v.maxTextCharacters(),
                    "Max text characters must match the standard template default");
            assertEquals("60", v.maxTitleLength(),
                    "Max title length must match the standard template default");
            assertEquals("false", v.logAiSensitive(),
                    "log.ai.sensitive must match the standard template default (false)");
        });
    }

    // =========================================================================
    // Windows-style path round-trip
    // =========================================================================

    /**
     * Verifies that a Windows mapped-drive path survives a set/get round-trip through
     * {@link GuiConfigurationValues} without any transformation.
     */
    @Test
    @Order(2)
    void windowsMappedDrivePath_survivesRoundTrip() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            String windowsPath = "S:\\Dokumente\\Eingang";
            GuiConfigurationValues updated = ws.editorState().values().withSourceFolder(windowsPath);
            ws.editorState = ws.editorState().withValues(updated);

            assertEquals(windowsPath, ws.editorState().values().sourceFolder(),
                    "Windows mapped-drive path must survive a set/get round-trip unchanged");
        });
    }

    /**
     * Verifies that a Windows path with a drive letter and deep subfolders survives unchanged.
     */
    @Test
    @Order(3)
    void windowsDeepPath_remainsUnchangedAfterRoundTrip() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            String path = "H:\\Archiv\\2024\\Rechnungen\\eingehend";
            GuiConfigurationValues updated = ws.editorState().values()
                    .withTargetFolder(path)
                    .withSqliteFile("H:\\Archiv\\db\\umbenenner.sqlite3");
            ws.editorState = ws.editorState().withValues(updated);

            assertEquals(path, ws.editorState().values().targetFolder(),
                    "Windows deep path must remain intact");
            assertEquals("H:\\Archiv\\db\\umbenenner.sqlite3", ws.editorState().values().sqliteFile(),
                    "SQLite path with Windows drive letter must remain intact");
        });
    }

    // =========================================================================
    // Directory-picker hook: selection updates editor state
    // =========================================================================

    /**
     * Verifies that when the directory-picker hook returns a specific path the editor state
     * is updated accordingly.
     * <p>
     * The hook replaces the native dialog so the test runs headless. This mirrors what the
     * "Quellordner"-button handler does: it calls the picker, and if the result is non-null
     * it writes the value into the editor state.
     */
    @Test
    @Order(4)
    void directoryPickerHook_whenPathSelected_updatesSourceFolderInState() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            String expected = "S:\\Quellordner";

            // Replace the directory-picker hook: always return the expected path.
            ws.directoryPickerDialog = (title, initialPath) -> expected;

            // Simulate what the button handler does: call picker, update state on non-null result.
            String picked = ws.directoryPickerDialog.apply("Quellordner ausw\u00e4hlen",
                    ws.editorState().values().sourceFolder());
            if (picked != null) {
                ws.editorState = ws.editorState()
                        .withValues(ws.editorState().values().withSourceFolder(picked));
            }

            assertEquals(expected, ws.editorState().values().sourceFolder(),
                    "After picker selection the editor state must reflect the chosen path");
        });
    }

    // =========================================================================
    // File-picker hook: cancel leaves state unchanged
    // =========================================================================

    /**
     * Verifies that when the file-picker hook returns {@code null} (cancelled) the editor
     * state remains unchanged.
     */
    @Test
    @Order(5)
    void filePickerHook_whenCancelled_leavesEditorStateUnchanged() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            String originalSqlite = ws.editorState().values().sqliteFile();

            // Replace the file-picker hook: always return null (cancel).
            ws.filePickerDialog = (title, initialPath, filters) -> null;

            // Simulate button handler: null result means do nothing.
            String picked = ws.filePickerDialog.pick("SQLite-Datei ausw\u00e4hlen",
                    ws.editorState().values().sqliteFile(), java.util.List.of());
            if (picked != null) {
                ws.editorState = ws.editorState()
                        .withValues(ws.editorState().values().withSqliteFile(picked));
            }

            assertEquals(originalSqlite, ws.editorState().values().sqliteFile(),
                    "Cancelled file picker must leave the editor state unchanged");
        });
    }

    // =========================================================================
    // Provider fields: updating one provider does not affect the other
    // =========================================================================

    /**
     * Verifies that updating the Claude provider model does not modify the OpenAI-compatible
     * provider configuration.
     */
    @Test
    @Order(6)
    void updatingClaudeModel_doesNotAffectOpenAiBlock() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            GuiProviderConfigurationState originalOpenAi =
                    ws.editorState().values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE);

            // Update Claude model only.
            GuiProviderConfigurationState currentClaude =
                    ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE);
            GuiProviderConfigurationState updatedClaude = new GuiProviderConfigurationState(
                    currentClaude.baseUrl(), "claude-3-opus", currentClaude.timeoutSeconds(),
                    currentClaude.apiKey());

            GuiConfigurationValues updated = ws.editorState().values()
                    .withProviderConfiguration(AiProviderFamily.CLAUDE, updatedClaude);
            ws.editorState = ws.editorState().withValues(updated);

            assertEquals("claude-3-opus",
                    ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE).model(),
                    "Claude model must be updated");
            assertEquals(originalOpenAi.model(),
                    ws.editorState().values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model(),
                    "OpenAI-compatible model must remain unchanged");
        });
    }

    // =========================================================================
    // Dirty state after field change
    // =========================================================================

    /**
     * Verifies that modifying a field value via the {@code withX} path produces a dirty state.
     */
    @Test
    @Order(7)
    void fieldChange_makesDirty() throws Exception {
        runOnFx(() -> {
            GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
            ws.requestNewConfiguration();

            assertFalse(ws.editorState().isDirty(), "Precondition: must be clean after Neu");

            GuiConfigurationValues modified = ws.editorState().values()
                    .withSourceFolder("./modified/source");
            ws.editorState = ws.editorState().withValues(modified);

            assertTrue(ws.editorState().isDirty(),
                    "Modifying a field value must make the editor state dirty");
        });
    }

    // =========================================================================
    // Threading: Files.exists check in checkExistsAndSave runs off the FX thread
    // =========================================================================

    /**
     * Verifies that the path-existence check inside {@code checkExistsAndSave} is not performed
     * on the FX Application Thread.
     * <p>
     * The test exercises the full {@code checkExistsAndSave} path by injecting a file chooser
     * that returns an existing file (causing the overwrite-check to be reached) and a capturing
     * thread factory that records the thread name when the checker runs. The overwrite-confirmation
     * supplier is stubbed to return YES so the writer is called, which proves that the
     * {@code Files.exists} call ran inside the background checker thread.
     *
     * @throws Exception if the FX thread task fails or times out
     */
    @Test
    @Order(8)
    void checkExistsAndSave_pathCheckRunsOnWorkerThread_notOnFxThread() throws Exception {
        // Create an existing file so the checker finds it and enters the overwrite dialog path.
        java.nio.file.Path existingFile = java.nio.file.Files.createTempFile(
                "gui-checker-thread-test-", ".properties");
        existingFile.toFile().deleteOnExit();

        AtomicReference<String> checkerThreadName = new AtomicReference<>();
        AtomicBoolean writerCalled = new AtomicBoolean(false);
        CountDownLatch writerLatch = new CountDownLatch(1);

        GuiConfigurationFileWriter capturingWriter = (values, path) -> {
            writerCalled.set(true);
            writerLatch.countDown();
            return GuiConfigurationSaveResult.saved(path);
        };

        CountDownLatch setupLatch = new CountDownLatch(1);
        AtomicReference<Throwable> fxError = new AtomicReference<>();

        Platform.runLater(() -> {
            try {
                GuiStartupContext context = new GuiStartupContext(
                        GuiConfigurationTemplateFactory.createStandardTemplate(),
                        Optional.empty(),
                        configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
                        capturingWriter,
                        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 ws = new GuiConfigurationEditorWorkspace(context);
                ws.requestNewConfiguration();

                // Stub the save dialog to return the existing file without opening a native dialog.
                ws.saveDialogFunction = (chooser, owner) -> existingFile.toFile();

                // Capture the checker thread name; run the real Runnable so Files.exists is called.
                ws.pathCheckerThreadFactory = task -> {
                    Thread t = new Thread(() -> {
                        checkerThreadName.set(Thread.currentThread().getName());
                        task.run();
                    }, "gui-path-checker-test");
                    t.setDaemon(true);
                    return t;
                };

                // Auto-confirm the overwrite dialog so the writer is called after the check.
                ws.overwriteConfirmationSupplier = () ->
                        Optional.of(javafx.scene.control.ButtonType.YES);

                // Trigger checkExistsAndSave via requestSaveConfigurationAs.
                ws.requestSaveConfigurationAs();
            } catch (Throwable t) {
                fxError.set(t);
            } finally {
                setupLatch.countDown();
            }
        });

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

        // Wait for the background writer to confirm the check-and-save cycle completed.
        assertTrue(writerLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
                "Writer must be called within timeout (overwrite confirmed)");

        String threadName = checkerThreadName.get();
        assertFalse(threadName == null || threadName.contains("JavaFX Application Thread"),
                "The Files.exists check in checkExistsAndSave must NOT run on the FX Application Thread "
                        + "but the checker ran on: " + threadName);
        assertTrue(writerCalled.get(),
                "Capturing writer must have been called after overwrite was confirmed");
    }

    // =========================================================================
    // withX methods on GuiConfigurationValues
    // =========================================================================

    /**
     * Verifies that the {@code withX} copy methods on {@link GuiConfigurationValues} produce
     * independent copies without affecting unrelated fields.
     */
    @Test
    @Order(9)
    void withXMethods_produceCopiesWithoutAffectingOtherFields() throws Exception {
        runOnFx(() -> {
            GuiConfigurationValues original = GuiConfigurationTemplateFactory.createStandardValues();

            GuiConfigurationValues modified = original
                    .withSourceFolder("A")
                    .withTargetFolder("B")
                    .withSqliteFile("C")
                    .withPromptTemplateFile("D")
                    .withRuntimeLockFile("E")
                    .withLogDirectory("F")
                    .withLogLevel("DEBUG")
                    .withMaxRetriesTransient("5")
                    .withMaxPages("20")
                    .withMaxTextCharacters("1000")
                    .withMaxTitleLength("80")
                    .withLogAiSensitive("true")
                    .withActiveProviderFamily("openai-compatible");

            assertEquals("A", modified.sourceFolder());
            assertEquals("B", modified.targetFolder());
            assertEquals("C", modified.sqliteFile());
            assertEquals("D", modified.promptTemplateFile());
            assertEquals("E", modified.runtimeLockFile());
            assertEquals("F", modified.logDirectory());
            assertEquals("DEBUG", modified.logLevel());
            assertEquals("5", modified.maxRetriesTransient());
            assertEquals("20", modified.maxPages());
            assertEquals("1000", modified.maxTextCharacters());
            assertEquals("80", modified.maxTitleLength());
            assertEquals("true", modified.logAiSensitive());
            assertEquals("openai-compatible", modified.activeProviderFamily());

            // Original must not be changed.
            assertEquals("./work/local/source", original.sourceFolder(),
                    "Original values must be immutable");
        });
    }

    // =========================================================================
    // 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;
    }
}
