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

import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.BatchRunOutcome;
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.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
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.DocumentTerminalFinalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
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.FingerprintTechnicalError;
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.RunLockUnavailableException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException;
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.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
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;

/**
 * Tests for {@link DefaultBatchRunProcessingUseCase}.
 * <p>
 * Covers:
 * <ul>
 *   <li>Lock acquisition and release lifecycle</li>
 *   <li>Source folder scanning and per-document processing loop</li>
 *   <li>Happy path: candidate passes pre-checks, persistence is invoked</li>
 *   <li>Deterministic content errors: no usable text, page limit exceeded</li>
 *   <li>Technical extraction errors: controlled per-document end, batch continues</li>
 *   <li>Source folder access failure: batch fails with FAILURE outcome</li>
 *   <li>Idempotency: fingerprint failure → not historised</li>
 * </ul>
 */
class BatchRunProcessingUseCaseTest {

    private static final int TEST_MAX_TITLE_LENGTH = 60;

    @TempDir
    Path tempDir;

    // -------------------------------------------------------------------------
    // Lock lifecycle tests
    // -------------------------------------------------------------------------

    @Test
    void execute_successfullyAcquiresAndReleasesLock() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("test-run-1"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(lockPort.wasAcquireCalled(), "Lock acquire should be called");
        assertTrue(lockPort.wasReleaseCalled(), "Lock release should be called");
        assertTrue(outcome.isSuccess(), "Batch should complete successfully");
    }

    @Test
    void execute_returnsLockUnavailableWhenLockCannotBeAcquired() throws Exception {
        CountingRunLockPort lockPort = new CountingRunLockPort(true);
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("test-run-2"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isLockUnavailable(), "Outcome should be LOCK_UNAVAILABLE when lock cannot be acquired");
        assertTrue(outcome.isFailure(), "LOCK_UNAVAILABLE also reports as failure for exit code derivation");
        assertFalse(outcome.isSuccess(), "Batch should not succeed when lock unavailable");
    }

    /**
     * Regression test: when acquire() fails, release() must NOT be called.
     */
    @Test
    void execute_doesNotReleaseLockWhenAcquireFails() throws Exception {
        CountingRunLockPort lockPort = new CountingRunLockPort(true);
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("test-run-f1"), Instant.now());

        useCase.execute(context);

        assertEquals(1, lockPort.acquireCallCount(), "acquire() should be called exactly once");
        assertEquals(0, lockPort.releaseCallCount(),
                "release() must NOT be called when acquire() failed – would delete another instance's lock file");
    }

    @Test
    void execute_releasesLockEvenOnUnexpectedError() throws Exception {
        ErrorAfterAcquireLockPort lockPort = new ErrorAfterAcquireLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("test-run-3"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(lockPort.wasAcquireCalled(), "Lock acquire should be called");
        assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even after unexpected error");
        assertTrue(outcome.isSuccess() || outcome.isFailure());
    }

    // -------------------------------------------------------------------------
    // Source folder scanning and candidate processing
    // -------------------------------------------------------------------------

    @Test
    void execute_withNoCandidates_returnsSuccess() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("empty"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Empty candidate list should still yield SUCCESS");
    }

    @Test
    void execute_happyPath_candidatePassesPreChecks_persistenceInvoked() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("document.pdf");
        PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(success);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("happy"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Happy path should yield SUCCESS");
        assertEquals(1, extractionPort.callCount(), "Extraction should be called exactly once");
        assertEquals(1, processor.processCallCount(), "processor should be called exactly once");
    }

    @Test
    void execute_noUsableText_candidateEndsControlled_batchContinues() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("image-only.pdf");
        PdfExtractionSuccess emptySuccess = new PdfExtractionSuccess("   ", new PdfPageCount(1));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(emptySuccess);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("no-text"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "No-usable-text pre-check failure should not abort the batch run");
        assertEquals(1, processor.processCallCount(), "processor should still be called for content errors");
    }

    @Test
    void execute_pageLimitExceeded_candidateEndsControlled_batchContinues() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("big.pdf");
        PdfExtractionSuccess manyPages = new PdfExtractionSuccess("Some text", new PdfPageCount(10));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(manyPages);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("page-limit"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Page limit exceeded should not abort the batch run");
        assertEquals(1, processor.processCallCount(), "processor should still be called for page limit errors");
    }

    @Test
    void execute_extractionContentError_candidateEndsControlled_batchContinues() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("encrypted.pdf");
        PdfExtractionContentError contentError = new PdfExtractionContentError("PDF is encrypted");
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(contentError);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("content-error"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Extraction content error should not abort the batch run");
        assertEquals(1, processor.processCallCount(), "processor should be called for content errors");
    }

    @Test
    void execute_extractionTechnicalError_candidateEndsControlled_batchContinues() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("corrupt.pdf");
        PdfExtractionTechnicalError technicalError = new PdfExtractionTechnicalError("I/O error reading file", null);
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(technicalError);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("tech-error"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Technical extraction error should not abort the batch run");
        assertEquals(1, processor.processCallCount(), "processor should be called for technical errors");
    }

    @Test
    void execute_sourceAccessException_returnsFailure() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidatesPort failingPort = () -> {
            throw new SourceDocumentAccessException("Source folder not readable");
        };

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, failingPort, new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("access-fail"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isFailure(), "Source folder access failure should yield FAILURE outcome");
        assertFalse(outcome.isSuccess(), "Source folder access failure must not be SUCCESS");
        assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even when source access fails");
    }

    // -------------------------------------------------------------------------
    // Fingerprint failure handling: not historised
    // -------------------------------------------------------------------------

    @Test
    void execute_fingerprintFailure_candidateNotHistorised_batchContinues() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("unreadable.pdf");
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        // Fingerprint always fails → processor must NOT be called
        FingerprintPort alwaysFailingFingerprintPort = c ->
                new FingerprintTechnicalError("Cannot read file", null);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, new NoOpExtractionPort(),
                alwaysFailingFingerprintPort, processor);
        BatchRunContext context = new BatchRunContext(new RunId("fp-fail"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Fingerprint failure should not abort the batch run");
        assertEquals(0, processor.processCallCount(),
                "processor must NOT be called when fingerprint computation fails (pre-fingerprint failure)");
    }

    @Test
    void execute_fingerprintFailure_extractionNotCalled() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("unreadable.pdf");
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(
                new PdfExtractionSuccess("text", new PdfPageCount(1)));

        FingerprintPort alwaysFailingFingerprintPort = c ->
                new FingerprintTechnicalError("Cannot read file", null);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator());
        BatchRunContext context = new BatchRunContext(new RunId("fp-fail-no-extract"), Instant.now());

        useCase.execute(context);

        assertEquals(0, extractionPort.callCount(),
                "PDF extraction must NOT be called when fingerprint computation fails");
    }

    /**
     * Mixed-batch test: one document per outcome type in a single run.
     * Proves that no individual outcome aborts the overall batch.
     */
    @Test
    void execute_mixedBatch_allOutcomeTypes_batchOverallSucceeds() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate goodCandidate           = makeCandidate("good.pdf");
        SourceDocumentCandidate noTextCandidate         = makeCandidate("notext.pdf");
        SourceDocumentCandidate pageLimitCandidate      = makeCandidate("toobig.pdf");
        SourceDocumentCandidate technicalErrorCandidate = makeCandidate("broken.pdf");
        SourceDocumentCandidate contentErrorCandidate   = makeCandidate("encrypted.pdf");
        SourceDocumentCandidate fpFailCandidate         = makeCandidate("unreadable.pdf");

        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(
                goodCandidate, noTextCandidate, pageLimitCandidate,
                technicalErrorCandidate, contentErrorCandidate, fpFailCandidate));

        MappedExtractionPort extractionPort = new MappedExtractionPort()
                .with(goodCandidate,           new PdfExtractionSuccess("Invoice text", new PdfPageCount(1)))
                .with(noTextCandidate,         new PdfExtractionSuccess("   ", new PdfPageCount(1)))
                .with(pageLimitCandidate,      new PdfExtractionSuccess("Some text", new PdfPageCount(10)))
                .with(technicalErrorCandidate, new PdfExtractionTechnicalError("I/O error", null))
                .with(contentErrorCandidate,   new PdfExtractionContentError("PDF is encrypted"));

        // fpFailCandidate gets a fingerprint failure; others get a valid fingerprint
        FingerprintPort mappedFingerprintPort = candidate -> {
            if (candidate.uniqueIdentifier().equals("unreadable.pdf")) {
                return new FingerprintTechnicalError("Cannot read", null);
            }
            return new FingerprintSuccess(makeFingerprint(candidate.uniqueIdentifier()));
        };

        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                mappedFingerprintPort, processor);
        BatchRunContext context = new BatchRunContext(new RunId("mixed"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "Mixed batch with all outcome types must yield batch SUCCESS");
        // 5 candidates with successful fingerprint → processor called 5 times
        // 1 candidate with fingerprint failure → processor NOT called
        assertEquals(5, processor.processCallCount(),
                "processor must be called for each candidate with a successful fingerprint");
        // Extraction called for 5 candidates (not for fpFailCandidate)
        assertEquals(5, extractionPort.callCount(),
                "Extraction must be attempted for each of the 5 candidates with a valid fingerprint");
    }

    @Test
    void execute_multipleCandidates_allProcessed_batchSucceeds() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        List<SourceDocumentCandidate> candidates = List.of(
                makeCandidate("a.pdf"),
                makeCandidate("b.pdf"),
                makeCandidate("c.pdf")
        );
        PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice content", new PdfPageCount(2));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(candidates);
        FixedExtractionPort extractionPort = new FixedExtractionPort(success);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor);
        BatchRunContext context = new BatchRunContext(new RunId("multi"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(), "All three candidates processed should yield SUCCESS");
        assertEquals(3, extractionPort.callCount(), "Extraction should be called once per candidate");
        assertEquals(3, processor.processCallCount(), "processor should be called once per candidate");
    }

    // -------------------------------------------------------------------------
    // Document-level persistence failure handling
    // -------------------------------------------------------------------------

    /**
     * Document-level persistence failures must not escalate to a hard batch failure.
     * The batch outcome must be SUCCESS even when a document's persistence step fails,
     * because the batch loop ran to completion and the failed document will be retried
     * in a subsequent run.
     */
    @Test
    void execute_documentPersistenceFailure_batchOutcomeIsSuccess() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("document.pdf");
        PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(success);

        // Use a coordinator that always fails persistence
        DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
                new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
                new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
                new NoOpProcessingLogger(), 3, TEST_MAX_TITLE_LENGTH, "openai-compatible") {
            @Override
            public boolean processDeferredOutcome(
                    de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
                    de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint,
                    de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context,
                    java.time.Instant attemptStart,
                    java.util.function.Function<de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
                // Always report persistence failure
                return false;
            }
        };

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), failingProcessor);
        BatchRunContext context = new BatchRunContext(new RunId("persist-fail"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(),
                "Document-level persistence failure must not escalate to batch FAILURE — "
                + "the batch ran to completion and the document will be retried");
        assertFalse(outcome.isFailure(),
                "Batch must not signal FAILURE when only a document-level persistence error occurred");
    }

    /**
     * Mixed batch: one document completes normally, one has a persistence failure.
     * The batch outcome must still be SUCCESS because both documents were processed
     * by the loop. Document-level failures do not escalate to exit code 1.
     */
    @Test
    void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsSuccess() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate goodCandidate = makeCandidate("good.pdf");
        SourceDocumentCandidate failCandidate = makeCandidate("fails.pdf");

        PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(goodCandidate, failCandidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(success);

        // Coordinator that succeeds for first document, fails persistence for second
        DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
                new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
                new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
                new NoOpProcessingLogger(), 3, TEST_MAX_TITLE_LENGTH, "openai-compatible") {
            private int callCount = 0;

            @Override
            public boolean processDeferredOutcome(
                    de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
                    de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint,
                    de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context,
                    java.time.Instant attemptStart,
                    java.util.function.Function<de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
                callCount++;
                // First document succeeds, second fails persistence
                return callCount == 1;
            }
        };

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), selectiveFailingProcessor);
        BatchRunContext context = new BatchRunContext(new RunId("mixed-persist-fail"), Instant.now());

        BatchRunOutcome outcome = useCase.execute(context);

        assertTrue(outcome.isSuccess(),
                "Batch must be SUCCESS even when one document had a persistence failure — "
                + "the batch loop ran to completion");
        assertFalse(outcome.isFailure(),
                "Document-level persistence failure in one candidate must not make the batch FAILURE");
    }

    // -------------------------------------------------------------------------
    // Logger-Delegation (VoidMethodCallMutator abdecken)
    // -------------------------------------------------------------------------

    @Test
    void execute_fingerprintFailure_logsWarning() throws Exception {
        // Prüft, dass bei Fingerprint-Fehler eine Warnung geloggt wird (handleFingerprintError)
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("unreadable.pdf");
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));

        FingerprintPort alwaysFailingFingerprintPort = c ->
                new FingerprintTechnicalError("Datei nicht lesbar", null);

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(),
                alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("fp-warn"), Instant.now()));

        assertTrue(capturingLogger.warnCallCount > 0,
                "Bei Fingerprint-Fehler muss eine Warnung geloggt werden");
    }

    @Test
    void execute_sourceAccessException_logsError() throws Exception {
        // Prüft, dass bei Quellordner-Zugriffsfehler ein Fehler geloggt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidatesPort failingPort = () -> {
            throw new SourceDocumentAccessException("Quellordner nicht lesbar");
        };

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), failingPort, new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("source-err"), Instant.now()));

        assertTrue(capturingLogger.errorCallCount > 0,
                "Bei Quellordner-Zugriffsfehler muss ein Fehler geloggt werden");
    }

    @Test
    void execute_batchStart_logsInfo() throws Exception {
        // Prüft, dass beim Batch-Start mindestens die erwarteten Info-Einträge geloggt werden.
        // Erwartete info()-Aufrufe ohne Kandidaten: execute L130 + execute L145 + processCandidates L178 + L190 = 4
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("start-log"), Instant.now()));

        // Ohne Kandidaten: L130 + L145 + L178 + L190 = 4 info()-Aufrufe
        assertTrue(capturingLogger.infoCallCount >= 4,
                "Batch-Start muss mindestens 4 Info-Einträge loggen (L130/L145/L178/L190), war: "
                        + capturingLogger.infoCallCount);
    }

    @Test
    void execute_lockUnavailable_logsWarning() throws Exception {
        // Prüft, dass bei nicht verfügbarem Lock eine Warnung geloggt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        CountingRunLockPort lockPort = new CountingRunLockPort(true);
        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
                new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("lock-warn"), Instant.now()));

        assertTrue(capturingLogger.warnCallCount > 0,
                "Bei nicht verfügbarem Lock muss eine Warnung geloggt werden");
    }

    @Test
    void execute_extractionSuccess_logsPreCheckPassedInfoAndDebug() throws Exception {
        // Prüft, dass bei erfolgreich verarbeiteter Datei debug() durch logExtractionResult
        // und info() durch logProcessingOutcome aufgerufen wird.
        // Erwartete debug()-Aufrufe für einen Kandidaten (success-Pfad):
        //   lock acquired + fingerprint computed + logExtractionResult + lock released = 4
        //   Ohne logExtractionResult-Aufruf wären es nur 3 debug()-Aufrufe.
        // Erwartete info()-Aufrufe für einen Kandidaten (success-Pfad):
        //   Batch initiiert + Batch gestartet + Kandidaten gefunden + erkannte Quelldatei
        //   + logProcessingOutcome (PreCheckPassed) + Batch abgeschlossen = 6
        //   Ohne logProcessingOutcome-Aufruf wären es 5 info()-Aufrufe.
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("invoice.pdf");
        PdfExtractionSuccess success = new PdfExtractionSuccess("Rechnungstext", new PdfPageCount(1));
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(success);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("log-precheck"), Instant.now()));

        // Ohne logExtractionResult wären es nur 3 debug()-Aufrufe; mit logExtractionResult >= 4
        assertTrue(capturingLogger.debugCallCount >= 4,
                "logExtractionResult muss bei PdfExtractionSuccess debug() aufrufen (erwartet >= 4, war: "
                        + capturingLogger.debugCallCount + ")");
        // Ohne logProcessingOutcome wären es 5 info()-Aufrufe; mit logProcessingOutcome >= 6
        assertTrue(capturingLogger.infoCallCount >= 6,
                "logProcessingOutcome muss bei PreCheckPassed info() aufrufen (erwartet >= 6, war: "
                        + capturingLogger.infoCallCount + ")");
    }

    @Test
    void execute_extractionContentError_logsDebugAndPreCheckFailedWarn() throws Exception {
        // Prüft, dass bei PdfExtractionContentError debug (logExtractionResult) und warn (logProcessingOutcome) geloggt wird.
        // Erwartete debug()-Aufrufe: 4 (lock acquired + fingerprint + logExtractionResult (content) + lock released)
        // Erwartete info()-Aufrufe: 5 (Batch initiiert + gestartet + Kandidaten gefunden + erkannte Quelldatei + abgeschlossen)
        // Erwartete warn()-Aufrufe: >= 1 (PreCheckFailed)
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("encrypted.pdf");
        PdfExtractionContentError contentError = new PdfExtractionContentError("PDF verschlüsselt");
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(contentError);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("log-content-error"), Instant.now()));

        // Ohne logExtractionResult wären es nur 3 debug()-Aufrufe; mit logExtractionResult >= 4
        assertTrue(capturingLogger.debugCallCount >= 4,
                "logExtractionResult muss bei PdfExtractionContentError debug() aufrufen (erwartet >= 4, war: "
                        + capturingLogger.debugCallCount + ")");
        // PreCheckFailed wird als WARN geloggt; ohne diesen Aufruf wären es 5 info()-Aufrufe
        assertTrue(capturingLogger.infoCallCount >= 5,
                "Batch-Ablauf muss bei PdfExtractionContentError mind. 5 info()-Aufrufe erzeugen (erwartet >= 5, war: "
                        + capturingLogger.infoCallCount + ")");
        assertTrue(capturingLogger.warnCallCount >= 1,
                "logProcessingOutcome muss bei PreCheckFailed warn() aufrufen (erwartet >= 1, war: "
                        + capturingLogger.warnCallCount + ")");
    }

    @Test
    void execute_extractionTechnicalError_logsDebugAndWarn() throws Exception {
        // Prüft, dass bei PdfExtractionTechnicalError debug (logExtractionResult) und warn (logProcessingOutcome) geloggt wird.
        // Erwartete debug()-Aufrufe: 4 (lock acquired + fingerprint + logExtractionResult + lock released)
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("broken.pdf");
        PdfExtractionTechnicalError technicalError = new PdfExtractionTechnicalError("I/O-Fehler", null);
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(technicalError);
        TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator();

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, extractionPort,
                new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("log-tech-error"), Instant.now()));

        // Ohne logExtractionResult wären es nur 3 debug()-Aufrufe; mit logExtractionResult >= 4
        assertTrue(capturingLogger.debugCallCount >= 4,
                "logExtractionResult muss bei PdfExtractionTechnicalError debug() aufrufen (erwartet >= 4, war: "
                        + capturingLogger.debugCallCount + ")");
        // logProcessingOutcome ruft warn() auf für TechnicalDocumentError
        assertTrue(capturingLogger.warnCallCount > 0,
                "logProcessingOutcome muss bei TechnicalDocumentError warn() aufrufen");
    }

    // -------------------------------------------------------------------------
    // Batch-level integration tests (real coordinator + capturing repos)
    // These prove that skip and finalization semantics work in the actual batch run,
    // not just at the coordinator unit-test level.
    // -------------------------------------------------------------------------

    /**
     * Full batch integration: a candidate whose fingerprint maps to a SUCCESS record
     * must be historised as SKIPPED_ALREADY_PROCESSED without triggering the pipeline.
     */
    @Test
    void execute_successDocument_batchRunHistorisesSkippedAlreadyProcessed() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("already-done.pdf");
        DocumentFingerprint fingerprint = makeFingerprint(candidate.uniqueIdentifier());

        // Capturing repos to inspect what was persisted
        BatchCapturingDocumentRecordRepository recordRepo = new BatchCapturingDocumentRecordRepository();
        BatchCapturingProcessingAttemptRepository attemptRepo = new BatchCapturingProcessingAttemptRepository();
        BatchCapturingUnitOfWorkPort unitOfWork = new BatchCapturingUnitOfWorkPort(recordRepo, attemptRepo);

        // Repo returns SUCCESS for this fingerprint
        DocumentRecord successRecord = new DocumentRecord(
                fingerprint, new SourceDocumentLocator("/tmp/already-done.pdf"), "already-done.pdf",
                ProcessingStatus.SUCCESS, FailureCounters.zero(),
                null, java.time.Instant.now(), java.time.Instant.now(), java.time.Instant.now(),
                "/target", "2026-01-15 - Rechnung.pdf");
        recordRepo.setLookupResult(new DocumentTerminalSuccess(successRecord));

        DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWork,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");

        // Fingerprint port returns the pre-defined fingerprint for this candidate
        FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new FixedCandidatesPort(List.of(candidate)), new NoOpExtractionPort(),
                fixedFingerprintPort, realCoordinator);
        BatchRunContext context = new BatchRunContext(new RunId("skip-success-run"), Instant.now());

        useCase.execute(context);

        // Exactly one skip attempt must be recorded
        assertEquals(1, attemptRepo.savedAttempts.size(),
                "Exactly one skip attempt must be historised for a SUCCESS document");
        assertEquals(ProcessingStatus.SKIPPED_ALREADY_PROCESSED, attemptRepo.savedAttempts.get(0).status(),
                "Skip attempt status must be SKIPPED_ALREADY_PROCESSED");
        assertFalse(attemptRepo.savedAttempts.get(0).retryable(),
                "Skip attempt must not be retryable");
    }

    /**
     * Full batch integration: a candidate whose fingerprint maps to a FAILED_FINAL record
     * must be historised as SKIPPED_FINAL_FAILURE without triggering the pipeline.
     */
    @Test
    void execute_finallyFailedDocument_batchRunHistorisesSkippedFinalFailure() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate("permanent-failure.pdf");
        DocumentFingerprint fingerprint = makeFingerprint(candidate.uniqueIdentifier());

        BatchCapturingDocumentRecordRepository recordRepo = new BatchCapturingDocumentRecordRepository();
        BatchCapturingProcessingAttemptRepository attemptRepo = new BatchCapturingProcessingAttemptRepository();
        BatchCapturingUnitOfWorkPort unitOfWork = new BatchCapturingUnitOfWorkPort(recordRepo, attemptRepo);

        // Repo returns FAILED_FINAL for this fingerprint
        DocumentRecord failedFinalRecord = new DocumentRecord(
                fingerprint, new SourceDocumentLocator("/tmp/permanent-failure.pdf"), "permanent-failure.pdf",
                ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0),
                java.time.Instant.now(), null, java.time.Instant.now(), java.time.Instant.now(),
                null, null);
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(failedFinalRecord));

        DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWork,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");

        FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, new FixedCandidatesPort(List.of(candidate)), new NoOpExtractionPort(),
                fixedFingerprintPort, realCoordinator);
        BatchRunContext context = new BatchRunContext(new RunId("skip-final-failure-run"), Instant.now());

        useCase.execute(context);

        // Exactly one skip attempt must be recorded
        assertEquals(1, attemptRepo.savedAttempts.size(),
                "Exactly one skip attempt must be historised for a FAILED_FINAL document");
        assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, attemptRepo.savedAttempts.get(0).status(),
                "Skip attempt status must be SKIPPED_FINAL_FAILURE");
        assertFalse(attemptRepo.savedAttempts.get(0).retryable(),
                "Skip attempt must not be retryable");
    }

    /**
     * Full batch integration: a batch with one terminal (SUCCESS) and one processable document
     * handles each independently — the terminal document is skipped, the processable one is processed.
     * Document errors on one candidate must not block processing of the other.
     */
    @Test
    void execute_mixedTerminalAndProcessable_eachHandledIndependentlyInBatch() throws Exception {
        MockRunLockPort lockPort = new MockRunLockPort();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate terminalCandidate = makeCandidate("terminal.pdf");
        SourceDocumentCandidate processableCandidate = makeCandidate("processable.pdf");

        DocumentFingerprint terminalFp = makeFingerprint(terminalCandidate.uniqueIdentifier());
        DocumentFingerprint processableFp = makeFingerprint(processableCandidate.uniqueIdentifier());

        BatchCapturingDocumentRecordRepository recordRepo = new BatchCapturingDocumentRecordRepository();
        BatchCapturingProcessingAttemptRepository attemptRepo = new BatchCapturingProcessingAttemptRepository();
        BatchCapturingUnitOfWorkPort unitOfWork = new BatchCapturingUnitOfWorkPort(recordRepo, attemptRepo);

        // Terminal candidate: SUCCESS in repo
        DocumentRecord successRecord = new DocumentRecord(
                terminalFp, new SourceDocumentLocator("/tmp/terminal.pdf"), "terminal.pdf",
                ProcessingStatus.SUCCESS, FailureCounters.zero(),
                null, java.time.Instant.now(), java.time.Instant.now(), java.time.Instant.now(),
                "/target", "2026-01-15 - Rechnung.pdf");

        // Per-fingerprint lookup: terminal gets SUCCESS, processable gets Unknown
        recordRepo.setLookupByFingerprint(terminalFp, new DocumentTerminalSuccess(successRecord));
        recordRepo.setLookupByFingerprint(processableFp, new DocumentUnknown());

        DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWork,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");

        FingerprintPort perCandidateFingerprintPort = candidate -> {
            if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp);
            return new FingerprintSuccess(processableFp);
        };

        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(
                List.of(terminalCandidate, processableCandidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(
                new PdfExtractionSuccess("Invoice text", new PdfPageCount(1)));

        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, lockPort, candidatesPort, extractionPort,
                perCandidateFingerprintPort, realCoordinator);
        BatchRunContext context = new BatchRunContext(new RunId("mixed-batch-run"), Instant.now());

        useCase.execute(context);

        // Two attempts must be recorded (one skip + one pipeline attempt)
        assertEquals(2, attemptRepo.savedAttempts.size(),
                "Two attempts must be recorded: one skip and one pipeline attempt");

        // First attempt: skip for the terminal candidate
        assertEquals(ProcessingStatus.SKIPPED_ALREADY_PROCESSED, attemptRepo.savedAttempts.get(0).status(),
                "First attempt must be SKIPPED_ALREADY_PROCESSED for the terminal candidate");

        // Second attempt: pipeline result for the processable candidate (AI fails → FAILED_RETRYABLE transient)
        assertNotEquals(ProcessingStatus.SKIPPED_ALREADY_PROCESSED, attemptRepo.savedAttempts.get(1).status(),
                "Second attempt must not be a skip — it must be the pipeline result for the processable candidate");
    }

    // -------------------------------------------------------------------------
    // Log correlation tests
    // -------------------------------------------------------------------------

    @Test
    void execute_preFingerprintError_logContainsRunIdAndCandidateDescription() throws Exception {
        // When fingerprint computation fails, the warning log must reference both the run-ID
        // and the candidate's unique identifier (pre-fingerprint correlation rule).
        String runIdValue = "run-correlation-pre-fp";
        String candidateFilename = "unreadable-candidate.pdf";

        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(
                List.of(makeCandidate(candidateFilename)));

        // Fingerprint port that always fails
        FingerprintPort failingFingerprintPort = c ->
                new FingerprintTechnicalError("File not readable", null);

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(),
                failingFingerprintPort, new NoOpDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

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

        // At least one warning message must contain both run-ID and candidate filename
        boolean correlationPresent = capturingLogger.warnMessages.stream()
                .anyMatch(m -> m.contains(runIdValue) && m.contains(candidateFilename));
        assertTrue(correlationPresent,
                "Pre-fingerprint warning must reference both run-ID '" + runIdValue
                        + "' and candidate '" + candidateFilename + "'. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
    }

    @Test
    void execute_postFingerprintProcessing_logContainsFingerprintHex() throws Exception {
        // After a successful fingerprint computation, at least one log message must contain
        // the fingerprint's SHA-256 hex value (post-fingerprint correlation rule).
        String candidateFilename = "identifiable.pdf";

        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        RuntimeConfiguration config = buildConfig(tempDir);

        SourceDocumentCandidate candidate = makeCandidate(candidateFilename);
        FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
        FixedExtractionPort extractionPort = new FixedExtractionPort(
                new PdfExtractionSuccess("Some invoice text", new PdfPageCount(1)));

        // Deterministic fingerprint port so we can verify the exact hex in the log
        AlwaysSuccessFingerprintPort fingerprintPort = new AlwaysSuccessFingerprintPort();
        DocumentFingerprint expectedFingerprint = ((FingerprintSuccess) fingerprintPort.computeFingerprint(candidate)).fingerprint();

        DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
                config, new MockRunLockPort(), candidatesPort, extractionPort,
                fingerprintPort, new TrackingDocumentProcessingCoordinator(),
                buildStubAiNamingService(), capturingLogger);

        useCase.execute(new BatchRunContext(new RunId("run-correlation-post-fp"), Instant.now()));

        String fingerprintHex = expectedFingerprint.sha256Hex();
        boolean fingerprintInLog = capturingLogger.allMessages().stream()
                .anyMatch(m -> m.contains(fingerprintHex));
        assertTrue(fingerprintInLog,
                "At least one log message must contain the fingerprint hex '" + fingerprintHex
                        + "' after successful fingerprint computation. "
                        + "Captured messages: " + capturingLogger.allMessages());
    }

    // -------------------------------------------------------------------------
    // Fingerprint filter (mini-run) behaviour
    // -------------------------------------------------------------------------

    @Test
    void execute_withFingerprintFilter_processesOnlyMatchingCandidates() throws Exception {
        // Three candidates in the source folder
        SourceDocumentCandidate c1 = makeCandidate("doc1.pdf");
        SourceDocumentCandidate c2 = makeCandidate("doc2.pdf");
        SourceDocumentCandidate c3 = makeCandidate("doc3.pdf");

        AlwaysSuccessFingerprintPort fpPort = new AlwaysSuccessFingerprintPort();
        DocumentFingerprint fp1 = ((FingerprintSuccess) fpPort.computeFingerprint(c1)).fingerprint();
        DocumentFingerprint fp3 = ((FingerprintSuccess) fpPort.computeFingerprint(c3)).fingerprint();

        // Filter selects only c1 and c3
        java.util.Set<DocumentFingerprint> filter = java.util.Set.of(fp1, fp3);

        TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
        RuntimeConfiguration config = buildConfig(tempDir);
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, new MockRunLockPort(),
                new FixedCandidatesPort(List.of(c1, c2, c3)),
                new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
                fpPort, coordinator);

        BatchRunContext filteredContext = new BatchRunContext(new RunId("filter-run"), Instant.now())
                .withFingerprintFilter(filter);
        BatchRunOutcome outcome = useCase.execute(filteredContext);

        assertTrue(outcome.isSuccess());
        // Only c1 and c3 must reach the coordinator (c2 skipped)
        assertEquals(2, coordinator.processCallCount(),
                "Only the 2 filtered candidates should reach the coordinator; c2 must be skipped");
    }

    @Test
    void execute_withFingerprintFilter_emptyFilter_processesNothing() throws Exception {
        SourceDocumentCandidate c1 = makeCandidate("docA.pdf");

        TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
        RuntimeConfiguration config = buildConfig(tempDir);
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, new MockRunLockPort(),
                new FixedCandidatesPort(List.of(c1)),
                new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
                new AlwaysSuccessFingerprintPort(), coordinator);

        BatchRunContext filteredContext = new BatchRunContext(new RunId("empty-filter-run"), Instant.now())
                .withFingerprintFilter(java.util.Set.of());
        BatchRunOutcome outcome = useCase.execute(filteredContext);

        assertTrue(outcome.isSuccess());
        assertEquals(0, coordinator.processCallCount(),
                "Empty filter must result in no documents being processed");
    }

    @Test
    void execute_withoutFingerprintFilter_processesAllCandidates() throws Exception {
        SourceDocumentCandidate c1 = makeCandidate("all1.pdf");
        SourceDocumentCandidate c2 = makeCandidate("all2.pdf");

        TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
        RuntimeConfiguration config = buildConfig(tempDir);
        DefaultBatchRunProcessingUseCase useCase = buildUseCase(
                config, new MockRunLockPort(),
                new FixedCandidatesPort(List.of(c1, c2)),
                new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
                new AlwaysSuccessFingerprintPort(), coordinator);

        // No filter → regular run
        BatchRunContext regularContext = new BatchRunContext(new RunId("regular-run"), Instant.now());
        BatchRunOutcome outcome = useCase.execute(regularContext);

        assertTrue(outcome.isSuccess());
        assertEquals(2, coordinator.processCallCount(),
                "Without a filter all candidates must reach the coordinator");
    }

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

    /**
     * Builds a minimal stub {@link AiNamingService} that always returns an AI technical failure.
     * Suitable for tests that do not care about the AI pipeline outcome.
     */
    private static AiNamingService buildStubAiNamingService() {
        AiInvocationPort stubAiPort = request ->
                new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
        PromptPort stubPromptPort = new PromptPort() {
            @Override
            public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
                return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
            }
            @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 = () -> java.time.Instant.EPOCH;
        AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
        return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
                TEST_MAX_TITLE_LENGTH);
    }

    private static DefaultBatchRunProcessingUseCase buildUseCase(
            RuntimeConfiguration runtimeConfig,
            RunLockPort lockPort,
            SourceDocumentCandidatesPort candidatesPort,
            PdfTextExtractionPort extractionPort,
            FingerprintPort fingerprintPort,
            DocumentProcessingCoordinator processor) {
        return new DefaultBatchRunProcessingUseCase(
                runtimeConfig, lockPort, candidatesPort, extractionPort, fingerprintPort, processor,
                buildStubAiNamingService(), new NoOpProcessingLogger());
    }

    private static RuntimeConfiguration buildConfig(Path tempDir) throws Exception {
        // maxPages set to 3 – useful for page-limit tests; maxRetriesTransient set to 3
        return new RuntimeConfiguration(3, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
    }

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

    /** Creates a deterministic fake fingerprint from a string (padded to 64 hex chars). */
    private static DocumentFingerprint makeFingerprint(String seed) {
        String hex = String.format("%064x", Math.abs(seed.hashCode()));
        // Ensure exactly 64 lowercase hex chars
        return new DocumentFingerprint(hex.substring(0, 64));
    }

    // -------------------------------------------------------------------------
    // Mock / Stub implementations
    // -------------------------------------------------------------------------

    /** Simple mock that tracks whether acquire and release were called. */
    private static class MockRunLockPort implements RunLockPort {
        private boolean acquireCalled = false;
        private boolean releaseCalled = false;

        @Override
        public void acquire() { acquireCalled = true; }

        @Override
        public void release() { releaseCalled = true; }

        @Override
        public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
            return java.util.Optional.empty();
        }

        boolean wasAcquireCalled() { return acquireCalled; }
        boolean wasReleaseCalled() { return releaseCalled; }
    }

    /** Counting lock port – optionally fails on acquire. */
    private static class CountingRunLockPort implements RunLockPort {
        private final boolean failOnAcquire;
        private int acquireCount = 0;
        private int releaseCount = 0;

        CountingRunLockPort(boolean failOnAcquire) { this.failOnAcquire = failOnAcquire; }

        @Override
        public void acquire() {
            acquireCount++;
            if (failOnAcquire) throw new RunLockUnavailableException("Another instance already running");
        }

        @Override
        public void release() { releaseCount++; }

        @Override
        public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
            return java.util.Optional.empty();
        }

        int acquireCallCount() { return acquireCount; }
        int releaseCallCount() { return releaseCount; }
    }

    /** Lock port that succeeds on acquire and tracks both calls. */
    private static class ErrorAfterAcquireLockPort implements RunLockPort {
        private boolean acquireCalled = false;
        private boolean releaseCalled = false;

        @Override
        public void acquire() { acquireCalled = true; }

        @Override
        public void release() { releaseCalled = true; }

        @Override
        public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
            return java.util.Optional.empty();
        }

        boolean wasAcquireCalled() { return acquireCalled; }
        boolean wasReleaseCalled() { return releaseCalled; }
    }

    /** Returns an empty candidate list. */
    private static class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
        @Override
        public List<SourceDocumentCandidate> loadCandidates() {
            return List.of();
        }
    }

    /** Returns a fixed list of candidates. */
    private static class FixedCandidatesPort implements SourceDocumentCandidatesPort {
        private final List<SourceDocumentCandidate> candidates;

        FixedCandidatesPort(List<SourceDocumentCandidate> candidates) {
            this.candidates = candidates;
        }

        @Override
        public List<SourceDocumentCandidate> loadCandidates() {
            return candidates;
        }
    }

    /** Returns a fixed extraction result for any candidate; counts calls. */
    private static class FixedExtractionPort implements PdfTextExtractionPort {
        private final PdfExtractionResult result;
        private int calls = 0;

        FixedExtractionPort(PdfExtractionResult result) { this.result = result; }

        @Override
        public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
            calls++;
            return result;
        }

        int callCount() { return calls; }
    }

    /** No-op extraction port that should never be called in tests that use EmptyCandidatesPort. */
    private static class NoOpExtractionPort implements PdfTextExtractionPort {
        @Override
        public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
            throw new UnsupportedOperationException("Should not be called");
        }
    }

    /** Per-candidate extraction port: maps each candidate to a fixed result; counts calls. */
    private static class MappedExtractionPort implements PdfTextExtractionPort {
        private final Map<SourceDocumentCandidate, PdfExtractionResult> resultMap = new LinkedHashMap<>();
        private int calls = 0;

        MappedExtractionPort with(SourceDocumentCandidate candidate, PdfExtractionResult result) {
            resultMap.put(candidate, result);
            return this;
        }

        @Override
        public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
            calls++;
            PdfExtractionResult result = resultMap.get(candidate);
            if (result == null) {
                throw new IllegalStateException("No extraction result mapped for candidate: " + candidate);
            }
            return result;
        }

        int callCount() { return calls; }
    }

    /**
     * Fingerprint port that always returns a deterministic success based on the candidate's
     * unique identifier.
     */
    private static class AlwaysSuccessFingerprintPort implements FingerprintPort {
        @Override
        public FingerprintResult computeFingerprint(SourceDocumentCandidate candidate) {
            String hex = String.format("%064x", Math.abs(candidate.uniqueIdentifier().hashCode()));
            return new FingerprintSuccess(new DocumentFingerprint(hex.substring(0, 64)));
        }
    }

    /**
     * No-op DocumentProcessingCoordinator that does nothing (for tests that only care about
     * lock/batch lifecycle, not persistence).
     */
    private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
        NoOpDocumentProcessingCoordinator() {
            super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
                    new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");
        }
    }

    /**
     * Tracking DocumentProcessingCoordinator that counts how many times {@code process()} is called.
     */
    private static class TrackingDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
        private int processCallCount = 0;

        TrackingDocumentProcessingCoordinator() {
            super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
                    new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");
        }

        @Override
        public boolean process(
                de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
                de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint,
                de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome outcome,
                de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context,
                java.time.Instant attemptStart) {
            processCallCount++;
            // Delegate to super so the real logic runs (with no-op repos)
            return super.process(candidate, fingerprint, outcome, context, attemptStart);
        }

        @Override
        public boolean processDeferredOutcome(
                de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
                de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint,
                de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context,
                java.time.Instant attemptStart,
                java.util.function.Function<de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
            processCallCount++;
            // Delegate to super so the real logic runs (with no-op repos)
            return super.processDeferredOutcome(candidate, fingerprint, context, attemptStart, pipelineExecutor);
        }

        int processCallCount() { return processCallCount; }
    }

    private static class NoOpTargetFolderPort implements TargetFolderPort {
        @Override
        public String getTargetFolderLocator() {
            return "/tmp/target";
        }

        @Override
        public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
            return new ResolvedTargetFilename(baseName);
        }

        @Override
        public void tryDeleteTargetFile(String resolvedFilename) {
            // No-op
        }
    }

    private static class NoOpTargetFileCopyPort implements TargetFileCopyPort {
        @Override
        public TargetFileCopyResult copyToTarget(
                de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
                String resolvedFilename) {
            return new TargetFileCopySuccess();
        }
    }

    /** No-op DocumentRecordRepository for use in test instances. */
    private static class NoOpDocumentRecordRepository implements DocumentRecordRepository {
        @Override
        public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
            // Return DocumentUnknown so the processor always takes the "new document" path
            return new DocumentUnknown();
        }

        @Override
        public void create(DocumentRecord record) {
            // No-op
        }

        @Override
        public void update(DocumentRecord record) {
            // No-op
        }

        @Override
        public void deleteByFingerprint(DocumentFingerprint fingerprint) {
            // No-op
        }
    }

    /** No-op ProcessingAttemptRepository for use in test instances. */
    private static class NoOpProcessingAttemptRepository implements ProcessingAttemptRepository {
        @Override
        public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
            return 1;
        }

        @Override
        public void save(ProcessingAttempt attempt) {
            // No-op
        }

        @Override
        public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
            return List.of();
        }

        @Override
        public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
            return null;
        }

        @Override
        public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
            // No-op
        }
    }

    /** No-op UnitOfWorkPort for use in test instances. */
    private static class NoOpUnitOfWorkPort implements UnitOfWorkPort {
        @Override
        public void executeInTransaction(Consumer<TransactionOperations> operations) {
            // No-op - just execute the operations directly without transaction
            operations.accept(new TransactionOperations() {
                @Override
                public void saveProcessingAttempt(ProcessingAttempt attempt) {
                    // No-op
                }

                @Override
                public void createDocumentRecord(DocumentRecord record) {
                    // No-op
                }

                @Override
                public void updateDocumentRecord(DocumentRecord record) {
                    // No-op
                }

                @Override
                public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
                    // No-op
                }

                @Override
                public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
                    // No-op
                }
            });
        }
    }

    /** No-op ProcessingLogger for use in test instances. */
    private static class NoOpProcessingLogger implements ProcessingLogger {
        @Override
        public void info(String message, Object... args) {
            // No-op
        }

        @Override
        public void debug(String message, Object... args) {
            // No-op
        }

        @Override
        public void debugSensitiveAiContent(String message, Object... args) {
            // No-op: sensitivity is controlled by the Log4jProcessingLogger adapter
        }

        @Override
        public void warn(String message, Object... args) {
            // No-op
        }

        @Override
        public void error(String message, Object... args) {
            // No-op
        }
    }

    /**
     * Captures formatted log messages for each log level.
     * Used by log-correlation tests that must inspect message content.
     */
    private static class MessageCapturingProcessingLogger implements ProcessingLogger {
        final List<String> infoMessages = new ArrayList<>();
        final List<String> debugMessages = new ArrayList<>();
        final List<String> debugSensitiveAiContentMessages = new ArrayList<>();
        final List<String> warnMessages = new ArrayList<>();
        final List<String> errorMessages = new ArrayList<>();

        /** Formats a message template with its arguments the same way SLF4J/Log4j2 does. */
        private static String format(String message, Object... args) {
            if (args == null || args.length == 0) return message;
            StringBuilder sb = new StringBuilder();
            int argIndex = 0;
            int start = 0;
            int pos;
            while ((pos = message.indexOf("{}", start)) != -1 && argIndex < args.length) {
                sb.append(message, start, pos);
                sb.append(args[argIndex++]);
                start = pos + 2;
            }
            sb.append(message, start, message.length());
            return sb.toString();
        }

        @Override
        public void info(String message, Object... args) {
            infoMessages.add(format(message, args));
        }

        @Override
        public void debug(String message, Object... args) {
            debugMessages.add(format(message, args));
        }

        @Override
        public void debugSensitiveAiContent(String message, Object... args) {
            debugSensitiveAiContentMessages.add(format(message, args));
        }

        @Override
        public void warn(String message, Object... args) {
            warnMessages.add(format(message, args));
        }

        @Override
        public void error(String message, Object... args) {
            errorMessages.add(format(message, args));
        }

        List<String> allMessages() {
            List<String> all = new ArrayList<>();
            all.addAll(infoMessages);
            all.addAll(debugMessages);
            all.addAll(debugSensitiveAiContentMessages);
            all.addAll(warnMessages);
            all.addAll(errorMessages);
            return all;
        }
    }

    /**
     * DocumentRecordRepository for batch integration tests.
     * Supports per-fingerprint lookup results and records written records.
     */
    private static class BatchCapturingDocumentRecordRepository implements DocumentRecordRepository {
        private final Map<String, DocumentRecordLookupResult> lookupByFp = new java.util.HashMap<>();
        private DocumentRecordLookupResult defaultResult = new DocumentUnknown();
        final List<DocumentRecord> createdRecords = new ArrayList<>();
        final List<DocumentRecord> updatedRecords = new ArrayList<>();

        void setLookupResult(DocumentRecordLookupResult result) {
            this.defaultResult = result;
        }

        void setLookupByFingerprint(DocumentFingerprint fp, DocumentRecordLookupResult result) {
            lookupByFp.put(fp.sha256Hex(), result);
        }

        @Override
        public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
            return lookupByFp.getOrDefault(fingerprint.sha256Hex(), defaultResult);
        }

        @Override
        public void create(DocumentRecord record) {
            createdRecords.add(record);
        }

        @Override
        public void update(DocumentRecord record) {
            updatedRecords.add(record);
        }

        @Override
        public void deleteByFingerprint(DocumentFingerprint fingerprint) {
            // No-op in tests
        }
    }

    /**
     * ProcessingAttemptRepository for batch integration tests. Records all saved attempts.
     */
    private static class BatchCapturingProcessingAttemptRepository implements ProcessingAttemptRepository {
        final List<ProcessingAttempt> savedAttempts = new ArrayList<>();

        @Override
        public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
            return savedAttempts.size() + 1;
        }

        @Override
        public void save(ProcessingAttempt attempt) {
            savedAttempts.add(attempt);
        }

        @Override
        public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
            return savedAttempts.stream()
                    .filter(a -> a.fingerprint().sha256Hex().equals(fingerprint.sha256Hex()))
                    .toList();
        }

        @Override
        public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
            return savedAttempts.stream()
                    .filter(a -> a.fingerprint().sha256Hex().equals(fingerprint.sha256Hex())
                            && a.status() == ProcessingStatus.PROPOSAL_READY)
                    .reduce((first, second) -> second)
                    .orElse(null);
        }

        @Override
        public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
            // No-op in tests
        }
    }

    /**
     * UnitOfWorkPort for batch integration tests. Executes operations directly
     * against the capturing repos.
     */
    private static class BatchCapturingUnitOfWorkPort implements UnitOfWorkPort {
        private final BatchCapturingDocumentRecordRepository recordRepo;
        private final BatchCapturingProcessingAttemptRepository attemptRepo;

        BatchCapturingUnitOfWorkPort(BatchCapturingDocumentRecordRepository recordRepo,
                                     BatchCapturingProcessingAttemptRepository attemptRepo) {
            this.recordRepo = recordRepo;
            this.attemptRepo = attemptRepo;
        }

        @Override
        public void executeInTransaction(Consumer<TransactionOperations> operations) {
            operations.accept(new TransactionOperations() {
                @Override
                public void saveProcessingAttempt(ProcessingAttempt attempt) {
                    attemptRepo.save(attempt);
                }

                @Override
                public void createDocumentRecord(DocumentRecord record) {
                    recordRepo.create(record);
                }

                @Override
                public void updateDocumentRecord(DocumentRecord record) {
                    recordRepo.update(record);
                }

                @Override
                public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
                    // No-op in tests
                }

                @Override
                public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
                    // No-op in tests
                }
            });
        }
    }

    /** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */
    private static class CapturingProcessingLogger implements ProcessingLogger {
        int infoCallCount = 0;
        int debugCallCount = 0;
        int warnCallCount = 0;
        int errorCallCount = 0;

        @Override
        public void info(String message, Object... args) {
            infoCallCount++;
        }

        @Override
        public void debug(String message, Object... args) {
            debugCallCount++;
        }

        @Override
        public void debugSensitiveAiContent(String message, Object... args) {
            // intentionally empty
        }

        @Override
        public void warn(String message, Object... args) {
            warnCallCount++;
        }

        @Override
        public void error(String message, Object... args) {
            errorCallCount++;
        }
    }
}