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.ManualFileRenameDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenamePersistenceFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFileMissing;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
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.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.TargetFileRenameFailureFileNotFound;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
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 DefaultManualFileRenameUseCase}.
 * <p>
 * Alle Mocks sind handgeschrieben (kein Mockito). Jeder Test prüft ausschließlich
 * das zurückgegebene Ergebnis sowie die an die Mock-Ports weitergegebenen Parameter.
 * Protokollaufrufe werden nicht verifiziert.
 */
class DefaultManualFileRenameUseCaseTest {

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

    private static final String CURRENT_FILE = "2024-01-01 - Rechnung.pdf";
    private static final String DESIRED_BASE = "2024-01-01 - Korrigierte Rechnung";
    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 successRecord(String lastTargetFileName) {
        return new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelldatei.pdf"),
                "quelldatei.pdf",
                ProcessingStatus.SUCCESS,
                FailureCounters.zero(),
                null,
                FIXED_NOW.minusSeconds(60),
                FIXED_NOW.minusSeconds(120),
                FIXED_NOW.minusSeconds(60),
                "/zielordner",
                lastTargetFileName);
    }

    private static DocumentRecord successRecordWithoutTargetFile() {
        return new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelldatei.pdf"),
                "quelldatei.pdf",
                ProcessingStatus.SUCCESS,
                FailureCounters.zero(),
                null,
                FIXED_NOW.minusSeconds(60),
                FIXED_NOW.minusSeconds(120),
                FIXED_NOW.minusSeconds(60),
                null,
                null);
    }

    private static DocumentRecord failedRecord() {
        return new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelldatei.pdf"),
                "quelldatei.pdf",
                ProcessingStatus.FAILED_FINAL,
                FailureCounters.zero(),
                FIXED_NOW.minusSeconds(60),
                null,
                FIXED_NOW.minusSeconds(120),
                FIXED_NOW.minusSeconds(60),
                null,
                null);
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden zum Erstellen von Stubs
    // -------------------------------------------------------------------------

    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 TargetFileRenamePort renamePortReturning(TargetFileRenameResult result) {
        return (oldName, newName) -> 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
    // -------------------------------------------------------------------------

    @Test
    void rename_delegatesToAllPortsAndReturnsSuccess_whenNoConflict() {
        List<String[]> renameArgs = new ArrayList<>();
        List<DocumentRecord> updatedRecords = new ArrayList<>();

        TargetFileRenamePort renamePort = (oldName, newName) -> {
            renameArgs.add(new String[]{oldName, newName});
            return new TargetFileRenameSuccess();
        };

        UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePort,
                uow,
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameSuccess.class);
        ManualFileRenameSuccess success = (ManualFileRenameSuccess) result;
        assertThat(success.previousFileName()).isEqualTo(CURRENT_FILE);
        assertThat(success.appliedFileName()).isEqualTo(DESIRED_FULL);
        assertThat(success.conflictSuffixApplied()).isFalse();

        assertThat(renameArgs).hasSize(1);
        assertThat(renameArgs.get(0)[0]).isEqualTo(CURRENT_FILE);
        assertThat(renameArgs.get(0)[1]).isEqualTo(DESIRED_FULL);

        assertThat(updatedRecords).hasSize(1);
        assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(DESIRED_FULL);
        assertThat(updatedRecords.get(0).updatedAt()).isEqualTo(FIXED_NOW);
    }

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

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

        List<DocumentRecord> updatedRecords = new ArrayList<>();
        UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(suffixedName)),
                renamePortReturning(new TargetFileRenameSuccess()),
                uow,
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

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

        assertThat(updatedRecords).hasSize(1);
        assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(suffixedName);
    }

    // -------------------------------------------------------------------------
    // Testfall 3: No-Op – gewünschter Name ist identisch mit aktuellem
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsNoOp_whenNewNameEqualsCurrent() {
        String currentName = DESIRED_FULL; // Gleicher Name wie gewünscht

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(currentName))),
                folderPortReturning(new ResolvedTargetFilename("wird nicht aufgerufen.pdf")),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameNoOpIdenticalTarget.class);
        ManualFileRenameNoOpIdenticalTarget noOp = (ManualFileRenameNoOpIdenticalTarget) result;
        assertThat(noOp.existingFileName()).isEqualTo(currentName);
    }

    // -------------------------------------------------------------------------
    // Testfall 4: No-Op – TargetFolderPort meldet identischen Inhalt
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsNoOp_whenTargetFolderReportsIdenticalContent() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ExistingIdenticalTargetFile(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameNoOpIdenticalTarget.class);
        assertThat(((ManualFileRenameNoOpIdenticalTarget) result).existingFileName()).isEqualTo(DESIRED_FULL);
    }

    // -------------------------------------------------------------------------
    // Testfall 5: Dokument nicht gefunden
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsDocumentNotFound_whenRepositoryReturnsDocumentUnknown() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

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

    // -------------------------------------------------------------------------
    // Testfall 6: Ungültiger Zustand – kein Zieldateiname vorhanden
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsInvalidState_whenDocumentHasNoTargetFilename() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecordWithoutTargetFile())),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

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

    // -------------------------------------------------------------------------
    // Testfall 7: Ungültiger Zustand – Status ist nicht SUCCESS
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsInvalidState_whenStatusIsNotSuccess() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalFinalFailure(failedRecord())),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

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

    // -------------------------------------------------------------------------
    // Testfall 8: Bisherige Zieldatei nicht mehr vorhanden
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsSourceFileMissing_whenOldFileGone() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameFailureFileNotFound(CURRENT_FILE)),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameSourceFileMissing.class);
        assertThat(((ManualFileRenameSourceFileMissing) result).expectedFileName()).isEqualTo(CURRENT_FILE);
    }

    // -------------------------------------------------------------------------
    // Testfall 9: Technischer Dateisystemfehler beim Umbenennen
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsFileSystemFailure_whenRenameHasTechnicalError() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameTechnicalFailure("Datei gesperrt")),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameFileSystemFailure.class);
        assertThat(((ManualFileRenameFileSystemFailure) result).message()).contains("Datei gesperrt");
    }

    // -------------------------------------------------------------------------
    // Testfall 10: Persistenzfehler → Rollback der Umbenennung
    // -------------------------------------------------------------------------

    @Test
    void rename_rollsBackRename_whenPersistenceFails() {
        List<String[]> renameArgs = new ArrayList<>();

        TargetFileRenamePort renamePort = (oldName, newName) -> {
            renameArgs.add(new String[]{oldName, newName});
            return new TargetFileRenameSuccess();
        };

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePort,
                throwingUnitOfWork(new DocumentPersistenceException("DB nicht erreichbar")),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        // Ergebnis ist PersistenceFailure
        assertThat(result).isInstanceOf(ManualFileRenamePersistenceFailure.class);

        // Erster Aufruf: Eigentliche Umbenennung
        assertThat(renameArgs.get(0)[0]).isEqualTo(CURRENT_FILE);
        assertThat(renameArgs.get(0)[1]).isEqualTo(DESIRED_FULL);

        // Zweiter Aufruf: Rollback
        assertThat(renameArgs).hasSize(2);
        assertThat(renameArgs.get(1)[0]).isEqualTo(DESIRED_FULL);
        assertThat(renameArgs.get(1)[1]).isEqualTo(CURRENT_FILE);
    }

    // -------------------------------------------------------------------------
    // Testfall 11: Persistenzfehler + Rollback schlägt fehl → PersistenceFailure
    // -------------------------------------------------------------------------

    @Test
    void rename_logsErrorButStillReturnsPersistenceFailure_whenRollbackRenameAlsoFails() {
        List<String[]> renameArgs = new ArrayList<>();

        TargetFileRenamePort renamePort = (oldName, newName) -> {
            renameArgs.add(new String[]{oldName, newName});
            // Erster Aufruf: Erfolg; Zweiter Aufruf (Rollback): technischer Fehler
            if (renameArgs.size() == 1) {
                return new TargetFileRenameSuccess();
            } else {
                return new TargetFileRenameTechnicalFailure("Rollback fehlgeschlagen");
            }
        };

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePort,
                throwingUnitOfWork(new DocumentPersistenceException("DB-Fehler")),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        // Trotz Rollback-Fehler: Ergebnis bleibt PersistenceFailure
        assertThat(result).isInstanceOf(ManualFileRenamePersistenceFailure.class);
        // Rollback wurde versucht (2 Aufrufe insgesamt)
        assertThat(renameArgs).hasSize(2);
    }

    // -------------------------------------------------------------------------
    // Testfall 12: Technischer Fehler beim Zielordner-Zugriff
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsFileSystemFailure_whenTargetFolderTechnicalFailure() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPortReturning(new TargetFolderTechnicalFailure("Laufwerk nicht erreichbar")),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

        assertThat(result).isInstanceOf(ManualFileRenameFileSystemFailure.class);
        assertThat(((ManualFileRenameFileSystemFailure) result).message()).contains("Laufwerk nicht erreichbar");
    }

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

    @Test
    void rename_appendsPdfExtensionAutomatically() {
        List<String[]> folderArgs = new ArrayList<>();

        TargetFolderPort folderPort = new TargetFolderPort() {
            @Override public String getTargetFolderLocator() { return "/zielordner"; }
            @Override
            public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
                folderArgs.add(new String[]{baseName});
                return new ResolvedTargetFilename(baseName);
            }
            @Override public void tryDeleteTargetFile(String name) {
                // intentionally empty
            }
        };

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
                folderPort,
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        // Eingabe ohne .pdf-Erweiterung
        useCase.rename(new ManualFileRenameRequest(FINGERPRINT, "2024-01-01 - Ohne Erweiterung"));

        // Genau einmal aufgerufen, mit .pdf-Erweiterung
        assertThat(folderArgs).hasSize(1);
        assertThat(folderArgs.get(0)[0]).endsWith(".pdf");
        assertThat(folderArgs.get(0)[0]).isEqualTo("2024-01-01 - Ohne Erweiterung.pdf");
    }

    // -------------------------------------------------------------------------
    // Testfall: Nicht-processable Dokumentstatus → DocumentNotFound
    // -------------------------------------------------------------------------

    @Test
    void rename_returnsDocumentNotFound_whenDocumentIsKnownProcessable() {
        DocumentRecord knownRecord = new DocumentRecord(
                FINGERPRINT,
                new SourceDocumentLocator("/quelldatei.pdf"),
                "quelldatei.pdf",
                ProcessingStatus.FAILED_RETRYABLE,
                FailureCounters.zero(),
                FIXED_NOW,
                null,
                FIXED_NOW,
                FIXED_NOW,
                null,
                null);

        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentKnownProcessable(knownRecord)),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

        ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));

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

    // -------------------------------------------------------------------------
    // Testfall: Konstruktor-Null-Guards
    // -------------------------------------------------------------------------

    @Test
    void constructor_rejectsNullRepository() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
                null,
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger()));
    }

    @Test
    void constructor_rejectsNullTargetFolderPort() {
        assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentUnknown()),
                null,
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger()));
    }

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

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

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

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

    @Test
    void rename_rejectsNullRequest() {
        DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
                repositoryReturning(new DocumentUnknown()),
                folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
                renamePortReturning(new TargetFileRenameSuccess()),
                alwaysSucceedingUnitOfWork(),
                fixedClock(),
                noOpLogger());

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

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

    /** Führt keine Persistenzoperationen durch. */
    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
        }
    }

    /** Zeichnet updateDocumentRecord-Aufrufe auf. */
    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
        }
    }
}
