package org.raddatz.familienarchiv.document;

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.annotation.AnnotationRepository;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.document.transcription.TranscriptionQueueProjection;
import org.raddatz.familienarchiv.document.transcription.TranscriptionWeeklyStatsProjection;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.document.ThumbnailAspect;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.domain.Specification;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

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

import org.springframework.dao.DataIntegrityViolationException;

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

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentRepositoryTest {

    @Autowired
    private DocumentRepository documentRepository;

    @Autowired
    private PersonRepository personRepository;

    @Autowired
    private TagRepository tagRepository;

    @Autowired
    private AnnotationRepository annotationRepository;

    @Autowired
    private TranscriptionBlockRepository transcriptionBlockRepository;

    @Autowired
    private EntityManagerFactory entityManagerFactory;

    @Autowired
    private EntityManager entityManager;

    // ─── save and findById ────────────────────────────────────────────────────

    @Test
    void save_persistsDocument_andFindByIdReturnsSameDocument() {
        Document document = Document.builder()
                .title("Testbrief")
                .originalFilename("testbrief.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build();

        Document saved = documentRepository.save(document);
        Optional<Document> found = documentRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getTitle()).isEqualTo("Testbrief");
        assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
    }

    // ─── thumbnailAspect + pageCount round-trip ───────────────────────────────

    @Test
    void save_persistsThumbnailAspectAndPageCount() {
        Document document = Document.builder()
                .title("Mit Aspekt")
                .originalFilename("aspect.pdf")
                .status(DocumentStatus.UPLOADED)
                .thumbnailAspect(ThumbnailAspect.LANDSCAPE)
                .pageCount(7)
                .build();

        Document saved = documentRepository.save(document);
        Document found = documentRepository.findById(saved.getId()).orElseThrow();

        assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
        assertThat(found.getPageCount()).isEqualTo(7);
    }

    @Test
    void save_thumbnailAspectAndPageCount_defaultToNull() {
        Document document = Document.builder()
                .title("Ohne Aspekt")
                .originalFilename("no_aspect.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build();

        Document saved = documentRepository.save(document);
        Document found = documentRepository.findById(saved.getId()).orElseThrow();

        assertThat(found.getThumbnailAspect()).isNull();
        assertThat(found.getPageCount()).isNull();
    }

    // ─── findByStatus ─────────────────────────────────────────────────────────

    @Test
    void findByStatus_returnsOnlyDocumentsWithMatchingStatus() {
        documentRepository.save(Document.builder()
                .title("Placeholder Doc")
                .originalFilename("placeholder.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build());
        documentRepository.save(Document.builder()
                .title("Uploaded Doc")
                .originalFilename("uploaded.pdf")
                .status(DocumentStatus.UPLOADED)
                .build());

        List<Document> placeholders = documentRepository.findByStatus(DocumentStatus.PLACEHOLDER);

        assertThat(placeholders).extracting(Document::getStatus)
                .containsOnly(DocumentStatus.PLACEHOLDER);
    }

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

    @Test
    void findByOriginalFilename_returnsDocument_whenFilenameMatches() {
        documentRepository.save(Document.builder()
                .title("Omas Brief")
                .originalFilename("omas_brief.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build());

        Optional<Document> found = documentRepository.findByOriginalFilename("omas_brief.pdf");

        assertThat(found).isPresent();
        assertThat(found.get().getTitle()).isEqualTo("Omas Brief");
    }

    @Test
    void findByOriginalFilename_returnsEmpty_whenFilenameDoesNotExist() {
        Optional<Document> found = documentRepository.findByOriginalFilename("does_not_exist.pdf");

        assertThat(found).isEmpty();
    }

    // ─── existsByOriginalFilename ─────────────────────────────────────────────

    @Test
    void existsByOriginalFilename_returnsTrue_whenDocumentExists() {
        documentRepository.save(Document.builder()
                .title("Brief")
                .originalFilename("brief.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build());

        assertThat(documentRepository.existsByOriginalFilename("brief.pdf")).isTrue();
    }

    @Test
    void existsByOriginalFilename_returnsFalse_whenDocumentDoesNotExist() {
        assertThat(documentRepository.existsByOriginalFilename("nonexistent.pdf")).isFalse();
    }

    // ─── findBySenderId ───────────────────────────────────────────────────────

    @Test
    void findBySenderId_returnsDocuments_whereSenderIdMatches() {
        Person sender = personRepository.save(Person.builder()
                .firstName("Hans")
                .lastName("Müller")
                .build());
        documentRepository.save(Document.builder()
                .title("Brief von Hans")
                .originalFilename("brief_hans.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(sender)
                .build());

        List<Document> docs = documentRepository.findBySenderId(sender.getId());

        assertThat(docs).hasSize(1);
        assertThat(docs.get(0).getSender().getId()).isEqualTo(sender.getId());
    }

    // ─── countByMetadataCompleteFalse ─────────────────────────────────────────

    @Test
    void countByMetadataCompleteFalse_returnsNumberOfIncompleteDocuments() {
        documentRepository.save(Document.builder()
                .title("Incomplete")
                .originalFilename("incomplete.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .metadataComplete(false)
                .build());
        documentRepository.save(Document.builder()
                .title("Complete")
                .originalFilename("complete.pdf")
                .status(DocumentStatus.UPLOADED)
                .metadataComplete(true)
                .build());

        assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
    }

    // ─── findAll (PageRequest) — recent activity ──────────────────────────────

    @Test
    void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() {
        for (int i = 0; i < 10; i++) {
            documentRepository.save(Document.builder()
                    .title("Doc " + i).originalFilename("doc" + i + ".pdf")
                    .status(DocumentStatus.PLACEHOLDER).build());
        }

        Page<Document> result = documentRepository.findAll(
                PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")));

        assertThat(result.getContent()).hasSize(3);
        assertThat(result.getTotalElements()).isEqualTo(10);
    }

    // ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────

    @Test
    void findByMetadataCompleteFalse_withPageable_returnsOnlyIncompleteAndRespectsSizeCap() {
        for (int i = 0; i < 5; i++) {
            documentRepository.save(Document.builder()
                    .title("Incomplete " + i).originalFilename("inc" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED).metadataComplete(false).build());
        }
        documentRepository.save(Document.builder()
                .title("Complete").originalFilename("complete.pdf")
                .status(DocumentStatus.REVIEWED).metadataComplete(true).build());

        Page<Document> result = documentRepository.findByMetadataCompleteFalse(
                PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")));

        assertThat(result.getContent()).hasSize(3);
        assertThat(result.getTotalElements()).isEqualTo(5);
        assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
    }

    // ─── findSegmentationQueue ────────────────────────────────────────────────

    @Test
    void findSegmentationQueue_excludes_PLACEHOLDER_documents() {
        documentRepository.save(uploaded("Hochgeladener Brief"));
        documentRepository.save(Document.builder()
                .title("Platzhalter").originalFilename("placeholder.pdf")
                .status(DocumentStatus.PLACEHOLDER).build());

        List<TranscriptionQueueProjection> result = documentRepository.findSegmentationQueue(10);

        assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
                .containsExactly("Hochgeladener Brief")
                .doesNotContain("Platzhalter");
    }

    @Test
    void findSegmentationQueue_excludes_documents_that_already_have_annotations() {
        Document withAnnotation = documentRepository.save(uploaded("Mit Annotation"));
        documentRepository.save(uploaded("Ohne Annotation"));
        annotationRepository.save(annotation(withAnnotation.getId()));

        List<TranscriptionQueueProjection> result = documentRepository.findSegmentationQueue(10);

        assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
                .containsExactly("Ohne Annotation")
                .doesNotContain("Mit Annotation");
    }

    // ─── findTranscriptionQueue ───────────────────────────────────────────────

    @Test
    void findTranscriptionQueue_returns_documents_with_annotations_below_90pct_reviewed() {
        Document doc = documentRepository.save(uploaded("Tagebuch"));
        DocumentAnnotation ann = annotationRepository.save(annotation(doc.getId()));
        // One block, not reviewed → 0 / 1 = 0% < 90%
        transcriptionBlockRepository.save(block(doc.getId(), ann.getId(), "Text", false));

        List<TranscriptionQueueProjection> result = documentRepository.findTranscriptionQueue(10);

        assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
                .contains("Tagebuch");
    }

    @Test
    void findTranscriptionQueue_returns_zero_textedBlockCount_when_no_transcription_blocks() {
        Document doc = documentRepository.save(uploaded("Nur Annotation"));
        annotationRepository.save(annotation(doc.getId()));
        // No transcription blocks at all — annotationCount=1, textedBlockCount=0

        List<TranscriptionQueueProjection> result = documentRepository.findTranscriptionQueue(10);

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getTextedBlockCount()).isEqualTo(0);
        assertThat(result.get(0).getAnnotationCount()).isEqualTo(1);
    }

    // ─── findReadyToReadQueue ─────────────────────────────────────────────────

    @Test
    void findReadyToReadQueue_returns_documents_with_at_least_90pct_reviewed() {
        Document doc = documentRepository.save(uploaded("Urkunde"));
        DocumentAnnotation ann = annotationRepository.save(annotation(doc.getId()));
        // One block, reviewed → 1 / 1 = 100% >= 90%
        transcriptionBlockRepository.save(block(doc.getId(), ann.getId(), "Text", true));

        List<TranscriptionQueueProjection> result = documentRepository.findReadyToReadQueue(10);

        assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
                .contains("Urkunde");
    }

    // ─── findWeeklyStats ──────────────────────────────────────────────────────

    @Test
    void findWeeklyStats_returns_zeros_when_database_is_empty() {
        TranscriptionWeeklyStatsProjection stats = documentRepository.findWeeklyStats();

        assertThat(stats.getSegmentationCount()).isEqualTo(0L);
        assertThat(stats.getTranscriptionCount()).isEqualTo(0L);
    }

    // ─── hasTags specification — AND/OR + hierarchy ───────────────────────────

    @Test
    void hasTags_and_findsDocumentThatHasBothTags() {
        Tag tagA = tagRepository.save(Tag.builder().name("TagA").build());
        Tag tagB = tagRepository.save(Tag.builder().name("TagB").build());
        Tag tagC = tagRepository.save(Tag.builder().name("TagC").build());

        Document docAB = documentRepository.save(Document.builder()
                .title("DocAB").originalFilename("docab.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(tagA, tagB))).build());
        documentRepository.save(Document.builder()
                .title("DocA").originalFilename("doca.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(tagA))).build());

        // AND: must have both TagA and TagB
        List<UUID> setA = tagRepository.findDescendantIdsByName("TagA").stream().toList();
        List<UUID> setB = tagRepository.findDescendantIdsByName("TagB").stream().toList();
        List<UUID> setC = tagRepository.findDescendantIdsByName("TagC").stream().toList();

        var spec = DocumentSpecifications.hasTags(
                List.of(new HashSet<>(setA), new HashSet<>(setB)), false);
        List<Document> results = documentRepository.findAll(spec);

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getId()).isEqualTo(docAB.getId());
    }

    @Test
    void hasTags_or_findsDocumentThatHasEitherTag() {
        Tag tagA = tagRepository.save(Tag.builder().name("OrTagA").build());
        Tag tagB = tagRepository.save(Tag.builder().name("OrTagB").build());

        Document docA = documentRepository.save(Document.builder()
                .title("OrDocA").originalFilename("ordoca.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(tagA))).build());
        Document docB = documentRepository.save(Document.builder()
                .title("OrDocB").originalFilename("ordocb.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(tagB))).build());

        List<UUID> setA = tagRepository.findDescendantIdsByName("OrTagA").stream().toList();
        List<UUID> setB = tagRepository.findDescendantIdsByName("OrTagB").stream().toList();

        var spec = DocumentSpecifications.hasTags(
                List.of(new HashSet<>(setA), new HashSet<>(setB)), true);
        List<Document> results = documentRepository.findAll(spec);

        assertThat(results).hasSize(2);
        assertThat(results).extracting(Document::getId).containsExactlyInAnyOrder(docA.getId(), docB.getId());
    }

    @Test
    void hasTags_hierarchySearch_findsDocumentTaggedWithChildWhenSearchingByParent() {
        Tag parent = tagRepository.save(Tag.builder().name("HierParent").build());
        Tag child = tagRepository.save(Tag.builder().name("HierChild").parentId(parent.getId()).build());

        Document docWithChild = documentRepository.save(Document.builder()
                .title("DocWithChild").originalFilename("docwithchild.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(child))).build());
        documentRepository.save(Document.builder()
                .title("DocWithParent").originalFilename("docwithparent.pdf").status(DocumentStatus.UPLOADED)
                .tags(new HashSet<>(Set.of(parent))).build());

        // Searching by "HierParent" should include descendants (HierChild)
        List<UUID> parentAndDescendants = tagRepository.findDescendantIdsByName("HierParent")
                .stream().toList();

        // Must include both parent and child IDs
        assertThat(parentAndDescendants).contains(parent.getId(), child.getId());

        var spec = DocumentSpecifications.hasTags(
                List.of(new HashSet<>(parentAndDescendants)), false);
        List<Document> results = documentRepository.findAll(spec);

        assertThat(results).hasSize(2); // both doc-with-child and doc-with-parent match
    }

    @Test
    void findDescendantIdsByName_returnsOnlyMatchingTag_whenNoChildren() {
        Tag tag = tagRepository.save(Tag.builder().name("Leaf").build());

        List<UUID> ids = tagRepository.findDescendantIdsByName("Leaf")
                .stream().toList();

        assertThat(ids).containsExactly(tag.getId());
    }

    @Test
    void findDescendantIdsByName_returnsParentAndAllDescendants() {
        Tag grandparent = tagRepository.save(Tag.builder().name("Grandparent").build());
        Tag parent2 = tagRepository.save(Tag.builder().name("ParentNode").parentId(grandparent.getId()).build());
        Tag child2 = tagRepository.save(Tag.builder().name("ChildNode").parentId(parent2.getId()).build());

        List<UUID> ids = tagRepository.findDescendantIdsByName("Grandparent")
                .stream().toList();

        assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
    }

    // ─── query-count — entity-graph assertions ────────────────────────────────

    @Test
    void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
        Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
        Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
        Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
        for (int i = 0; i < 10; i++) {
            documentRepository.save(Document.builder()
                    .title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .sender(sender)
                    .receivers(new HashSet<>(Set.of(receiver)))
                    .tags(new HashSet<>(Set.of(tag)))
                    .build());
        }
        entityManager.flush();
        entityManager.clear();
        Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        Specification<Document> allDocs = (root, query, cb) -> null;
        documentRepository.findAll(allDocs, PageRequest.of(0, 10));

        assertThat(stats.getPrepareStatementCount())
                .as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
                .isLessThanOrEqualTo(5);
    }

    @Test
    void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
        Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
        Set<Person> receivers = new HashSet<>();
        for (int i = 0; i < 3; i++) {
            receivers.add(personRepository.save(
                    Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
        }
        Set<Tag> tags = new HashSet<>();
        for (int i = 0; i < 5; i++) {
            tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
        }
        Document doc = documentRepository.save(Document.builder()
                .title("FindByIdQc").originalFilename("findbyid_qc.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(sender).receivers(receivers).tags(tags)
                .build());
        entityManager.flush();
        entityManager.clear();
        Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        documentRepository.findById(doc.getId());

        assertThat(stats.getPrepareStatementCount())
                .as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
                .isLessThanOrEqualTo(2);
    }

    @Test
    void findAll_withPageable_loadsSenderWithoutNPlusOne() {
        Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
        Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
        for (int i = 0; i < 5; i++) {
            documentRepository.save(Document.builder()
                    .title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .sender(sender)
                    .tags(new HashSet<>(Set.of(tag)))
                    .build());
        }
        entityManager.flush();
        entityManager.clear();
        Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));

        assertThat(stats.getPrepareStatementCount())
                .as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
                .isLessThanOrEqualTo(5);
    }

    @Test
    void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
        Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
        Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
        for (int i = 0; i < 5; i++) {
            documentRepository.save(Document.builder()
                    .title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
                    .status(DocumentStatus.UPLOADED)
                    .sender(sender)
                    .tags(new HashSet<>(Set.of(tag)))
                    .build());
        }
        entityManager.flush();
        entityManager.clear();
        Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        Specification<Document> allDocs = (root, query, cb) -> null;
        documentRepository.findAll(allDocs);

        assertThat(stats.getPrepareStatementCount())
                .as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
                .isLessThanOrEqualTo(5);
    }

    // ─── V69 date-range CHECK constraints (#678) ──────────────────────────────

    @Test
    void save_acceptsRange_whenEndEqualsStart() {
        // chk_meta_date_end_after_start is end >= start, so equal dates are valid.
        // Real Postgres + Flyway here (H2 would not enforce the CHECK) pins the
        // app guard's isBefore semantics to the actual constraint — guards drift (AC2).
        LocalDate day = LocalDate.of(1917, 1, 10);
        Document saved = documentRepository.saveAndFlush(Document.builder()
                .title("Gleicher Tag")
                .originalFilename("gleicher_tag.pdf")
                .status(DocumentStatus.UPLOADED)
                .documentDate(day)
                .metaDatePrecision(DatePrecision.RANGE)
                .metaDateEnd(day)
                .build());

        Document found = documentRepository.findById(saved.getId()).orElseThrow();
        assertThat(found.getDocumentDate()).isEqualTo(day);
        assertThat(found.getMetaDateEnd()).isEqualTo(day);
        assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
    }

    @Test
    void save_rejectsRange_whenEndBeforeStart_atDbLevel() {
        // The app guard normally intercepts this, so the DB CHECK never fires in practice.
        // Persisting directly proves chk_meta_date_end_after_start actually rejects end < start
        // (H2 would not) — if the app guard ever regresses, a bad row still can't reach the table,
        // and this is exactly the violation the GlobalExceptionHandler backstop turns into a 400.
        Document doc = Document.builder()
                .title("Verdrehte Spanne")
                .originalFilename("verdreht.pdf")
                .status(DocumentStatus.UPLOADED)
                .documentDate(LocalDate.of(1917, 1, 11))
                .metaDatePrecision(DatePrecision.RANGE)
                .metaDateEnd(LocalDate.of(1917, 1, 10))
                .build();

        assertThatThrownBy(() -> documentRepository.saveAndFlush(doc))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    // ─── seeding helpers ─────────────────────────────────────────────────────

    private Document uploaded(String title) {
        return Document.builder()
                .title(title)
                .originalFilename(title.toLowerCase().replace(" ", "_") + ".pdf")
                .status(DocumentStatus.UPLOADED)
                .build();
    }

    private DocumentAnnotation annotation(UUID documentId) {
        return DocumentAnnotation.builder()
                .documentId(documentId)
                .pageNumber(1)
                .x(0.1).y(0.1).width(0.5).height(0.3)
                .color("#ff0000")
                .build();
    }

    private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, boolean reviewed) {
        return TranscriptionBlock.builder()
                .documentId(documentId)
                .annotationId(annotationId)
                .text(text)
                .sortOrder(0)
                .reviewed(reviewed)
                .build();
    }

    // ─── searchDocumentsByPersonId (via Specification) ───────────────────────

    private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
        Specification<Document> spec = (root, query, cb) -> {
            if (query != null) query.distinct(true);
            var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
            var personPredicate = cb.or(
                    cb.equal(root.get("sender"), person),
                    cb.equal(receiversJoin, person));
            var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
            if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
            if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
            return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
        };
        return documentRepository.findAll(spec, PageRequest.of(0, 10));
    }

    @Test
    void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
        Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
        Document doc = documentRepository.save(Document.builder()
                .title("Senderbrief").originalFilename("sender.pdf")
                .status(DocumentStatus.UPLOADED).sender(person).build());

        Page<Document> result = searchByPerson(person, null, null);

        assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
    }

    @Test
    void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
        Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
        Document doc = documentRepository.save(Document.builder()
                .title("Empfängerbrief").originalFilename("receiver.pdf")
                .status(DocumentStatus.UPLOADED)
                .receivers(new java.util.HashSet<>(List.of(person))).build());

        Page<Document> result = searchByPerson(person, null, null);

        assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
    }

    @Test
    void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
        Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
        Document doc = documentRepository.save(Document.builder()
                .title("SenderEmpfänger").originalFilename("both.pdf")
                .status(DocumentStatus.UPLOADED).sender(person)
                .receivers(new java.util.HashSet<>(List.of(person))).build());

        Page<Document> result = searchByPerson(person, null, null);

        assertThat(result.getContent()).hasSize(1);
        assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
    }

    @Test
    void searchByPersonSpec_excludesDocuments_outsideDateRange() {
        Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
        Document inside = documentRepository.save(Document.builder()
                .title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
                .sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
        documentRepository.save(Document.builder()
                .title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
                .sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());

        Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));

        assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
    }

    @Test
    void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
        Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
        Person other = personRepository.save(Person.builder().lastName("Braun").build());
        documentRepository.save(Document.builder()
                .title("Fremder Brief").originalFilename("other.pdf")
                .status(DocumentStatus.UPLOADED).sender(other).build());

        Page<Document> result = searchByPerson(person, null, null);

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