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

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;

import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
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.ModelPriceRepository;
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.ExistingIdenticalTargetFile;
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.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.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
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.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;

/**
 * Application-level service that implements the per-document processing logic.
 * <p>
 * This service is the single authoritative place for the decision rules:
 * idempotency checks, status/counter mapping, target-copy finalization, retry
 * finalization, skip semantics, and consistent two-level persistence.
 *
 * <h2>Processing order per candidate</h2>
 * <ol>
 *   <li>Load the document master record by fingerprint.</li>
 *   <li>If the overall status is {@link ProcessingStatus#SUCCESS} →
 *       <strong>log skip at INFO with fingerprint</strong>;
 *       persist a skip attempt with {@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED}.
 *       Failure counters are not changed.</li>
 *   <li>If the overall status is {@link ProcessingStatus#FAILED_FINAL} →
 *       <strong>log skip at INFO with fingerprint</strong>;
 *       persist a skip attempt with {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}.
 *       Failure counters are not changed.</li>
 *   <li>If the overall status is {@link ProcessingStatus#PROPOSAL_READY} → load the
 *       leading proposal attempt and execute the target-copy finalization flow:
 *       build the base filename, resolve duplicates,
 *       <strong>log generated target filename at INFO with fingerprint</strong>,
 *       write the copy, persist SUCCESS or FAILED_RETRYABLE.</li>
 *   <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map
 *       the result into status, counters, and retryable flag. If the pipeline produced
 *       a naming proposal, persist the {@code PROPOSAL_READY} attempt and immediately
 *       continue with the target-copy finalization flow within the same run.</li>
 *   <li><strong>Log retry decision at INFO with fingerprint and error classification</strong>:
 *       FAILED_RETRYABLE (will retry in a later scheduler run) or
 *       FAILED_FINAL (retry budget exhausted, no further processing).</li>
 *   <li>Persist exactly one historised processing attempt for the identified document.</li>
 *   <li>Persist the updated document master record.</li>
 * </ol>
 *
 * <h2>Retry finalization rules</h2>
 * <ul>
 *   <li><strong>Deterministic content errors:</strong> The first historised occurrence
 *       leads to {@link ProcessingStatus#FAILED_RETRYABLE} (content-error counter incremented
 *       by 1). The second historised occurrence leads to {@link ProcessingStatus#FAILED_FINAL}
 *       (content-error counter incremented by 1). No further retry is possible.</li>
 *   <li><strong>Transient technical errors:</strong> The transient-error counter is
 *       incremented by 1 per occurrence. The document remains
 *       {@link ProcessingStatus#FAILED_RETRYABLE} as long as the counter is strictly less
 *       than {@code maxRetriesTransient}. The attempt that causes the counter to reach
 *       {@code maxRetriesTransient} finalises the document to
 *       {@link ProcessingStatus#FAILED_FINAL}. Valid values of {@code maxRetriesTransient}
 *       are integers &ge; 1; the value 0 is invalid startup configuration.</li>
 *   <li><strong>Skip events</strong> ({@code SKIPPED_ALREADY_PROCESSED},
 *       {@code SKIPPED_FINAL_FAILURE}) never change any failure counter.</li>
 * </ul>
 *
 * <h2>Status transitions</h2>
 * <ul>
 *   <li>Pre-check passed + AI naming proposal ready → {@link ProcessingStatus#PROPOSAL_READY}</li>
 *   <li>First deterministic content failure → {@link ProcessingStatus#FAILED_RETRYABLE}</li>
 *   <li>Second deterministic content failure → {@link ProcessingStatus#FAILED_FINAL}</li>
 *   <li>Technical infrastructure failure → {@link ProcessingStatus#FAILED_RETRYABLE}</li>
 *   <li>Technical failure at transient retry limit → {@link ProcessingStatus#FAILED_FINAL}</li>
 *   <li>{@link ProcessingStatus#PROPOSAL_READY} + successful target copy + consistent
 *       persistence → {@link ProcessingStatus#SUCCESS}</li>
 *   <li>{@link ProcessingStatus#PROPOSAL_READY} + first copy failure + successful immediate retry
 *       → treated as successful copy, proceeds to {@link ProcessingStatus#SUCCESS}</li>
 *   <li>{@link ProcessingStatus#PROPOSAL_READY} + both copy attempts fail → cross-run
 *       {@link ProcessingStatus#FAILED_RETRYABLE}, transient error counter +1</li>
 *   <li>{@link ProcessingStatus#PROPOSAL_READY} + technical failure → {@link ProcessingStatus#FAILED_RETRYABLE},
 *       transient error counter +1</li>
 *   <li>{@link ProcessingStatus#SUCCESS} → {@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED} skip</li>
 *   <li>{@link ProcessingStatus#FAILED_FINAL} → {@link ProcessingStatus#SKIPPED_FINAL_FAILURE} skip</li>
 * </ul>
 *
 * <h2>Log correlation</h2>
 * <p>
 * All log entries emitted by this coordinator are post-fingerprint: the fingerprint is
 * available for every document that reaches this coordinator. Relevant log entries carry
 * the document fingerprint for unambiguous correlation across runs.
 *
 * <h2>Leading source for the naming proposal (verbindlich)</h2>
 * <p>
 * When a document is in {@code PROPOSAL_READY} state, the authoritative source for the
 * validated title, resolved date, date source, and AI reasoning is the most recent
 * {@code PROPOSAL_READY} attempt in the history. This coordinator never reconstructs
 * proposal data from the document master record or re-invokes the AI when a valid
 * {@code PROPOSAL_READY} attempt already exists.
 *
 * <h2>SUCCESS condition (verbindlich)</h2>
 * <p>
 * {@code SUCCESS} is set only after:
 * <ol>
 *   <li>The target copy has been successfully written.</li>
 *   <li>The final target filename is determined.</li>
 *   <li>The persistence (attempt + master record) has been consistently committed.</li>
 * </ol>
 * If persistence fails after a successful target copy, a best-effort rollback of the
 * newly written copy is attempted before the error is recorded.
 *
 * <h2>Persistence consistency</h2>
 * <p>
 * For every identified document (except PROPOSAL_READY that fails before producing any
 * persistent artifact), both the processing attempt and the master record are written
 * atomically via a unit of work. If either write fails, both writes are rolled back and
 * the failure is logged. The batch run continues with the next candidate.
 *
 * <h2>Pre-fingerprint failures</h2>
 * <p>
 * Failures that occur before a successful fingerprint is available are <em>not</em>
 * historised in SQLite. They are handled by the caller.
 */
public class DocumentProcessingCoordinator {

    private final DocumentRecordRepository documentRecordRepository;
    private final ProcessingAttemptRepository processingAttemptRepository;
    private final UnitOfWorkPort unitOfWorkPort;
    private final TargetFolderPort targetFolderPort;
    private final TargetFileCopyPort targetFileCopyPort;
    private final ProcessingLogger logger;
    private final int maxRetriesTransient;
    private final int maxTitleLength;
    private final String activeProviderIdentifier;
    /**
     * Optionales Repository fuer Modell-Preis-Snapshots.
     *
     * <p>Wird beim Bau eines Attempts mit erfolgreichem KI-Aufruf konsultiert,
     * um die Snapshot-Preise zum aktiven Modell zu laden. {@code null}
     * bedeutet, dass kein Repository verdrahtet wurde – der Coordinator
     * arbeitet dann ohne Snapshot-Lookup, und alle Preis-Felder bleiben
     * {@code null}. Damit bleiben bestehende Aufrufer ohne Token-Tracking
     * lauffaehig.
     */
    private final ModelPriceRepository modelPriceRepository;
    /**
     * Markierung fuer den Headless-Betrieb.
     *
     * <p>Im Headless-Betrieb wird zusaetzlich zum normalen WARN-Log auf
     * fehlende Preis-Eintraege ein Hinweis auf den CLI-Befehl
     * {@code --upsert-model-price} ausgegeben.
     */
    private final boolean headlessMode;

    /**
     * Optional per-run completion forwarder that is consulted by
     * {@link #publishCompletion(SourceDocumentCandidate, DocumentFingerprint, DocumentCompletionStatus,
     * String, LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
     * <p>
     * Assigned by the inbound use case for the duration of a single run and cleared before the
     * use case returns. A {@code null} value means no external observer is attached and the
     * completion event is dropped silently — the default for headless callers.
     * <p>
     * Accessed from the batch thread only. Not volatile because installation and read occur
     * on the same thread (the one executing the batch).
     */
    private Consumer<DocumentCompletionEvent> completionForwarder;

    /**
     * Creates the document processing coordinator with all required ports, logger,
     * the transient retry limit, the configured maximum base title length, and the
     * active AI provider identifier.
     * <p>
     * {@code maxRetriesTransient} is the maximum number of historised transient error attempts
     * per fingerprint before the document is finalised to
     * {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to
     * reach this value finalises the document. Must be &gt;= 1.
     * <p>
     * {@code maxTitleLength} is the configured maximum length for the base title portion
     * of the generated target filename. The value is forwarded to
     * {@link TargetFilenameBuildingService} when the target filename is built from a
     * persisted naming proposal. Must be &gt;= 1.
     * <p>
     * {@code activeProviderIdentifier} is the opaque string identifier of the AI provider
     * that is active for this run (e.g. {@code "openai-compatible"} or {@code "claude"}).
     * It is written to the attempt history for every attempt that involves an AI call,
     * enabling provider-level traceability per attempt without introducing
     * provider-specific logic in the application layer.
     *
     * @param documentRecordRepository    port for reading and writing the document master record;
     *                                    must not be null
     * @param processingAttemptRepository port for writing and reading the attempt history;
     *                                    must not be null
     * @param unitOfWorkPort              port for executing operations atomically; must not be null
     * @param targetFolderPort            port for target folder duplicate resolution and cleanup;
     *                                    must not be null
     * @param targetFileCopyPort          port for copying source files to the target folder;
     *                                    must not be null
     * @param logger                      for processing-related logging; must not be null
     * @param maxRetriesTransient         maximum number of historised transient error attempts
     *                                    before finalisation; must be &gt;= 1
     * @param maxTitleLength              configured maximum base title length; must be &gt;= 1
     * @param activeProviderIdentifier    opaque identifier of the active AI provider for this run;
     *                                    must not be null or blank
     * @throws NullPointerException     if any object parameter is null
     * @throws IllegalArgumentException if {@code maxRetriesTransient} or {@code maxTitleLength}
     *                                  is less than 1, or if {@code activeProviderIdentifier}
     *                                  is blank
     */
    public DocumentProcessingCoordinator(
            DocumentRecordRepository documentRecordRepository,
            ProcessingAttemptRepository processingAttemptRepository,
            UnitOfWorkPort unitOfWorkPort,
            TargetFolderPort targetFolderPort,
            TargetFileCopyPort targetFileCopyPort,
            ProcessingLogger logger,
            int maxRetriesTransient,
            int maxTitleLength,
            String activeProviderIdentifier) {
        this(documentRecordRepository, processingAttemptRepository, unitOfWorkPort,
                targetFolderPort, targetFileCopyPort, logger,
                maxRetriesTransient, maxTitleLength, activeProviderIdentifier,
                null, false);
    }

    /**
     * Erweiterter Konstruktor mit Modell-Preis-Repository und Headless-Hinweis.
     *
     * <p>Identisch zum bestehenden Konstruktor; zusaetzlich werden ein
     * {@link ModelPriceRepository} fuer Snapshot-Lookups und ein
     * Flag zum Headless-Modus injiziert. Beim Bau eines Attempts mit
     * erfolgreichem KI-Aufruf wird der Snapshot-Preis ueber das Repository
     * geladen. Faellt der Lookup mit Exception aus, wird der Attempt mit
     * Snapshot-Feldern auf {@code null} persistiert und der Fehler ge&shy;
     * loggt. Im Headless-Modus wird zudem ein Hinweis auf den CLI-Befehl
     * {@code --upsert-model-price} ausgegeben, wenn das Modell keinen Preis
     * hat.
     *
     * @param modelPriceRepository Repository-Port; darf {@code null} sein
     * @param headlessMode         {@code true} wenn der Lauf headless ist
     */
    public DocumentProcessingCoordinator(
            DocumentRecordRepository documentRecordRepository,
            ProcessingAttemptRepository processingAttemptRepository,
            UnitOfWorkPort unitOfWorkPort,
            TargetFolderPort targetFolderPort,
            TargetFileCopyPort targetFileCopyPort,
            ProcessingLogger logger,
            int maxRetriesTransient,
            int maxTitleLength,
            String activeProviderIdentifier,
            ModelPriceRepository modelPriceRepository,
            boolean headlessMode) {
        if (maxRetriesTransient < 1) {
            throw new IllegalArgumentException(
                    "maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
        }
        if (maxTitleLength < 1) {
            throw new IllegalArgumentException(
                    "maxTitleLength must be >= 1, got: " + maxTitleLength);
        }
        Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null");
        if (activeProviderIdentifier.isBlank()) {
            throw new IllegalArgumentException("activeProviderIdentifier must not be blank");
        }
        this.documentRecordRepository =
                Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
        this.processingAttemptRepository =
                Objects.requireNonNull(processingAttemptRepository, "processingAttemptRepository must not be null");
        this.unitOfWorkPort =
                Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
        this.targetFolderPort =
                Objects.requireNonNull(targetFolderPort, "targetFolderPort must not be null");
        this.targetFileCopyPort =
                Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
        this.logger = Objects.requireNonNull(logger, "logger must not be null");
        this.maxRetriesTransient = maxRetriesTransient;
        this.maxTitleLength = maxTitleLength;
        this.activeProviderIdentifier = activeProviderIdentifier;
        this.modelPriceRepository = modelPriceRepository;
        this.headlessMode = headlessMode;
        this.completionForwarder = null;
    }

    /**
     * Installs or removes a per-run completion forwarder.
     * <p>
     * When non-null, the forwarder is consulted at every terminal candidate resolution and
     * receives a {@link DocumentCompletionEvent} describing the outcome. A {@code null} value
     * detaches any previously installed forwarder.
     * <p>
     * This method is the single seam by which inbound adapters (e.g. the JavaFX GUI) attach a
     * live-progress observer to the document coordinator without widening the coordinator's
     * constructor surface. It must only be called from the thread that will drive the batch
     * run.
     *
     * @param forwarder the new forwarder, or {@code null} to detach the current one
     */
    public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
        this.completionForwarder = forwarder;
    }

    /**
     * Applies the full processing logic for one identified document candidate.
     * <p>
     * Convenience overload that accepts a pre-computed outcome (for callers that have
     * already determined the outcome before calling this method).
     *
     * @param candidate    the source document candidate being processed; must not be null
     * @param fingerprint  the successfully computed fingerprint; must not be null
     * @param outcome      the pipeline result; must not be null
     * @param context      the current batch run context; must not be null
     * @param attemptStart the instant at which processing began; must not be null
     * @return true if processing and persistence succeeded, false if persistence failed
     */
    public boolean process(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentProcessingOutcome outcome,
            BatchRunContext context,
            Instant attemptStart) {

        Objects.requireNonNull(candidate, "candidate must not be null");
        Objects.requireNonNull(fingerprint, "fingerprint must not be null");
        Objects.requireNonNull(outcome, "outcome must not be null");
        Objects.requireNonNull(context, "context must not be null");
        Objects.requireNonNull(attemptStart, "attemptStart must not be null");

        return processDeferredOutcome(candidate, fingerprint, context, attemptStart, ignored -> outcome);
    }

    /**
     * Applies the full processing logic for one identified document candidate,
     * loading the document master record internally and deferring pipeline execution
     * until the terminal-state check passes.
     * <p>
     * This method never throws. All persistence failures are caught, logged, and
     * treated as controlled per-document failures so the batch run can continue.
     *
     * <h2>Processing order</h2>
     * <ol>
     *   <li>Load the document master record.</li>
     *   <li>If the status is {@code SUCCESS} → persist
     *       {@code SKIPPED_ALREADY_PROCESSED}.</li>
     *   <li>If the status is {@code FAILED_FINAL} → persist
     *       {@code SKIPPED_FINAL_FAILURE}.</li>
     *   <li>If the status is {@code PROPOSAL_READY} → execute the target-copy
     *       finalization without invoking the AI pipeline again.</li>
     *   <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and
     *       persist the outcome. If the pipeline produced a naming proposal, the
     *       intermediate {@code PROPOSAL_READY} attempt is persisted and the
     *       target-copy finalization is executed within the same run.</li>
     * </ol>
     *
     * @param candidate      the source document candidate; must not be null
     * @param fingerprint    the successfully computed fingerprint; must not be null
     * @param context        the current batch run context; must not be null
     * @param attemptStart   the instant at which processing began; must not be null
     * @param pipelineExecutor executes the extraction + AI pipeline when needed; must not be null
     * @return true if processing and persistence succeeded, false if a persistence failure occurred
     */
    public boolean processDeferredOutcome(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            BatchRunContext context,
            Instant attemptStart,
            Function<SourceDocumentCandidate, DocumentProcessingOutcome> pipelineExecutor) {

        Objects.requireNonNull(candidate, "candidate must not be null");
        Objects.requireNonNull(fingerprint, "fingerprint must not be null");
        Objects.requireNonNull(context, "context must not be null");
        Objects.requireNonNull(attemptStart, "attemptStart must not be null");
        Objects.requireNonNull(pipelineExecutor, "pipelineExecutor must not be null");

        // Step 1: Load the document master record
        DocumentRecordLookupResult lookupResult =
                documentRecordRepository.findByFingerprint(fingerprint);
        // Step 2: Handle persistence lookup failure
        if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) {
            logger.error("Cannot process '{}': master record lookup failed: {}",
                    candidate.uniqueIdentifier(), failure.errorMessage());
            return false;
        }

        // Step 3: Determine the action based on the lookup result
        return switch (lookupResult) {
            case DocumentTerminalSuccess terminalSuccess -> {
                logger.info("Skipping '{}': already successfully processed (fingerprint: {}).",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex());
                yield persistSkipAttempt(
                        candidate, fingerprint, terminalSuccess.record(),
                        ProcessingStatus.SKIPPED_ALREADY_PROCESSED,
                        context, attemptStart);
            }

            case DocumentTerminalFinalFailure terminalFailure -> {
                logger.info("Skipping '{}': already finally failed (fingerprint: {}).",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex());
                yield persistSkipAttempt(
                        candidate, fingerprint, terminalFailure.record(),
                        ProcessingStatus.SKIPPED_FINAL_FAILURE,
                        context, attemptStart);
            }

            case DocumentKnownProcessable knownProcessable
                    when knownProcessable.record().overallStatus() == ProcessingStatus.PROPOSAL_READY -> {
                // Naming proposal is present — execute the target-copy finalization
                // without triggering a new AI call
                logger.info("Finalizing '{}': naming proposal present, proceeding to target copy "
                                + "(fingerprint: {}).",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex());
                yield finalizeProposalReady(
                        candidate, fingerprint, knownProcessable.record(), context, attemptStart);
            }

            case DocumentUnknown ignored -> {
                DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate);
                yield processAndPersistNewDocument(candidate, fingerprint, outcome, context, attemptStart);
            }

            case DocumentKnownProcessable knownProcessable -> {
                DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate);
                yield processAndPersistKnownDocument(
                        candidate, fingerprint, outcome, knownProcessable.record(),
                        context, attemptStart);
            }

            default -> {
                logger.error("Unexpected lookup result type for '{}': {}",
                        candidate.uniqueIdentifier(), lookupResult.getClass().getSimpleName());
                yield false;
            }
        };
    }

    // =========================================================================
    // Target-copy finalization path
    // =========================================================================

    /**
     * Finalizes a document whose status is {@code PROPOSAL_READY}.
     * <p>
     * Processing order:
     * <ol>
     *   <li>Load the leading {@code PROPOSAL_READY} attempt (authoritative proposal source).</li>
     *   <li>Build the base filename from the proposal's date and title.</li>
     *   <li>Resolve the first available unique filename in the target folder.</li>
     *   <li>Copy the source file to the target folder.</li>
     *   <li>If the copy fails: attempt exactly one immediate within-run retry of the same
     *       physical copy step. No new AI call and no new naming-proposal derivation occur.
     *       If the retry also fails, treat the combined failure as a transient error and
     *       skip the SUCCESS path.</li>
     *   <li>Persist a new {@code SUCCESS} attempt and update the master record.</li>
     *   <li>If persistence fails after a successful copy: attempt best-effort rollback
     *       of the copy and persist {@code FAILED_RETRYABLE} instead.</li>
     * </ol>
     * <p>
     * A missing or inconsistent {@code PROPOSAL_READY} attempt is treated as a
     * document-level technical error (retryable, transient counter +1).
     *
     * @return true if SUCCESS was persisted, false if a persistence failure occurred
     */
    private boolean finalizeProposalReady(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentRecord existingRecord,
            BatchRunContext context,
            Instant attemptStart) {

        Instant now = Instant.now();

        // --- Step 1: Load the leading PROPOSAL_READY attempt ---
        ProcessingAttempt proposalAttempt;
        try {
            proposalAttempt = processingAttemptRepository.findLatestProposalReadyAttempt(fingerprint);
        } catch (DocumentPersistenceException e) {
            logger.error("Failed to load leading PROPOSAL_READY attempt for '{}': {}",
                    candidate.uniqueIdentifier(), e.getMessage(), e);
            return persistTransientError(
                    candidate, fingerprint, existingRecord, context, attemptStart, now,
                    "Failed to load naming proposal from history: " + e.getMessage());
        }

        if (proposalAttempt == null) {
            logger.error("Document '{}' has PROPOSAL_READY status but no matching attempt "
                            + "found in history. Inconsistent persistence state.",
                    candidate.uniqueIdentifier());
            return persistTransientError(
                    candidate, fingerprint, existingRecord, context, attemptStart, now,
                    "Status is PROPOSAL_READY but no PROPOSAL_READY attempt exists in history");
        }

        // Log sensitive AI content (raw response, reasoning) if configured
        if (proposalAttempt.aiRawResponse() != null) {
            logger.debugSensitiveAiContent("AI raw response for '{}' (fingerprint: {}): {}",
                    candidate.uniqueIdentifier(), fingerprint.sha256Hex(), proposalAttempt.aiRawResponse());
        }
        if (proposalAttempt.aiReasoning() != null) {
            logger.debugSensitiveAiContent("AI reasoning for '{}' (fingerprint: {}): {}",
                    candidate.uniqueIdentifier(), fingerprint.sha256Hex(), proposalAttempt.aiReasoning());
        }

        // --- Step 2: Build base filename from the proposal ---
        TargetFilenameBuildingService.BaseFilenameResult filenameResult =
                TargetFilenameBuildingService.buildBaseFilename(proposalAttempt, maxTitleLength);

        if (filenameResult instanceof TargetFilenameBuildingService.InconsistentProposalState inconsistent) {
            logger.error("Inconsistent proposal state for '{}': {}",
                    candidate.uniqueIdentifier(), inconsistent.reason());
            return persistTransientError(
                    candidate, fingerprint, existingRecord, context, attemptStart, now,
                    "Inconsistent proposal state: " + inconsistent.reason());
        }

        String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();

        // --- Step 3: Resolve unique filename in target folder ---
        // Passing the source fingerprint enables the adapter to detect an identical existing
        // target file and return ExistingIdenticalTargetFile instead of a numbered suffix.
        TargetFilenameResolutionResult resolutionResult =
                targetFolderPort.resolveUniqueFilename(baseFilename, fingerprint);

        if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
            logger.error("Duplicate resolution failed for '{}': {}",
                    candidate.uniqueIdentifier(), folderFailure.errorMessage());
            return persistTransientError(
                    candidate, fingerprint, existingRecord, context, attemptStart, now,
                    "Target folder duplicate resolution failed: " + folderFailure.errorMessage());
        }

        // Identical-content shortcut: target already exists with the same content — treat as
        // SUCCESS without writing a new copy.
        if (resolutionResult instanceof ExistingIdenticalTargetFile identicalFile) {
            logger.info("Target file '{}' already exists with identical content for '{}' "
                            + "(fingerprint: {}). Treating as success without new copy.",
                    identicalFile.existingFilename(), candidate.uniqueIdentifier(),
                    fingerprint.sha256Hex());
            return persistTargetCopySuccess(
                    candidate, fingerprint, existingRecord, context, attemptStart, now,
                    identicalFile.existingFilename(),
                    targetFolderPort.getTargetFolderLocator(),
                    proposalAttempt);
        }

        String resolvedFilename =
                ((ResolvedTargetFilename) resolutionResult).resolvedFilename();
        logger.info("Generated target filename for '{}' (fingerprint: {}): '{}'.",
                candidate.uniqueIdentifier(), fingerprint.sha256Hex(), resolvedFilename);

        // --- Step 4: Copy file to target (with one immediate within-run retry) ---
        TargetFileCopyResult copyResult =
                targetFileCopyPort.copyToTarget(candidate.locator(), resolvedFilename);

        if (copyResult instanceof TargetFileCopyTechnicalFailure firstCopyFailure) {
            // First copy attempt failed — perform exactly one immediate within-run retry.
            // The retry reuses the same resolved filename and document context; no new AI
            // call, no new naming-proposal derivation. This mechanism does not increment
            // the cross-run transient-error counter by itself.
            logger.warn("First target copy attempt failed for '{}': {}. Performing immediate within-run retry.",
                    candidate.uniqueIdentifier(), firstCopyFailure.errorMessage());

            TargetFileCopyResult retryCopyResult =
                    targetFileCopyPort.copyToTarget(candidate.locator(), resolvedFilename);

            if (retryCopyResult instanceof TargetFileCopyTechnicalFailure retryCopyFailure) {
                // Immediate retry also failed — the combined failure is escalated as a
                // cross-run transient technical error. No further within-run retry is
                // attempted. This is the only document-level result for persistence.
                logger.error("Immediate within-run retry also failed for '{}': {}",
                        candidate.uniqueIdentifier(), retryCopyFailure.errorMessage());
                String combinedMessage = "Target file copy failed after immediate within-run retry."
                        + " First attempt: " + firstCopyFailure.errorMessage()
                        + "; Retry attempt: " + retryCopyFailure.errorMessage();
                return persistTransientError(
                        candidate, fingerprint, existingRecord, context, attemptStart, now,
                        combinedMessage);
            }

            // Immediate retry succeeded — proceed to SUCCESS path as if the copy
            // had succeeded on the first attempt.
            logger.info("Immediate within-run retry succeeded for '{}'.", candidate.uniqueIdentifier());
        }

        // Copy succeeded — attempt to persist SUCCESS
        // If persistence fails: rollback the copy (best-effort) and persist FAILED_RETRYABLE
        String targetFolderLocator = targetFolderPort.getTargetFolderLocator();

        return persistTargetCopySuccess(
                candidate, fingerprint, existingRecord, context, attemptStart, now,
                resolvedFilename, targetFolderLocator, proposalAttempt);
    }

    /**
     * Persists the SUCCESS attempt and updated master record after a successful target copy.
     * <p>
     * If the atomic persistence fails after the copy has already been written, a
     * best-effort rollback of the target file is attempted and
     * {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead.
     * <p>
     * On successful persistence, a terminal completion event is published to the attached
     * {@link BatchRunProgressObserver}; the event carries the resolved final filename,
     * the date and reasoning taken from the authoritative {@code PROPOSAL_READY} attempt.
     * On persistence failure the completion event is published by
     * {@link #persistTransientErrorAfterPersistenceFailure}.
     *
     * @param proposalAttempt the authoritative naming-proposal attempt used to populate
     *                        the observer event's date and reasoning fields; must not be
     *                        {@code null}
     * @return true if SUCCESS was persisted; false if persistence itself failed
     */
    private boolean persistTargetCopySuccess(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentRecord existingRecord,
            BatchRunContext context,
            Instant attemptStart,
            Instant now,
            String resolvedFilename,
            String targetFolderLocator,
            ProcessingAttempt proposalAttempt) {

        try {
            int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);

            ProcessingAttempt successAttempt = new ProcessingAttempt(
                    fingerprint, context.runId(), attemptNumber, attemptStart, now,
                    ProcessingStatus.SUCCESS, null, null, false,
                    null, null, null, null, null, null, null, null, null, null,
                    resolvedFilename);

            DocumentRecord successRecord = buildSuccessRecord(
                    existingRecord, candidate, now, targetFolderLocator, resolvedFilename);

            unitOfWorkPort.executeInTransaction(txOps -> {
                txOps.saveProcessingAttempt(successAttempt);
                txOps.updateDocumentRecord(successRecord);
            });

            logger.info("Document '{}' successfully processed. Target: '{}'.",
                    candidate.uniqueIdentifier(), resolvedFilename);
            publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SUCCESS,
                    resolvedFilename,
                    proposalAttempt.resolvedDate(),
                    proposalAttempt.aiReasoning(),
                    null,
                    attemptStart, now);
            return true;

        } catch (DocumentPersistenceException e) {
            // Persistence failed after a successful copy — rollback the copy (best-effort)
            logger.error("Persistence failed after successful target copy for '{}': {}. "
                            + "Attempting best-effort rollback of target file '{}'.",
                    candidate.uniqueIdentifier(), e.getMessage(), resolvedFilename);
            targetFolderPort.tryDeleteTargetFile(resolvedFilename);

            // Persist FAILED_RETRYABLE to record the incident
            persistTransientErrorAfterPersistenceFailure(
                    candidate, fingerprint, existingRecord, context, attemptStart,
                    Instant.now(),
                    "Persistence failed after successful target copy (best-effort rollback attempted): "
                            + e.getMessage(),
                    proposalAttempt);
            return false;
        }
    }

    /**
     * Persists a transient error for a document-level technical failure during the
     * target-copy finalization stage.
     * <p>
     * The retry decision (status and updated counters) is derived via the central
     * rule in {@link ProcessingOutcomeTransition}, keeping the target-copy finalization
     * path consistent with the AI pipeline path. The transient error counter is always
     * incremented by exactly one. This method does not count the within-run immediate
     * retry — only the combined outcome of the retry is reported here.
     *
     * @return true if the error was persisted; false if the error persistence itself failed
     */
    private boolean persistTransientError(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentRecord existingRecord,
            BatchRunContext context,
            Instant attemptStart,
            Instant now,
            String errorMessage) {

        // Delegate to the central retry rule so the target-copy path and the AI pipeline
        // path are governed by the same logic without duplication.
        ProcessingOutcomeTransition.ProcessingOutcome transition =
                ProcessingOutcomeTransition.forKnownDocument(
                        new TechnicalDocumentError(candidate, errorMessage, null),
                        existingRecord.failureCounters(),
                        maxRetriesTransient);
        FailureCounters updatedCounters = transition.counters();
        ProcessingStatus errorStatus = transition.overallStatus();
        boolean retryable = transition.retryable();

        try {
            int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
            ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
                    fingerprint, context.runId(), attemptNumber, attemptStart, now,
                    errorStatus,
                    errorStatus.name(),
                    errorMessage, retryable);

            DocumentRecord errorRecord = buildTransientErrorRecord(
                    existingRecord, candidate, updatedCounters, errorStatus, now);

            unitOfWorkPort.executeInTransaction(txOps -> {
                txOps.saveProcessingAttempt(errorAttempt);
                txOps.updateDocumentRecord(errorRecord);
            });

            if (!retryable) {
                logger.warn("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — "
                                + "transient error limit reached ({}/{} attempts). No further retry.",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
                        updatedCounters.transientErrorCount(), maxRetriesTransient);
            } else {
                logger.warn("Retry decision for '{}' (fingerprint: {}): FAILED_RETRYABLE — "
                                + "transient error, will retry in later run ({}/{} attempts).",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
                        updatedCounters.transientErrorCount(), maxRetriesTransient);
            }
            publishCompletion(candidate, fingerprint,
                    retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
                            : DocumentCompletionStatus.FAILED_PERMANENT,
                    null, null, null, errorMessage, attemptStart, now);
            return true;

        } catch (DocumentPersistenceException persistEx) {
            logger.error("Failed to persist transient error for '{}': {}",
                    candidate.uniqueIdentifier(), persistEx.getMessage(), persistEx);
            return false;
        }
    }

    /**
     * Attempts to persist a transient error after a persistence failure that occurred
     * following a successful target copy. This is a secondary persistence effort;
     * its failure is logged but does not change the return value.
     * <p>
     * Applies the same transient limit check as {@link #persistTransientError} via the
     * central rule in {@link ProcessingOutcomeTransition}: if the incremented counter
     * reaches {@code maxRetriesTransient}, the secondary attempt is persisted as
     * {@link ProcessingStatus#FAILED_FINAL}.
     */
    private void persistTransientErrorAfterPersistenceFailure(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentRecord existingRecord,
            BatchRunContext context,
            Instant attemptStart,
            Instant now,
            String errorMessage,
            ProcessingAttempt proposalAttempt) {

        ProcessingOutcomeTransition.ProcessingOutcome transition =
                ProcessingOutcomeTransition.forKnownDocument(
                        new TechnicalDocumentError(candidate, errorMessage, null),
                        existingRecord.failureCounters(),
                        maxRetriesTransient);
        FailureCounters updatedCounters = transition.counters();
        ProcessingStatus errorStatus = transition.overallStatus();

        boolean secondaryPersisted = false;
        try {
            int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
            ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
                    fingerprint, context.runId(), attemptNumber, attemptStart, now,
                    errorStatus,
                    errorStatus.name(),
                    errorMessage, transition.retryable());

            DocumentRecord errorRecord = buildTransientErrorRecord(
                    existingRecord, candidate, updatedCounters, errorStatus, now);

            unitOfWorkPort.executeInTransaction(txOps -> {
                txOps.saveProcessingAttempt(errorAttempt);
                txOps.updateDocumentRecord(errorRecord);
            });
            secondaryPersisted = true;

        } catch (DocumentPersistenceException secondaryEx) {
            logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
                    candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
        }

        // Observer notification: even when secondary persistence itself failed the candidate's
        // terminal resolution in this run is still a copy/persistence failure. Emitting a single
        // completion event keeps the observer in sync with the user-visible state even though
        // nothing new was persisted.
        String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
        publishCompletion(candidate, fingerprint,
                transition.retryable()
                        ? DocumentCompletionStatus.FAILED_RETRYABLE
                        : DocumentCompletionStatus.FAILED_PERMANENT,
                null, null, reasoning, errorMessage, attemptStart, now);

        if (!secondaryPersisted) {
            logger.debug("Completion for '{}' reported without secondary persistence record.",
                    candidate.uniqueIdentifier());
        }
    }

    // =========================================================================
    // Skip path
    // =========================================================================

    /**
     * Persists a skip attempt and updates the master record's {@code updatedAt} timestamp.
     * Skip events do not change any failure counter or overall status.
     */
    private boolean persistSkipAttempt(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentRecord existingRecord,
            ProcessingStatus skipStatus,
            BatchRunContext context,
            Instant attemptStart) {

        Instant now = Instant.now();

        try {
            int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);

            ProcessingAttempt skipAttempt = ProcessingAttempt.withoutAiFields(
                    fingerprint, context.runId(), attemptNumber,
                    attemptStart, now, skipStatus,
                    null, null, false);

            DocumentRecord skipRecord = buildSkipRecord(existingRecord, candidate, now);

            unitOfWorkPort.executeInTransaction(txOps -> {
                txOps.saveProcessingAttempt(skipAttempt);
                txOps.updateDocumentRecord(skipRecord);
            });

            logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
                    attemptNumber, candidate.uniqueIdentifier(), skipStatus);
            DocumentCompletionStatus completionStatus =
                    skipStatus == ProcessingStatus.SKIPPED_ALREADY_PROCESSED
                            ? DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
                            : DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
            publishCompletion(candidate, fingerprint, completionStatus,
                    null, null, null, null, attemptStart, now);
            return true;

        } catch (DocumentPersistenceException e) {
            logger.error("Failed to persist skip attempt for '{}': {}",
                    candidate.uniqueIdentifier(), e.getMessage(), e);
            return false;
        }
    }

    // =========================================================================
    // New document path
    // =========================================================================

    /**
     * Processes a brand-new document: persists the pipeline attempt and master record,
     * then — if the pipeline produced a naming proposal — immediately continues with
     * the target-copy finalization within the same run.
     * <p>
     * The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
     * as a historised attempt before finalization, preserving the invariant that the
     * leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
     * attempt in the history.
     *
     * @return true if processing and persistence succeeded, false if a persistence failure occurred
     */
    private boolean processAndPersistNewDocument(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentProcessingOutcome pipelineOutcome,
            BatchRunContext context,
            Instant attemptStart) {

        Instant now = Instant.now();
        ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome);
        DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now);
        boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
                pipelineOutcome, txOps -> txOps.createDocumentRecord(newRecord));

        // If the pipeline yielded a naming proposal and the intermediate state was persisted
        // successfully, continue with the target-copy finalization within the same run.
        // The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
        // finalization step — no new AI call is triggered.
        if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
            return finalizeProposalReady(candidate, fingerprint, newRecord, context, attemptStart);
        }
        return persistedOk;
    }

    // =========================================================================
    // Known processable document path (non-PROPOSAL_READY)
    // =========================================================================

    /**
     * Processes a known document whose persisted status is not yet {@code PROPOSAL_READY}:
     * persists the pipeline attempt and updated master record, then — if the pipeline
     * produced a naming proposal — immediately continues with the target-copy finalization
     * within the same run.
     * <p>
     * The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
     * as a historised attempt before finalization, preserving the invariant that the
     * leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
     * attempt in the history.
     *
     * @return true if processing and persistence succeeded, false if a persistence failure occurred
     */
    private boolean processAndPersistKnownDocument(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentProcessingOutcome pipelineOutcome,
            DocumentRecord existingRecord,
            BatchRunContext context,
            Instant attemptStart) {

        Instant now = Instant.now();
        ProcessingOutcomeTransition.ProcessingOutcome outcome =
                mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters());
        DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now);
        boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
                pipelineOutcome, txOps -> txOps.updateDocumentRecord(updatedRecord));

        // If the pipeline yielded a naming proposal and the intermediate state was persisted
        // successfully, continue with the target-copy finalization within the same run.
        // The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
        // finalization step — no new AI call is triggered.
        if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
            return finalizeProposalReady(candidate, fingerprint, updatedRecord, context, attemptStart);
        }
        return persistedOk;
    }

    // =========================================================================
    // Extraction outcome mapping
    // =========================================================================

    private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForNewDocument(
            DocumentProcessingOutcome pipelineOutcome) {
        return ProcessingOutcomeTransition.forNewDocument(pipelineOutcome, maxRetriesTransient);
    }

    private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForKnownDocument(
            DocumentProcessingOutcome pipelineOutcome,
            FailureCounters existingCounters) {
        return ProcessingOutcomeTransition.forKnownDocument(pipelineOutcome, existingCounters, maxRetriesTransient);
    }

    // =========================================================================
    // Record assembly helpers
    // =========================================================================

    private DocumentRecord buildNewDocumentRecord(
            DocumentFingerprint fingerprint,
            SourceDocumentCandidate candidate,
            ProcessingOutcomeTransition.ProcessingOutcome outcome,
            Instant now) {
        boolean isProposalReady = outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY;
        return new DocumentRecord(
                fingerprint,
                new SourceDocumentLocator(candidate.locator().value()),
                candidate.uniqueIdentifier(),
                outcome.overallStatus(),
                outcome.counters(),
                isProposalReady ? null : now,  // lastFailureInstant
                null,                           // lastSuccessInstant (only on final SUCCESS)
                now,                            // createdAt
                now,                            // updatedAt
                null,                           // lastTargetPath (not yet set)
                null                            // lastTargetFileName (not yet set)
        );
    }

    private DocumentRecord buildUpdatedDocumentRecord(
            DocumentRecord existingRecord,
            SourceDocumentCandidate candidate,
            ProcessingOutcomeTransition.ProcessingOutcome outcome,
            Instant now) {
        boolean isProposalReady = outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY;
        return new DocumentRecord(
                existingRecord.fingerprint(),
                new SourceDocumentLocator(candidate.locator().value()),
                candidate.uniqueIdentifier(),
                outcome.overallStatus(),
                outcome.counters(),
                isProposalReady ? existingRecord.lastFailureInstant() : now,
                existingRecord.lastSuccessInstant(),  // success only set by target-copy finalization
                existingRecord.createdAt(),
                now,                                   // updatedAt
                existingRecord.lastTargetPath(),        // carry over, not changed here
                existingRecord.lastTargetFileName()     // carry over, not changed here
        );
    }

    private DocumentRecord buildSkipRecord(
            DocumentRecord existingRecord,
            SourceDocumentCandidate candidate,
            Instant now) {
        return new DocumentRecord(
                existingRecord.fingerprint(),
                new SourceDocumentLocator(candidate.locator().value()),
                candidate.uniqueIdentifier(),
                existingRecord.overallStatus(),
                existingRecord.failureCounters(),
                existingRecord.lastFailureInstant(),
                existingRecord.lastSuccessInstant(),
                existingRecord.createdAt(),
                now,                                   // updatedAt
                existingRecord.lastTargetPath(),
                existingRecord.lastTargetFileName()
        );
    }

    private DocumentRecord buildSuccessRecord(
            DocumentRecord existingRecord,
            SourceDocumentCandidate candidate,
            Instant now,
            String targetFolderLocator,
            String resolvedFilename) {
        return new DocumentRecord(
                existingRecord.fingerprint(),
                new SourceDocumentLocator(candidate.locator().value()),
                candidate.uniqueIdentifier(),
                ProcessingStatus.SUCCESS,
                existingRecord.failureCounters(),  // counters unchanged on success
                existingRecord.lastFailureInstant(),
                now,                               // lastSuccessInstant
                existingRecord.createdAt(),
                now,                               // updatedAt
                targetFolderLocator,               // lastTargetPath
                resolvedFilename                   // lastTargetFileName
        );
    }

    private DocumentRecord buildTransientErrorRecord(
            DocumentRecord existingRecord,
            SourceDocumentCandidate candidate,
            FailureCounters updatedCounters,
            ProcessingStatus targetStatus,
            Instant now) {
        return new DocumentRecord(
                existingRecord.fingerprint(),
                new SourceDocumentLocator(candidate.locator().value()),
                candidate.uniqueIdentifier(),
                targetStatus,
                updatedCounters,
                now,                               // lastFailureInstant
                existingRecord.lastSuccessInstant(),
                existingRecord.createdAt(),
                now,                               // updatedAt
                existingRecord.lastTargetPath(),    // carry over
                existingRecord.lastTargetFileName() // carry over
        );
    }

    // =========================================================================
    // Common persistence flow (AI pipeline path)
    // =========================================================================

    /**
     * Loads the next attempt number, builds and persists the attempt together with the
     * document record atomically.
     *
     * @return true if persistence succeeded, false if a persistence exception occurred
     */
    private boolean persistAttemptAndRecord(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            BatchRunContext context,
            Instant attemptStart,
            Instant now,
            ProcessingOutcomeTransition.ProcessingOutcome outcome,
            DocumentProcessingOutcome pipelineOutcome,
            Consumer<UnitOfWorkPort.TransactionOperations> recordWriter) {

        try {
            int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
            ProcessingAttempt attempt =
                    buildAttempt(fingerprint, context, attemptNumber, attemptStart, now,
                            outcome, pipelineOutcome);

            unitOfWorkPort.executeInTransaction(txOps -> {
                txOps.saveProcessingAttempt(attempt);
                recordWriter.accept(txOps);
            });

            if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE) {
                logger.warn("Retry decision for '{}' (fingerprint: {}): FAILED_RETRYABLE — "
                                + "will retry in later scheduler run. "
                                + "ContentErrors={}, TransientErrors={}.",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
                        outcome.counters().contentErrorCount(),
                        outcome.counters().transientErrorCount());
            } else if (outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) {
                logger.warn("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — "
                                + "permanently failed, no further retry. "
                                + "ContentErrors={}, TransientErrors={}.",
                        candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
                        outcome.counters().contentErrorCount(),
                        outcome.counters().transientErrorCount());
            } else {
                logger.info("Document '{}' processed: status={} (fingerprint: {}). "
                                + "ContentErrors={}, TransientErrors={}.",
                        candidate.uniqueIdentifier(), outcome.overallStatus(), fingerprint.sha256Hex(),
                        outcome.counters().contentErrorCount(),
                        outcome.counters().transientErrorCount());
            }
            // Pipeline-Pfad: terminale Auflösungen werden dem Progress-Observer gemeldet.
            // PROPOSAL_READY ist ein Zwischenstatus; die Finalisierung publiziert das eigentliche
            // Completion-Event (SUCCESS oder Fehler).
            if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
                String pipelineFailureMessage = null;
                if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE
                        || outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) {
                    pipelineFailureMessage = buildFailureMessage(pipelineOutcome, outcome);
                }
                publishCompletion(candidate, fingerprint, toCompletionStatus(outcome),
                        null, null, null, pipelineFailureMessage, attemptStart, now);
            }
            return true;

        } catch (DocumentPersistenceException e) {
            logger.error("Failed to persist processing result for '{}': {}",
                    candidate.uniqueIdentifier(), e.getMessage(), e);
            return false;
        }
    }

    // =========================================================================
    // Attempt builder (AI pipeline path)
    // =========================================================================

    /**
     * Constructs a {@link ProcessingAttempt} from the pipeline outcome, including AI
     * traceability fields when available. The {@code finalTargetFileName} is null for
     * all pipeline-path attempts (target copy is handled separately).
     */
    private ProcessingAttempt buildAttempt(
            DocumentFingerprint fingerprint,
            BatchRunContext context,
            int attemptNumber,
            Instant startedAt,
            Instant endedAt,
            ProcessingOutcomeTransition.ProcessingOutcome outcome,
            DocumentProcessingOutcome pipelineOutcome) {

        String failureClass = null;
        String failureMessage = null;

        if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE
                || outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) {
            failureClass = outcome.overallStatus().name();
            failureMessage = buildFailureMessage(pipelineOutcome, outcome);
        }

        return switch (pipelineOutcome) {
            case NamingProposalReady proposalReady -> {
                AiAttemptContext ctx = proposalReady.aiContext();
                NamingProposal proposal = proposalReady.proposal();
                PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
                yield new ProcessingAttempt(
                        fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
                        outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
                        activeProviderIdentifier,
                        ctx.modelName(), ctx.promptIdentifier(),
                        ctx.processedPageCount(), ctx.sentCharacterCount(),
                        ctx.aiRawResponse(),
                        proposal.aiReasoning(),
                        proposal.resolvedDate(), proposal.dateSource(), proposal.validatedTitle(),
                        null,   // finalTargetFileName — set only on SUCCESS attempts
                        ctx.inputTokens(), ctx.outputTokens(),
                        ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
                        snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
                );
            }
            case AiTechnicalFailure techFail -> {
                AiAttemptContext ctx = techFail.aiContext();
                PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
                yield new ProcessingAttempt(
                        fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
                        outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
                        activeProviderIdentifier,
                        ctx.modelName(), ctx.promptIdentifier(),
                        ctx.processedPageCount(), ctx.sentCharacterCount(),
                        ctx.aiRawResponse(),
                        null, null, null, null,
                        null,   // finalTargetFileName
                        ctx.inputTokens(), ctx.outputTokens(),
                        ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
                        snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
                );
            }
            case AiFunctionalFailure funcFail -> {
                AiAttemptContext ctx = funcFail.aiContext();
                PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
                yield new ProcessingAttempt(
                        fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
                        outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
                        activeProviderIdentifier,
                        ctx.modelName(), ctx.promptIdentifier(),
                        ctx.processedPageCount(), ctx.sentCharacterCount(),
                        ctx.aiRawResponse(),
                        null, null, null, null,
                        null,   // finalTargetFileName
                        ctx.inputTokens(), ctx.outputTokens(),
                        ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
                        snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
                );
            }
            default -> ProcessingAttempt.withoutAiFields(
                    fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
                    outcome.overallStatus(), failureClass, failureMessage, outcome.retryable()
            );
        };
    }

    /**
     * Laedt den Preis-Snapshot fuer den aktiven Provider und das uebergebene Modell.
     *
     * <p>Bei fehlendem Repository (Verdrahtung ohne Token-Tracking) liefert die
     * Methode einen leeren Snapshot ({@code (null, null)}) und protokolliert nichts.
     *
     * <p>Bei fehlendem Eintrag (kein Preis konfiguriert) gibt die Methode einen
     * leeren Snapshot zurueck und schreibt eine WARN-Logzeile. Im Headless-Modus
     * wird zusaetzlich ein Hinweis auf den CLI-Befehl ergaenzt.
     *
     * <p>Bei Lookup-Exception schreibt die Methode eine ERROR-Logzeile und liefert
     * einen leeren Snapshot. Der aufrufende Code verwendet ihn unveraendert weiter,
     * sodass der Attempt persistiert wird (Token-Daten bleiben verfuegbar, nur die
     * Preisfelder bleiben {@code null}).
     *
     * @param modelName aktiver Modellname; darf {@code null} sein (z.B. wenn kein KI-Aufruf erfolgte)
     * @return Snapshot mit Input-/Output-Preis (Nano-USD/Token) oder {@code null}-Feldern bei Fehler
     */
    private PriceSnapshot loadPriceSnapshot(String modelName) {
        if (modelPriceRepository == null || modelName == null) {
            return PriceSnapshot.empty();
        }
        try {
            java.util.Optional<ModelPriceView> view =
                    modelPriceRepository.findByProviderAndModelName(activeProviderIdentifier, modelName);
            if (view.isEmpty()) {
                if (headlessMode) {
                    logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" – "
                                    + "Tokens werden persistiert, Snapshot bleibt leer. "
                                    + "Hinweis: Modell-Preise koennen mit --upsert-model-price ergaenzt werden. Siehe betrieb.md.",
                            activeProviderIdentifier, modelName);
                } else {
                    logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" – "
                                    + "Tokens werden persistiert, Snapshot bleibt leer.",
                            activeProviderIdentifier, modelName);
                }
                return PriceSnapshot.empty();
            }
            ModelPriceView priceView = view.get();
            return new PriceSnapshot(
                    priceView.priceInputPerTokenNanoUsd(),
                    priceView.priceOutputPerTokenNanoUsd());
        } catch (RuntimeException ex) {
            logger.error("Preis-Lookup fuer Provider \"{}\" und Modell \"{}\" fehlgeschlagen: {} – "
                            + "Attempt wird mit Snapshot-Feldern auf null persistiert.",
                    activeProviderIdentifier, modelName, ex.getMessage(), ex);
            return PriceSnapshot.empty();
        }
    }

    /**
     * Halterung fuer den geladenen Preis-Snapshot.
     *
     * @param inputPriceNanoUsd  Input-Preis in Nano-USD pro Token oder {@code null}
     * @param outputPriceNanoUsd Output-Preis in Nano-USD pro Token oder {@code null}
     */
    private record PriceSnapshot(Long inputPriceNanoUsd, Long outputPriceNanoUsd) {

        /**
         * Liefert einen Snapshot ohne Werte (beide Felder {@code null}).
         *
         * @return leerer Snapshot
         */
        static PriceSnapshot empty() {
            return new PriceSnapshot(null, null);
        }
    }

    /**
     * Builds a human-readable failure message from the pipeline outcome and status outcome.
     */
    private String buildFailureMessage(
            DocumentProcessingOutcome pipelineOutcome,
            ProcessingOutcomeTransition.ProcessingOutcome outcome) {
        String base = switch (outcome.overallStatus()) {
            case FAILED_RETRYABLE -> "Processing failed (retryable). ";
            case FAILED_FINAL -> "Processing failed finally (not retryable). ";
            default -> outcome.overallStatus().name() + ". ";
        };

        String detail = switch (pipelineOutcome) {
            case de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed pf ->
                    "Reason: " + pf.failureReasonDescription();
            case de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError te ->
                    "Technical: " + te.errorMessage();
            case AiTechnicalFailure ai ->
                    "AI technical error: " + ai.errorMessage();
            case AiFunctionalFailure ai ->
                    "AI functional error: " + ai.errorMessage();
            default -> "ContentErrors=" + outcome.counters().contentErrorCount()
                    + ", TransientErrors=" + outcome.counters().transientErrorCount();
        };

        return base + detail;
    }

    // =========================================================================
    // Progress observer dispatch
    // =========================================================================

    /**
     * Publishes a single terminal completion event for the candidate to the attached
     * {@link BatchRunProgressObserver}.
     * <p>
     * Must be called exactly once per terminal resolution of a candidate (success, retryable
     * failure, permanent failure, skip). Intermediate states such as
     * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} must not
     * produce a completion event.
     * <p>
     * Any runtime exception thrown by the observer is caught and logged at warn level and must
     * not affect persistence or batch flow.
     *
     * @param candidate        die Kandidatdatei, über die berichtet wird; darf nicht null sein
     * @param fingerprint      der inhaltsbasierte Fingerprint des Dokuments; darf nicht null sein
     * @param status           der aggregierte Abschlussstatus; darf nicht null sein
     * @param finalFileName    der finale Zieldateiname bei Erfolg; sonst {@code null}
     * @param resolvedDate     das aufgelöste Datum bei Erfolg; bei Fehlern kann es {@code null} sein
     * @param aiReasoning      das KI-Reasoning, wenn für dieses Ergebnis eines vorliegt;
     *                         sonst {@code null}
     * @param failureMessage   eine lesbare Fehlerbeschreibung bei Fehler-Status; bei Erfolg
     *                         und Überspringen {@code null}
     * @param startInstant     der Zeitpunkt, zu dem die Verarbeitung des Kandidaten begann
     * @param endInstant       der Zeitpunkt der terminalen Auflösung
     */
    private void publishCompletion(
            SourceDocumentCandidate candidate,
            DocumentFingerprint fingerprint,
            DocumentCompletionStatus status,
            String finalFileName,
            LocalDate resolvedDate,
            String aiReasoning,
            String failureMessage,
            Instant startInstant,
            Instant endInstant) {
        Consumer<DocumentCompletionEvent> forwarder = completionForwarder;
        if (forwarder == null) {
            return;
        }
        Duration duration = Duration.between(startInstant, endInstant);
        if (duration.isNegative()) {
            duration = Duration.ZERO;
        }
        try {
            forwarder.accept(new DocumentCompletionEvent(
                    candidate.uniqueIdentifier(),
                    fingerprint,
                    status,
                    finalFileName,
                    resolvedDate,
                    aiReasoning,
                    failureMessage,
                    duration));
        } catch (RuntimeException forwarderFailure) {
            logger.warn("Progress forwarder beim Melden des Abschlusses für '{}' aufgetreten: {}",
                    candidate.uniqueIdentifier(), forwarderFailure.getMessage(), forwarderFailure);
        }
    }

    /**
     * Maps the aggregated retryable/terminal semantics of a pipeline-path persistence outcome
     * to the observer-level {@link DocumentCompletionStatus}.
     * <p>
     * Callers guarantee that the outcome does not represent
     * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} — that
     * intermediate state is never reported as a completion.
     */
    private static DocumentCompletionStatus toCompletionStatus(
            ProcessingOutcomeTransition.ProcessingOutcome outcome) {
        return outcome.retryable()
                ? DocumentCompletionStatus.FAILED_RETRYABLE
                : DocumentCompletionStatus.FAILED_PERMANENT;
    }
}
