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

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
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.PersistenceLookupTechnicalFailure;
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.ResolvedTargetFilename;
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.TargetFileCopyTechnicalFailure;
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.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;

/**
 * Unit tests for {@link DocumentProcessingCoordinator}.
 * <p>
 * Covers:
 * <ul>
 *   <li>Minimal rules: status, counter and retryable flag mapping</li>
 *   <li>Skip logic for SUCCESS and FAILED_FINAL documents</li>
 *   <li>New document path (DocumentUnknown)</li>
 *   <li>Known processable document path (DocumentKnownProcessable)</li>
 *   <li>Persistence lookup failure: no attempt written</li>
 *   <li>Persistence write failure: controlled failure, no crash</li>
 *   <li>Skip events do not change error counters</li>
 * </ul>
 */
class DocumentProcessingCoordinatorTest {

    private static final String FINGERPRINT_HEX =
            "a".repeat(64); // 64 lowercase hex chars

    /** Default transient retry limit used in the shared {@link #processor} instance. */
    private static final int DEFAULT_MAX_RETRIES_TRANSIENT = 3;

    /** Default base title length used across all coordinator constructions in this test. */
    private static final int TEST_MAX_TITLE_LENGTH = 60;

    private CapturingDocumentRecordRepository recordRepo;
    private CapturingProcessingAttemptRepository attemptRepo;
    private CapturingUnitOfWorkPort unitOfWorkPort;
    private DocumentProcessingCoordinator processor;

    private SourceDocumentCandidate candidate;
    private DocumentFingerprint fingerprint;
    private BatchRunContext context;
    private Instant attemptStart;

    @BeforeEach
    void setUp() {
        recordRepo = new CapturingDocumentRecordRepository();
        attemptRepo = new CapturingProcessingAttemptRepository();
        unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
        processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        candidate = new SourceDocumentCandidate(
                "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
        fingerprint = new DocumentFingerprint(FINGERPRINT_HEX);
        context = new BatchRunContext(new RunId("run-001"), Instant.now());
        attemptStart = Instant.now();
    }

    // -------------------------------------------------------------------------
    // New document (DocumentUnknown) path
    // -------------------------------------------------------------------------

    @Test
    void process_newDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = buildNamingProposalOutcome();

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        // Two attempts written: first PROPOSAL_READY (pipeline stage), then SUCCESS (target-copy stage).
        // The intermediate PROPOSAL_READY attempt is the authoritative source for finalization.
        assertEquals(2, attemptRepo.savedAttempts.size(),
                "PROPOSAL_READY must be historised before the SUCCESS attempt in the same run");
        ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.PROPOSAL_READY, proposalAttempt.status());
        assertFalse(proposalAttempt.retryable());
        assertNull(proposalAttempt.failureClass());
        assertNull(proposalAttempt.failureMessage());

        ProcessingAttempt successAttempt = attemptRepo.savedAttempts.get(1);
        assertEquals(ProcessingStatus.SUCCESS, successAttempt.status());
        assertNotNull(successAttempt.finalTargetFileName(),
                "SUCCESS attempt must carry the final target filename");

        // One master record created for the new document; one update from the finalization stage.
        assertEquals(1, recordRepo.createdRecords.size());
        DocumentRecord createdRecord = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.PROPOSAL_READY, createdRecord.overallStatus());
        assertEquals(0, createdRecord.failureCounters().contentErrorCount());
        assertEquals(0, createdRecord.failureCounters().transientErrorCount());
        // The initial PROPOSAL_READY record has no success/failure timestamps yet.
        assertNull(createdRecord.lastSuccessInstant());
        assertNull(createdRecord.lastFailureInstant());

        assertEquals(1, recordRepo.updatedRecords.size(),
                "Master record must be updated to SUCCESS after the target-copy stage");
        DocumentRecord finalRecord = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus());
        assertNotNull(finalRecord.lastSuccessInstant(),
                "lastSuccessInstant must be set after successful target copy");
        assertNotNull(finalRecord.lastTargetFileName(),
                "Master record must carry the final target filename after SUCCESS");
    }

    @Test
    void process_newDocument_noUsableText_persistsFailedFinal_contentCounterOne() {
        // NO_USABLE_TEXT (image-only PDF) finalises immediately — no retry.
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, attempt.status());
        assertFalse(attempt.retryable());

        assertEquals(1, recordRepo.createdRecords.size());
        DocumentRecord record = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus());
        assertEquals(1, record.failureCounters().contentErrorCount());
        assertEquals(0, record.failureCounters().transientErrorCount());
        assertNotNull(record.lastFailureInstant());
        assertNull(record.lastSuccessInstant());
    }

    @Test
    void process_newDocument_firstPageLimitExceeded_persistsFailedRetryable_contentCounterOne() {
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, attempt.status());
        assertTrue(attempt.retryable());

        assertEquals(1, recordRepo.createdRecords.size());
        DocumentRecord record = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, record.overallStatus());
        assertEquals(1, record.failureCounters().contentErrorCount());
        assertEquals(0, record.failureCounters().transientErrorCount());
        assertNotNull(record.lastFailureInstant());
        assertNull(record.lastSuccessInstant());
    }

    @Test
    void process_newDocument_technicalError_persistsFailedRetryable_transientCounterOne() {
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new TechnicalDocumentError(
                candidate, "I/O error", null);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, attempt.status());
        assertTrue(attempt.retryable());

        assertEquals(1, recordRepo.createdRecords.size());
        DocumentRecord record = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, record.overallStatus());
        assertEquals(0, record.failureCounters().contentErrorCount());
        assertEquals(1, record.failureCounters().transientErrorCount());
    }

    // -------------------------------------------------------------------------
    // Known processable document path (DocumentKnownProcessable)
    // -------------------------------------------------------------------------

    @Test
    void process_knownDocument_secondContentError_persistsFailedFinal_contentCounterTwo() {
        // Existing record: first content error already recorded
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(1, 0));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, attempt.status());
        assertFalse(attempt.retryable());

        assertEquals(1, recordRepo.updatedRecords.size());
        DocumentRecord record = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus());
        assertEquals(2, record.failureCounters().contentErrorCount());
        assertEquals(0, record.failureCounters().transientErrorCount());
    }

    @Test
    void process_knownDocument_technicalError_incrementsTransientCounter_remainsRetryable() {
        // Starting with 1 transient error; with DEFAULT_MAX_RETRIES_TRANSIENT=3, counter
        // becomes 2 after this run which is still below the limit → FAILED_RETRYABLE
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(0, 1));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        DocumentProcessingOutcome outcome = new TechnicalDocumentError(
                candidate, "Timeout", null);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, recordRepo.updatedRecords.size());
        DocumentRecord record = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, record.overallStatus());
        assertEquals(0, record.failureCounters().contentErrorCount());
        assertEquals(2, record.failureCounters().transientErrorCount());
        assertTrue(attemptRepo.savedAttempts.get(0).retryable());
    }

    @Test
    void process_knownDocument_technicalError_atTransientLimit_persistsFailedFinal() {
        // Counter already at limit - 1: the next error finalises the document
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(0, DEFAULT_MAX_RETRIES_TRANSIENT - 1));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        DocumentProcessingOutcome outcome = new TechnicalDocumentError(
                candidate, "Timeout at limit", null);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, recordRepo.updatedRecords.size());
        DocumentRecord record = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus(),
                "Document must be finalised when transient limit is reached");
        assertEquals(DEFAULT_MAX_RETRIES_TRANSIENT, record.failureCounters().transientErrorCount(),
                "Transient counter must be incremented to the limit value");
        assertFalse(attemptRepo.savedAttempts.get(0).retryable(),
                "Attempt must not be retryable when transient limit is reached");
    }

    @Test
    void process_newDocument_technicalError_maxRetriesTransient1_immediatelyFinalises() {
        // With maxRetriesTransient=1, the very first transient error finalises the document
        DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentUnknown());

        DocumentProcessingOutcome outcome = new TechnicalDocumentError(
                candidate, "I/O error", null);

        coordinatorWith1Retry.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, recordRepo.createdRecords.size());
        DocumentRecord record = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus(),
                "With maxRetriesTransient=1, the first transient error must immediately finalise");
        assertEquals(1, record.failureCounters().transientErrorCount());
        assertFalse(attemptRepo.savedAttempts.get(0).retryable());
    }

    @Test
    void process_knownDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(0, 1));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        DocumentProcessingOutcome outcome = buildNamingProposalOutcome();

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        // Two record updates: first to PROPOSAL_READY (pipeline stage), then to SUCCESS (target-copy stage).
        assertEquals(2, recordRepo.updatedRecords.size(),
                "PROPOSAL_READY update must be followed by a SUCCESS update in the same run");
        DocumentRecord intermediate = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus());
        // Counters unchanged on naming proposal success
        assertEquals(0, intermediate.failureCounters().contentErrorCount());
        assertEquals(1, intermediate.failureCounters().transientErrorCount());
        // lastSuccessInstant is still null after the intermediate PROPOSAL_READY write.
        assertNull(intermediate.lastSuccessInstant());

        DocumentRecord finalRecord = recordRepo.updatedRecords.get(1);
        assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus());
        assertNotNull(finalRecord.lastSuccessInstant(),
                "lastSuccessInstant must be set after the successful target copy");
        assertNotNull(finalRecord.lastTargetFileName());
    }

    // -------------------------------------------------------------------------
    // Skip paths
    // -------------------------------------------------------------------------

    @Test
    void process_terminalSuccess_persistsSkipAttemptWithSkippedAlreadyProcessed() {
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.SUCCESS,
                FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));

        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.SKIPPED_ALREADY_PROCESSED, attempt.status());
        assertFalse(attempt.retryable());
        assertNull(attempt.failureClass());
        assertNull(attempt.failureMessage());

        // Master record updated (only updatedAt changes)
        assertEquals(1, recordRepo.updatedRecords.size());
        DocumentRecord record = recordRepo.updatedRecords.get(0);
        // Status and counters remain unchanged
        assertEquals(ProcessingStatus.SUCCESS, record.overallStatus());
        assertEquals(0, record.failureCounters().contentErrorCount());
        assertEquals(0, record.failureCounters().transientErrorCount());
    }

    @Test
    void process_terminalFinalFailure_persistsSkipAttemptWithSkippedFinalFailure() {
        DocumentRecord existingRecord = buildRecord(
                ProcessingStatus.FAILED_FINAL,
                new FailureCounters(2, 0));
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));

        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, attempt.status());
        assertFalse(attempt.retryable());

        // Master record updated (only updatedAt changes); counters unchanged
        assertEquals(1, recordRepo.updatedRecords.size());
        DocumentRecord record = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus());
        assertEquals(2, record.failureCounters().contentErrorCount());
        assertEquals(0, record.failureCounters().transientErrorCount());
    }

    @Test
    void process_skipEvent_doesNotChangeErrorCounters() {
        FailureCounters originalCounters = new FailureCounters(1, 3);
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, originalCounters);
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));

        processor.process(candidate, fingerprint,
                new PreCheckPassed(candidate, new PdfExtractionSuccess("t", new PdfPageCount(1))),
                context, attemptStart);

        DocumentRecord updated = recordRepo.updatedRecords.get(0);
        assertEquals(originalCounters.contentErrorCount(), updated.failureCounters().contentErrorCount(),
                "Skip must not change content error counter");
        assertEquals(originalCounters.transientErrorCount(), updated.failureCounters().transientErrorCount(),
                "Skip must not change transient error counter");
    }

    // -------------------------------------------------------------------------
    // Persistence lookup failure
    // -------------------------------------------------------------------------

    @Test
    void process_persistenceLookupFailure_noAttemptWritten_noException() {
        recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("DB unavailable", null));

        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        // Must not throw
        assertDoesNotThrow(() ->
                processor.process(candidate, fingerprint, outcome, context, attemptStart));

        // No attempt written, no record created/updated
        assertEquals(0, attemptRepo.savedAttempts.size(),
                "No attempt must be written when lookup fails");
        assertEquals(0, recordRepo.createdRecords.size());
        assertEquals(0, recordRepo.updatedRecords.size());
    }

    // -------------------------------------------------------------------------
    // Persistence write failure: controlled, no crash
    // -------------------------------------------------------------------------

    @Test
    void process_persistenceWriteFailure_doesNotThrow_batchContinues() {
        recordRepo.setLookupResult(new DocumentUnknown());
        // Make the unit of work throw
        unitOfWorkPort.failOnExecute = true;

        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        // Must not propagate the exception
        assertDoesNotThrow(() ->
                processor.process(candidate, fingerprint, outcome, context, attemptStart));
    }

    // -------------------------------------------------------------------------
    // Attempt number monotonicity
    // -------------------------------------------------------------------------

    @Test
    void process_attemptNumberIsAssignedFromRepository() {
        recordRepo.setLookupResult(new DocumentUnknown());
        attemptRepo.nextAttemptNumber = 3; // Simulate 2 prior attempts

        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertEquals(1, attemptRepo.savedAttempts.size());
        assertEquals(3, attemptRepo.savedAttempts.get(0).attemptNumber(),
                "Attempt number must be taken from the repository");
    }

    // -------------------------------------------------------------------------
    // Rückgabewert von process() und processDeferredOutcome()
    // -------------------------------------------------------------------------

    @Test
    void process_newDocument_preCheckPassed_returnsTrue() {
        // Prüft, dass process() bei erfolgreichem Persistieren true zurückgibt
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(result, "process() muss bei erfolgreichem Persistieren true zurückgeben");
    }

    @Test
    void process_newDocument_persistenceFailure_returnsFalse() {
        // Prüft, dass process() bei Persistenzfehler false zurückgibt (nicht true)
        recordRepo.setLookupResult(new DocumentUnknown());
        unitOfWorkPort.failOnExecute = true;
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertFalse(result, "process() muss bei Persistenzfehler false zurückgeben");
    }

    @Test
    void process_knownDocument_persistenceFailure_returnsFalse() {
        // Prüft, dass process() bei Persistenzfehler im known-Pfad false zurückgibt
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(0, 1));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        unitOfWorkPort.failOnExecute = true;
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertFalse(result, "process() muss bei Persistenzfehler im known-Pfad false zurückgeben");
    }

    @Test
    void process_knownDocument_preCheckPassed_returnsTrue() {
        // Prüft, dass process() bei erfolgreichem Persistieren im known-Pfad true zurückgibt
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(0, 1));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(result, "process() muss bei erfolgreichem Persistieren im known-Pfad true zurückgeben");
    }

    @Test
    void process_terminalSuccess_skipPersistenceSuccess_returnsTrue() {
        // Prüft, dass process() beim Skip-Pfad (SUCCESS) true zurückgibt
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(result, "process() muss beim erfolgreichen Skip-Persistieren true zurückgeben");
    }

    @Test
    void process_terminalFinalFailure_skipPersistenceSuccess_returnsTrue() {
        // Prüft, dass process() beim Skip-Pfad (FAILED_FINAL) true zurückgibt
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(result, "process() muss beim erfolgreichen Skip-Persistieren true zurückgeben");
    }

    @Test
    void process_terminalSuccess_skipPersistenceFailure_returnsFalse() {
        // Prüft, dass process() false zurückgibt, wenn das Skip-Persistieren fehlschlägt
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
        unitOfWorkPort.failOnExecute = true;
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertFalse(result, "process() muss false zurückgeben, wenn Skip-Persistierung fehlschlägt");
    }

    @Test
    void process_persistenceLookupFailure_returnsFalse() {
        // Prüft, dass process() bei Lookup-Fehler false zurückgibt
        recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("DB nicht verfügbar", null));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart);

        assertFalse(result, "process() muss bei Lookup-Fehler false zurückgeben");
    }

    // -------------------------------------------------------------------------
    // Fehlermeldungsinhalt (buildFailureMessage)
    // -------------------------------------------------------------------------

    @Test
    void process_newDocument_firstContentError_failureMessageContainsFailureReason() {
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_RETRYABLE");
        assertFalse(attempt.failureMessage().isBlank(),
                "Fehlermeldung darf nicht leer sein bei FAILED_RETRYABLE");
        assertTrue(attempt.failureMessage().contains("No usable text in extracted PDF content"),
                "Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
    }

    @Test
    void process_knownDocument_secondContentError_failureMessageContainsFinalStatus() {
        // Prüft, dass die Fehlermeldung bei FAILED_FINAL den Fehlergrund enthält
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0));
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_FINAL");
        assertFalse(attempt.failureMessage().isBlank(),
                "Fehlermeldung darf nicht leer sein bei FAILED_FINAL");
        assertTrue(attempt.failureMessage().contains("Document page count exceeds configured limit"),
                "Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
    }

    @Test
    void process_newDocument_technicalError_failureMessageContainsTechnicalDetail() {
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new TechnicalDocumentError(candidate, "Timeout", null);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
        assertNotNull(attempt.failureMessage());
        assertTrue(attempt.failureMessage().contains("Timeout"),
                "Fehlermeldung muss den technischen Fehlerdetail enthalten: " + attempt.failureMessage());
    }

    @Test
    void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() {
        // Prüft, dass der im selben Lauf persistierte PROPOSAL_READY-Versuch ohne
        // failureClass/failureMessage geschrieben wird.
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = buildNamingProposalOutcome();

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.PROPOSAL_READY)
                .findFirst()
                .orElseThrow(() -> new AssertionError("PROPOSAL_READY attempt must be persisted"));
        assertNull(proposalAttempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein");
        assertNull(proposalAttempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein");
    }

    // -------------------------------------------------------------------------
    // Zeitstempel-Korrektheit in buildUpdatedDocumentRecord (negierte Bedingung)
    // -------------------------------------------------------------------------

    @Test
    void process_knownDocument_namingProposalReady_intermediateRecordKeepsPreviousFailureAndHasNoSuccess() {
        // Prüft den persistierten Zwischenzustand (PROPOSAL_READY-Update) im selben Lauf:
        // lastSuccessInstant bleibt null bis zur Zielkopie; lastFailureInstant aus dem Vorgänger bleibt erhalten.
        Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z");
        DocumentRecord existingRecord = new DocumentRecord(
                fingerprint,
                new SourceDocumentLocator("/tmp/test.pdf"),
                "test.pdf",
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(0, 1),
                previousFailureInstant,  // lastFailureInstant vorhanden
                null,                    // noch kein Erfolgszeitpunkt
                Instant.now(),
                Instant.now(),
                null,
                null
        );
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        DocumentProcessingOutcome outcome = buildNamingProposalOutcome();

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        // Der erste Update-Record ist der Zwischenzustand (PROPOSAL_READY) vor der Finalisierung.
        DocumentRecord intermediate = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus());
        assertNull(intermediate.lastSuccessInstant(),
                "lastSuccessInstant muss im PROPOSAL_READY-Zwischenstand null bleiben");
        assertEquals(previousFailureInstant, intermediate.lastFailureInstant(),
                "lastFailureInstant muss im PROPOSAL_READY-Zwischenstand den Vorgänger-Wert beibehalten");
    }

    @Test
    void process_knownDocument_contentError_lastFailureInstantSetAndLastSuccessInstantFromPreviousRecord() {
        // Prüft, dass bei FAILURE am known-Dokument lastFailureInstant gesetzt
        // und lastSuccessInstant aus dem Vorgänger-Datensatz übernommen wird (negierte Bedingung abdecken)
        Instant previousSuccessInstant = Instant.parse("2025-01-10T08:00:00Z");
        DocumentRecord existingRecord = new DocumentRecord(
                fingerprint,
                new SourceDocumentLocator("/tmp/test.pdf"),
                "test.pdf",
                ProcessingStatus.FAILED_RETRYABLE,
                new FailureCounters(0, 0),
                null,                      // noch keine Fehlzeit
                previousSuccessInstant,    // vorheriger Erfolg vorhanden
                Instant.now(),
                Instant.now(),
                null,
                null
        );
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        processor.process(candidate, fingerprint, outcome, context, attemptStart);

        DocumentRecord updated = recordRepo.updatedRecords.get(0);
        assertNotNull(updated.lastFailureInstant(),
                "lastFailureInstant muss nach Inhaltsfehler gesetzt sein");
        assertEquals(previousSuccessInstant, updated.lastSuccessInstant(),
                "lastSuccessInstant muss bei Fehler den Vorgänger-Wert beibehalten");
    }

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

    @Test
    void process_persistenceLookupFailure_logsErrorMessage() {
        // Prüft, dass bei Lookup-Fehler ein Fehler-Log-Eintrag erzeugt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

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

    @Test
    void process_terminalSuccess_skipPath_logsInfoMessage() {
        // Prüft, dass beim Überspringen eines bereits erfolgreich verarbeiteten Dokuments geloggt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(capturingLogger.infoCallCount > 0,
                "Beim Überspringen eines erfolgreich verarbeiteten Dokuments muss eine Info geloggt werden");
    }

    @Test
    void process_terminalFinalFailure_skipPath_logsInfoMessage() {
        // Prüft, dass beim Überspringen eines final fehlgeschlagenen Dokuments geloggt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(capturingLogger.infoCallCount > 0,
                "Beim Überspringen eines final fehlgeschlagenen Dokuments muss eine Info geloggt werden");
    }

    @Test
    void process_newDocument_preCheckPassed_logsInfo() {
        // Prüft, dass nach erfolgreichem Persistieren einer neuen Datei geloggt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(capturingLogger.warnCallCount > 0,
                "Nach Verarbeiten eines neuen Dokuments mit PreCheckPassed muss eine Warnung geloggt werden");
    }

    @Test
    void process_newDocument_persistenceFailure_logsError() {
        // Prüft, dass bei Persistenzfehler ein Fehler-Log-Eintrag erzeugt wird
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentUnknown());
        unitOfWorkPort.failOnExecute = true;
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

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

    @Test
    void process_terminalSuccess_successfulSkip_logsDebug() {
        // Prüft, dass nach erfolgreichem Skip-Persistieren ein Debug-Log erzeugt wird (persistSkipAttempt L301)
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(capturingLogger.debugCallCount > 0,
                "Nach erfolgreichem Skip-Persistieren muss ein Debug-Eintrag geloggt werden");
    }

    @Test
    void process_terminalSuccess_skipPersistenceFailure_logsError() {
        // Prüft, dass bei Persistenzfehler im Skip-Pfad ein Fehler geloggt wird (persistSkipAttempt L306)
        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturingLogger =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
        unitOfWorkPort.failOnExecute = true;
        DocumentProcessingOutcome outcome = new PreCheckPassed(
                candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));

        coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart);

        assertTrue(capturingLogger.errorCallCount > 0,
                "Bei Persistenzfehler im Skip-Pfad muss ein Fehler geloggt werden");
    }

    // -------------------------------------------------------------------------
    // PROPOSAL_READY finalization path
    // -------------------------------------------------------------------------

    @Test
    void processDeferredOutcome_proposalReady_successfulCopy_persistsSuccessWithTargetFileName() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });

        assertTrue(result, "Finalization should succeed");

        ProcessingAttempt successAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.SUCCESS)
                .findFirst()
                .orElse(null);
        assertNotNull(successAttempt, "A SUCCESS attempt must be persisted");
        assertNotNull(successAttempt.finalTargetFileName(), "SUCCESS attempt must carry the final target filename");

        DocumentRecord updated = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.SUCCESS, updated.overallStatus());
        assertNotNull(updated.lastTargetFileName(), "Master record must carry the final target filename");
        assertNotNull(updated.lastTargetPath(), "Master record must carry the target folder path");
        assertNotNull(updated.lastSuccessInstant(), "lastSuccessInstant must be set on SUCCESS");
    }

    @Test
    void processDeferredOutcome_proposalReady_missingProposalAttempt_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        // No PROPOSAL_READY attempt pre-populated

        // persistTransientError returns true when the error record was persisted successfully
        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted");
        assertTrue(errorAttempt.retryable(), "Transient error must be retryable");
    }

    @Test
    void processDeferredOutcome_proposalReady_inconsistentProposalNullDate_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        ProcessingAttempt badProposal = new ProcessingAttempt(
                fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
                ProcessingStatus.PROPOSAL_READY, null, null, false,
                null,
                "model", "prompt", 1, 100, "{}", "reason",
                null, DateSource.AI_PROVIDED, "Rechnung", null);
        attemptRepo.savedAttempts.add(badProposal);

        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted for inconsistent proposal state");
    }

    @Test
    void processDeferredOutcome_proposalReady_duplicateResolutionFailure_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        boolean result = coordinatorWithFailingFolder.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when duplicate resolution fails");
    }

    @Test
    void processDeferredOutcome_proposalReady_copyFailure_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        boolean result = coordinatorWithFailingCopy.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when file copy fails");
    }

    @Test
    void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds60Chars_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        // Title of 61 characters violates the 60-char base-title rule — inconsistent persistence state
        ProcessingAttempt badProposal = new ProcessingAttempt(
                fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
                ProcessingStatus.PROPOSAL_READY, null, null, false,
                null,
                "model", "prompt", 1, 100, "{}", "reason",
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
                "A".repeat(61), null);
        attemptRepo.savedAttempts.add(badProposal);

        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt,
                "A FAILED_RETRYABLE attempt must be persisted when the proposal title is inconsistent");
        assertTrue(errorAttempt.retryable(), "Inconsistent proposal error must be retryable");
    }

    @Test
    void processDeferredOutcome_proposalReady_inconsistentProposalTitleWithDisallowedChars_persistsTransientError() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));

        // Exclamation mark is a disallowed character in the fachliche Titelregel
        ProcessingAttempt badProposal = new ProcessingAttempt(
                fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
                ProcessingStatus.PROPOSAL_READY, null, null, false,
                null,
                "model", "prompt", 1, 100, "{}", "reason",
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
                "Rechnung!", null);
        attemptRepo.savedAttempts.add(badProposal);

        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt,
                "A FAILED_RETRYABLE attempt must be persisted when the proposal title has disallowed characters");
    }

    @Test
    void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_returnsFalse() {
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());
        unitOfWorkPort.failOnExecute = true;

        boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertFalse(result, "Should return false when persistence fails after successful copy");
    }

    @Test
    void processDeferredOutcome_proposalReady_firstCopyFails_immediateRetrySucceeds_persistsSuccess() {
        // First copy attempt fails, immediate within-run retry succeeds → SUCCESS
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CountingTargetFileCopyPort countingCopyPort = new CountingTargetFileCopyPort(1); // fail first call only
        DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> {
                    throw new AssertionError("Pipeline must not run for PROPOSAL_READY");
                });

        assertTrue(result, "Should succeed when immediate retry of target copy succeeds");

        ProcessingAttempt successAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.SUCCESS)
                .findFirst()
                .orElse(null);
        assertNotNull(successAttempt, "A SUCCESS attempt must be persisted after a successful immediate retry");

        DocumentRecord updated = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.SUCCESS, updated.overallStatus(),
                "Master record must show SUCCESS after successful immediate retry");

        assertEquals(2, countingCopyPort.callCount,
                "copyToTarget must have been called exactly twice: first attempt + one retry");
    }

    @Test
    void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_persistsTransientError() {
        // Both the first copy attempt and the immediate retry fail → FAILED_RETRYABLE
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CountingTargetFileCopyPort countingCopyPort = new CountingTargetFileCopyPort(2); // fail both calls
        DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when both copy attempts fail");
        assertTrue(errorAttempt.retryable(), "Error must be retryable after exhausting immediate retry");

        assertEquals(2, countingCopyPort.callCount,
                "copyToTarget must have been called exactly twice: first attempt + one immediate retry");
    }

    @Test
    void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_maxRetriesTransient1_persistsFailedFinal() {
        // maxRetriesTransient=1: both copy attempts fail → the transient error immediately finalises
        // because the central retry rule (ProcessingOutcomeTransition) treats the first cross-run
        // transient error as FAILED_FINAL when the limit is 1.
        // The within-run immediate retry does NOT count toward this limit.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both
        DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");

        boolean result = coordinatorWith1Retry.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
        ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_FINAL)
                .findFirst()
                .orElse(null);
        assertNotNull(errorAttempt,
                "With maxRetriesTransient=1, both copy attempts failing must produce FAILED_FINAL");
        assertFalse(errorAttempt.retryable(),
                "With maxRetriesTransient=1, the error must not be retryable");

        DocumentRecord updated = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, updated.overallStatus());
        assertEquals(1, updated.failureCounters().transientErrorCount(),
                "Transient error counter must be 1 after the first cross-run transient error");
    }

    @Test
    void processDeferredOutcome_proposalReady_copyFailure_retryDecisionLog_containsFailedRetryable() {
        // Verifies that when a copy failure leads to FAILED_RETRYABLE in persistTransientError,
        // the retry-decision log message specifically contains "FAILED_RETRYABLE" and
        // "will retry in later run" — the branch-specific text that distinguishes it from the
        // FAILED_FINAL branch. This kills the negated-conditional mutation on the retryable flag check.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.anyWarnContains("FAILED_RETRYABLE"),
                "Retry decision log for a retryable transient copy error must contain FAILED_RETRYABLE. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
        assertTrue(capturingLogger.anyWarnContains("will retry in later run"),
                "Retry decision log for a retryable transient error must contain 'will retry in later run'. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
    }

    @Test
    void processDeferredOutcome_proposalReady_copyFailure_maxRetriesTransient1_retryDecisionLog_containsFailedFinal() {
        // Verifies that when a copy failure with maxRetriesTransient=1 leads to FAILED_FINAL in
        // persistTransientError, the retry-decision log message contains "FAILED_FINAL" and
        // "transient error limit reached" — the branch-specific text that distinguishes it
        // from the FAILED_RETRYABLE branch.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
                1 /* maxRetriesTransient=1 → immediately final */, TEST_MAX_TITLE_LENGTH,
                "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.anyWarnContains("FAILED_FINAL"),
                "Retry decision log for a finalising transient copy error must contain FAILED_FINAL. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
        assertTrue(capturingLogger.anyWarnContains("transient error limit reached"),
                "Retry decision log for a finalising transient error must contain 'transient error limit reached'. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
    }

    @Test
    void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() {
        // Ensures that during the immediate retry path no pipeline (AI) execution happens
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CountingTargetFileCopyPort countingCopyPort = new CountingTargetFileCopyPort(1); // fail first, succeed second
        DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCountingCopy.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("AI pipeline must NOT run during immediate retry"); });

        // No FAILED_RETRYABLE must have been persisted — the retry succeeded
        long failedRetryableCount = attemptRepo.savedAttempts.stream()
                .filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
                .count();
        assertEquals(0, failedRetryableCount,
                "No FAILED_RETRYABLE must be persisted when immediate retry succeeds");
    }

    // -------------------------------------------------------------------------
    // Sequential multi-run lifecycle tests
    // -------------------------------------------------------------------------

    @Test
    void process_contentErrorLifecycle_pageLimitExceeded_firstRunRetryable_secondRunFinal_thirdRunSkipped() {
        // PAGE_LIMIT_EXCEEDED follows the 1-retry rule: first run → FAILED_RETRYABLE, second → FAILED_FINAL.
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome contentError = new PreCheckFailed(
                candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);

        // Run 1: new document, first content error → FAILED_RETRYABLE
        processor.process(candidate, fingerprint, contentError, context, attemptStart);

        DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, afterRun1.overallStatus(),
                "First PAGE_LIMIT_EXCEEDED must yield FAILED_RETRYABLE");
        assertEquals(1, afterRun1.failureCounters().contentErrorCount());
        assertTrue(attemptRepo.savedAttempts.get(0).retryable(),
                "First content error attempt must be retryable");

        // Run 2: known document (FAILED_RETRYABLE, contentErrorCount=1), second content error → FAILED_FINAL
        recordRepo.setLookupResult(new DocumentKnownProcessable(afterRun1));

        processor.process(candidate, fingerprint, contentError, context, attemptStart);

        DocumentRecord afterRun2 = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, afterRun2.overallStatus(),
                "Second content error must yield FAILED_FINAL");
        assertEquals(2, afterRun2.failureCounters().contentErrorCount());
        assertFalse(attemptRepo.savedAttempts.get(1).retryable(),
                "Second content error attempt must not be retryable");

        // Run 3: terminal FAILED_FINAL → SKIPPED_FINAL_FAILURE; counters must not change
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(afterRun2));

        processor.process(candidate, fingerprint, contentError, context, attemptStart);

        assertEquals(3, attemptRepo.savedAttempts.size(),
                "Three attempts must be recorded across the three runs");
        ProcessingAttempt skipAttempt = attemptRepo.savedAttempts.get(2);
        assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, skipAttempt.status());
        assertFalse(skipAttempt.retryable());

        DocumentRecord afterRun3 = recordRepo.updatedRecords.get(1);
        assertEquals(2, afterRun3.failureCounters().contentErrorCount(),
                "Content error counter must remain 2 after a SKIPPED_FINAL_FAILURE event");
        assertEquals(0, afterRun3.failureCounters().transientErrorCount(),
                "Transient error counter must remain 0 after a SKIPPED_FINAL_FAILURE event");
    }

    @Test
    void process_contentErrorLifecycle_noUsableText_immediatelyFinal_secondRunSkipped() {
        // NO_USABLE_TEXT (image-only PDF): first run is immediately FAILED_FINAL, second is skipped.
        recordRepo.setLookupResult(new DocumentUnknown());
        DocumentProcessingOutcome noTextError = new PreCheckFailed(
                candidate, PreCheckFailureReason.NO_USABLE_TEXT);

        // Run 1: new document → FAILED_FINAL immediately
        processor.process(candidate, fingerprint, noTextError, context, attemptStart);

        DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, afterRun1.overallStatus(),
                "NO_USABLE_TEXT must yield FAILED_FINAL immediately");
        assertEquals(1, afterRun1.failureCounters().contentErrorCount());
        assertFalse(attemptRepo.savedAttempts.get(0).retryable());

        // Run 2: terminal FAILED_FINAL → SKIPPED_FINAL_FAILURE; counters must not change
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(afterRun1));

        processor.process(candidate, fingerprint, noTextError, context, attemptStart);

        assertEquals(2, attemptRepo.savedAttempts.size());
        ProcessingAttempt skipAttempt = attemptRepo.savedAttempts.get(1);
        assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, skipAttempt.status());

        DocumentRecord afterRun2 = recordRepo.updatedRecords.get(0);
        assertEquals(1, afterRun2.failureCounters().contentErrorCount(),
                "Content error counter must remain 1 after SKIPPED_FINAL_FAILURE");
    }

    @Test
    void process_transientErrorLifecycle_maxRetriesTransient2_firstRetryable_secondFinal() {
        // maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
        DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2,
                TEST_MAX_TITLE_LENGTH, "openai-compatible");
        DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null);

        // Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1
        recordRepo.setLookupResult(new DocumentUnknown());

        coordinatorWith2Retries.process(candidate, fingerprint, transientError, context, attemptStart);

        DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_RETRYABLE, afterRun1.overallStatus(),
                "First transient error must yield FAILED_RETRYABLE when limit not yet reached");
        assertEquals(1, afterRun1.failureCounters().transientErrorCount());
        assertTrue(attemptRepo.savedAttempts.get(0).retryable());

        // Run 2: transientErrorCount=1, second transient error reaches limit=2 → FAILED_FINAL
        recordRepo.setLookupResult(new DocumentKnownProcessable(afterRun1));

        coordinatorWith2Retries.process(candidate, fingerprint, transientError, context, attemptStart);

        DocumentRecord afterRun2 = recordRepo.updatedRecords.get(0);
        assertEquals(ProcessingStatus.FAILED_FINAL, afterRun2.overallStatus(),
                "Second transient error must yield FAILED_FINAL when maxRetriesTransient=2 is reached");
        assertEquals(2, afterRun2.failureCounters().transientErrorCount(),
                "Transient error counter must equal maxRetriesTransient after finalisation");
        assertFalse(attemptRepo.savedAttempts.get(1).retryable(),
                "Final transient error attempt must not be retryable");
    }

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

    private ProcessingAttempt buildValidProposalAttempt() {
        return new ProcessingAttempt(
                fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
                ProcessingStatus.PROPOSAL_READY, null, null, false,
                "openai-compatible",
                "gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
    }

    private DocumentProcessingOutcome buildNamingProposalOutcome() {
        AiAttemptContext ctx = new AiAttemptContext(
                "gpt-4", "prompt-v1.txt", 1, 500, "{\"title\":\"Rechnung\",\"reasoning\":\"r\"}");
        NamingProposal proposal = new NamingProposal(
                LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "AI reasoning");
        return new NamingProposalReady(candidate, proposal, ctx);
    }

    private DocumentRecord buildRecord(ProcessingStatus status, FailureCounters counters) {
        Instant now = Instant.now();
        return new DocumentRecord(
                fingerprint,
                new SourceDocumentLocator("/tmp/test.pdf"),
                "test.pdf",
                status,
                counters,
                status == ProcessingStatus.SUCCESS ? null : now,
                status == ProcessingStatus.SUCCESS ? now : null,
                now,
                now,
                null,
                null
        );
    }

    // -------------------------------------------------------------------------
    // Capturing test doubles
    // -------------------------------------------------------------------------

    private static class CapturingDocumentRecordRepository implements DocumentRecordRepository {
        private DocumentRecordLookupResult lookupResult = new DocumentUnknown();
        final List<DocumentRecord> createdRecords = new ArrayList<>();
        final List<DocumentRecord> updatedRecords = new ArrayList<>();

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

        @Override
        public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
            return lookupResult;
        }

        @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
        }
    }

    private static class CapturingProcessingAttemptRepository implements ProcessingAttemptRepository {
        final List<ProcessingAttempt> savedAttempts = new ArrayList<>();
        int nextAttemptNumber = 1;
        boolean failOnSave = false;

        @Override
        public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
            return nextAttemptNumber;
        }

        @Override
        public void save(ProcessingAttempt attempt) {
            if (failOnSave) {
                throw new DocumentPersistenceException("Simulated save failure");
            }
            savedAttempts.add(attempt);
        }

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

        @Override
        public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
            return savedAttempts.stream()
                    .filter(a -> a.status() == de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.PROPOSAL_READY)
                    .reduce((first, second) -> second)
                    .orElse(null);
        }

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

    private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
        private final CapturingDocumentRecordRepository recordRepo;
        private final CapturingProcessingAttemptRepository attemptRepo;
        boolean failOnExecute = false;
        CapturingUnitOfWorkPort(CapturingDocumentRecordRepository recordRepo, 
                               CapturingProcessingAttemptRepository attemptRepo) {
            this.recordRepo = recordRepo;
            this.attemptRepo = attemptRepo;
        }
        
        @Override
        public void executeInTransaction(Consumer<TransactionOperations> operations) {
            if (failOnExecute) {
                throw new DocumentPersistenceException("Simulated transaction failure");
            }
            
            // Execute the operations with mock transaction operations that delegate to repos
            TransactionOperations mockOps = new TransactionOperations() {
                @Override
                public void saveProcessingAttempt(ProcessingAttempt attempt) {
                    attemptRepo.savedAttempts.add(attempt);
                }

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

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

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

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

    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
        }
    }

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

        @Override
        public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
            return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
        }

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

    private static class FailingTargetFileCopyPort implements TargetFileCopyPort {
        @Override
        public TargetFileCopyResult copyToTarget(
                de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
                String resolvedFilename) {
            return new TargetFileCopyTechnicalFailure("Simulated copy failure", false);
        }
    }

    private static class CountingTargetFileCopyPort implements TargetFileCopyPort {
        private int callCount = 0;
        private final int failFirstNCalls;

        CountingTargetFileCopyPort(int failFirstNCalls) {
            this.failFirstNCalls = failFirstNCalls;
        }

        @Override
        public TargetFileCopyResult copyToTarget(
                de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
                String resolvedFilename) {
            callCount++;
            if (callCount <= failFirstNCalls) {
                return new TargetFileCopyTechnicalFailure("Simulated copy failure on call " + callCount, false);
            }
            return new TargetFileCopySuccess();
        }
    }

    /** Counts calls to {@link #tryDeleteTargetFile(String)} for mutation detection. */
    private static class CapturingTargetFolderPort implements TargetFolderPort {
        int tryDeleteCallCount = 0;

        @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) {
            tryDeleteCallCount++;
        }
    }

    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();
        }
    }

    // -------------------------------------------------------------------------
    // Logging correlation content tests (fingerprint and classification in messages)
    // -------------------------------------------------------------------------

    @Test
    void process_terminalSuccess_skipLog_containsFingerprintForCorrelation() {
        // Proves that the skip log for a SUCCESS document contains the document fingerprint,
        // satisfying the post-fingerprint log correlation rule.
        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentTerminalSuccess(
                buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero())));

        coordinatorWithCapturing.process(candidate, fingerprint,
                new PreCheckPassed(candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))),
                context, attemptStart);

        assertTrue(capturingLogger.anyInfoContains(FINGERPRINT_HEX),
                "Skip log for SUCCESS must contain the document fingerprint for correlation. "
                        + "Captured info messages: " + capturingLogger.infoMessages);
    }

    @Test
    void process_terminalFinalFailure_skipLog_containsFingerprintForCorrelation() {
        // Proves that the skip log for a FAILED_FINAL document contains the document fingerprint,
        // satisfying the post-fingerprint log correlation rule.
        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentTerminalFinalFailure(
                buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0))));

        coordinatorWithCapturing.process(candidate, fingerprint,
                new PreCheckFailed(candidate, PreCheckFailureReason.NO_USABLE_TEXT),
                context, attemptStart);

        assertTrue(capturingLogger.anyInfoContains(FINGERPRINT_HEX),
                "Skip log for FAILED_FINAL must contain the document fingerprint for correlation. "
                        + "Captured info messages: " + capturingLogger.infoMessages);
    }

    @Test
    void process_firstContentError_retryDecisionLog_containsFingerprintAndFailedRetryable() {
        // Proves that the retry decision log for a first retryable content error contains
        // both the document fingerprint and the FAILED_RETRYABLE classification.
        // Uses PAGE_LIMIT_EXCEEDED which follows the 1-retry rule.
        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        recordRepo.setLookupResult(new DocumentUnknown());

        coordinatorWithCapturing.process(candidate, fingerprint,
                new PreCheckFailed(candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED),
                context, attemptStart);

        assertTrue(capturingLogger.anyWarnContains(FINGERPRINT_HEX),
                "Retry decision log must contain the document fingerprint. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
        assertTrue(capturingLogger.anyWarnContains("FAILED_RETRYABLE"),
                "Retry decision log must contain the FAILED_RETRYABLE classification. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
    }

    @Test
    void process_secondContentError_retryDecisionLog_containsFingerprintAndFailedFinal() {
        // Proves that the retry decision log for the finalising deterministic content error
        // contains both the document fingerprint and the FAILED_FINAL classification.
        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing =
                new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
                        new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                        DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");
        // Existing record already has one content error — second content error finalises
        recordRepo.setLookupResult(new DocumentKnownProcessable(
                buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0))));

        coordinatorWithCapturing.process(candidate, fingerprint,
                new PreCheckFailed(candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED),
                context, attemptStart);

        assertTrue(capturingLogger.anyWarnContains(FINGERPRINT_HEX),
                "Finalising retry decision log must contain the document fingerprint. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
        assertTrue(capturingLogger.anyWarnContains("FAILED_FINAL"),
                "Finalising retry decision log must contain the FAILED_FINAL classification. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
        assertTrue(capturingLogger.anyWarnContains("permanently failed"),
                "Finalising retry decision log must contain 'permanently failed' to distinguish "
                        + "the FAILED_FINAL branch from the generic status log. "
                        + "Captured warn messages: " + capturingLogger.warnMessages);
    }

    // -------------------------------------------------------------------------
    // Finalization path logging: error, warn, and info calls in key paths
    // -------------------------------------------------------------------------

    @Test
    void processDeferredOutcome_proposalReady_missingProposalAttempt_logsError() {
        // Missing PROPOSAL_READY attempt in history — finalizeProposalReady must log an error.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        // No attempt pre-loaded — proposalAttempt == null branch

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.errorCallCount > 0,
                "An error must be logged when the PROPOSAL_READY attempt is missing from history");
    }

    @Test
    void processDeferredOutcome_proposalReady_inconsistentProposalState_logsError() {
        // Inconsistent proposal state (null date) — finalizeProposalReady must log an error.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        ProcessingAttempt badProposal = new ProcessingAttempt(
                fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
                ProcessingStatus.PROPOSAL_READY, null, null, false,
                null,
                "model", "prompt", 1, 100, "{}", "reason",
                null, DateSource.AI_PROVIDED, "Rechnung", null);
        attemptRepo.savedAttempts.add(badProposal);

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.errorCallCount > 0,
                "An error must be logged when the proposal state is inconsistent");
    }

    @Test
    void processDeferredOutcome_proposalReady_duplicateResolutionFailure_logsError() {
        // Duplicate resolution failure — finalizeProposalReady must log an error.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.errorCallCount > 0,
                "An error must be logged when duplicate resolution fails");
    }

    @Test
    void processDeferredOutcome_proposalReady_resolvedFilename_logsInfo() {
        // Successful duplicate resolution — resolved filename must be logged at INFO.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });

        assertTrue(capturingLogger.infoCallCount > 0,
                "Resolved target filename must be logged at INFO level");
    }

    @Test
    void processDeferredOutcome_proposalReady_firstCopyFails_logsWarn() {
        // First copy attempt fails → immediate retry: a WARN must be logged for the first failure.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        CountingTargetFileCopyPort onlyFirstFails = new CountingTargetFileCopyPort(1);
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });

        assertTrue(capturingLogger.warnCallCount > 0,
                "A WARN must be logged when the first copy attempt fails and an immediate retry is triggered");
    }

    @Test
    void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_logsError() {
        // Both copy attempts fail — finalizeProposalReady must log an error.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        CountingTargetFileCopyPort bothFail = new CountingTargetFileCopyPort(2);
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), bothFail, capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.errorCallCount > 0,
                "An error must be logged when both copy attempts fail");
    }

    @Test
    void processDeferredOutcome_proposalReady_immediateRetrySucceeds_logsInfo() {
        // First copy fails, immediate retry succeeds — a success INFO must be logged.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        CountingTargetFileCopyPort onlyFirstFails = new CountingTargetFileCopyPort(1);
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });

        assertTrue(capturingLogger.infoCallCount > 0,
                "An INFO must be logged when the immediate within-run retry succeeds");
    }

    /** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */
    private static class CapturingProcessingLogger implements ProcessingLogger {
        int infoCallCount = 0;
        int debugCallCount = 0;
        int debugSensitiveAiContentCallCount = 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) {
            debugSensitiveAiContentCallCount++;
        }

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

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

    /**
     * Captures formatted log messages for content verification.
     * Resolves {@code {}} placeholders in SLF4J/Log4j2 style for later inspection.
     */
    private static class MessageCapturingProcessingLogger implements ProcessingLogger {
        final List<String> infoMessages = new ArrayList<>();
        final List<String> warnMessages = new ArrayList<>();
        final List<String> errorMessages = new ArrayList<>();

        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) {
            // Not captured for content — debug messages are not decision-level events
        }

        @Override
        public void debugSensitiveAiContent(String message, Object... args) {
            // Not captured — tested separately
        }

        @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));
        }

        boolean anyInfoContains(String text) {
            return infoMessages.stream().anyMatch(m -> m.contains(text));
        }

        boolean anyWarnContains(String text) {
            return warnMessages.stream().anyMatch(m -> m.contains(text));
        }

        boolean anyErrorContains(String text) {
            return errorMessages.stream().anyMatch(m -> m.contains(text));
        }
    }

    // -------------------------------------------------------------------------
    // AI sensitive content logging in finalization path
    // -------------------------------------------------------------------------

    @Test
    void processDeferredOutcome_proposalReady_aiContentNotNull_callsDebugSensitiveAiContent() {
        // buildValidProposalAttempt() has non-null aiRawResponse and aiReasoning.
        // The conditional guards at lines 398 and 402 of finalizeProposalReady must
        // trigger the debugSensitiveAiContent call when the values are present.
        // If negated, the calls would be suppressed for non-null values — detectable here.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt()); // aiRawResponse="{}", aiReasoning="reason"

        CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(
                candidate, fingerprint, context, attemptStart,
                c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });

        assertTrue(capturingLogger.debugSensitiveAiContentCallCount >= 2,
                "debugSensitiveAiContent must be called for aiRawResponse and aiReasoning "
                        + "when both are non-null. Actual call count: "
                        + capturingLogger.debugSensitiveAiContentCallCount);
    }

    // -------------------------------------------------------------------------
    // Best-effort rollback path: tryDeleteTargetFile and secondary persistence
    // -------------------------------------------------------------------------

    @Test
    void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_callsTryDeleteTargetFile() {
        // When persistence fails after a successful copy, the best-effort rollback
        // must call tryDeleteTargetFile to clean up the orphaned target file.
        // This test kills the 'removed call to tryDeleteTargetFile' mutation.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());
        unitOfWorkPort.failOnExecute = true;

        CapturingTargetFolderPort capturingFolderPort = new CapturingTargetFolderPort();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingFolderPort.tryDeleteCallCount > 0,
                "tryDeleteTargetFile must be called at least once for best-effort rollback "
                        + "when persistence fails after a successful copy");
    }

    @Test
    void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_logsSecondaryFailure() {
        // When persistence fails after a successful copy and the secondary persistence
        // attempt in persistTransientErrorAfterPersistenceFailure also fails,
        // an error must be logged for the secondary failure.
        // This kills the 'removed call to persistTransientErrorAfterPersistenceFailure' mutation.
        DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
        recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
        attemptRepo.savedAttempts.add(buildValidProposalAttempt());
        unitOfWorkPort.failOnExecute = true; // both primary and secondary persistence fail

        MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
        DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
                recordRepo, attemptRepo, unitOfWorkPort,
                new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
                DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible");

        coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

        assertTrue(capturingLogger.anyErrorContains("Secondary persistence failure")
                        || capturingLogger.anyErrorContains("secondary"),
                "An error must be logged for the secondary persistence failure. "
                        + "Captured error messages: " + capturingLogger.errorMessages);
    }
}