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

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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
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.Consumer;

import org.junit.jupiter.api.Test;

import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;

/**
 * Synchronous tests for {@link GuiBatchRunCoordinator}.
 * <p>
 * These tests substitute the background worker thread and the FX dispatcher with
 * in-thread runners so behaviour can be verified deterministically without a running
 * JavaFX runtime.
 */
class GuiBatchRunCoordinatorTest {

    private static final Path ANY_CONFIG = Paths.get("ignored.properties");
    private static final DocumentFingerprint DUMMY_FP = new DocumentFingerprint("a".repeat(64));

    @Test
    void start_withNullPath_throws() {
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                (path, observer, token) -> GuiBatchRunLaunchOutcome.completed(),
                syncThreadFactory(), syncDispatcher(), noOpListener());
        assertFalse(coordinator.isRunning());
        try {
            coordinator.start(null);
        } catch (NullPointerException expected) {
            // expected
            return;
        }
        throw new AssertionError("Expected NullPointerException");
    }

    @Test
    void completedRun_dispatchesEventsAndSummaryOnFxThread() {
        List<String> events = new ArrayList<>();
        GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
            @Override public void onRunStarted(RunId runId, int totalCandidates) {
                events.add("started:" + totalCandidates);
            }
            @Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
                events.add("row:" + row.status() + ":" + row.originalFileName());
            }
            @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
                events.add("ended:started=" + outcome.successfullyStarted()
                        + ",completed=" + outcome.batchCompletedNormally()
                        + ",summary=" + summary.successCount() + "/" + summary.failedCount()
                        + "/" + summary.skippedCount());
            }
        };
        GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
            observer.onRunStarted(new RunId("run-1"), 2);
            observer.onDocumentCompleted(new DocumentCompletionEvent(
                    "a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
                    "2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", null, Duration.ofMillis(20)));
            observer.onDocumentCompleted(new DocumentCompletionEvent(
                    "b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT,
                    null, null, null, null, Duration.ofMillis(10)));
            observer.onRunEnded(new RunSummary(1, 1, 0));
            return GuiBatchRunLaunchOutcome.completed();
        };
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher, syncThreadFactory(), syncDispatcher(), listener);

        boolean started = coordinator.start(ANY_CONFIG);
        assertTrue(started);

        assertEquals(List.of(
                "started:2",
                "row:SUCCESS:a.pdf",
                "row:FAILED_PERMANENT:b.pdf",
                "ended:started=true,completed=true,summary=1/1/0"), events);
        assertFalse(coordinator.isRunning());
    }

    @Test
    void skippedDocument_dispatchesSkippedRowAndCountsCorrectly() {
        List<String> events = new ArrayList<>();
        GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
            @Override public void onRunStarted(RunId runId, int totalCandidates) {
                events.add("started:" + totalCandidates);
            }
            @Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
                events.add("row:" + row.status() + ":" + row.originalFileName());
            }
            @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
                events.add("ended:started=" + outcome.successfullyStarted()
                        + ",completed=" + outcome.batchCompletedNormally()
                        + ",summary=" + summary.successCount() + "/" + summary.failedCount()
                        + "/" + summary.skippedCount());
            }
        };
        GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
            observer.onRunStarted(new RunId("run-skip"), 1);
            observer.onDocumentCompleted(new DocumentCompletionEvent(
                    "c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
                    null, null, null, null, Duration.ofMillis(5)));
            observer.onRunEnded(new RunSummary(0, 0, 1));
            return GuiBatchRunLaunchOutcome.completed();
        };
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher, syncThreadFactory(), syncDispatcher(), listener);

        boolean started = coordinator.start(ANY_CONFIG);
        assertTrue(started);

        assertEquals(List.of(
                "started:1",
                "row:SKIPPED_ALREADY_PROCESSED:c.pdf",
                "ended:started=true,completed=true,summary=0/0/1"), events);
        assertFalse(coordinator.isRunning());
    }

    @Test
    void startWhileRunning_returnsFalseWithoutDoubleDispatch() {
        // Launcher installs a rendezvous so the first run is still "running" while we
        // attempt the second start.
        CountDownLatch firstRunActive = new CountDownLatch(1);
        CountDownLatch releaseFirstRun = new CountDownLatch(1);
        AtomicBoolean firstStarted = new AtomicBoolean();

        GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
            firstStarted.set(true);
            firstRunActive.countDown();
            try {
                releaseFirstRun.await(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return GuiBatchRunLaunchOutcome.completed();
        };
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher,
                task -> {
                    Thread thread = new Thread(task, "test-worker");
                    thread.setDaemon(true);
                    return thread;
                },
                Runnable::run,   // direct FX dispatch
                noOpListener());

        try {
            assertTrue(coordinator.start(ANY_CONFIG));
            assertTrue(firstRunActive.await(5, TimeUnit.SECONDS));
            assertTrue(coordinator.isRunning());
            assertFalse(coordinator.start(ANY_CONFIG), "second start must be rejected");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new AssertionError(e);
        } finally {
            releaseFirstRun.countDown();
            waitUntilIdle(coordinator);
        }
        assertTrue(firstStarted.get());
        assertFalse(coordinator.isRunning());
    }

    @Test
    void requestCancellation_setsFlagForLauncherToObserve() {
        AtomicReference<BatchRunCancellationToken> seenToken = new AtomicReference<>();
        GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
            seenToken.set(token);
            return GuiBatchRunLaunchOutcome.completed();
        };
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher, syncThreadFactory(), syncDispatcher(), noOpListener());

        // Start must run synchronously via our sync thread factory.
        coordinator.start(ANY_CONFIG);
        BatchRunCancellationToken token = seenToken.get();
        assertNotNull(token);

        // Token is polled by the launcher only; after the run the flag has been consumed
        // and reset to false for the next run.
        assertFalse(coordinator.isCancellationRequested(),
                "Cancellation flag must reset back to false before the run starts");

        // Starting a second run and cancelling before the launcher observes → flag true.
        CountDownLatch launcherRunning = new CountDownLatch(1);
        CountDownLatch cancelBeforeReturn = new CountDownLatch(1);
        AtomicBoolean sawCancelled = new AtomicBoolean();
        GuiBatchRunLauncher slowLauncher = (configPath, observer, token1) -> {
            launcherRunning.countDown();
            try {
                cancelBeforeReturn.await(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            sawCancelled.set(token1.isCancellationRequested());
            return GuiBatchRunLaunchOutcome.completed();
        };
        GuiBatchRunCoordinator coord2 = new GuiBatchRunCoordinator(
                slowLauncher,
                task -> {
                    Thread thread = new Thread(task, "test-worker-2");
                    thread.setDaemon(true);
                    return thread;
                },
                Runnable::run, noOpListener());

        try {
            coord2.start(ANY_CONFIG);
            assertTrue(launcherRunning.await(5, TimeUnit.SECONDS));
            coord2.requestCancellation();
            assertTrue(coord2.isCancellationRequested());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new AssertionError(e);
        } finally {
            cancelBeforeReturn.countDown();
            waitUntilIdle(coord2);
        }
        assertTrue(sawCancelled.get(), "Launcher must see isCancellationRequested=true");
    }

    @Test
    void launcherException_yieldsFailedAfterStartOutcome() {
        AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
        GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
            throw new IllegalStateException("boom");
        };
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher, syncThreadFactory(), syncDispatcher(),
                new GuiBatchRunCoordinator.Listener() {
                    @Override public void onRunStarted(RunId runId, int totalCandidates) {
                        // intentionally empty
                    }
                    @Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
                        // intentionally empty
                    }
                    @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
                        captured.set(outcome);
                    }
                });

        coordinator.start(ANY_CONFIG);

        GuiBatchRunLaunchOutcome outcome = captured.get();
        assertNotNull(outcome);
        assertTrue(outcome.successfullyStarted());
        assertFalse(outcome.batchCompletedNormally());
        assertTrue(outcome.failureMessage().isPresent());
    }

    @Test
    void nullLauncherResult_mapsToFailedAfterStart() {
        AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
        GuiBatchRunLauncher launcher = (configPath, observer, token) -> null;
        GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
                launcher, syncThreadFactory(), syncDispatcher(),
                new GuiBatchRunCoordinator.Listener() {
                    @Override public void onRunStarted(RunId runId, int totalCandidates) {
                        // intentionally empty
                    }
                    @Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
                        // intentionally empty
                    }
                    @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
                        captured.set(outcome);
                    }
                });

        coordinator.start(ANY_CONFIG);

        GuiBatchRunLaunchOutcome outcome = captured.get();
        assertNotNull(outcome);
        assertTrue(outcome.successfullyStarted());
        assertFalse(outcome.batchCompletedNormally());
    }

    @Test
    void resultRowIcons_matchSpecification() {
        assertEquals("✓", row(DocumentCompletionStatus.SUCCESS).statusIcon());
        assertEquals("↻", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
        assertEquals("×", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
        assertEquals("≡", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
    }

    @Test
    void launchOutcomeFactories_populateFailureMessages() {
        GuiBatchRunLaunchOutcome completed = GuiBatchRunLaunchOutcome.completed();
        assertTrue(completed.successfullyStarted());
        assertTrue(completed.batchCompletedNormally());
        assertTrue(completed.failureMessage().isEmpty());

        GuiBatchRunLaunchOutcome rejected = GuiBatchRunLaunchOutcome.rejected("nope");
        assertFalse(rejected.successfullyStarted());
        assertFalse(rejected.batchCompletedNormally());
        assertEquals("nope", rejected.failureMessage().orElseThrow());

        GuiBatchRunLaunchOutcome failedAfter = GuiBatchRunLaunchOutcome.failedAfterStart("boom");
        assertTrue(failedAfter.successfullyStarted());
        assertFalse(failedAfter.batchCompletedNormally());
        assertEquals("boom", failedAfter.failureMessage().orElseThrow());
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
        return new GuiBatchRunResultRow(
                "x.pdf", DUMMY_FP, status, null, null, null, null, Duration.ofMillis(1));
    }

    private static GuiBatchRunCoordinator.Listener noOpListener() {
        return new GuiBatchRunCoordinator.Listener() {
            @Override public void onRunStarted(RunId runId, int totalCandidates) {
                // intentionally empty
            }
            @Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
                // intentionally empty
            }
            @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
                // intentionally empty
            }
        };
    }

    private static java.util.function.Function<Runnable, Thread> syncThreadFactory() {
        return task -> new Thread(task) {
            @Override public synchronized void start() {
                // Execute the task on the current thread so the test stays fully synchronous.
                task.run();
            }
        };
    }

    private static Consumer<Runnable> syncDispatcher() {
        return Runnable::run;
    }

    private static void waitUntilIdle(GuiBatchRunCoordinator coordinator) {
        long deadline = System.currentTimeMillis() + 5_000;
        while (coordinator.isRunning() && System.currentTimeMillis() < deadline) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }

    @Test
    void noOpObserverAndNeverCancelled_singletonsCallableWithoutEffects() {
        BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
        noOp.onRunStarted(new RunId("x"), 0);
        noOp.onDocumentCompleted(new DocumentCompletionEvent(
                "a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null, Duration.ZERO));
        noOp.onRunEnded(new RunSummary(0, 0, 0));
        assertSame(noOp, BatchRunProgressObserver.noOp());
        assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
        assertSame(BatchRunCancellationToken.neverCancelled(),
                BatchRunCancellationToken.neverCancelled());
    }

    @Test
    void resultRow_rejectsInvalidInput() {
        try {
            new GuiBatchRunResultRow(" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
                    null, null, null, null, Duration.ZERO);
            throw new AssertionError("expected IllegalArgumentException");
        } catch (IllegalArgumentException expected) { /* ok */ }
        try {
            new GuiBatchRunResultRow("x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
                    null, null, null, null, Duration.ofSeconds(-1));
            throw new AssertionError("expected IllegalArgumentException");
        } catch (IllegalArgumentException expected) { /* ok */ }
    }

    @Test
    void resultRow_optionalHoldersNormaliseNullToEmpty() {
        GuiBatchRunResultRow row = new GuiBatchRunResultRow(
                "x.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT,
                null, null, null, null, Duration.ZERO);
        assertNull(row.finalFileName().orElse(null));
        assertNull(row.resolvedDate().orElse(null));
        assertNull(row.aiReasoning().orElse(null));
        assertNull(row.aiFailureMessage().orElse(null));
    }
}
