package de.gecheckt.pdf.umbenenner.application.usecase;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
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.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;

/**
 * Focused tests for the progress observer and cancellation token behaviour wired into
 * {@link DefaultBatchRunProcessingUseCase} and forwarded to
 * {@link DocumentProcessingCoordinator}.
 */
class BatchRunProgressObservationTest {

    private static final int TEST_MAX_TITLE = 60;

    @TempDir
    Path tempDir;

    // =========================================================================
    // Value object invariants
    // =========================================================================

    private static final DocumentFingerprint DUMMY_FP =
            new DocumentFingerprint("a".repeat(64));

    @Test
    void documentCompletionEvent_rejectsBlankFilename() {
        assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
                " ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null, Duration.ZERO));
    }

    @Test
    void documentCompletionEvent_rejectsNegativeDuration() {
        assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
                "x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null,
                Duration.ofSeconds(-1)));
    }

    @Test
    void documentCompletionEvent_carriesOptionalFields() {
        DocumentCompletionEvent event = new DocumentCompletionEvent(
                "x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
                LocalDate.of(2026, 3, 1), "weil wichtig", null, Duration.ofMillis(123));

        assertEquals("x.pdf", event.originalFileName());
        assertEquals(DocumentCompletionStatus.SUCCESS, event.status());
        assertEquals("2026-03-01 - Titel.pdf", event.finalFileName());
        assertEquals(LocalDate.of(2026, 3, 1), event.resolvedDate());
        assertEquals("weil wichtig", event.aiReasoning());
        assertEquals(Duration.ofMillis(123), event.processingDuration());
    }

    @Test
    void runSummary_rejectsNegativeCounts() {
        assertThrows(IllegalArgumentException.class, () -> new RunSummary(-1, 0, 0));
        assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, -1, 0));
        assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, 0, -1));
    }

    @Test
    void runSummary_totalProcessedSumsCounts() {
        RunSummary summary = new RunSummary(2, 3, 4);
        assertEquals(9, summary.totalProcessed());
    }

    @Test
    void noOpObserver_isSingletonAndSilent() {
        BatchRunProgressObserver a = BatchRunProgressObserver.noOp();
        BatchRunProgressObserver b = BatchRunProgressObserver.noOp();
        assertSame(a, b);
        a.onRunStarted(new RunId("r-1"), 5);
        a.onDocumentCompleted(new DocumentCompletionEvent(
                "x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, null, null, null, null, Duration.ZERO));
        a.onRunEnded(new RunSummary(0, 0, 0));
    }

    @Test
    void neverCancelledToken_isSingletonAndAlwaysFalse() {
        BatchRunCancellationToken a = BatchRunCancellationToken.neverCancelled();
        BatchRunCancellationToken b = BatchRunCancellationToken.neverCancelled();
        assertSame(a, b);
        assertFalse(a.isCancellationRequested());
    }

    // =========================================================================
    // Use case lifecycle callbacks
    // =========================================================================

    @Test
    void useCase_emitsRunStartedAndEndedForEmptyFolder() {
        RecordingObserver observer = new RecordingObserver();
        CapturingCoordinator coordinator = new CapturingCoordinator();
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                new NoOpLock(), new EmptyCandidatesPort(), coordinator, observer,
                BatchRunCancellationToken.neverCancelled());

        BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("empty"), Instant.now()));

        assertTrue(outcome.isSuccess());
        assertEquals(List.of("started:0", "ended:0/0/0"), observer.events);
    }

    @Test
    void useCase_forwardsCoordinatorCompletionEventsAndCountsThem() {
        RecordingObserver observer = new RecordingObserver();
        PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
                DocumentCompletionStatus.SUCCESS,
                DocumentCompletionStatus.FAILED_RETRYABLE,
                DocumentCompletionStatus.FAILED_PERMANENT,
                DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                new NoOpLock(), new FixedCandidatesPort(
                        makeCandidate("a.pdf"),
                        makeCandidate("b.pdf"),
                        makeCandidate("c.pdf"),
                        makeCandidate("d.pdf")),
                coordinator, observer, BatchRunCancellationToken.neverCancelled());

        BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("mixed"), Instant.now()));

        assertTrue(outcome.isSuccess());
        // Observer must see exactly one onStarted, 4 completed events, and one onEnded.
        assertEquals(6, observer.events.size(), () -> observer.events.toString());
        assertEquals("started:4", observer.events.get(0));
        assertEquals("ended:1/2/1", observer.events.get(observer.events.size() - 1));
    }

    @Test
    void useCase_stopsBeforeNextCandidateWhenCancellationRequested() {
        RecordingObserver observer = new RecordingObserver();
        PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
                DocumentCompletionStatus.SUCCESS,
                DocumentCompletionStatus.SUCCESS,
                DocumentCompletionStatus.SUCCESS));
        ToggleCancellationToken cancellation = new ToggleCancellationToken();
        // Cancel after the first candidate has been processed.
        coordinator.onBeforeReturn = () -> {
            if (coordinator.invocations() == 1) {
                cancellation.request();
            }
        };
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                new NoOpLock(), new FixedCandidatesPort(
                        makeCandidate("a.pdf"),
                        makeCandidate("b.pdf"),
                        makeCandidate("c.pdf")),
                coordinator, observer, cancellation);

        BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("cancel"), Instant.now()));

        assertTrue(outcome.isSuccess());
        // Only the first candidate ran — cancellation is polled before the second.
        assertEquals(1, coordinator.invocations());
        // Observer saw the start, one completion, and the run end with (1,0,0).
        assertEquals(List.of("started:3", "completed:SUCCESS:a.pdf", "ended:1/0/0"),
                observer.events);
    }

    @Test
    void useCase_isSafeWhenObserverThrowsFromCallbacks() {
        ThrowingObserver observer = new ThrowingObserver();
        PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
                DocumentCompletionStatus.SUCCESS));
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
                coordinator, observer, BatchRunCancellationToken.neverCancelled());

        // Must not bubble up — thrown runtime exceptions are isolated.
        BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("throw"), Instant.now()));
        assertTrue(outcome.isSuccess());
        assertTrue(observer.startedThrown.get(),
                "onRunStarted was invoked even though it threw");
        assertTrue(observer.completedThrown.get(),
                "onDocumentCompleted was invoked even though it threw");
        assertTrue(observer.endedThrown.get(),
                "onRunEnded was invoked even though it threw");
    }

    @Test
    void useCase_removesForwarderAfterRun() {
        RecordingObserver observer = new RecordingObserver();
        PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
                DocumentCompletionStatus.SUCCESS));
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
                coordinator, observer, BatchRunCancellationToken.neverCancelled());

        useCase.execute(new BatchRunContext(new RunId("clean"), Instant.now()));

        assertNull(coordinator.currentForwarder(),
                "Forwarder must be removed after the run to avoid leaking observers across runs");
    }

    // =========================================================================
    // Helpers / stubs
    // =========================================================================

    private DefaultBatchRunProcessingUseCase buildUseCase(
            RunLockPort lock,
            SourceDocumentCandidatesPort candidates,
            DocumentProcessingCoordinator coordinator,
            BatchRunProgressObserver observer,
            BatchRunCancellationToken token) {
        RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
                3, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
        return new DefaultBatchRunProcessingUseCase(
                runtimeConfig,
                lock,
                candidates,
                new PassThroughExtractionPort(),
                new AlwaysSuccessFingerprintPort(),
                coordinator,
                buildStubAiNamingService(),
                new SilentLogger(),
                observer,
                token);
    }

    private static AiNamingService buildStubAiNamingService() {
        AiInvocationPort stubAi = req -> {
            throw new IllegalStateException("AI must not be invoked in these tests");
        };
        PromptPort stubPrompt = new PromptPort() {
            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
                return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
            }
            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
                return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
            }
        };
        ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
        AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
        return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
    }

    private static SourceDocumentCandidate makeCandidate(String filename) {
        return new SourceDocumentCandidate(filename, 1024L,
                new SourceDocumentLocator("/tmp/" + filename));
    }

    private static final class NoOpLock implements RunLockPort {
        @Override public void acquire() {
            // intentionally empty
        }
        @Override public void release() {
            // intentionally empty
        }
        @Override
        public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
            return java.util.Optional.empty();
        }
    }

    private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
        @Override public List<SourceDocumentCandidate> loadCandidates() { return List.of(); }
    }

    private static final class FixedCandidatesPort implements SourceDocumentCandidatesPort {
        private final List<SourceDocumentCandidate> all;
        FixedCandidatesPort(SourceDocumentCandidate... items) { this.all = List.of(items); }
        @Override public List<SourceDocumentCandidate> loadCandidates() { return all; }
    }

    private static final class PassThroughExtractionPort implements PdfTextExtractionPort {
        @Override
        public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
            return new PdfExtractionContentError("nicht relevant für den Beobachter-Test");
        }
    }

    private static final class AlwaysSuccessFingerprintPort implements FingerprintPort {
        @Override
        public FingerprintResult computeFingerprint(SourceDocumentCandidate candidate) {
            String hex = String.format("%064x",
                    Math.abs((long) candidate.uniqueIdentifier().hashCode()));
            return new FingerprintSuccess(new DocumentFingerprint(hex.substring(0, 64)));
        }
    }

    private static final class SilentLogger implements ProcessingLogger {
        @Override public void info(String message, Object... args) {
            // intentionally empty
        }
        @Override public void warn(String message, Object... args) {
            // intentionally empty
        }
        @Override public void error(String message, Object... args) {
            // intentionally empty
        }
        @Override public void debug(String message, Object... args) {
            // intentionally empty
        }
        @Override public void debugSensitiveAiContent(String message, Object... args) {
            // intentionally empty
        }
    }

    private static final class RecordingObserver implements BatchRunProgressObserver {
        final List<String> events = new ArrayList<>();
        @Override
        public void onRunStarted(RunId runId, int totalCandidates) {
            events.add("started:" + totalCandidates);
        }
        @Override
        public void onDocumentCompleted(DocumentCompletionEvent event) {
            events.add("completed:" + event.status() + ":" + event.originalFileName());
        }
        @Override
        public void onRunEnded(RunSummary summary) {
            events.add("ended:" + summary.successCount() + "/"
                    + summary.failedCount() + "/" + summary.skippedCount());
        }
    }

    private static final class ThrowingObserver implements BatchRunProgressObserver {
        final java.util.concurrent.atomic.AtomicBoolean startedThrown = new java.util.concurrent.atomic.AtomicBoolean();
        final java.util.concurrent.atomic.AtomicBoolean completedThrown = new java.util.concurrent.atomic.AtomicBoolean();
        final java.util.concurrent.atomic.AtomicBoolean endedThrown = new java.util.concurrent.atomic.AtomicBoolean();
        @Override public void onRunStarted(RunId runId, int totalCandidates) {
            startedThrown.set(true);
            throw new IllegalStateException("boom-started");
        }
        @Override public void onDocumentCompleted(DocumentCompletionEvent event) {
            completedThrown.set(true);
            throw new IllegalStateException("boom-completed");
        }
        @Override public void onRunEnded(RunSummary summary) {
            endedThrown.set(true);
            throw new IllegalStateException("boom-ended");
        }
    }

    private static final class ToggleCancellationToken implements BatchRunCancellationToken {
        private volatile boolean requested = false;
        void request() { requested = true; }
        @Override public boolean isCancellationRequested() { return requested; }
    }

    /**
     * Coordinator stub that lets the use case install a forwarder but never publishes.
     */
    private static class CapturingCoordinator extends DocumentProcessingCoordinator {
        CapturingCoordinator() {
            super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
                    NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
                    new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
        }

        @Override
        public boolean processDeferredOutcome(
                SourceDocumentCandidate candidate,
                DocumentFingerprint fingerprint,
                BatchRunContext context,
                Instant attemptStart,
                java.util.function.Function<SourceDocumentCandidate,
                        de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
            // Nothing to process — this stub does not trigger the forwarder.
            return true;
        }
    }

    /**
     * Coordinator stub that publishes a configurable sequence of completion events to the
     * installed forwarder, one per candidate that the use case hands it.
     */
    private static class PublishingCoordinator extends DocumentProcessingCoordinator {
        private final List<DocumentCompletionStatus> statuses;
        private Consumer<DocumentCompletionEvent> currentForwarder;
        private final AtomicInteger invocations = new AtomicInteger();
        Runnable onBeforeReturn = () -> { };

        PublishingCoordinator(List<DocumentCompletionStatus> statuses) {
            super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
                    NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
                    new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
            this.statuses = statuses;
        }

        @Override
        public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
            this.currentForwarder = forwarder;
        }

        Consumer<DocumentCompletionEvent> currentForwarder() {
            return currentForwarder;
        }

        int invocations() {
            return invocations.get();
        }

        @Override
        public boolean processDeferredOutcome(
                SourceDocumentCandidate candidate,
                DocumentFingerprint fingerprint,
                BatchRunContext context,
                Instant attemptStart,
                java.util.function.Function<SourceDocumentCandidate,
                        de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
            int index = invocations.getAndIncrement();
            if (index < statuses.size() && currentForwarder != null) {
                currentForwarder.accept(new DocumentCompletionEvent(
                        candidate.uniqueIdentifier(),
                        fingerprint,
                        statuses.get(index),
                        null, null, null, null, Duration.ofMillis(10)));
            }
            onBeforeReturn.run();
            return true;
        }
    }

    // No-op repo/port stubs used only to satisfy the coordinator constructor. The tests
    // never reach the underlying operations because PublishingCoordinator and
    // CapturingCoordinator override the public entry point of the coordinator.
    private static final class NoRecords implements DocumentRecordRepository {
        static final NoRecords INSTANCE = new NoRecords();
        @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint f) {
            return new DocumentUnknown();
        }
        @Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
            // intentionally empty
        }
        @Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
            // intentionally empty
        }
        @Override public void deleteByFingerprint(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
    }

    private static final class NoAttempts implements ProcessingAttemptRepository {
        static final NoAttempts INSTANCE = new NoAttempts();
        @Override public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { return 1; }
        @Override public void save(ProcessingAttempt attempt) {
            // intentionally empty
        }
        @Override public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
            return List.of();
        }
        @Override public ProcessingAttempt findLatestProposalReadyAttempt(
                DocumentFingerprint fingerprint) { return null; }
        @Override public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
    }

    private static final class NoUow implements UnitOfWorkPort {
        static final NoUow INSTANCE = new NoUow();
        @Override
        public void executeInTransaction(
                java.util.function.Consumer<TransactionOperations> operations) {
            throw new DocumentPersistenceException(
                    "UnitOfWorkPort must not be called in BatchRunProgressObservationTest");
        }
    }

    private static final class NoTargetFolder implements TargetFolderPort {
        static final NoTargetFolder INSTANCE = new NoTargetFolder();
        @Override public TargetFilenameResolutionResult resolveUniqueFilename(
                String baseFilename, DocumentFingerprint sourceFingerprint) {
            return new ResolvedTargetFilename(baseFilename);
        }
        @Override public String getTargetFolderLocator() { return "/tmp/target"; }
        @Override public void tryDeleteTargetFile(String filename) {
            // intentionally empty
        }
    }

    private static final class NoTargetCopy implements TargetFileCopyPort {
        static final NoTargetCopy INSTANCE = new NoTargetCopy();
        @Override public TargetFileCopyResult copyToTarget(
                SourceDocumentLocator source, String resolvedFilename) {
            return new TargetFileCopySuccess();
        }
    }
}
