package de.gecheckt.pdf.umbenenner.bootstrap.e2e;

import static org.assertj.core.api.Assertions.assertThat;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

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

import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;

/**
 * Deterministic end-to-end tests for the complete batch processing pipeline.
 * <p>
 * Each test method is independent and uses its own {@link E2ETestContext} backed by a
 * JUnit {@code @TempDir}. All infrastructure adapters (SQLite, filesystem, PDF extraction,
 * fingerprinting) are real production implementations. Only the AI invocation port is
 * replaced by a configurable {@link StubAiInvocationPort} to avoid real HTTP calls.
 *
 * <h2>End-to-end invariants verified</h2>
 * <ul>
 *   <li><strong>Happy-path to {@code SUCCESS}</strong>: a single run produces a historised
 *       {@code PROPOSAL_READY} attempt followed immediately by a {@code SUCCESS} attempt;
 *       the document's master record reaches {@code SUCCESS} and the target file is on disk
 *       after the first run.</li>
 *   <li><strong>Deterministic content error</strong>: blank PDFs (no extractable text) reach
 *       {@code FAILED_FINAL} immediately after the first run, because {@code NO_USABLE_TEXT}
 *       is not retryable — an image-only scan without OCR text will not change on retry.</li>
 *   <li><strong>Transient technical error</strong>: AI stub failures produce
 *       {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
 *   <li><strong>Transient error exhaustion</strong>: repeated AI stub failures across
 *       {@code maxRetriesTransient} runs increment the transient counter each time until
 *       the limit is reached and the document is finalized to {@code FAILED_FINAL}.</li>
 *   <li><strong>Skip after {@code SUCCESS}</strong>: a document whose status is
 *       {@code SUCCESS} generates exactly one {@code SKIPPED_ALREADY_PROCESSED} attempt
 *       in the next run; the overall status and target file remain unchanged.</li>
 *   <li><strong>Skip after {@code FAILED_FINAL}</strong>: a document whose status is
 *       {@code FAILED_FINAL} generates exactly one {@code SKIPPED_FINAL_FAILURE} attempt
 *       in the next run; the overall status and failure counters remain unchanged.</li>
 *   <li><strong>Target copy error with immediate within-run retry (success)</strong>: when the
 *       first copy attempt fails but the immediate within-run retry succeeds, the document is
 *       recorded as {@code SUCCESS} and no transient error counter is incremented.</li>
 *   <li><strong>Target copy error with immediate within-run retry (failure)</strong>: when both
 *       the initial and immediate-retry copy attempts fail, the document is recorded as
 *       {@code FAILED_RETRYABLE} with an incremented transient counter.</li>
 *   <li><strong>Duplicate target filename suffix</strong>: when two distinct documents produce
 *       the same base target filename in the same batch run, the second receives a {@code (1)}
 *       suffix to avoid overwriting the first.</li>
 *   <li><strong>Mixed batch outcome</strong>: a batch run that contains both successfully
 *       processed documents and documents with content errors completes with
 *       {@link BatchRunOutcome#SUCCESS}, confirming that document-level failures do not
 *       abort the batch or change the overall exit-code-relevant outcome.</li>
 * </ul>
 *
 * <h2>Document text used in tests</h2>
 * <p>
 * Searchable PDFs embed enough text to pass the minimum-text pre-check. The AI stub
 * returns a title of {@code "Stromabrechnung"} and date {@code "2024-01-15"} by default,
 * producing a target filename of {@code "2024-01-15 - Stromabrechnung.pdf"}.
 */
class BatchRunEndToEndTest {

    private static final String SAMPLE_PDF_TEXT =
            "Stromabrechnung Kundenname Musterstadt Datum 15.01.2024 Betrag 123,45 EUR";

    // =========================================================================
    // Scenario 1: Happy-path to SUCCESS
    // =========================================================================

    /**
     * Verifies the complete single-run happy-path:
     * <ol>
     *   <li>AI stub returns a valid proposal; the coordinator first persists the
     *       {@code PROPOSAL_READY} attempt (authoritative source for the naming proposal)
     *       and the master record transitions through {@code PROPOSAL_READY}.</li>
     *   <li>Without leaving the same run the target-copy finalization executes and a second
     *       attempt with status {@code SUCCESS} is historised; the master record reaches
     *       {@code SUCCESS} and the target file is visible on disk.</li>
     * </ol>
     * This confirms the leading-proposal-attempt rule as well as the per-document-per-run
     * invariant of exactly two historised attempts (PROPOSAL_READY, then SUCCESS).
     */
    @Test
    void happyPath_singleRun_reachesSuccess(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("rechnung.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("rechnung.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // --- Single run: AI produces proposal → PROPOSAL_READY historised → SUCCESS ---
            BatchRunOutcome run = ctx.runBatch();

            assertThat(run).isEqualTo(BatchRunOutcome.SUCCESS);
            assertThat(ctx.aiStub.invocationCount())
                    .as("AI must be invoked exactly once within the single run")
                    .isEqualTo(1);

            DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(record.lastSuccessInstant()).isNotNull();
            assertThat(record.lastTargetFileName()).isNotNull();

            List<String> targetFiles = ctx.listTargetFiles();
            assertThat(targetFiles).hasSize(1);
            assertThat(targetFiles.get(0)).endsWith(".pdf");
            assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue();

            // Two historised attempts: first PROPOSAL_READY, then SUCCESS with final target filename
            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(attempts.get(1).finalTargetFileName()).isNotNull();
        }
    }

    // =========================================================================
    // Scenario 2: Deterministic content error (NO_USABLE_TEXT) → immediate FAILED_FINAL
    // =========================================================================

    /**
     * Verifies that a blank PDF (no extractable text) reaches {@code FAILED_FINAL} immediately
     * in a single run without any retry:
     * <ol>
     *   <li>Run 1: blank PDF → pre-check fails ({@code NO_USABLE_TEXT}) →
     *       {@code FAILED_FINAL} immediately, content error counter = 1.</li>
     *   <li>Run 2: document is already terminal → {@code SKIPPED_FINAL_FAILURE}.</li>
     * </ol>
     * No AI call is made because the content pre-check prevents it.
     * An image-only scan without OCR text will not change between runs, so no retry is useful.
     */
    @Test
    void deterministicContentError_noUsableText_immediatelyFailedFinal(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createBlankPdf("blank.pdf");
            Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // --- Run 1: NO_USABLE_TEXT → FAILED_FINAL immediately ---
            ctx.runBatch();

            assertThat(ctx.aiStub.invocationCount())
                    .as("AI must not be called for a blank PDF")
                    .isEqualTo(0);

            DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record1.overallStatus())
                    .as("Blank PDF must finalise immediately without retry")
                    .isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(1);
            assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(0);

            List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
            assertThat(attempts1).hasSize(1);
            assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(attempts1.get(0).retryable()).isFalse();

            // --- Run 2: terminal FAILED_FINAL → skip ---
            ctx.runBatch();

            DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(record2.failureCounters().contentErrorCount())
                    .as("Content error counter must not change after a skip")
                    .isEqualTo(1);

            List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
            assertThat(attempts2).hasSize(2);
            assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);

            // No target file should exist
            assertThat(ctx.listTargetFiles()).isEmpty();
        }
    }

    // =========================================================================
    // Scenario 3: Transient technical error → FAILED_RETRYABLE
    // =========================================================================

    /**
     * Verifies that a transient AI failure produces {@code FAILED_RETRYABLE} with an
     * incremented transient error counter, and that no target file is written.
     * The document remains retryable in subsequent runs until the transient limit is reached.
     */
    @Test
    void transientAiFailure_producesFailedRetryable(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            ctx.aiStub.configureTechnicalFailure();

            ctx.runBatch();

            assertThat(ctx.aiStub.invocationCount())
                    .as("AI must have been invoked (and failed) once")
                    .isEqualTo(1);

            DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(record.failureCounters().transientErrorCount()).isEqualTo(1);
            assertThat(record.failureCounters().contentErrorCount()).isEqualTo(0);

            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(1);
            assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(attempts.get(0).retryable()).isTrue();

            assertThat(ctx.listTargetFiles()).isEmpty();
        }
    }

    // =========================================================================
    // Scenario 4: Skip after SUCCESS
    // =========================================================================

    /**
     * Verifies the skip-after-success invariant:
     * after a document reaches {@code SUCCESS} in a single run (two historised attempts:
     * {@code PROPOSAL_READY} followed by {@code SUCCESS}), a subsequent run records a
     * {@code SKIPPED_ALREADY_PROCESSED} attempt without changing the overall status,
     * failure counters, or the target file.
     */
    @Test
    void skipAfterSuccess_secondRun_recordsSkip(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // Reach SUCCESS in a single run (PROPOSAL_READY → SUCCESS within the same run)
            ctx.runBatch();

            DocumentRecord successRecord = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(successRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
            String targetFileBefore = successRecord.lastTargetFileName();

            // --- Subsequent run: should produce skip ---
            ctx.aiStub.resetInvocationCount();
            BatchRunOutcome runSkip = ctx.runBatch();

            assertThat(runSkip).isEqualTo(BatchRunOutcome.SUCCESS);
            assertThat(ctx.aiStub.invocationCount())
                    .as("AI must not be called for an already-successful document")
                    .isEqualTo(0);

            DocumentRecord recordAfterSkip = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(recordAfterSkip.overallStatus())
                    .as("Overall status must remain SUCCESS after a skip")
                    .isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(recordAfterSkip.lastTargetFileName())
                    .as("Target filename must not change after a skip")
                    .isEqualTo(targetFileBefore);

            // Three historised attempts total: PROPOSAL_READY, SUCCESS, SKIPPED_ALREADY_PROCESSED
            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(3);
            assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(attempts.get(2).status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
            assertThat(attempts.get(2).retryable()).isFalse();

            // Target file count must remain exactly one
            assertThat(ctx.listTargetFiles()).hasSize(1);
        }
    }

    // =========================================================================
    // Scenario 5: Skip after FAILED_FINAL
    // =========================================================================

    /**
     * Verifies the skip-after-final-failure invariant:
     * after a document reaches {@code FAILED_FINAL} (in a single blank-PDF run), a second run
     * records a {@code SKIPPED_FINAL_FAILURE} attempt without changing the overall status
     * or failure counters.
     */
    @Test
    void skipAfterFailedFinal_secondRun_recordsSkip(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createBlankPdf("blank.pdf");
            Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // Reach FAILED_FINAL in a single blank-PDF run (NO_USABLE_TEXT finalises immediately)
            ctx.runBatch(); // → FAILED_FINAL

            DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
            int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();

            // --- Run 2: should produce skip ---
            ctx.runBatch();

            DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record2.overallStatus())
                    .as("Overall status must remain FAILED_FINAL after a skip")
                    .isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(record2.failureCounters().contentErrorCount())
                    .as("Failure counters must not change after a skip")
                    .isEqualTo(contentErrorsBefore);

            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
            assertThat(attempts.get(1).retryable()).isFalse();
        }
    }

    // =========================================================================
    // Scenario 6: Target copy error with immediate within-run retry
    // =========================================================================
    //
    // Note: the former scenario "existing PROPOSAL_READY with later finalization" was
    // removed because the coordinator now reaches SUCCESS within the same run as the AI
    // call, which makes it impossible to produce a persisted PROPOSAL_READY state between
    // two runs via the natural pipeline flow. The underlying invariant "no new AI call if
    // a usable PROPOSAL_READY attempt already exists" is fully covered by the
    // application-level unit test
    // {@code DocumentProcessingCoordinatorTest#processDeferredOutcome_proposalReady_successfulCopy_persistsSuccessWithTargetFileName},
    // which injects a lookup result with status PROPOSAL_READY and asserts that the AI
    // pipeline is not invoked.

    /**
     * Verifies the immediate within-run retry for target copy failures within the
     * single-run pipeline:
     * <ul>
     *   <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on the
     *       first invocation but delegates to the real adapter on the second.</li>
     *   <li>Within the same run the AI stub produces a naming proposal, the
     *       {@code PROPOSAL_READY} attempt is historised, the first copy attempt fails,
     *       and the immediate within-run retry succeeds.</li>
     *   <li>The document is recorded as {@code SUCCESS} — without incrementing the
     *       cross-run transient error counter.</li>
     * </ul>
     * The immediate retry does not count as a cross-run transient error.
     */
    @Test
    void targetCopyError_immediateRetrySucceeds_recordsSuccess(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // The copy port fails once and succeeds on the immediate within-run retry.
            TargetFileCopyPort realAdapter =
                    new de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter(
                            ctx.targetFolder());
            AtomicInteger copyCallCount = new AtomicInteger(0);

            TargetFileCopyPort stubWithRetry = (locator, resolvedFilename) -> {
                int call = copyCallCount.incrementAndGet();
                if (call == 1) {
                    // First attempt: simulate a transient write failure
                    return new TargetFileCopyTechnicalFailure(
                            "Simulated write failure on first attempt", true);
                }
                // Second attempt (immediate within-run retry): delegate to real adapter
                return realAdapter.copyToTarget(locator, resolvedFilename);
            };

            ctx.setTargetFileCopyPortOverride(stubWithRetry);
            ctx.runBatch();

            assertThat(copyCallCount.get())
                    .as("Copy port must have been called twice (initial + retry) in the same run")
                    .isEqualTo(2);

            DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(record.failureCounters().transientErrorCount())
                    .as("Immediate within-run retry must not increment the transient error counter")
                    .isEqualTo(0);

            // Two historised attempts: PROPOSAL_READY then SUCCESS (the immediate within-run
            // retry of the physical copy does not produce an extra historised attempt).
            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);

            List<String> targetFiles = ctx.listTargetFiles();
            assertThat(targetFiles).hasSize(1);
            assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue();
        }
    }

    // =========================================================================
    // Scenario 8: Transient error exhaustion → FAILED_FINAL
    // =========================================================================

    /**
     * Verifies the complete transient error exhaustion path over multiple runs:
     * <ol>
     *   <li>Run 1: AI stub fails technically → {@code FAILED_RETRYABLE},
     *       transient counter = 1 (below limit 3).</li>
     *   <li>Run 2: AI stub fails again → {@code FAILED_RETRYABLE},
     *       transient counter = 2 (below limit 3).</li>
     *   <li>Run 3: AI stub fails again → transient counter reaches the limit (3 = 3) →
     *       {@code FAILED_FINAL}; no target file is ever written.</li>
     * </ol>
     * This confirms the {@code maxRetriesTransient} boundary: the run that pushes the
     * counter to the configured limit is the run that finalises the document.
     */
    @Test
    void transientErrors_multipleRuns_exhaustsLimit_reachesFailedFinal(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            ctx.aiStub.configureTechnicalFailure();

            // --- Run 1: counter 0 → 1, below limit → FAILED_RETRYABLE ---
            ctx.runBatch();

            DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(1);
            assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(0);

            List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
            assertThat(attempts1).hasSize(1);
            assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(attempts1.get(0).retryable()).isTrue();

            // --- Run 2: counter 1 → 2, below limit → FAILED_RETRYABLE ---
            ctx.runBatch();

            DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(record2.failureCounters().transientErrorCount()).isEqualTo(2);

            List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
            assertThat(attempts2).hasSize(2);
            assertThat(attempts2.get(1).retryable()).isTrue();

            // --- Run 3: counter 2 → 3 = limit → FAILED_FINAL ---
            ctx.runBatch();

            DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record3.overallStatus())
                    .as("Transient counter reaching the configured limit must finalise the document")
                    .isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(record3.failureCounters().transientErrorCount())
                    .as("Transient counter must equal maxRetriesTransient after exhaustion")
                    .isEqualTo(E2ETestContext.MAX_RETRIES_TRANSIENT);
            assertThat(record3.failureCounters().contentErrorCount()).isEqualTo(0);

            List<ProcessingAttempt> attempts3 = ctx.findAttempts(fp);
            assertThat(attempts3).hasSize(3);
            assertThat(attempts3.get(2).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(attempts3.get(2).retryable()).isFalse();

            assertThat(ctx.listTargetFiles()).isEmpty();
        }
    }

    // =========================================================================
    // Scenario 9: Target copy error – both attempts fail → FAILED_RETRYABLE
    // =========================================================================

    /**
     * Verifies the failure path of the immediate within-run retry mechanism, within the
     * single-run pipeline:
     * <ul>
     *   <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on every call.</li>
     *   <li>Within a single run the AI stub produces a valid proposal, the
     *       {@code PROPOSAL_READY} attempt is historised, the initial copy attempt fails,
     *       the immediate within-run retry also fails, and the combined result is
     *       classified as a transient technical error and persisted as
     *       {@code FAILED_RETRYABLE} with an incremented transient counter.</li>
     * </ul>
     * This confirms that the within-run retry does not suppress the error when both
     * attempts fail, and that the transient counter is incremented exactly once.
     */
    @Test
    void targetCopyError_bothAttemptsFail_reachesFailedRetryable(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // Both copy attempts (initial + immediate within-run retry) fail deterministically
            ctx.setTargetFileCopyPortOverride(
                    (locator, resolvedFilename) ->
                            new TargetFileCopyTechnicalFailure(
                                    "Simulated persistent write failure", true));

            ctx.runBatch();

            DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
            assertThat(record.overallStatus())
                    .as("Both copy attempts failing must produce FAILED_RETRYABLE "
                            + "(transient error, limit not yet reached)")
                    .isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(record.failureCounters().transientErrorCount())
                    .as("The double copy failure must increment the transient counter exactly once")
                    .isEqualTo(1);

            // Two historised attempts: first PROPOSAL_READY, then FAILED_RETRYABLE from the
            // failed copy finalization (no SUCCESS, no extra attempt for the within-run retry).
            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
            assertThat(attempts.get(1).retryable()).isTrue();

            assertThat(ctx.listTargetFiles()).isEmpty();
        }
    }

    // =========================================================================
    // Scenario 10: Two documents with identical target name → duplicate suffix
    // =========================================================================

    /**
     * Verifies the duplicate target filename suffix rule at end-to-end level:
     * when two distinct source documents both resolve to the same base target name
     * ({@code "2024-01-15 - Stromabrechnung.pdf"}) within the same single-run pipeline,
     * the second document written to the target folder must receive a {@code (1)} suffix.
     * <p>
     * Both PDFs are processed sequentially in the same run: each one goes through
     * AI-proposal → {@code PROPOSAL_READY} → target copy → {@code SUCCESS}. The first
     * written claims the base name, the second receives
     * {@code "2024-01-15 - Stromabrechnung(1).pdf"}.
     */
    @Test
    void twoDifferentDocuments_sameProposedName_secondGetsDuplicateSuffix(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            // Two distinct PDFs; the AI stub returns the same title and date for both
            ctx.createSearchablePdf("rechnung1.pdf", SAMPLE_PDF_TEXT);
            ctx.createSearchablePdf("rechnung2.pdf",
                    "Stromabrechnung Zweiter Kunde Musterstadt Datum 15.01.2024 Betrag 99,00 EUR");

            Path pdf1 = ctx.sourceFolder().resolve("rechnung1.pdf");
            Path pdf2 = ctx.sourceFolder().resolve("rechnung2.pdf");
            DocumentFingerprint fp1 = ctx.computeFingerprint(pdf1);
            DocumentFingerprint fp2 = ctx.computeFingerprint(pdf2);

            // --- Single run: both PDFs are processed and copied within the same run ---
            ctx.runBatch();

            assertThat(ctx.findDocumentRecord(fp1).orElseThrow().overallStatus())
                    .isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(ctx.findDocumentRecord(fp2).orElseThrow().overallStatus())
                    .isEqualTo(ProcessingStatus.SUCCESS);

            List<String> targetFiles = ctx.listTargetFiles();
            assertThat(targetFiles)
                    .as("Both distinct documents must produce separate target files")
                    .hasSize(2);
            assertThat(targetFiles)
                    .as("Base name must exist for the first document written")
                    .anyMatch(f -> f.equals("2024-01-15 - Stromabrechnung.pdf"));
            assertThat(targetFiles)
                    .as("Duplicate suffix (1) must be appended for the second document written")
                    .anyMatch(f -> f.equals("2024-01-15 - Stromabrechnung(1).pdf"));
        }
    }

    // =========================================================================
    // Scenario 11: Mixed batch – document failures do not affect batch outcome
    // =========================================================================

    /**
     * Verifies that document-level failures do not cause a batch-level failure:
     * <ol>
     *   <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
     *       (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
     *       {@code FAILED_FINAL} immediately (no retry for {@code NO_USABLE_TEXT}).
     *       {@link BatchRunOutcome#SUCCESS} is returned.</li>
     *   <li>Run 2: the searchable PDF is skipped as {@code SKIPPED_ALREADY_PROCESSED};
     *       the blank PDF is skipped as {@code SKIPPED_FINAL_FAILURE} (already terminal).
     *       {@link BatchRunOutcome#SUCCESS} is returned.</li>
     * </ol>
     * This confirms the exit-code contract: only hard bootstrap or infrastructure
     * failures produce a non-zero exit code; document-level errors do not.
     */
    @Test
    void mixedBatch_oneSuccess_oneContentError_batchOutcomeIsSuccess(@TempDir Path tempDir)
            throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("good.pdf", SAMPLE_PDF_TEXT);
            ctx.createBlankPdf("blank.pdf");

            Path goodPdf = ctx.sourceFolder().resolve("good.pdf");
            Path blankPdf = ctx.sourceFolder().resolve("blank.pdf");
            DocumentFingerprint fpGood = ctx.computeFingerprint(goodPdf);
            DocumentFingerprint fpBlank = ctx.computeFingerprint(blankPdf);

            // --- Run 1 ---
            BatchRunOutcome run1 = ctx.runBatch();

            assertThat(run1)
                    .as("Batch must complete with SUCCESS even when individual documents fail")
                    .isEqualTo(BatchRunOutcome.SUCCESS);
            assertThat(ctx.findDocumentRecord(fpGood).orElseThrow().overallStatus())
                    .as("Searchable PDF must reach SUCCESS within the same single run")
                    .isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
                    .as("Blank PDF (NO_USABLE_TEXT) must finalise immediately to FAILED_FINAL")
                    .isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
                    .failureCounters().contentErrorCount()).isEqualTo(1);

            // The successfully processed document already has its target file on disk
            assertThat(ctx.listTargetFiles())
                    .as("Target file of the successfully processed document must exist after run 1")
                    .hasSize(1);

            // --- Run 2 ---
            BatchRunOutcome run2 = ctx.runBatch();

            assertThat(run2)
                    .as("Batch must complete with SUCCESS when all documents are skipped")
                    .isEqualTo(BatchRunOutcome.SUCCESS);

            DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
            assertThat(goodRecord.overallStatus())
                    .as("Already successful document must remain SUCCESS after being skipped")
                    .isEqualTo(ProcessingStatus.SUCCESS);

            DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
            assertThat(blankRecord.overallStatus())
                    .as("Terminal FAILED_FINAL must remain unchanged after skip")
                    .isEqualTo(ProcessingStatus.FAILED_FINAL);
            assertThat(blankRecord.failureCounters().contentErrorCount())
                    .as("Content error counter must not change after SKIPPED_FINAL_FAILURE")
                    .isEqualTo(1);

            // Exactly one target file from the successfully processed document
            List<String> targetFiles = ctx.listTargetFiles();
            assertThat(targetFiles).hasSize(1);
            assertThat(targetFiles.get(0)).endsWith(".pdf");
        }
    }
}
