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

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;

/**
 * Batch processing implementation of {@link BatchRunProcessingUseCase}.
 * <p>
 * Orchestrates the complete batch processing workflow per candidate:
 * <ol>
 *   <li>Acquire exclusive run lock to prevent concurrent instances.</li>
 *   <li>Scan source folder for PDF candidates.</li>
 *   <li>For each candidate, execute the processing order:
 *     <ol type="a">
 *       <li>Compute fingerprint.</li>
 *       <li>If a fingerprint filter is active in the {@link BatchRunContext}, skip
 *           candidates whose fingerprint is not in the filter (no event, no persistence).</li>
 *       <li>Load document master record.</li>
 *       <li>If already {@code SUCCESS} → persist skip attempt with
 *           {@code SKIPPED_ALREADY_PROCESSED}.</li>
 *       <li>If already {@code FAILED_FINAL} → persist skip attempt with
 *           {@code SKIPPED_FINAL_FAILURE}.</li>
 *       <li>Otherwise execute the pipeline (extraction + pre-checks).</li>
 *       <li>Map result into status, counters and retryable flag.</li>
 *       <li>Persist exactly one historised processing attempt.</li>
 *       <li>Persist the updated document master record.</li>
 *     </ol>
 *   </li>
 *   <li>Release lock and return structured outcome for Bootstrap exit code mapping.</li>
 * </ol>
 *
 * <h2>Fingerprint filter (mini-run)</h2>
 * <p>
 * When the {@link BatchRunContext} carries a fingerprint filter, the run restricts
 * processing to exactly those candidates whose SHA-256 fingerprint is contained in
 * the filter. Candidates not in the filter are silently skipped — no completion event
 * is emitted, no persistence record is written, and they do not count toward the
 * progress total reported to the {@link BatchRunProgressObserver}.
 * <p>
 * To provide the correct total count for the progress bar, fingerprints of all source
 * candidates are computed up front before the observer is notified of the run start.
 * Only filter-matching candidates are included in the total and the processing loop.
 *
 * <h2>Idempotency</h2>
 * <p>
 * Documents are identified exclusively by their SHA-256 content fingerprint. A document
 * whose overall status is {@code SUCCESS} or {@code FAILED_FINAL} is skipped in every
 * subsequent run; only a skip attempt is historised.
 *
 * <h2>Pre-fingerprint failures</h2>
 * <p>
 * If the fingerprint computation fails (e.g. the file is no longer readable), the
 * candidate is logged as a non-identifiable run event and is <em>not</em> historised
 * in SQLite. The batch run continues with the next candidate.
 *
 * <h2>Persistence consistency</h2>
 * <p>
 * For every identified document, the processing attempt and the master record are
 * written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
 * document are caught and logged; the batch run continues with the remaining candidates.
 */
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {

    private final RuntimeConfiguration runtimeConfiguration;
    private final RunLockPort runLockPort;
    private final SourceDocumentCandidatesPort sourceDocumentCandidatesPort;
    private final PdfTextExtractionPort pdfTextExtractionPort;
    private final FingerprintPort fingerprintPort;
    private final DocumentProcessingCoordinator documentProcessingCoordinator;
    private final AiNamingService aiNamingService;
    private final ProcessingLogger logger;
    private final BatchRunProgressObserver progressObserver;
    private final BatchRunCancellationToken cancellationToken;

    /**
     * Creates the batch use case with the runtime configuration and all required ports for the flow.
     * <p>
     * The runtime configuration contains only the application-level settings needed
     * during batch processing. Technical infrastructure configuration (paths, persistence,
     * API details, etc.) is managed by Bootstrap.
     *
     * @param runtimeConfiguration        the runtime configuration containing application-level settings; must not be null
     * @param runLockPort                 for exclusive run locking; must not be null
     * @param sourceDocumentCandidatesPort for loading PDF candidates from the source folder;
     *                                    must not be null
     * @param pdfTextExtractionPort       for extracting text and page count from a single PDF;
     *                                    must not be null
     * @param fingerprintPort             for computing the SHA-256 fingerprint of a candidate;
     *                                    must not be null
     * @param documentProcessingCoordinator for applying decision logic and persisting results;
     *                                    must not be null
     * @param aiNamingService             for running the AI naming pipeline after pre-checks;
     *                                    must not be null
     * @param logger                      for processing-related logging; must not be null
     * @throws NullPointerException if any parameter is null
     */
    public DefaultBatchRunProcessingUseCase(
            RuntimeConfiguration runtimeConfiguration,
            RunLockPort runLockPort,
            SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
            PdfTextExtractionPort pdfTextExtractionPort,
            FingerprintPort fingerprintPort,
            DocumentProcessingCoordinator documentProcessingCoordinator,
            AiNamingService aiNamingService,
            ProcessingLogger logger) {
        this(runtimeConfiguration, runLockPort, sourceDocumentCandidatesPort, pdfTextExtractionPort,
                fingerprintPort, documentProcessingCoordinator, aiNamingService, logger,
                BatchRunProgressObserver.noOp(), BatchRunCancellationToken.neverCancelled());
    }

    /**
     * Creates the batch use case with a progress observer and cancellation token attached.
     * <p>
     * The observer is invoked on the batch thread for run start, per-candidate completion,
     * and run end. The cancellation token is polled between candidates; a requested
     * cancellation is honoured before starting the next candidate and never interrupts a
     * candidate that is already being processed (soft-stop).
     *
     * @param runtimeConfiguration        the runtime configuration; must not be null
     * @param runLockPort                 run-lock port; must not be null
     * @param sourceDocumentCandidatesPort candidate source port; must not be null
     * @param pdfTextExtractionPort       PDF text extraction port; must not be null
     * @param fingerprintPort             fingerprint port; must not be null
     * @param documentProcessingCoordinator per-document coordinator; must not be null
     * @param aiNamingService             AI naming service; must not be null
     * @param logger                      logger; must not be null
     * @param progressObserver            progress observer; must not be null, use
     *                                    {@link BatchRunProgressObserver#noOp()} when none is
     *                                    needed
     * @param cancellationToken           cancellation token; must not be null, use
     *                                    {@link BatchRunCancellationToken#neverCancelled()}
     *                                    when cancellation is not needed
     * @throws NullPointerException if any parameter is null
     */
    public DefaultBatchRunProcessingUseCase(
            RuntimeConfiguration runtimeConfiguration,
            RunLockPort runLockPort,
            SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
            PdfTextExtractionPort pdfTextExtractionPort,
            FingerprintPort fingerprintPort,
            DocumentProcessingCoordinator documentProcessingCoordinator,
            AiNamingService aiNamingService,
            ProcessingLogger logger,
            BatchRunProgressObserver progressObserver,
            BatchRunCancellationToken cancellationToken) {
        this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
        this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
        this.sourceDocumentCandidatesPort = Objects.requireNonNull(
                sourceDocumentCandidatesPort, "sourceDocumentCandidatesPort must not be null");
        this.pdfTextExtractionPort = Objects.requireNonNull(
                pdfTextExtractionPort, "pdfTextExtractionPort must not be null");
        this.fingerprintPort = Objects.requireNonNull(fingerprintPort, "fingerprintPort must not be null");
        this.documentProcessingCoordinator = Objects.requireNonNull(
                documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
        this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
        this.logger = Objects.requireNonNull(logger, "logger must not be null");
        this.progressObserver = Objects.requireNonNull(progressObserver, "progressObserver must not be null");
        this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
    }

    @Override
    public BatchRunOutcome execute(BatchRunContext context) {
        logger.info("Batch processing initiated. RunId: {}", context.runId());
        boolean lockAcquired = false;

        try {
            // Attempt to acquire the exclusive run lock
            try {
                runLockPort.acquire();
                lockAcquired = true;
                logger.debug("Run lock acquired successfully.");
            } catch (RunLockUnavailableException e) {
                logger.warn("Run lock not available – another instance is already running. "
                        + "This instance terminates immediately.");
                return BatchRunOutcome.LOCK_UNAVAILABLE;
            }

            logger.info("Batch run started. RunId: {}, Start: {}",
                    context.runId(), context.startInstant());

            // Load and process all candidates
            return processCandidates(context);

        } catch (Exception e) {
            logger.error("Unexpected error during batch processing", e);
            return BatchRunOutcome.FAILURE;
        } finally {
            releaseLockIfAcquired(lockAcquired);
        }
    }

    /**
     * Loads candidates and processes them one by one, respecting any fingerprint filter
     * present on the {@link BatchRunContext}.
     * <p>
     * Document-level failures — including content errors, transient technical errors,
     * and individual persistence failures — do not affect the batch outcome. The batch
     * completes with {@link BatchRunOutcome#SUCCESS} as long as the source folder is accessible
     * and the processing loop runs to completion without a hard infrastructure error.
     * Document-level persistence failures are logged by the coordinator and retried in
     * subsequent runs; they must not escalate to a hard batch failure.
     * <p>
     * Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
     * the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
     * <p>
     * When a fingerprint filter is active, all source-folder candidates are scanned but their
     * fingerprints are computed up front to determine which candidates belong to the effective
     * candidate list. Only filter-matching candidates count toward the total reported to the
     * observer and are included in the processing loop.
     *
     * @param context the current batch run context
     * @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
     *         or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
     */
    private BatchRunOutcome processCandidates(BatchRunContext context) {
        List<SourceDocumentCandidate> allCandidates;
        try {
            allCandidates = sourceDocumentCandidatesPort.loadCandidates();
        } catch (SourceDocumentAccessException e) {
            logger.error("Cannot access source folder: {}", e.getMessage(), e);
            return BatchRunOutcome.FAILURE;
        }
        logger.info("Found {} PDF candidate(s) in source folder.", allCandidates.size());

        // When a fingerprint filter is active, pre-compute fingerprints to determine
        // the effective candidate list and the correct total for the progress observer.
        Optional<Set<DocumentFingerprint>> filter = context.fingerprintFilter();
        List<SourceDocumentCandidate> effectiveCandidates;
        if (filter.isPresent()) {
            effectiveCandidates = buildFilteredCandidateList(allCandidates, filter.get(), context);
            logger.info("Fingerprint filter active: {} of {} candidate(s) match.",
                    effectiveCandidates.size(), allCandidates.size());
        } else {
            effectiveCandidates = allCandidates;
        }

        // Notify observer of the effective candidate count up-front so observers can size
        // their progress bars. The count reflects the filter-matched candidates and remains
        // fixed for the remainder of the run (also when the run is cancelled early).
        try {
            progressObserver.onRunStarted(context.runId(), effectiveCandidates.size());
        } catch (RuntimeException e) {
            logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
        }

        // Wrap the user-supplied observer so the per-run summary can be computed by counting
        // forwarded completion events.
        CountingCompletionObserver forwardingObserver =
                new CountingCompletionObserver(progressObserver, logger);
        documentProcessingCoordinator.installCompletionForwarder(forwardingObserver);
        try {
            int processedCount = 0;
            boolean cancelled = false;
            for (SourceDocumentCandidate candidate : effectiveCandidates) {
                if (cancellationTokenRequested()) {
                    cancelled = true;
                    logger.info("Cancellation requested before processing next candidate. "
                            + "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
                            context.runId(), processedCount, effectiveCandidates.size());
                    break;
                }
                processCandidate(candidate, context);
                processedCount++;
            }

            logger.info("Batch run {}. Processed {} candidate(s). RunId: {}",
                    cancelled ? "cancelled" : "completed",
                    processedCount, context.runId());
        } finally {
            documentProcessingCoordinator.installCompletionForwarder(null);
            try {
                progressObserver.onRunEnded(forwardingObserver.summary());
            } catch (RuntimeException e) {
                logger.warn("Progress observer threw on onRunEnded: {}", e.getMessage(), e);
            }
        }

        return BatchRunOutcome.SUCCESS;
    }

    /**
     * Pre-computes fingerprints for all raw candidates and returns the subset whose
     * fingerprint is contained in the given filter set.
     * <p>
     * Candidates for which fingerprint computation fails are logged at warn level and
     * excluded from the effective list (consistent with the regular per-candidate
     * fingerprint-error handling).
     *
     * @param allCandidates all candidates from the source folder scan
     * @param filter        the set of fingerprints to match against
     * @param context       the current batch run context (used for logging)
     * @return the ordered sub-list of candidates whose fingerprints are in the filter
     */
    private List<SourceDocumentCandidate> buildFilteredCandidateList(
            List<SourceDocumentCandidate> allCandidates,
            Set<DocumentFingerprint> filter,
            BatchRunContext context) {

        List<SourceDocumentCandidate> matched = new ArrayList<>();
        for (SourceDocumentCandidate candidate : allCandidates) {
            FingerprintResult result = fingerprintPort.computeFingerprint(candidate);
            switch (result) {
                case FingerprintTechnicalError error -> {
                    logger.warn("Fingerprint computation failed for '{}' during filter pre-pass "
                                    + "(RunId: {}): {} — candidate excluded.",
                            candidate.uniqueIdentifier(), context.runId(), error.errorMessage());
                }
                case FingerprintSuccess success -> {
                    if (filter.contains(success.fingerprint())) {
                        matched.add(candidate);
                    }
                }
            }
        }
        return matched;
    }

    private boolean cancellationTokenRequested() {
        try {
            return cancellationToken.isCancellationRequested();
        } catch (RuntimeException e) {
            logger.warn("Cancellation token threw while being polled; treating as not cancelled: {}",
                    e.getMessage(), e);
            return false;
        }
    }

    /**
     * Releases the run lock if it was previously acquired.
     * <p>
     * The lock is only released if it was successfully acquired. If acquire() failed
     * (another instance holds the lock), this instance must not attempt to release it.
     *
     * @param lockAcquired whether the lock was acquired by this instance
     */
    private void releaseLockIfAcquired(boolean lockAcquired) {
        if (lockAcquired) {
            try {
                runLockPort.release();
                logger.debug("Run lock released.");
            } catch (RuntimeException e) {
                logger.warn("Warning: Failed to release run lock.", e);
            }
        }
    }

    /**
     * Processes a single PDF candidate through the complete pipeline.
     * <p>
     * Processing order:
     * <ol>
     *   <li><strong>Log:</strong> detected source file at INFO level with run-ID (pre-fingerprint
     *       correlation via run-ID and candidate description).</li>
     *   <li>Record the attempt start instant.</li>
     *   <li>Compute the SHA-256 fingerprint of the candidate file content.</li>
     *   <li>If fingerprint computation fails: log as non-identifiable run event with run-ID
     *       and return true — no SQLite record is created, no persistence failure.</li>
     *   <li>Load document master record.</li>
     *   <li>If already {@code SUCCESS} → log skip at INFO level with fingerprint;
     *       persist skip attempt with {@code SKIPPED_ALREADY_PROCESSED}.</li>
     *   <li>If already {@code FAILED_FINAL} → log skip at INFO level with fingerprint;
     *       persist skip attempt with {@code SKIPPED_FINAL_FAILURE}.</li>
     *   <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming).</li>
     *   <li>Map result into status, counters, and retryable flag.</li>
     *   <li><strong>Log:</strong> retry decision at INFO level with fingerprint and error
     *       classification (FAILED_RETRYABLE or FAILED_FINAL).</li>
     *   <li>Persist exactly one historised processing attempt.</li>
     *   <li>Persist the updated document master record.</li>
     * </ol>
     * <p>
     * Per-document errors do not abort the overall batch run. Each candidate ends
     * controlled regardless of its outcome.
     * <p>
     * Post-fingerprint log entries carry the document fingerprint for correlation.
     * Pre-fingerprint log entries (steps 1–4) use run-ID and candidate description.
     *
     * @param candidate the candidate to process
     * @param context   the current batch run context
     * @return true if the candidate was processed without persistence failures (fingerprint
     *         errors return true; persistence failures return false)
     */
    private boolean processCandidate(SourceDocumentCandidate candidate, BatchRunContext context) {
        logger.info("Detected source file '{}' for processing (RunId: {}).",
                candidate.uniqueIdentifier(), context.runId());

        Instant attemptStart = Instant.now();
        FingerprintResult fingerprintResult = fingerprintPort.computeFingerprint(candidate);

        return switch (fingerprintResult) {
            case FingerprintTechnicalError fingerprintError -> {
                handleFingerprintError(candidate, fingerprintError, context);
                yield true;  // fingerprint errors are not persistence failures
            }
            case FingerprintSuccess fingerprintSuccess ->
                handleFingerprintSuccess(candidate, fingerprintSuccess, context, attemptStart);
        };
    }

    /**
     * Handles a fingerprint computation error by logging it as a non-identifiable run event.
     * No SQLite record is created for this candidate.
     * <p>
     * Log entries before a successful fingerprint are correlated via the batch run identifier
     * and the candidate description, as no fingerprint is available for document-level
     * correlation.
     *
     * @param candidate the candidate that could not be fingerprinted
     * @param error     the fingerprint error
     * @param context   the current batch run context; used for run-level log correlation
     */
    private void handleFingerprintError(
            SourceDocumentCandidate candidate,
            FingerprintTechnicalError error,
            BatchRunContext context) {
        logger.warn("Fingerprint computation failed for '{}' (RunId: {}): {} — candidate not historised.",
                candidate.uniqueIdentifier(), context.runId(), error.errorMessage());
    }

    /**
     * Handles a successful fingerprint computation by delegating to the processor.
     * The processor loads the document master record, checks terminal status, executes
     * the pipeline as needed, and persists results consistently.
     *
     * @param candidate the candidate to process
     * @param fingerprintSuccess the successful fingerprint result
     * @param context the batch run context
     * @param attemptStart the instant when processing started
     * @return true if processing and persistence succeeded, false if a persistence failure occurred
     */
    private boolean handleFingerprintSuccess(
            SourceDocumentCandidate candidate,
            FingerprintSuccess fingerprintSuccess,
            BatchRunContext context,
            Instant attemptStart) {
        DocumentFingerprint fingerprint = fingerprintSuccess.fingerprint();
        logger.debug("Fingerprint computed for '{}': {}",
                candidate.uniqueIdentifier(), fingerprint.sha256Hex());

        return documentProcessingCoordinator.processDeferredOutcome(
            candidate,
            fingerprint,
            context,
            attemptStart,
            this::runExtractionPipeline);
    }

    /**
     * Runs the full pipeline for the given candidate: extraction, pre-checks, and AI naming.
     * <p>
     * This method is called after a successful fingerprint computation. The result is
     * passed to {@link DocumentProcessingCoordinator}, which applies it only when the document is
     * not in a terminal state.
     * <p>
     * Processing order:
     * <ol>
     *   <li>Extract PDF text and page count via the extraction port.</li>
     *   <li>Evaluate pre-checks (text quality, page limit). If any pre-check fails,
     *       return the failure outcome immediately — no AI call is made.</li>
     *   <li>If pre-checks pass, run the AI naming pipeline to obtain a naming proposal
     *       or classify the AI result as a technical or functional failure.</li>
     * </ol>
     *
     * @param candidate the candidate to run through the pipeline
     * @return the pipeline outcome; one of {@code PreCheckFailed}, {@code TechnicalDocumentError},
     *         {@code NamingProposalReady}, {@code AiTechnicalFailure}, or {@code AiFunctionalFailure}
     */
    private DocumentProcessingOutcome runExtractionPipeline(SourceDocumentCandidate candidate) {
        PdfExtractionResult extractionResult =
                pdfTextExtractionPort.extractTextAndPageCount(candidate);

        logExtractionResult(candidate, extractionResult);

        DocumentProcessingOutcome preCheckOutcome =
                DocumentProcessingService.processDocument(candidate, extractionResult, runtimeConfiguration);

        // If pre-checks did not pass, return the failure outcome immediately.
        // This avoids an AI call for documents that cannot be processed.
        if (!(preCheckOutcome instanceof PreCheckPassed preCheckPassed)) {
            logProcessingOutcome(candidate, preCheckOutcome);
            return preCheckOutcome;
        }

        // Pre-checks passed — run the AI naming pipeline
        logger.info("Pre-checks passed for '{}'. Invoking AI naming pipeline.",
                candidate.uniqueIdentifier());
        DocumentProcessingOutcome aiOutcome = aiNamingService.invoke(preCheckPassed);
        logProcessingOutcome(candidate, aiOutcome);
        return aiOutcome;
    }

    /**
     * Logs the PDF extraction result for a candidate.
     *
     * @param candidate the candidate being processed
     * @param result the extraction result to log
     */
    private void logExtractionResult(SourceDocumentCandidate candidate, PdfExtractionResult result) {
        switch (result) {
            case de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess success -> {
                logger.debug("PDF extraction successful for '{}'. Pages: {}, Text length: {} chars.",
                        candidate.uniqueIdentifier(),
                        success.pageCount().value(),
                        success.extractedText().length());
            }
            case de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError contentError -> {
                logger.debug("PDF content extraction failed for '{}' (content problem): {}",
                        candidate.uniqueIdentifier(), contentError.reason());
            }
            case de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError technicalError -> {
                logger.debug("PDF extraction technical error for '{}': {}",
                        candidate.uniqueIdentifier(), technicalError.errorMessage());
            }
            default -> {
                // Handle any other cases
            }
        }
    }

    /**
     * Logs the processing outcome for a candidate.
     *
     * @param candidate the candidate being processed
     * @param outcome the processing outcome to log
     */
    private void logProcessingOutcome(SourceDocumentCandidate candidate, DocumentProcessingOutcome outcome) {
        switch (outcome) {
            case de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed failed ->
                logger.warn("Pre-checks failed for '{}': {} (deterministic content error).",
                        candidate.uniqueIdentifier(), failed.failureReasonDescription());
            case de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError technicalError ->
                logger.warn("Processing failed for '{}': {} (transient technical error – retryable).",
                        candidate.uniqueIdentifier(), technicalError.errorMessage());
            case de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady ready ->
                logger.info("AI naming proposal ready for '{}': title='{}', date={}.",
                        candidate.uniqueIdentifier(),
                        ready.proposal().validatedTitle(),
                        ready.proposal().resolvedDate());
            case de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure aiTechnical ->
                logger.warn("AI invocation failed for '{}': {} (transient technical error – retryable).",
                        candidate.uniqueIdentifier(), aiTechnical.errorMessage());
            case de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure aiFunctional ->
                logger.warn("AI naming failed for '{}': {} (deterministic content error).",
                        candidate.uniqueIdentifier(), aiFunctional.errorMessage());
            default -> { /* other outcomes are handled elsewhere */ }
        }
    }
}