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

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

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Test;

import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.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.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;

/**
 * Tests für {@link DefaultManualFileCopyUseCase}.
 * <p>
 * Alle Mocks sind handgeschrieben (kein Mockito). Jeder Test prüft das zurückgegebene
 * Ergebnis und die für das atomare Verhalten relevanten Port-Aufrufe.
 */
class DefaultManualFileCopyUseCaseTest {

    private static final DocumentFingerprint FINGERPRINT =
            new DocumentFingerprint("b".repeat(64));

    private static final String DESIRED_BASE = "2024-01-01 - Manuell benannt";
    private static final String DESIRED_FULL = DESIRED_BASE + ".pdf";

    private static final Instant FIXED_NOW = Instant.parse("2024-06-01T10:00:00Z");

    // -------------------------------------------------------------------------
    // Hilfsmethoden zum Erstellen von Testdaten
    // -------------------------------------------------------------------------

    private static DocumentRecord recordWithStatus(ProcessingStatus status) {
        return new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelle/datei.pdf"),
                "datei.pdf",
                status,
                FailureCounters.zero(),
                FIXED_NOW.minusSeconds(60),
                null,
                FIXED_NOW.minusSeconds(120),
                FIXED_NOW.minusSeconds(60),
                null,
                null);
    }

    private static DocumentRecord successRecord() {
        return new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelle/datei.pdf"),
                "datei.pdf",
                ProcessingStatus.SUCCESS,
                FailureCounters.zero(),
                null,
                FIXED_NOW.minusSeconds(60),
                FIXED_NOW.minusSeconds(120),
                FIXED_NOW.minusSeconds(60),
                "/zielordner",
                "alt.pdf");
    }

    // -------------------------------------------------------------------------
    // Stub-Helfer
    // -------------------------------------------------------------------------

    private static ProcessingLogger noOpLogger() {
        return new ProcessingLogger() {
            @Override public void info(String msg, Object... args) {
                // intentionally empty
            }
            @Override public void debug(String msg, Object... args) {
                // intentionally empty
            }
            @Override public void debugSensitiveAiContent(String msg, Object... args) {
                // intentionally empty
            }
            @Override public void warn(String msg, Object... args) {
                // intentionally empty
            }
            @Override public void error(String msg, Object... args) {
                // intentionally empty
            }
        };
    }

    private static ClockPort fixedClock() {
        return () -> FIXED_NOW;
    }

    private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) {
        return new DocumentRecordRepository() {
            @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; }
            @Override public void create(DocumentRecord r) {
                // intentionally empty
            }
            @Override public void update(DocumentRecord r) {
                // intentionally empty
            }
            @Override public void deleteByFingerprint(DocumentFingerprint fp) {
                // intentionally empty
            }
        };
    }

    private static TargetFolderPort folderPortReturning(TargetFilenameResolutionResult result) {
        return new TargetFolderPort() {
            @Override public String getTargetFolderLocator() { return "/zielordner"; }
            @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; }
            @Override public void tryDeleteTargetFile(String name) {
                // intentionally empty
            }
        };
    }

    private static TargetFileCopyPort copyPortReturning(TargetFileCopyResult result) {
        return (sourceLocator, resolvedFilename) -> result;
    }

    private static UnitOfWorkPort alwaysSucceedingUnitOfWork() {
        return ops -> ops.accept(new NoOpTransactionOperations());
    }

    private static UnitOfWorkPort throwingUnitOfWork(RuntimeException ex) {
        return ops -> { throw ex; };
    }

    // -------------------------------------------------------------------------
    // Testfall 1: Erfolgreicher Pfad ohne Konflikt (FAILED_FINAL als Eingangsstatus)
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsSuccess_andUpdatesRecordToSuccess_whenNoConflict() {
        List<DocumentRecord> updatedRecords = new ArrayList<>();
        UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                uow,
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
        ManualFileCopySuccess success = (ManualFileCopySuccess) result;
        assertThat(success.appliedFileName()).isEqualTo(DESIRED_FULL);
        assertThat(success.conflictSuffixApplied()).isFalse();

        assertThat(updatedRecords).hasSize(1);
        DocumentRecord updated = updatedRecords.get(0);
        assertThat(updated.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
        assertThat(updated.lastTargetFileName()).isEqualTo(DESIRED_FULL);
        assertThat(updated.lastTargetPath()).isEqualTo("/zielordner");
        assertThat(updated.lastSuccessInstant()).isEqualTo(FIXED_NOW);
        assertThat(updated.updatedAt()).isEqualTo(FIXED_NOW);
    }

    // -------------------------------------------------------------------------
    // Testfall 2: Konflikt mit anderer Datei → Suffix angewendet
    // -------------------------------------------------------------------------

    @Test
    void copy_appliesSuffix_whenConflictWithDifferentFingerprint() {
        String suffixedName = DESIRED_BASE + "(1).pdf";

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPortReturning(new ResolvedTargetFilename(suffixedName)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
        ManualFileCopySuccess success = (ManualFileCopySuccess) result;
        assertThat(success.appliedFileName()).isEqualTo(suffixedName);
        assertThat(success.conflictSuffixApplied()).isTrue();
    }

    // -------------------------------------------------------------------------
    // Testfall 3: Identische Zieldatei vorhanden → No-Op, Stammsatz wird trotzdem
    // auf SUCCESS gehoben
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsNoOp_andUpdatesRecord_whenTargetFolderReportsIdenticalContent() {
        List<DocumentRecord> updatedRecords = new ArrayList<>();
        List<String> copyAttempts = new ArrayList<>();

        TargetFileCopyPort copyPort = (locator, name) -> {
            copyAttempts.add(name);
            return new TargetFileCopySuccess();
        };
        UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPortReturning(new ExistingIdenticalTargetFile(DESIRED_FULL)),
                copyPort,
                uow,
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyNoOpIdenticalTarget.class);
        assertThat(((ManualFileCopyNoOpIdenticalTarget) result).existingFileName()).isEqualTo(DESIRED_FULL);

        // Es darf KEIN Schreibvorgang erfolgen, wenn identischer Inhalt schon existiert
        assertThat(copyAttempts).isEmpty();

        // Stammsatz wurde dennoch konsistent fortgeschrieben
        assertThat(updatedRecords).hasSize(1);
        assertThat(updatedRecords.get(0).overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
        assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(DESIRED_FULL);
    }

    // -------------------------------------------------------------------------
    // Testfall 4: Dokument unbekannt → DocumentNotFound
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsDocumentNotFound_whenRepositoryReturnsDocumentUnknown() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyDocumentNotFound.class);
    }

    // -------------------------------------------------------------------------
    // Testfall 5: Dokument bereits SUCCESS → InvalidState
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsInvalidState_whenDocumentAlreadySuccess() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord())),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyInvalidState.class);
    }

    // -------------------------------------------------------------------------
    // Testfall 6: DocumentKnownProcessable mit FAILED_RETRYABLE → Erfolg
    // -------------------------------------------------------------------------

    @Test
    void copy_acceptsDocumentKnownProcessable_withFailedRetryable() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentKnownProcessable(
                        recordWithStatus(ProcessingStatus.FAILED_RETRYABLE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
    }

    // -------------------------------------------------------------------------
    // Testfall 7: Lookup-Fehler → PersistenceFailure
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsPersistenceFailure_whenLookupFails() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new PersistenceLookupTechnicalFailure(
                        "DB nicht erreichbar", new RuntimeException("boom"))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
    }

    // -------------------------------------------------------------------------
    // Testfall 8: Zielordnerzugriff scheitert → FileSystemFailure
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsFileSystemFailure_whenTargetFolderTechnicalFailure() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPortReturning(new TargetFolderTechnicalFailure("Laufwerk weg")),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class);
        assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Laufwerk weg");
    }

    // -------------------------------------------------------------------------
    // Testfall 9: Kopie scheitert → FileSystemFailure
    // -------------------------------------------------------------------------

    @Test
    void copy_returnsFileSystemFailure_whenCopyFails() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopyTechnicalFailure(
                        "Quelldatei nicht lesbar", true)),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class);
        assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Quelldatei nicht lesbar");
    }

    // -------------------------------------------------------------------------
    // Testfall 10: Persistenzfehler nach erfolgreicher Kopie → Best-Effort-Rollback
    // -------------------------------------------------------------------------

    @Test
    void copy_attemptsTargetFileDeletion_whenPersistenceFails() {
        List<String> deletedFiles = new ArrayList<>();
        TargetFolderPort folderPort = new TargetFolderPort() {
            @Override public String getTargetFolderLocator() { return "/zielordner"; }
            @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
                return new ResolvedTargetFilename(DESIRED_FULL);
            }
            @Override public void tryDeleteTargetFile(String name) {
                deletedFiles.add(name);
            }
        };

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPort,
                copyPortReturning(new TargetFileCopySuccess()),
                throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
        // Best-Effort-Rollback: gerade geschriebene Datei löschen
        assertThat(deletedFiles).containsExactly(DESIRED_FULL);
    }

    // -------------------------------------------------------------------------
    // Testfall 11: Persistenzfehler bei No-Op (identischer Inhalt) → KEIN Löschen
    // -------------------------------------------------------------------------

    @Test
    void copy_doesNotDeleteIdenticalTargetFile_whenPersistenceFailsAfterNoOp() {
        List<String> deletedFiles = new ArrayList<>();
        TargetFolderPort folderPort = new TargetFolderPort() {
            @Override public String getTargetFolderLocator() { return "/zielordner"; }
            @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
                return new ExistingIdenticalTargetFile(DESIRED_FULL);
            }
            @Override public void tryDeleteTargetFile(String name) {
                deletedFiles.add(name);
            }
        };

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPort,
                copyPortReturning(new TargetFileCopySuccess()),
                throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")),
                fixedClock(),
                noOpLogger());

        ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
        // Identische, vor dem Lauf bereits vorhandene Datei darf nicht gelöscht werden
        assertThat(deletedFiles).isEmpty();
    }

    // -------------------------------------------------------------------------
    // Testfall 12: .pdf-Erweiterung wird automatisch angehängt
    // -------------------------------------------------------------------------

    @Test
    void copy_appendsPdfExtensionAutomatically() {
        List<String> baseNames = new ArrayList<>();
        TargetFolderPort folderPort = new TargetFolderPort() {
            @Override public String getTargetFolderLocator() { return "/zielordner"; }
            @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
                baseNames.add(baseName);
                return new ResolvedTargetFilename(baseName);
            }
            @Override public void tryDeleteTargetFile(String name) {
                // intentionally empty
            }
        };

        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(
                        recordWithStatus(ProcessingStatus.FAILED_FINAL))),
                folderPort,
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        useCase.copy(new ManualFileCopyRequest(FINGERPRINT, "Ohne Erweiterung"));

        assertThat(baseNames).hasSize(1);
        assertThat(baseNames.get(0)).isEqualTo("Ohne Erweiterung.pdf");
    }

    // -------------------------------------------------------------------------
    // Konstruktor-Null-Guards
    // -------------------------------------------------------------------------

    @Test
    void constructor_rejectsNullRepository() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                null,
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullTargetFolderPort() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                null,
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullTargetFileCopyPort() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                null,
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullUnitOfWorkPort() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                null,
                fixedClock(),
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullClock() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                null,
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullLogger() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                null));
    }

    @Test
    void copy_rejectsNullRequest() {
        DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                copyPortReturning(new TargetFileCopySuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        assertThatNullPointerException().isThrownBy(() -> useCase.copy(null));
    }

    // -------------------------------------------------------------------------
    // Hilfsklassen
    // -------------------------------------------------------------------------

    private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations {
        @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
            // intentionally empty
        }
        @Override public void createDocumentRecord(DocumentRecord record) {
            // intentionally empty
        }
        @Override public void updateDocumentRecord(DocumentRecord record) {
            // intentionally empty
        }
        @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
        @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
    }

    private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
        private final List<DocumentRecord> captured;

        RecordCapturingTransactionOperations(List<DocumentRecord> captured) {
            this.captured = captured;
        }

        @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
            // intentionally empty
        }
        @Override public void createDocumentRecord(DocumentRecord record) {
            // intentionally empty
        }
        @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
        @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
        @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
            // intentionally empty
        }
    }
}
