package org.raddatz.familienarchiv.document;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
import org.raddatz.familienarchiv.document.DocumentListItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
import org.raddatz.familienarchiv.document.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.document.MatchOffset;
import org.raddatz.familienarchiv.document.SearchMatchData;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.tag.TagService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.mock.web.MockMultipartFile;

import java.time.LocalDate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class DocumentServiceTest {

    // Used by tests that don't care about paging. 10 000 is chosen large enough
    // to hold any fixture in this file but small enough that totalPages math
    // stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
    // red flag — use this constant.
    private static final Pageable UNPAGED = PageRequest.of(0, 10_000);

    @Mock DocumentRepository documentRepository;
    @Mock PersonService personService;
    @Mock FileService fileService;
    @Mock TagService tagService;
    @Mock DocumentVersionService documentVersionService;
    @Mock AnnotationService annotationService;
    @Mock AuditService auditService;
    @Mock AuditLogQueryService auditLogQueryService;
    @Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
    @Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
    @Mock ApplicationEventPublisher eventPublisher;
    // Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
    // shared composition rather than a stub — the #726 single source of truth.
    @Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
    @InjectMocks DocumentService documentService;

    // ─── deleteDocument ───────────────────────────────────────────────────────

    @Test
    void deleteDocument_deletesById_whenExists() {
        UUID id = UUID.randomUUID();
        when(documentRepository.existsById(id)).thenReturn(true);

        documentService.deleteDocument(id, UUID.randomUUID());

        verify(documentRepository).deleteById(id);
    }

    @Test
    void deleteDocument_throwsNotFound_whenMissing() {
        UUID id = UUID.randomUUID();
        when(documentRepository.existsById(id)).thenReturn(false);

        assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining(id.toString());
        verify(documentRepository, never()).deleteById(any());
    }

    // ─── getDocumentById ──────────────────────────────────────────────────────

    @Test
    void getDocumentById_throwsNotFound_whenMissing() {
        UUID id = UUID.randomUUID();
        when(documentRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> documentService.getDocumentById(id))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining(id.toString());
    }

    @Test
    void getDocumentById_returnsDocument_whenFound() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Test").build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));

        assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
    }

    @Test
    void getDocumentById_doesNotQueryTranscription() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Test").build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));

        documentService.getDocumentById(id);

        verifyNoInteractions(transcriptionBlockQueryService);
    }

    @Test
    void getDocumentDetail_setsHasTranscriptionTrue_whenBlocksExist() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Test").build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true);

        assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isTrue();
    }

    @Test
    void getDocumentDetail_setsHasTranscriptionFalse_whenNoBlocksExist() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Test").build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false);

        assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isFalse();
    }

    // ─── updateDocument ───────────────────────────────────────────────────────

    @Test
    void updateDocument_throwsNotFound_whenMissing() {
        UUID id = UUID.randomUUID();
        when(documentRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> documentService.updateDocument(id, new DocumentUpdateDTO(), null, null))
                .isInstanceOf(DomainException.class);
    }

    @Test
    void updateDocument_setsArchiveBoxAndFolder() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setArchiveBox("K-03");
        dto.setArchiveFolder("Mappe B");

        documentService.updateDocument(id, dto, null, null);

        assertThat(doc.getArchiveBox()).isEqualTo("K-03");
        assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
    }

    @Test
    void updateDocument_persistsDatePrecisionEndAndRaw() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setDocumentDate(LocalDate.of(1917, 1, 10));
        dto.setMetaDatePrecision(DatePrecision.RANGE);
        dto.setMetaDateEnd(LocalDate.of(1917, 1, 11));
        dto.setMetaDateRaw("10.–11. Januar 1917");

        documentService.updateDocument(id, dto, null, null);

        assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
        assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
        assertThat(doc.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
    }

    @Test
    void updateDocument_preservesStoredPrecision_whenDtoOmitsIt() throws Exception {
        // Editing a doc (e.g. fixing a location typo) without touching the precision
        // controls must NOT fabricate a precision. The form omits the three precision
        // fields → they arrive null on the DTO → the stored values must be preserved.
        // Stored combo is RANGE + end: the only DB-valid way to have a non-null end
        // (chk_meta_date_end_only_for_range), so the carried-over state passes the guard.
        UUID id = UUID.randomUUID();
        Document doc = Document.builder()
                .id(id)
                .metaDatePrecision(DatePrecision.RANGE)
                .metaDateEnd(LocalDate.of(1916, 6, 30))
                .metaDateRaw("Juni 1916")
                .receivers(new HashSet<>())
                .tags(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setLocation("Berlin"); // unrelated edit; precision fields left null

        documentService.updateDocument(id, dto, null, null);

        assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
        assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
        assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
    }

    // ─── updateDocument save-time auto-title regeneration (#726) ──────────────
    //
    // Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
    // title equals what the factory builds from the CURRENTLY-persisted state. The edit form
    // round-trips the stored title verbatim when untouched, so an equal submission means the
    // user did not type over it. makeStored() seeds index/date/precision/location and sets the
    // stored title to the matching auto-title, mirroring a freshly-imported row.

    private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
        Document doc = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename(index)
                .documentDate(date)
                .metaDatePrecision(precision)
                .location(location)
                .receivers(new HashSet<>())
                .tags(new HashSet<>())
                .build();
        doc.setTitle(documentTitleFactory.build(doc));
        return doc;
    }

    /** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
    private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
                                             DatePrecision precision, String location) {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle(submittedTitle);
        dto.setDocumentDate(date);
        dto.setMetaDatePrecision(precision);
        dto.setLocation(location);
        return dto;
    }

    private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
        when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
        when(documentRepository.save(any())).thenReturn(stored);
        documentService.updateDocument(stored.getId(), dto, null, null);
        return stored;
    }

    @Test
    void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
        // title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
    }

    @Test
    void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
        stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title
        DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter");
    }

    @Test
    void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
        // user changed the date AND typed a new title in the same save
        DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
    }

    @Test
    void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München");
    }

    @Test
    void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        // location cleared (null), title untouched
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
    }

    @Test
    void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).doesNotContain("2028");
    }

    @Test
    void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
        // stored auto-title "C-0029 – 1928"; set a full day at DAY precision
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928");
    }

    @Test
    void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
        Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
        // stored auto-title is just "C-0029"; add a 1928 YEAR date
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
    }

    @Test
    void updateDocument_roundTripsSeasonLabel() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
        stored.setMetaDateRaw("Frühling 1943");
        stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
        dto.setMetaDateRaw("Frühling 1943");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
    }

    @Test
    void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
        // Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
        // must carry them from the entity (exercises the skip-null effective* resolvers).
        Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
        stored.setMetaDateRaw("Frühling 1943");
        stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
        DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1944");
    }

    @Test
    void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
        Document stored = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename("C-0029")
                .documentDate(LocalDate.of(1917, 1, 10))
                .metaDatePrecision(DatePrecision.RANGE)
                .metaDateEnd(LocalDate.of(1917, 1, 11))
                .receivers(new HashSet<>())
                .tags(new HashSet<>())
                .build();
        stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – 10.–11. Jan. 1917"
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle(stored.getTitle());
        dto.setDocumentDate(LocalDate.of(1918, 1, 10));
        dto.setMetaDatePrecision(DatePrecision.RANGE);
        dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 10.–11. Jan. 1918");
    }

    @Test
    void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isNotBlank();
    }

    @Test
    void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
        // originalFilename was reassigned by an earlier file-replace, so the stored title (built
        // at import from the old index) no longer matches build(currentState) → treated as manual.
        Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
        DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
    }

    @Test
    void updateDocument_idempotent_whenNothingChanges() throws Exception {
        Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        String before = stored.getTitle();
        DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");

        runUpdate(stored, dto);

        assertThat(stored.getTitle()).isEqualTo(before);
    }

    // ─── updateDocument date-range validation (#678) ──────────────────────────

    /** Builds a stored doc ready for an updateDocument call (collections initialised). */
    private static Document docForRangeUpdate(UUID id) {
        return Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
    }

    private static DocumentUpdateDTO rangeDto(LocalDate start, LocalDate end) {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setDocumentDate(start);
        dto.setMetaDatePrecision(DatePrecision.RANGE);
        dto.setMetaDateEnd(end);
        return dto;
    }

    @Test
    void updateDocument_rejectsRange_whenEndBeforeStart() {
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));

        DocumentUpdateDTO dto = rangeDto(LocalDate.of(1917, 1, 11), LocalDate.of(1917, 1, 10));

        assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(documentRepository, never()).save(any());
    }

    @Test
    void updateDocument_acceptsRange_whenEndEqualsStart() throws Exception {
        // AC2: the DB CHECK is end >= start, so equal dates are valid.
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        LocalDate same = LocalDate.of(1917, 1, 10);
        documentService.updateDocument(id, rangeDto(same, same), null, null);

        assertThat(doc.getMetaDateEnd()).isEqualTo(same);
        verify(documentRepository, atLeastOnce()).save(any());
    }

    @Test
    void updateDocument_acceptsRange_whenEndAfterStart() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.updateDocument(id,
                rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null);

        assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
        verify(documentRepository, atLeastOnce()).save(any());
    }

    @Test
    void updateDocument_acceptsRange_whenEndIsNull_openEnded() throws Exception {
        // AC3: an open-ended range (no end) is valid.
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.updateDocument(id,
                rangeDto(LocalDate.of(1917, 1, 10), null), null, null);

        verify(documentRepository, atLeastOnce()).save(any());
    }

    @Test
    void updateDocument_acceptsRange_whenStartNullAndEndSet() throws Exception {
        // AC4: mirrors the DB "meta_date IS NULL" escape — must NOT reject (and must not NPE).
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.updateDocument(id,
                rangeDto(null, LocalDate.of(1917, 1, 11)), null, null);

        assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
        verify(documentRepository, atLeastOnce()).save(any());
    }

    @Test
    void updateDocument_rejectsEndDate_whenPrecisionNotRange() {
        // AC6: an end date only makes sense for RANGE (mirrors chk_meta_date_end_only_for_range).
        // API-only — the edit form clears the end field off-RANGE — so close the 500 class here too.
        UUID id = UUID.randomUUID();
        Document doc = docForRangeUpdate(id);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setDocumentDate(LocalDate.of(1917, 1, 10));
        dto.setMetaDatePrecision(DatePrecision.MONTH);
        dto.setMetaDateEnd(LocalDate.of(1917, 1, 31));

        assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVALID_DATE_RANGE);
        verify(documentRepository, never()).save(any());
    }

    // ─── deleteTagCascading ───────────────────────────────────────────────────

    @Test
    void deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag() {
        UUID tagId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Familie").build();
        Document doc = Document.builder()
                .id(UUID.randomUUID())
                .tags(new HashSet<>(Set.of(tag)))
                .build();

        when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.deleteTagCascading(tagId);

        assertThat(doc.getTags()).isEmpty();
        verify(tagService).delete(tagId);
    }

    @Test
    void deleteTagCascading_worksWhenNoDocumentsHaveTag() {
        UUID tagId = UUID.randomUUID();
        when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of());

        documentService.deleteTagCascading(tagId);

        verify(documentRepository, never()).save(any());
        verify(tagService).delete(tagId);
    }

    // ─── createPlaceholder ────────────────────────────────────────────────────

    @Test
    void createPlaceholder_returnsExisting_whenAlreadyExists() {
        String filename = "scan001.pdf";
        Document existing = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
        when(documentRepository.existsByOriginalFilename(filename)).thenReturn(true);
        when(documentRepository.findByOriginalFilename(filename)).thenReturn(Optional.of(existing));

        Document result = documentService.createPlaceholder(filename);

        assertThat(result).isEqualTo(existing);
        verify(documentRepository, never()).save(any());
    }

    @Test
    void createPlaceholder_createsNew_whenNotExists() {
        String filename = "scan002.pdf";
        Document saved = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
        when(documentRepository.existsByOriginalFilename(filename)).thenReturn(false);
        when(documentRepository.save(any())).thenReturn(saved);

        Document result = documentService.createPlaceholder(filename);

        assertThat(result).isEqualTo(saved);
        verify(documentRepository).save(any());
    }

    // ─── getDocumentsByReceiver ───────────────────────────────────────────────

    @Test
    void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
        UUID receiverId = UUID.randomUUID();
        Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
        when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));

        assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
    }

    // ─── file hash propagation ───────────────────────────────────────────────

    @Test
    void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");

        Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(savedDoc);
        when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
        when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);

        documentService.createDocument(dto, file);

        org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
    }

    @Test
    void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder()
                .id(id).title("Alt").originalFilename("old.pdf")
                .status(DocumentStatus.UPLOADED).build();
        org.springframework.mock.web.MockMultipartFile newFile =
                new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
        FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");

        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
        when(documentRepository.save(any())).thenReturn(existing);

        documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);

        assertThat(existing.getFileHash()).isEqualTo("cafebabe");
    }

    // ─── versioning ───────────────────────────────────────────────────────────

    @Test
    void createDocument_recordsVersionAfterSave() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Neuer Brief");

        Document saved = Document.builder()
                .id(UUID.randomUUID()).title("Neuer Brief")
                .originalFilename("Neuer Brief").status(DocumentStatus.PLACEHOLDER)
                .build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        documentService.createDocument(dto, null);

        verify(documentVersionService, atLeastOnce()).recordVersion(any(Document.class));
    }

    @Test
    void updateDocument_recordsVersionAfterSave() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder()
                .id(id).title("Alt").originalFilename("alt.pdf")
                .status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);

        documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);

        verify(documentVersionService).recordVersion(any(Document.class));
    }

    // ─── backfillTitles — one-time stale-title cleanup (#726, FR-003) ─────────

    @Test
    void backfillTitles_rewritesStaleAutoTitle_andCountsIt() {
        Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        stale.setTitle("C-0029 – 2028 – Berlin"); // stale stored title (date typo never fixed)
        when(documentRepository.findAll()).thenReturn(List.of(stale));
        when(documentRepository.save(any())).thenReturn(stale);

        int count = documentService.backfillTitles();

        assertThat(count).isEqualTo(1);
        assertThat(stale.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
        verify(documentRepository).save(stale);
    }

    @Test
    void backfillTitles_skipsProse() {
        Document prose = makeStored("C-0030", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
        prose.setTitle("C-0030 – Brief an Mutter");
        when(documentRepository.findAll()).thenReturn(List.of(prose));

        int count = documentService.backfillTitles();

        assertThat(count).isZero();
        assertThat(prose.getTitle()).isEqualTo("C-0030 – Brief an Mutter");
        verify(documentRepository, never()).save(any());
    }

    @Test
    void backfillTitles_isIdempotent_forAlreadyCorrectTitle() {
        Document fresh = makeStored("C-0031", LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
        // title already equals build(current state) → nothing to do
        when(documentRepository.findAll()).thenReturn(List.of(fresh));

        int count = documentService.backfillTitles();

        assertThat(count).isZero();
        verify(documentRepository, never()).save(any());
    }

    @Test
    void backfillTitles_neverRecordsVersions() {
        Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
        stale.setTitle("C-0029 – 2028 – Berlin");
        when(documentRepository.findAll()).thenReturn(List.of(stale));
        when(documentRepository.save(any())).thenReturn(stale);

        documentService.backfillTitles();

        verify(documentVersionService, never()).recordVersion(any());
    }

    // ─── thumbnail dispatch ───────────────────────────────────────────────────

    @Test
    void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
        UUID savedId = UUID.randomUUID();
        Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
        when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenReturn(saved);
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));

        documentService.storeDocument(file, null);

        verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
    }

    @Test
    void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("No file");
        UUID savedId = UUID.randomUUID();
        Document saved = Document.builder().id(savedId).title("No file")
                .originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        documentService.createDocument(dto, null);

        verifyNoInteractions(thumbnailAsyncRunner);
    }

    @Test
    void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("With file");
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        UUID savedId = UUID.randomUUID();
        Document saved = Document.builder().id(savedId).title("With file")
                .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
        when(fileService.uploadFile(any(), any()))
                .thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));

        documentService.createDocument(dto, file);

        verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
    }

    @Test
    void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder()
                .id(id).title("Doc").originalFilename("old.pdf")
                .status(DocumentStatus.UPLOADED).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);

        documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);

        verifyNoInteractions(thumbnailAsyncRunner);
    }

    @Test
    void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder()
                .id(id).title("Doc").originalFilename("old.pdf")
                .status(DocumentStatus.UPLOADED).build();
        org.springframework.mock.web.MockMultipartFile newFile =
                new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);
        when(fileService.uploadFile(any(), any()))
                .thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));

        documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);

        verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
    }

    @Test
    void attachFile_dispatchesThumbnailAfterSave() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder()
                .id(id).title("Placeholder").originalFilename("placeholder")
                .status(DocumentStatus.PLACEHOLDER).build();
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);
        when(fileService.uploadFile(any(), any()))
                .thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));

        documentService.attachFile(id, file, null);

        verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
    }

    // ─── storeDocument ───────────────────────────────────────────────────────

    @Test
    void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
        FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
        Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();

        when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenReturn(saved);
        when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);

        org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
        documentService.storeDocument(file, null);

        verify(documentRepository).save(captor.capture());
        assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
    }

    @Test
    void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
        FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
        Document placeholder = Document.builder()
                .id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
                .status(DocumentStatus.PLACEHOLDER).build();

        when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
        when(documentRepository.save(any())).thenReturn(placeholder);
        when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);

        documentService.storeDocument(file, null);

        assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
    }

    @Test
    void storeDocument_marksResultAsNew_whenNoExistingDocument() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
        Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("new.pdf").build();

        when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenReturn(saved);
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));

        DocumentService.StoreResult result = documentService.storeDocument(file, null);

        assertThat(result.isNew()).isTrue();
    }

    @Test
    void storeDocument_marksResultAsNotNew_whenDocumentWithSameFilenameExists() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "existing.pdf", "application/pdf", new byte[]{1});
        Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("existing.pdf")
                .status(DocumentStatus.UPLOADED).build();

        when(documentRepository.findFirstByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/existing.pdf", "hash"));

        DocumentService.StoreResult result = documentService.storeDocument(file, null);

        assertThat(result.isNew()).isFalse();
    }

    // ─── backfillFileHashes ───────────────────────────────────────────────────

    @Test
    void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
        Document noFile = Document.builder().id(UUID.randomUUID()).build();
        when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());

        int count = documentService.backfillFileHashes();

        assertThat(count).isZero();
        verify(fileService, never()).downloadFileBytes(any());
    }

    @Test
    void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
        UUID docId = UUID.randomUUID();
        Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
        when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
        when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        documentService.backfillFileHashes();

        assertThat(doc.getFileHash()).isNotNull().hasSize(64);
        verify(documentRepository).save(doc);
    }

    @Test
    void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
        UUID docId = UUID.randomUUID();
        Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
        when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
        when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        documentService.backfillFileHashes();

        verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
    }

    // ─── getIncompleteCount ───────────────────────────────────────────────────

    @Test
    void getIncompleteCount_delegatesToRepository() {
        when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
        assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
    }

    // ─── findIncompleteDocuments ──────────────────────────────────────────────

    @Test
    void findIncompleteDocuments_returnsDTOsWithIdAndTitle() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Unvollständig").build();
        when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of(doc)));

        List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);

        assertThat(result).hasSize(1);
        assertThat(result.get(0).id()).isEqualTo(id);
        assertThat(result.get(0).title()).isEqualTo("Unvollständig");
    }

    @Test
    void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() {
        java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
        Document doc = Document.builder()
                .id(UUID.randomUUID())
                .title("Recent")
                .createdAt(createdAt)
                .build();
        when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of(doc)));

        List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);

        assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
    }

    @Test
    void findIncompleteDocuments_passesSizeToPageable() {
        when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
                .thenReturn(Page.empty());

        documentService.findIncompleteDocuments(3);

        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        verify(documentRepository).findByMetadataCompleteFalse(captor.capture());
        assertThat(captor.getValue().getPageSize()).isEqualTo(3);
        assertThat(captor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "createdAt"));
    }

    // ─── findNextIncompleteDocument ───────────────────────────────────────────

    @Test
    void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
        UUID currentId = UUID.randomUUID();
        Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
        when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
                .thenReturn(Optional.of(next));

        assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
    }

    @Test
    void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
        UUID currentId = UUID.randomUUID();
        when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
                .thenReturn(Optional.empty());

        assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
    }

    // ─── storeDocument metadataComplete ──────────────────────────────────────

    @Test
    void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
        when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenReturn(saved);
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.storeDocument(file, null);

        verify(documentRepository).save(captor.capture());
        assertThat(captor.getValue().isMetadataComplete()).isFalse();
    }

    @Test
    void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
                .status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
        when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));

        documentService.storeDocument(file, null);

        assertThat(existing.isMetadataComplete()).isTrue();
    }

    @Test
    void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
        when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
        when(personService.findByName(any(), any())).thenReturn(Optional.empty());

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.storeDocument(file, null);

        verify(documentRepository).save(captor.capture());
        assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12));
        assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)");
    }

    @Test
    void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1});
        Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build();
        when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
        when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.storeDocument(file, null);

        verify(documentRepository).save(captor.capture());
        assertThat(captor.getValue().getSender()).isEqualTo(walter);
    }

    @Test
    void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
        when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
        when(personService.findByName(any(), any())).thenReturn(Optional.empty());

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.storeDocument(file, null);

        verify(documentRepository).save(captor.capture());
        assertThat(captor.getValue().getSender()).isNull();
    }

    // ─── createDocument title fallback ────────────────────────────────────────

    @Test
    void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        // dto.title is null
        MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1});
        Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965")
                .originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, file);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965");
    }

    @Test
    void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("   ");
        MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1});
        Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980")
                .originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, file);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980");
    }

    @Test
    void createDocument_keepsDtoTitle_whenProvided() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Mein Titel");
        MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
        Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel")
                .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, file);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel");
    }

    // ─── createDocument metadataComplete ─────────────────────────────────────

    @Test
    void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        dto.setMetadataComplete(true);
        Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, null);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
    }

    @Test
    void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        // no documentDate, no senderId, no receiverIds, no metadataComplete flag
        Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, null);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
    }

    @Test
    void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        dto.setDocumentDate(LocalDate.of(2020, 1, 1));
        Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, null);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
    }

    // ─── updateDocument metadataComplete ─────────────────────────────────────

    @Test
    void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
                .status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setMetadataComplete(true);
        documentService.updateDocument(id, dto, null, null);

        assertThat(existing.isMetadataComplete()).isTrue();
    }

    @Test
    void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
        UUID id = UUID.randomUUID();
        Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
                .status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
        when(documentRepository.save(any())).thenReturn(existing);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        // metadataComplete not set → null
        documentService.updateDocument(id, dto, null, null);

        assertThat(existing.isMetadataComplete()).isFalse();
    }

    @Test
    void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
        UUID id1 = UUID.randomUUID();
        UUID id2 = UUID.randomUUID();
        Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
        Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
        when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
        when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        int count = documentService.backfillFileHashes();

        assertThat(count).isEqualTo(2);
    }

    // ─── titleFromFilename ────────────────────────────────────────────────────

    @Test
    void titleFromFilename_dateIso_name() {
        assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_Hans.pdf"))
                .isEqualTo("Hans Mueller (12.03.1965)");
    }

    @Test
    void titleFromFilename_dateCompact_name() {
        assertThat(DocumentService.titleFromFilename("19650312_Mueller_Hans.pdf"))
                .isEqualTo("Hans Mueller (12.03.1965)");
    }

    @Test
    void titleFromFilename_name_dateIso() {
        assertThat(DocumentService.titleFromFilename("Mueller_Hans_1965-03-12.pdf"))
                .isEqualTo("Hans Mueller (12.03.1965)");
    }

    @Test
    void titleFromFilename_name_dateCompact() {
        assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312.pdf"))
                .isEqualTo("Hans Mueller (12.03.1965)");
    }

    @Test
    void titleFromFilename_compound_lastName_dateFirst() {
        assertThat(DocumentService.titleFromFilename("18881025_de_Gruyter_Walter.pdf"))
                .isEqualTo("Walter de Gruyter (25.10.1888)");
    }

    @Test
    void titleFromFilename_compound_lastName_dateLast() {
        assertThat(DocumentService.titleFromFilename("de_Gruyter_Walter_18881025.pdf"))
                .isEqualTo("Walter de Gruyter (25.10.1888)");
    }

    @Test
    void titleFromFilename_fallsBackToStripExtension() {
        assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001");
    }

    @Test
    void titleFromFilename_null_returnsNull() {
        assertThat(DocumentService.titleFromFilename(null)).isNull();
    }

    // ─── titleFromFilename — tryParseDate invalid cases ───────────────────────

    @Test
    void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidMonth() {
        // 1965-13-12 → month 13 is invalid → tryParseDate returns null → fallback
        assertThat(DocumentService.titleFromFilename("1965-13-12_Mueller_Hans.pdf"))
                .isEqualTo("1965-13-12_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidDay() {
        // 1965-03-00 → day 0 is invalid → tryParseDate returns null → fallback
        assertThat(DocumentService.titleFromFilename("1965-03-00_Mueller_Hans.pdf"))
                .isEqualTo("1965-03-00_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidMonth() {
        // 19651312 → month 13 → invalid
        assertThat(DocumentService.titleFromFilename("19651312_Mueller_Hans.pdf"))
                .isEqualTo("19651312_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidDay() {
        // 19650300 → day 0 → invalid
        assertThat(DocumentService.titleFromFilename("19650300_Mueller_Hans.pdf"))
                .isEqualTo("19650300_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenStemHasNoExtension() {
        // No dot → parseFilenameData returns null → titleFromFilename returns null? No,
        // actually it returns null when filename is null, otherwise stripExtension is called.
        // Without a dot, dot = -1, strip returns the whole string.
        assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312"))
                .isEqualTo("Mueller_Hans_19650312");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenNamePartsContainNonLetters() {
        // Parts with numbers/hyphens fail the \p{L}+ regex → returns null → fallback
        assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_H4ns.pdf"))
                .isEqualTo("1965-03-12_Mueller_H4ns");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenOnlyTwoParts() {
        // "1965-03-12_Mueller.pdf" → less than 2 name parts → null → fallback
        assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller.pdf"))
                .isEqualTo("1965-03-12_Mueller");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenIsoDateHasMonthZero() {
        // 1965-00-12 → month 0 → m >= 1 is false → tryParseDate returns null
        assertThat(DocumentService.titleFromFilename("1965-00-12_Mueller_Hans.pdf"))
                .isEqualTo("1965-00-12_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenIsoDateHasDayAbove31() {
        // 1965-03-32 → day 32 > 31 → d <= 31 is false → tryParseDate returns null
        assertThat(DocumentService.titleFromFilename("1965-03-32_Mueller_Hans.pdf"))
                .isEqualTo("1965-03-32_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenCompactDateHasMonthZero() {
        // 19650012 → month 0 → m >= 1 is false
        assertThat(DocumentService.titleFromFilename("19650012_Mueller_Hans.pdf"))
                .isEqualTo("19650012_Mueller_Hans");
    }

    @Test
    void titleFromFilename_returnsStrippedName_whenCompactDateHasDayAbove31() {
        // 19650332 → day 32 > 31
        assertThat(DocumentService.titleFromFilename("19650332_Mueller_Hans.pdf"))
                .isEqualTo("19650332_Mueller_Hans");
    }

    // ─── updateDocumentTags — empty tag in list ───────────────────────────────

    @Test
    void updateDocumentTags_skipsEmptyTagNames() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
        Document doc = Document.builder().id(id).title("T").build();

        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findOrCreate("Familie")).thenReturn(tag);

        // List with empty string element — cleanName.isEmpty() branch hit
        documentService.updateDocumentTags(id, List.of("Familie", "  ", ""));

        verify(tagService).findOrCreate("Familie");
        verify(tagService, times(1)).findOrCreate(any()); // only "Familie" — others skipped
    }

    // ─── createDocument — with empty tag segment ──────────────────────────────

    @Test
    void createDocument_filtersEmptyTagSegments() throws Exception {
        UUID docId = UUID.randomUUID();
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Test");
        dto.setTags("Familie,  ,"); // middle and trailing blank segments

        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document d = inv.getArgument(0);
            if (d.getId() == null) {
                return Document.builder().id(docId).title(d.getTitle()).build();
            }
            return d;
        });
        when(documentRepository.findById(docId)).thenReturn(Optional.of(
                Document.builder().id(docId).title("Test").build()));
        when(tagService.findOrCreate("Familie")).thenReturn(tag);

        documentService.createDocument(dto, null);

        verify(tagService).findOrCreate("Familie");
        verify(tagService, times(1)).findOrCreate(any());
    }

    // ─── createDocument — with sender and receivers ───────────────────────────

    @Test
    void createDocument_setsSender_whenSenderIdIsProvided() throws Exception {
        UUID senderId = UUID.randomUUID();
        Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
        UUID docId = UUID.randomUUID();

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Test");
        dto.setSenderId(senderId);

        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document d = inv.getArgument(0);
            if (d.getId() == null) {
                Document saved = Document.builder().id(docId).title(d.getTitle()).build();
                return saved;
            }
            return d;
        });
        when(documentRepository.findById(docId)).thenReturn(Optional.of(
                Document.builder().id(docId).title("Test").build()));
        when(personService.getById(senderId)).thenReturn(sender);

        documentService.createDocument(dto, null);

        verify(personService).getById(senderId);
    }

    @Test
    void createDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
        UUID r1Id = UUID.randomUUID();
        UUID r2Id = UUID.randomUUID();
        UUID docId = UUID.randomUUID();
        Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
        Person r2 = Person.builder().id(r2Id).firstName("C").lastName("D").build();

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Test");
        dto.setReceiverIds(List.of(r1Id, r2Id));

        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document d = inv.getArgument(0);
            if (d.getId() == null) {
                return Document.builder().id(docId).title(d.getTitle()).build();
            }
            return d;
        });
        when(documentRepository.findById(docId)).thenReturn(Optional.of(
                Document.builder().id(docId).title("Test").build()));
        when(personService.getAllById(List.of(r1Id, r2Id))).thenReturn(List.of(r1, r2));

        documentService.createDocument(dto, null);

        verify(personService).getAllById(List.of(r1Id, r2Id));
    }

    // ─── createDocument — empty file fallback and blank tags ─────────────────

    @Test
    void createDocument_usesUnbenanntesDocument_whenFileIsEmptyAndTitleIsNull() throws Exception {
        // file != null but isEmpty() = true → falls through to title ternary
        // title == null → "Unbenanntes Dokument"
        MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
        DocumentUpdateDTO dto = new DocumentUpdateDTO(); // title = null

        Document saved = Document.builder().id(UUID.randomUUID()).title("Unbenanntes Dokument")
                .originalFilename("Unbenanntes Dokument").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, emptyFile);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).getOriginalFilename()).isEqualTo("Unbenanntes Dokument");
    }

    @Test
    void createDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        dto.setTags("   "); // not null but blank → condition false

        Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        documentService.createDocument(dto, null);

        verify(tagService, never()).findOrCreate(any());
    }

    @Test
    void createDocument_setsMetadataCompleteFalse_whenReceiverIdsIsEmptyList() throws Exception {
        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Doc");
        dto.setReceiverIds(List.of()); // not null but empty → !isEmpty() = false → false

        Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
                .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.save(any())).thenReturn(saved);
        when(documentRepository.findById(any())).thenReturn(Optional.of(saved));

        ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
        documentService.createDocument(dto, null);

        verify(documentRepository, atLeastOnce()).save(captor.capture());
        assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
    }

    // ─── updateDocument — empty file, blank tags, empty receivers ────────────

    @Test
    void updateDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setTags("  "); // not null but blank

        documentService.updateDocument(id, dto, null, null);

        verify(tagService, never()).findOrCreate(any());
    }

    @Test
    void updateDocument_clearsReceivers_whenReceiverIdsIsEmptyList() throws Exception {
        UUID id = UUID.randomUUID();
        Person r1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>(Set.of(r1))).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setReceiverIds(List.of()); // not null but empty → else → clear

        documentService.updateDocument(id, dto, null, null);

        assertThat(doc.getReceivers()).isEmpty();
    }

    @Test
    void updateDocument_skipsFileUpload_whenNewFileIsEmpty() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
        MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");

        documentService.updateDocument(id, dto, emptyFile, null);

        verify(fileService, never()).uploadFile(any(), any());
    }

    // ─── titleFromFilename — no date in any position ──────────────────────────

    @Test
    void titleFromFilename_returnsStripped_whenNeitherFirstNorLastPartIsDate() {
        // "Mueller_Hans_Schmitt.pdf" → 3 parts, none is a date → dateFromLast == null → null → stripExtension
        assertThat(DocumentService.titleFromFilename("Mueller_Hans_Schmitt.pdf"))
                .isEqualTo("Mueller_Hans_Schmitt");
    }

    @Test
    void updateDocument_setsTags_withEmptySegmentsFiltered() throws Exception {
        // Tags string with blank segment: "Familie,  ,Reise" → only "Familie" and "Reise" used
        UUID id = UUID.randomUUID();
        Tag t1 = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
        Tag t2 = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();

        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findOrCreate("Familie")).thenReturn(t1);
        when(tagService.findOrCreate("Reise")).thenReturn(t2);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setTags("Familie,  ,Reise"); // blank middle segment filtered

        documentService.updateDocument(id, dto, null, null);

        verify(tagService).findOrCreate("Familie");
        verify(tagService).findOrCreate("Reise");
        verify(tagService, times(2)).findOrCreate(any());
    }

    @Test
    void createDocument_setsTags_whenTagsStringIsProvided() throws Exception {
        UUID docId = UUID.randomUUID();
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("Test");
        dto.setTags("Familie");

        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document d = inv.getArgument(0);
            if (d.getId() == null) {
                return Document.builder().id(docId).title(d.getTitle()).build();
            }
            return d;
        });
        when(documentRepository.findById(docId)).thenReturn(Optional.of(
                Document.builder().id(docId).title("Test").build()));
        when(tagService.findOrCreate("Familie")).thenReturn(tag);

        documentService.createDocument(dto, null);

        verify(tagService).findOrCreate("Familie");
    }

    // ─── updateDocument — with sender / clear receivers ──────────────────────

    @Test
    void updateDocument_clearsSender_whenSenderIdIsNull() throws Exception {
        UUID id = UUID.randomUUID();
        Person existingSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
        Document doc = Document.builder().id(id).title("T").sender(existingSender).receivers(new HashSet<>()).build();

        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); // also for updateDocumentTags

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        // senderId is null — should clear sender

        documentService.updateDocument(id, dto, null, null);

        verify(documentRepository, atLeastOnce()).save(argThat(d -> d.getSender() == null));
    }

    @Test
    void updateDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
        UUID id = UUID.randomUUID();
        UUID r1Id = UUID.randomUUID();
        Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();

        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.getAllById(List.of(r1Id))).thenReturn(List.of(r1));

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setReceiverIds(List.of(r1Id));

        documentService.updateDocument(id, dto, null, null);

        verify(personService).getAllById(List.of(r1Id));
    }

    @Test
    void updateDocument_setsTags_whenTagsStringIsProvided() throws Exception {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();

        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findOrCreate("Reise")).thenReturn(tag);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setTags("Reise");

        documentService.updateDocument(id, dto, null, null);

        verify(tagService).findOrCreate("Reise");
    }

    @Test
    void updateDocument_setsSender_whenSenderIdIsProvided() throws Exception {
        // dto.getSenderId() != null → true branch: sets sender via personService
        UUID id = UUID.randomUUID();
        UUID senderId = UUID.randomUUID();
        Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.getById(senderId)).thenReturn(sender);

        DocumentUpdateDTO dto = new DocumentUpdateDTO();
        dto.setTitle("T");
        dto.setSenderId(senderId);

        documentService.updateDocument(id, dto, null, null);

        verify(personService).getById(senderId);
        assertThat(doc.getSender()).isEqualTo(sender);
    }

    // ─── stripExtension / parseFilenameData — null guard branches ────────────

    @Test
    void stripExtension_returnsNull_whenFilenameIsNull() throws Exception {
        // filename == null = true → null guard branch in private static method
        java.lang.reflect.Method method = DocumentService.class
                .getDeclaredMethod("stripExtension", String.class);
        method.setAccessible(true);
        String result = (String) method.invoke(null, (String) null);
        assertThat(result).isNull();
    }

    @Test
    void parseFilenameData_returnsNull_whenFilenameIsNull() throws Exception {
        // filename == null = true → null guard branch in private static method
        java.lang.reflect.Method method = DocumentService.class
                .getDeclaredMethod("parseFilenameData", String.class);
        method.setAccessible(true);
        Object result = method.invoke(null, (String) null);
        assertThat(result).isNull();
    }

    // ─── searchDocuments — pagination ────────────────────────────────────────

    @Test
    void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(),
                org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(1, 50));

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
        verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
    }

    @Test
    void searchDocuments_fastPath_propagatesPageableToDatabase() {
        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(),
                org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(3, 25));

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
        assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
        assertThat(captor.getValue().getPageSize()).isEqualTo(25);
    }

    @Test
    void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
        // The service MUST report the full match count from Page.getTotalElements(),
        // not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
        Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 50));

        assertThat(result.totalElements()).isEqualTo(120L);
        assertThat(result.pageNumber()).isZero();
        assertThat(result.pageSize()).isEqualTo(50);
        assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
        assertThat(result.items()).hasSize(1); // only the slice is enriched
    }

    @Test
    void searchDocuments_dateSort_DESC_ordersUndatedLast() {
        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
        Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
        assertThat(dateOrder).isNotNull();
        assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC);
        assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
        // Owner-decided tiebreaker (#668): title ASC, not createdAt.
        Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
        assertThat(tiebreak).isNotNull();
        assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
        assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
    }

    @Test
    void searchDocuments_dateSort_ASC_ordersUndatedLast() {
        // The ASC bug: Postgres puts NULLs FIRST on ascending sort without explicit
        // NULLS LAST, surfacing undated documents at the top. This is the red.
        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(),
                DocumentSort.DATE, "ASC", org.springframework.data.domain.PageRequest.of(0, 5));

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
        Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
        assertThat(dateOrder).isNotNull();
        assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC);
        assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
        // Owner-decided tiebreaker (#668): title ASC, not createdAt.
        Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
        assertThat(tiebreak).isNotNull();
        assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
        assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
    }

    @Test
    void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(),
                DocumentSort.UPDATED_AT, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
        assertThat(captor.getValue().getSort())
                .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
    }

    @Test
    void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
        // Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
        // back with totalElements = 120.
        List<Document> all = new java.util.ArrayList<>();
        for (int i = 0; i < 120; i++) {
            Person p = Person.builder()
                    .id(UUID.randomUUID())
                    .firstName("F" + i)
                    .lastName(String.format("L%03d", i))
                    .build();
            all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
        }
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(all);

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(1, 50));

        assertThat(result.totalElements()).isEqualTo(120L);
        assertThat(result.pageNumber()).isEqualTo(1);
        assertThat(result.pageSize()).isEqualTo(50);
        assertThat(result.totalPages()).isEqualTo(3);
        assertThat(result.items()).hasSize(50);
        // Page 1 (offset 50) under ascending sender sort should start at L050
        assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
    }

    @Test
    void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
        // Guards the JPA edge case where page * size > totalElements.
        // Must not throw, must return empty content + correct totalElements.
        List<Document> all = new java.util.ArrayList<>();
        for (int i = 0; i < 30; i++) {
            Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
            all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
        }
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(all);

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(10, 50));

        assertThat(result.items()).isEmpty();
        assertThat(result.totalElements()).isEqualTo(30L);
    }

    // ─── searchDocuments — status filter ─────────────────────────────────────

    @Test
    void searchDocuments_passesStatusSpecificationToRepository() {
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                new SearchFilters(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, false), null, null, UNPAGED);

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
    }

    @Test
    void searchDocuments_withNullStatus_doesNotFilterByStatus() {
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        documentService.searchDocuments(
                noFilters(), null, null, UNPAGED);

        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
    }

    // ─── getRecentActivity ────────────────────────────────────────────────────

    @Test
    void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
        Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build();
        Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build();
        Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build();

        Page<Document> page = new PageImpl<>(List.of(doc3, doc2));
        when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);

        List<Document> result = documentService.getRecentActivity(2);

        assertThat(result).hasSize(2);
        assertThat(result).containsExactly(doc3, doc2);
    }

    @Test
    void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() {
        Page<Document> page = new PageImpl<>(List.of());
        when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);

        documentService.getRecentActivity(3);

        ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
        verify(documentRepository).findAll(captor.capture());
        assertThat(captor.getValue().getPageSize()).isEqualTo(3);
        assertThat(captor.getValue().getSort())
                .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
    }

    // ─── searchDocuments — SENDER sort includes documents with null sender ─────

    @Test
    void searchDocuments_senderSort_includesDocumentsWithNullSender() {
        Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build();
        Document withSender = Document.builder().id(UUID.randomUUID()).title("Has Sender").sender(alice).build();
        Document noSender = Document.builder().id(UUID.randomUUID()).title("No Sender").build();

        // The repository returns both documents (no filtering by sender)
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(withSender, noSender));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.SENDER, "asc", UNPAGED);

        assertThat(result.items()).hasSize(2);
        assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
    }

    // ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────

    @Test
    void searchDocuments_receiverSort_emptyReceiversSortsToEnd() {
        Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Maier").build();
        Document withReceiver = Document.builder().id(UUID.randomUUID()).title("Has Receiver")
                .receivers(new HashSet<>(Set.of(alice))).build();
        Document noReceivers = Document.builder().id(UUID.randomUUID()).title("No Receivers")
                .receivers(new HashSet<>()).build();

        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(noReceivers, withReceiver));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.RECEIVER, "asc", UNPAGED);

        assertThat(result.items()).extracting(DocumentListItem::title)
                .containsExactly("Has Receiver", "No Receivers");
    }

    // ─── searchDocuments — undated docs stay in their person group (#668) ───────

    @Test
    void searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead() {
        // Locking test (#668): the in-memory SENDER comparator orders by sender name,
        // not by date, so an undated (null documentDate) letter must stay WITHIN its
        // sender's group — it must NOT float to the head of a multi-sender page.
        // Two senders, each with a dated + an undated doc. ASC by "lastName firstName":
        // "Adler Bob" < "Ziegler Anna", so both of Bob's docs come before both of Anna's.
        // The undated doc supplied FIRST in the input proves grouping (not date) wins:
        // were it ordered by date, the two undated docs would clump together at one end.
        Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build();
        Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build();
        Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated")
                .sender(bobAdler).documentDate(null).build();
        Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated")
                .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build();
        Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated")
                .sender(annaZiegler).documentDate(null).build();
        Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated")
                .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build();

        // Input order interleaves dated/undated so a date-based regression would reorder.
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.SENDER, "asc", UNPAGED);

        // Bob's group precedes Anna's group (ASC by sender). The sort is stable, so
        // within each group the input order is preserved (undatedBob, datedBob for Bob;
        // datedAnna, undatedAnna for Anna). The undated docs never jump to the head and
        // each stays inside its sender group — a date-based comparator would instead
        // clump the two undated docs together at one end.
        assertThat(result.items()).extracting(DocumentListItem::title)
                .containsExactly("Bob undated", "Bob dated", "Anna dated", "Anna undated");
    }

    @Test
    void searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead() {
        // DESC symmetry for the in-memory path: sender order reverses ("Ziegler Anna"
        // before "Adler Bob"), but the undated doc still sorts by sender, never by date,
        // so it stays within its group and does not surface at the page head.
        Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build();
        Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build();
        Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated")
                .sender(bobAdler).documentDate(null).build();
        Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated")
                .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build();
        Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated")
                .sender(annaZiegler).documentDate(null).build();
        Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated")
                .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build();

        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.SENDER, "desc", UNPAGED);

        // Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
        assertThat(result.items()).extracting(DocumentListItem::title)
                .containsExactly("Anna dated", "Anna undated", "Bob undated", "Bob dated");
    }

    @Test
    void searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification() {
        // Reachable UI state: "Nur undatierte" toggled on while grouped by sender.
        // The SENDER sort takes the in-memory path, but the undatedOnly predicate must
        // still be composed into the Specification handed to the repository — proven by
        // capturing the spec passed to findAll and confirming it filters to null dates.
        Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build();
        Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated")
                .sender(alice).documentDate(null).build();

        org.mockito.ArgumentCaptor<org.springframework.data.jpa.domain.Specification<Document>> specCaptor =
                org.mockito.ArgumentCaptor.forClass(org.springframework.data.jpa.domain.Specification.class);
        when(documentRepository.findAll(specCaptor.capture()))
                .thenReturn(List.of(undatedFromAlice));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters().withUndated(true),
                DocumentSort.SENDER, "asc", UNPAGED);

        // The in-memory path queried via a Specification (built by buildSearchSpec with
        // undatedOnly(true)) rather than skipping straight to a sorted findAll.
        assertThat(specCaptor.getValue()).isNotNull();
        assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Undated");
    }

    @Test
    void searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut() {
        // undated=true must bypass the pure-text RELEVANCE SQL shortcut, which
        // skips buildSearchSpec and would silently drop the undatedOnly predicate.
        when(documentRepository.findAllMatchingIdsByFts("brief")).thenReturn(List.of(UUID.randomUUID()));
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of());

        documentService.searchDocuments(
                new SearchFilters("brief", null, null, null, null, null, null, null, null, true),
                DocumentSort.RELEVANCE, null, UNPAGED);

        // The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
        verify(documentRepository).findAllMatchingIdsByFts("brief");
        verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
    }

    @Test
    void searchDocuments_senderSort_nullLastNameSortsToEnd() {
        // Without fix: null lastName produces sort key "null Smith" which compares
        // as 'n' (110) < 's' (115) and sorts BEFORE "smith" — wrong.
        // With fix (Objects.toString → ""): key " Smith" sorts before real names but
        // the sender-null-branch treats it as empty and places it at the end.
        Person withRealName = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("smith").build();
        Person withNullLastName = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName(null).build();

        Document docSmith = Document.builder().id(UUID.randomUUID()).title("smith doc").sender(withRealName).build();
        Document docNullName = Document.builder().id(UUID.randomUUID()).title("Null lastname doc").sender(withNullLastName).build();

        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(docNullName, docSmith));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                DocumentSort.SENDER, "asc", UNPAGED);

        // null lastName should sort to end (treated as empty), not before "smith" (as "null")
        assertThat(result.items()).extracting(DocumentListItem::title)
                .containsExactly("smith doc", "Null lastname doc");
    }

    // ─── searchDocuments — match data enrichment ──────────────────────────────

    @Test
    void searchDocuments_withTextQuery_includesMatchDataWithTitleOffsets() {
        UUID docId = UUID.randomUUID();
        Document doc = Document.builder().id(docId).title("Brief an Anna").build();
        // chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
        List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});

        List<Object[]> ftsRows = new java.util.ArrayList<>();
        ftsRows.add(new Object[]{docId, 0.5d, 1L});
        when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
        when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
        when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);

        DocumentSearchResult result = documentService.searchDocuments(
                new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
                DocumentSort.RELEVANCE, null, UNPAGED);

        assertThat(result.items()).hasSize(1);
        SearchMatchData md = result.items().get(0).matchData();
        assertThat(md.titleOffsets()).hasSize(1);
        assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
    }

    @Test
    void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
                .thenReturn(new PageImpl<>(List.of()));

        DocumentSearchResult result = documentService.searchDocuments(
                noFilters(),
                null, null, UNPAGED);

        assertThat(result.items()).isEmpty();
    }

    @Test
    void searchDocuments_withTextQuery_includesTranscriptionSnippetWhenPresent() {
        UUID docId = UUID.randomUUID();
        Document doc = Document.builder().id(docId).title("Dok").build();
        // Simulate ts_headline output with sentinel markers around the matched word
        String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
        List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});

        List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
        snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
        when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
        when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
        when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);

        DocumentSearchResult result = documentService.searchDocuments(
                new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
                DocumentSort.RELEVANCE, null, UNPAGED);

        SearchMatchData md = result.items().get(0).matchData();
        assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
        assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
    }

    // ─── parseHighlight unit tests ────────────────────────────────────────────

    @Test
    void parseHighlight_returnsNull_whenInputIsNull() {
        assertThat(DocumentService.parseHighlight(null)).isNull();
    }

    @Test
    void parseHighlight_returnsCleanTextAndEmptyOffsets_whenNoSentinels() {
        DocumentService.ParsedHighlight result = DocumentService.parseHighlight("plain text");
        assertThat(result.cleanText()).isEqualTo("plain text");
        assertThat(result.offsets()).isEmpty();
    }

    @Test
    void parseHighlight_extractsOffsetAndStripsDelimiters() {
        // \u0001 = start sentinel, \u0002 = stop sentinel
        DocumentService.ParsedHighlight result = DocumentService.parseHighlight("Das \u0001furchtbare\u0002 Wort");
        assertThat(result.cleanText()).isEqualTo("Das furchtbare Wort");
        assertThat(result.offsets()).containsExactly(new MatchOffset(4, 10)); // "furchtbare" at pos 4, len 10
    }

    @Test
    void parseHighlight_handlesMultipleHighlightedTerms() {
        DocumentService.ParsedHighlight result =
                DocumentService.parseHighlight("\u0001Hallo\u0002 und \u0001Welt\u0002");
        assertThat(result.cleanText()).isEqualTo("Hallo und Welt");
        assertThat(result.offsets()).containsExactly(
                new MatchOffset(0, 5),  // "Hallo"
                new MatchOffset(10, 4)  // "Welt"
        );
    }

    // ─── attachFile ───────────────────────────────────────────────────────────

    @Test
    void attachFile_throwsNotFound_whenDocumentDoesNotExist() {
        UUID id = UUID.randomUUID();
        when(documentRepository.findById(id)).thenReturn(Optional.empty());

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        assertThatThrownBy(() -> documentService.attachFile(id, file, null))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining(id.toString());
    }

    @Test
    void attachFile_setsStatusToUploaded_whenDocumentIsPlaceholder() throws Exception {
        UUID id = UUID.randomUUID();
        Document placeholder = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(placeholder));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        Document result = documentService.attachFile(id, file, null);

        assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
    }

    @Test
    void attachFile_doesNotChangeStatus_whenAlreadyUploaded() throws Exception {
        UUID id = UUID.randomUUID();
        Document uploaded = Document.builder().id(id).status(DocumentStatus.UPLOADED).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(uploaded));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        Document result = documentService.attachFile(id, file, null);

        assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
    }

    @Test
    void attachFile_setsFilePathAndContentType_fromUploadResult() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(fileService.uploadFile(any(), any()))
                .thenReturn(new FileService.UploadResult("s3/brief.pdf", "deadbeef"));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        Document result = documentService.attachFile(id, file, null);

        assertThat(result.getFilePath()).isEqualTo("s3/brief.pdf");
        assertThat(result.getFileHash()).isEqualTo("deadbeef");
        assertThat(result.getContentType()).isEqualTo("application/pdf");
        assertThat(result.getOriginalFilename()).isEqualTo("brief.pdf");
    }

    @Test
    void attachFile_throwsDomainException_whenFileUploadFails() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(fileService.uploadFile(any(), any())).thenThrow(new java.io.IOException("storage unavailable"));

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        assertThatThrownBy(() -> documentService.attachFile(id, file, null))
                .isInstanceOf(DomainException.class);
    }

    // ─── audit events ─────────────────────────────────────────────────────────

    @Test
    void updateDocument_logsMetadataUpdated_whenNoStatusChange() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).status(DocumentStatus.UPLOADED).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);

        verify(auditService).logAfterCommit(eq(AuditKind.METADATA_UPDATED), isNull(), eq(id), isNull());
        verify(auditService, never()).logAfterCommit(eq(AuditKind.STATUS_CHANGED), any(), any(), any());
    }

    @Test
    void updateDocument_logsStatusChanged_whenFileCausesPlaceholderToUploadedTransition() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document saved = inv.getArgument(0);
            saved.setStatus(DocumentStatus.UPLOADED);
            return saved;
        });
        MockMultipartFile file = new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[]{1});
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));

        documentService.updateDocument(id, new DocumentUpdateDTO(), file, null);

        verify(auditService).logAfterCommit(eq(AuditKind.STATUS_CHANGED), isNull(), eq(id), any());
        verify(auditService, never()).logAfterCommit(eq(AuditKind.METADATA_UPDATED), any(), any(), any());
    }

    @Test
    void storeDocument_logsFileUploaded_whenExistingDocumentWasPlaceholder() throws Exception {
        String filename = "test.pdf";
        Document existing = Document.builder()
                .id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
        when(documentRepository.save(any())).thenReturn(existing);

        MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1});
        documentService.storeDocument(file, null);

        verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(existing.getId()), isNull());
    }

    @Test
    void storeDocument_doesNotLogFileUploaded_whenDocumentIsAlreadyUploaded() throws Exception {
        String filename = "test.pdf";
        Document existing = Document.builder()
                .id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.UPLOADED).build();
        when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
        when(documentRepository.save(any())).thenReturn(existing);

        MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1});
        documentService.storeDocument(file, null);

        verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
    }

    @Test
    void attachFile_logsFileUploaded_whenDocumentWasPlaceholder() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document saved = inv.getArgument(0);
            saved.setStatus(DocumentStatus.UPLOADED);
            return saved;
        });

        MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
        documentService.attachFile(id, file, null);

        verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
    }

    // ─── storeDocumentWithBatchMetadata ──────────────────────────────────────

    private MockMultipartFile pdfFile(String name) {
        return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
    }

    private void stubStoreDocument(String filename) throws Exception {
        when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
    }

    @Test
    void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
        stubStoreDocument("scan01.pdf");
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO();
        meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));

        DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);

        assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
    }

    @Test
    void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
        UUID senderId = UUID.randomUUID();
        stubStoreDocument("scan02.pdf");
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Person sender = Person.builder().id(senderId).firstName("Anna").build();
        when(personService.getById(senderId)).thenReturn(sender);

        org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO();
        meta.setSenderId(senderId);

        DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);

        assertThat(result.document().getSender()).isEqualTo(sender);
    }

    @Test
    void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
        UUID docId = UUID.randomUUID();
        when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
        when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
        when(documentRepository.save(any())).thenAnswer(inv -> {
            Document d = inv.getArgument(0);
            if (d.getId() == null) d.setId(docId);
            return d;
        });
        when(documentRepository.findById(docId)).thenAnswer(inv -> {
            Document d = new Document();
            d.setId(docId);
            return Optional.of(d);
        });
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
        when(tagService.findOrCreate("Familie")).thenReturn(tag);

        org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO();
        meta.setTagNames(List.of("Familie"));

        documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);

        verify(tagService).findOrCreate("Familie");
    }

    @Test
    void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
        stubStoreDocument("scan04.pdf");
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO();
        meta.setTitles(List.of("Only One Title"));

        DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);

        assertThat(result.document().getTitle()).isEqualTo("scan04");
    }

    // ─── validateBatch ───────────────────────────────────────────────────────

    @Test
    void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
        assertThatThrownBy(() -> documentService.validateBatch(51, null))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining("50");
    }

    @Test
    void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
        documentService.validateBatch(50, null);
    }

    @Test
    void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
        org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO metadata =
                new org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO();
        metadata.setTitles(java.util.List.of("A", "B", "C"));

        assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining("titles");
    }

    // ─── applyBulkEditToDocument ─────────────────────────────────────────────

    private static org.raddatz.familienarchiv.document.DocumentBulkEditDTO bulkDto() {
        return new org.raddatz.familienarchiv.document.DocumentBulkEditDTO();
    }

    @Test
    void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
        UUID id = UUID.randomUUID();
        when(documentRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining(id.toString());
    }

    @Test
    void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
        UUID id = UUID.randomUUID();
        Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
        Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
        Document doc = Document.builder().id(id).title("T")
                .tags(new HashSet<>(Set.of(existing)))
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findOrCreate("Kurrent")).thenReturn(added);

        var dto = bulkDto();
        dto.setTagNames(List.of("Kurrent"));
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
    }

    @Test
    void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
        UUID id = UUID.randomUUID();
        Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
        Document doc = Document.builder().id(id).title("T")
                .tags(new HashSet<>(Set.of(existing)))
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        documentService.applyBulkEditToDocument(id, bulkDto(), null);

        assertThat(doc.getTags()).containsExactly(existing);
        verify(tagService, never()).findOrCreate(any());
    }

    @Test
    void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
        UUID id = UUID.randomUUID();
        Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
        Document doc = Document.builder().id(id).title("T")
                .tags(new HashSet<>(Set.of(existing)))
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = bulkDto();
        dto.setTagNames(List.of());
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getTags()).containsExactly(existing);
        verify(tagService, never()).findOrCreate(any());
    }

    @Test
    void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
        UUID id = UUID.randomUUID();
        UUID senderId = UUID.randomUUID();
        Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
        Person newSender = Person.builder().id(senderId).firstName("New").build();
        Document doc = Document.builder().id(id).title("T")
                .sender(oldSender)
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.getById(senderId)).thenReturn(newSender);

        var dto = bulkDto();
        dto.setSenderId(senderId);
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getSender()).isEqualTo(newSender);
    }

    @Test
    void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
        UUID id = UUID.randomUUID();
        Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
        Document doc = Document.builder().id(id).title("T")
                .sender(existing)
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        documentService.applyBulkEditToDocument(id, bulkDto(), null);

        assertThat(doc.getSender()).isEqualTo(existing);
        verify(personService, never()).getById(any());
    }

    @Test
    void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
        UUID id = UUID.randomUUID();
        UUID newReceiverId = UUID.randomUUID();
        Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
        Person added = Person.builder().id(newReceiverId).firstName("New").build();
        Document doc = Document.builder().id(id).title("T")
                .receivers(new HashSet<>(Set.of(existing)))
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));

        var dto = bulkDto();
        dto.setReceiverIds(List.of(newReceiverId));
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
    }

    @Test
    void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
        UUID id = UUID.randomUUID();
        Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
        Document doc = Document.builder().id(id).title("T")
                .receivers(new HashSet<>(Set.of(existing)))
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = bulkDto();
        dto.setReceiverIds(List.of());
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getReceivers()).containsExactly(existing);
        verify(personService, never()).getAllById(any());
    }

    @Test
    void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
        UUID id = UUID.randomUUID();
        UUID actorId = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenReturn(doc);

        documentService.applyBulkEditToDocument(id, bulkDto(), actorId);

        verify(documentVersionService).recordVersion(doc);
        verify(auditService).logAfterCommit(
                eq(AuditKind.METADATA_UPDATED),
                eq(actorId),
                eq(id),
                eq(java.util.Map.of("source", "BULK_EDIT")));
    }

    @Test
    void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T")
                .archiveBox("OldBox")
                .archiveFolder("OldFolder")
                .documentLocation("OldLocation")
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = bulkDto();
        dto.setArchiveBox("NewBox");
        dto.setArchiveFolder("NewFolder");
        dto.setDocumentLocation("NewLocation");
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
        assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
        assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
    }

    @Test
    void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
        // Sara C1 — unresolvable sender flows up as a per-document error chip
        // rather than aborting the controller's batch loop.
        UUID id = UUID.randomUUID();
        UUID unknownSender = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(personService.getById(unknownSender))
                .thenThrow(DomainException.notFound(
                        org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
                        "Person not found: " + unknownSender));

        var dto = bulkDto();
        dto.setSenderId(unknownSender);

        assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining(unknownSender.toString());
    }

    // ─── findIdsForFilter ────────────────────────────────────────────────────

    @Test
    void findIdsForFilter_returnsAllMatchingIds_uncapped() {
        Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
        Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of(d1, d2));

        List<UUID> result = documentService.findIdsForFilter(
                noFilters());

        assertThat(result).containsExactly(d1.getId(), d2.getId());
    }

    @Test
    void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
        // Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
        // test pinning this, a refactor that wired OR to AND (or vice versa)
        // would slip through.
        when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
                .thenReturn(List.of());
        when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());

        documentService.findIdsForFilter(
                new SearchFilters(null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false));

        // Spec built without throwing → OR branch was exercised. Coverage gain
        // is in not-throwing on the OR-specific code path; the actual SQL is
        // covered by JPA itself.
        verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
    }

    @Test
    void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
        when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());

        List<UUID> result = documentService.findIdsForFilter(
                new SearchFilters("xyz", null, null, null, null, null, null, null, null, false));

        assertThat(result).isEmpty();
        verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
    }

    // ─── batchMetadata ───────────────────────────────────────────────────────

    @Test
    void batchMetadata_returnsEmpty_whenIdsIsNull() {
        assertThat(documentService.batchMetadata(null)).isEmpty();
    }

    @Test
    void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
        assertThat(documentService.batchMetadata(List.of())).isEmpty();
    }

    @Test
    void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
        UUID id1 = UUID.randomUUID();
        UUID id2 = UUID.randomUUID();
        Document d1 = Document.builder().id(id1).title("Brief 1").build();
        Document d2 = Document.builder().id(id2).title("Brief 2").build();
        when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));

        var result = documentService.batchMetadata(List.of(id1, id2));

        assertThat(result).hasSize(2);
        assertThat(result.get(0).id()).isEqualTo(id1);
        assertThat(result.get(0).title()).isEqualTo("Brief 1");
        assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
    }

    @Test
    void batchMetadata_silentlyDropsUnknownIds() {
        UUID known = UUID.randomUUID();
        UUID missing = UUID.randomUUID();
        Document d = Document.builder().id(known).title("Found").build();
        when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));

        var result = documentService.batchMetadata(List.of(known, missing));

        assertThat(result).hasSize(1);
        assertThat(result.get(0).id()).isEqualTo(known);
    }

    @Test
    void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
        UUID id = UUID.randomUUID();
        Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
        when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));

        var result = documentService.batchMetadata(List.of(id));

        assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
    }

    @Test
    void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T")
                .archiveBox("KeepBox")
                .archiveFolder("KeepFolder")
                .documentLocation("KeepLocation")
                .receivers(new HashSet<>())
                .build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
        when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = bulkDto();
        dto.setArchiveBox("   ");
        dto.setArchiveFolder("");
        // documentLocation left null
        documentService.applyBulkEditToDocument(id, dto, null);

        assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
        assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
        assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
    }

    // ─── findById (no-throw variant) ───────────────────────────────────────────

    @Test
    void findById_returnsEmpty_whenDocumentDoesNotExist() {
        UUID id = UUID.randomUUID();
        when(documentRepository.findById(id)).thenReturn(Optional.empty());

        assertThat(documentService.findById(id)).isEmpty();
    }

    @Test
    void findById_returnsDocument_whenPresent() {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("T").build();
        when(documentRepository.findById(id)).thenReturn(Optional.of(doc));

        assertThat(documentService.findById(id)).contains(doc);
    }

    // ─── findForThumbnailBackfill ──────────────────────────────────────────────

    @Test
    void findForThumbnailBackfill_returnsRepositoryResult() {
        Document a = Document.builder().id(UUID.randomUUID()).title("A").build();
        Document b = Document.builder().id(UUID.randomUUID()).title("B").build();
        when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
                .thenReturn(List.of(a, b));

        assertThat(documentService.findForThumbnailBackfill()).containsExactly(a, b);
    }

    // ─── findByOriginalFilename ────────────────────────────────────────────────

    @Test
    void findByOriginalFilename_returnsRepositoryResult() {
        Document doc = Document.builder().id(UUID.randomUUID()).title("T").build();
        when(documentRepository.findByOriginalFilename("scan.pdf")).thenReturn(Optional.of(doc));

        assertThat(documentService.findByOriginalFilename("scan.pdf")).contains(doc);
    }

    // ─── save ──────────────────────────────────────────────────────────────────

    @Test
    void save_delegatesToRepository() {
        Document doc = Document.builder().id(UUID.randomUUID()).title("T").build();
        when(documentRepository.save(doc)).thenReturn(doc);

        assertThat(documentService.save(doc)).isEqualTo(doc);
        verify(documentRepository).save(doc);
    }

    // ─── getDensity ────────────────────────────────────────────────────────────

    private static DensityFilters anyFilters() {
        return new DensityFilters(null, null, null, null, null, null, null);
    }

    @Test
    void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
        when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());

        DocumentDensityResult result = documentService.getDensity(anyFilters());

        assertThat(result.buckets()).isEmpty();
        assertThat(result.minDate()).isNull();
        assertThat(result.maxDate()).isNull();
    }

    @Test
    void getDensity_groupsMatchingDocumentsByMonth() {
        Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
        Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
        Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
        when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
        when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());

        DocumentDensityResult result = documentService.getDensity(anyFilters());

        assertThat(result.buckets()).extracting(MonthBucket::month)
                .containsExactly("1915-08", "1915-09");
        assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
        assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
        assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
    }

    @Test
    void getDensity_excludesDocumentsWithNullDate() {
        Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
        Document undated = Document.builder().documentDate(null).build();
        when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
        when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());

        DocumentDensityResult result = documentService.getDensity(anyFilters());

        assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
    }

    @Test
    void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
        when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());

        DocumentDensityResult result = documentService.getDensity(
                new DensityFilters("xyz", null, null, null, null, null, null));

        assertThat(result.buckets()).isEmpty();
        verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
    }

    // --- getAllForTimeline ---

    @Test
    void getAllForTimeline_delegates_bulk_fetch_to_repository() {
        Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
        when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));

        List<Document> result = documentService.getAllForTimeline();

        assertThat(result).containsExactly(doc);
        verify(documentRepository).findAllForTimeline();
    }
}
