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

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.time.LocalDate;

import org.junit.jupiter.api.Test;

import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;

/**
 * Unit tests for {@link ProcessingOutcomeTransition} — the authoritative central retry rule.
 * <p>
 * These tests prove that:
 * <ul>
 *   <li>Deterministic content errors follow the first-retryable / second-final rule.</li>
 *   <li>Transient technical errors respect the configured {@code maxRetriesTransient} limit.</li>
 *   <li>{@code maxRetriesTransient = 1} immediately finalises on the first transient error.</li>
 *   <li>Naming proposals yield {@code PROPOSAL_READY} with unchanged counters.</li>
 *   <li>AI functional failures are governed by the same content-error rule.</li>
 *   <li>AI technical failures are governed by the same transient-error rule.</li>
 * </ul>
 * <p>
 * Skip events ({@code SKIPPED_ALREADY_PROCESSED}, {@code SKIPPED_FINAL_FAILURE}) are not
 * routed through this class — they never modify any failure counter.
 */
class ProcessingOutcomeTransitionTest {

    // -------------------------------------------------------------------------
    // Test fixtures
    // -------------------------------------------------------------------------

    private static final int LIMIT_1 = 1;
    private static final int LIMIT_2 = 2;
    private static final int LIMIT_3 = 3;

    private static SourceDocumentCandidate candidate() {
        return new SourceDocumentCandidate("test.pdf", 1024L,
                new SourceDocumentLocator("/tmp/test.pdf"));
    }

    private static AiAttemptContext aiContext() {
        return new AiAttemptContext("model", "prompt.txt", 1, 100, "{}");
    }

    // -------------------------------------------------------------------------
    // Naming proposal → PROPOSAL_READY (counters unchanged)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_namingProposalReady_returnsProposalReady_countersUnchanged() {
        NamingProposal proposal = new NamingProposal(
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason");
        NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext());

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus());
        assertFalse(result.retryable());
        assertEquals(0, result.counters().contentErrorCount());
        assertEquals(0, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_namingProposalReady_countersUnchangedFromExisting() {
        NamingProposal proposal = new NamingProposal(
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason");
        NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext());
        FailureCounters existing = new FailureCounters(1, 2);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3);

        assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus());
        assertFalse(result.retryable());
        // Counters must be preserved unchanged on a successful naming proposal
        assertEquals(1, result.counters().contentErrorCount(),
                "Content error counter must remain unchanged on PROPOSAL_READY");
        assertEquals(2, result.counters().transientErrorCount(),
                "Transient error counter must remain unchanged on PROPOSAL_READY");
    }

    // -------------------------------------------------------------------------
    // Deterministic content errors (PreCheckFailed)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_noUsableText_immediatelyFailedFinal_noRetry() {
        PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "NO_USABLE_TEXT must finalise immediately without retry");
        assertFalse(result.retryable());
        assertEquals(1, result.counters().contentErrorCount());
        assertEquals(0, result.counters().transientErrorCount());
    }

    @Test
    void forNewDocument_firstPageLimitExceeded_returnsFailedRetryable_contentCounterOne() {
        PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
                "PAGE_LIMIT_EXCEEDED first occurrence must be retryable");
        assertTrue(result.retryable());
        assertEquals(1, result.counters().contentErrorCount());
        assertEquals(0, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_secondPreCheckFailed_returnsFailedFinal_contentCounterTwo() {
        PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
        FailureCounters existing = new FailureCounters(1, 0);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
        assertFalse(result.retryable());
        assertEquals(2, result.counters().contentErrorCount());
        assertEquals(0, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_anySubsequentContentError_remainsFailedFinal() {
        // Count >= 1 before the attempt always leads to FAILED_FINAL (covers legacy data)
        for (int priorCount = 1; priorCount <= 5; priorCount++) {
            FailureCounters existing = new FailureCounters(priorCount, 0);
            PreCheckFailed outcome =
                    new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);

            ProcessingOutcomeTransition.ProcessingOutcome result =
                    ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);

            assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                    "Expected FAILED_FINAL for prior contentErrorCount=" + priorCount);
            assertFalse(result.retryable());
            assertEquals(priorCount + 1, result.counters().contentErrorCount());
        }
    }

    @Test
    void forNewDocument_contentError_transientCounterIsIrrelevant() {
        // PAGE_LIMIT_EXCEEDED is used here: it follows the 1-retry rule, and a non-zero
        // transient counter must not influence the content-error decision.
        PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(
                        outcome, new FailureCounters(0, 5), LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
                "Non-zero transient counter must not affect the content error decision");
        assertTrue(result.retryable());
    }

    // -------------------------------------------------------------------------
    // Deterministic content errors (AiFunctionalFailure)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_firstAiFunctionalFailure_returnsFailedRetryable_contentCounterOne() {
        AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext());

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
        assertTrue(result.retryable());
        assertEquals(1, result.counters().contentErrorCount());
    }

    @Test
    void forKnownDocument_secondAiFunctionalFailure_returnsFailedFinal() {
        AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext());
        FailureCounters existing = new FailureCounters(1, 0);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
        assertFalse(result.retryable());
        assertEquals(2, result.counters().contentErrorCount());
    }

    // -------------------------------------------------------------------------
    // Transient technical errors (TechnicalDocumentError)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_transientError_limitOne_immediatelyFinal() {
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "I/O error", null);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "With limit=1, the first transient error must immediately finalise");
        assertFalse(result.retryable());
        assertEquals(1, result.counters().transientErrorCount());
        assertEquals(0, result.counters().contentErrorCount());
    }

    @Test
    void forNewDocument_transientError_limitTwo_firstErrorRetryable() {
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout", null);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
        assertTrue(result.retryable());
        assertEquals(1, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_transientError_limitTwo_secondErrorFinal() {
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout again", null);
        FailureCounters existing = new FailureCounters(0, 1);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "Second transient error with limit=2 must finalise");
        assertFalse(result.retryable());
        assertEquals(2, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_transientError_limitThree_sequence() {
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);

        // First error: counter 0→1, 1 < 3 → retryable
        ProcessingOutcomeTransition.ProcessingOutcome first =
                ProcessingOutcomeTransition.forKnownDocument(
                        outcome, new FailureCounters(0, 0), LIMIT_3);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, first.overallStatus());
        assertEquals(1, first.counters().transientErrorCount());

        // Second error: counter 1→2, 2 < 3 → retryable
        ProcessingOutcomeTransition.ProcessingOutcome second =
                ProcessingOutcomeTransition.forKnownDocument(
                        outcome, new FailureCounters(0, 1), LIMIT_3);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, second.overallStatus());
        assertEquals(2, second.counters().transientErrorCount());

        // Third error: counter 2→3 = limit=3 → FAILED_FINAL
        ProcessingOutcomeTransition.ProcessingOutcome third =
                ProcessingOutcomeTransition.forKnownDocument(
                        outcome, new FailureCounters(0, 2), LIMIT_3);
        assertEquals(ProcessingStatus.FAILED_FINAL, third.overallStatus(),
                "Third transient error with limit=3 must finalise");
        assertFalse(third.retryable());
        assertEquals(3, third.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_transientError_legacyHighCounters_stillFinalise() {
        // Legacy data may have counters well above normal expectations.
        // The threshold check must still apply correctly.
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);
        FailureCounters existing = new FailureCounters(3, 10); // already far above limit

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "Transient counter above limit must still produce FAILED_FINAL");
        assertEquals(11, result.counters().transientErrorCount());
    }

    @Test
    void forNewDocument_transientError_contentCounterIsIrrelevant() {
        TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);

        // Non-zero content error counter must not affect the transient error decision
        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(
                        outcome, new FailureCounters(2, 0), LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
                "Content error counter must not affect transient error decision");
        assertEquals(1, result.counters().transientErrorCount());
    }

    // -------------------------------------------------------------------------
    // Transient technical errors (AiTechnicalFailure)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_aiTechnicalFailure_limitOne_immediatelyFinal() {
        AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext());

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "With limit=1, first AI technical failure must immediately finalise");
        assertFalse(result.retryable());
        assertEquals(1, result.counters().transientErrorCount());
    }

    @Test
    void forKnownDocument_aiTechnicalFailure_limitTwo_secondFinal() {
        AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext());
        FailureCounters existing = new FailureCounters(0, 1);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
        assertEquals(2, result.counters().transientErrorCount());
    }

    // -------------------------------------------------------------------------
    // PreCheckPassed routed through transition (edge case: no AI step taken)
    // -------------------------------------------------------------------------

    @Test
    void forNewDocument_preCheckPassed_limitOne_immediatelyFinal() {
        // PreCheckPassed without an AI outcome is treated as a transient error by the transition.
        // With limit=1 the first such error must immediately finalise to FAILED_FINAL.
        PreCheckPassed outcome = new PreCheckPassed(
                candidate(), new PdfExtractionSuccess("text", new PdfPageCount(1)));

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "With limit=1 a PreCheckPassed-routed transient error must immediately finalise");
        assertFalse(result.retryable());
        assertEquals(1, result.counters().transientErrorCount());
        assertEquals(0, result.counters().contentErrorCount());
    }

    @Test
    void forNewDocument_preCheckPassed_limitTwo_firstErrorRetryable() {
        // With limit=2 the first PreCheckPassed-routed transient error is retryable.
        PreCheckPassed outcome = new PreCheckPassed(
                candidate(), new PdfExtractionSuccess("text", new PdfPageCount(1)));

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
                "With limit=2 the first PreCheckPassed-routed transient error must be retryable");
        assertTrue(result.retryable());
        assertEquals(1, result.counters().transientErrorCount());
        assertEquals(0, result.counters().contentErrorCount());
    }

    @Test
    void forKnownDocument_preCheckPassed_limitTwo_secondErrorFinal() {
        // With limit=2 and an existing transient error count of 1,
        // the next PreCheckPassed-routed error increments to 2 = limit → FAILED_FINAL.
        PreCheckPassed outcome = new PreCheckPassed(
                candidate(), new PdfExtractionSuccess("text", new PdfPageCount(1)));
        FailureCounters existing = new FailureCounters(0, 1);

        ProcessingOutcomeTransition.ProcessingOutcome result =
                ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);

        assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
                "PreCheckPassed-routed error at transient limit must finalise to FAILED_FINAL");
        assertFalse(result.retryable());
        assertEquals(2, result.counters().transientErrorCount());
    }
}
