package org.raddatz.familienarchiv.person;

import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
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.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.document.DocumentRepository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.raddatz.familienarchiv.document.DatePrecision;

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

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

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

    @Autowired
    private PersonRepository personRepository;

    @Autowired
    private PersonNameAliasRepository aliasRepository;

    @Autowired
    private DocumentRepository documentRepository;

    @PersistenceContext
    private EntityManager entityManager;

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

    @Test
    void save_persistsPerson_andFindByIdReturnsSamePerson() {
        Person person = Person.builder()
                .firstName("Anna")
                .lastName("Schmidt")
                .build();

        Person saved = personRepository.save(person);
        Optional<Person> found = personRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getFirstName()).isEqualTo("Anna");
        assertThat(found.get().getLastName()).isEqualTo("Schmidt");
    }

    // ─── searchByName ─────────────────────────────────────────────────────────

    @Test
    void searchByName_findsByFirstName() {
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
        personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());

        List<Person> results = personRepository.searchByName("Hans");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getFirstName()).isEqualTo("Hans");
    }

    @Test
    void searchByName_findsByLastName() {
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
        personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());

        List<Person> results = personRepository.searchByName("Schmidt");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getLastName()).isEqualTo("Schmidt");
    }

    @Test
    void searchByName_isCaseInsensitive() {
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());

        List<Person> results = personRepository.searchByName("hans");

        assertThat(results).hasSize(1);
    }

    @Test
    void searchByName_findsByAlias() {
        personRepository.save(Person.builder()
                .firstName("Hans").lastName("Müller").alias("Opa Hans").build());

        List<Person> results = personRepository.searchByName("Opa Hans");

        assertThat(results).hasSize(1);
    }

    // ─── findAllByOrderByLastNameAscFirstNameAsc ──────────────────────────────

    @Test
    void findAllByOrderByLastNameAscFirstNameAsc_returnsSortedByLastNameThenFirstName() {
        personRepository.save(Person.builder().firstName("Bernd").lastName("Ziegler").build());
        personRepository.save(Person.builder().firstName("Anna").lastName("Müller").build());
        personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());

        List<Person> sorted = personRepository.findAllByOrderByLastNameAscFirstNameAsc();

        assertThat(sorted).extracting(Person::getLastName)
                .startsWith("Müller", "Müller");
        assertThat(sorted.stream()
                .filter(p -> p.getLastName().equals("Müller"))
                .map(Person::getFirstName)
                .toList())
                .containsExactly("Anna", "Clara");
    }

    // ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───

    @Test
    void findByAlias_returnsExactCaseMatchOnly() {
        personRepository.save(Person.builder()
                .firstName("Karl").lastName("Brandt").alias("Opa Karl").build());

        assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
        assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
    }

    @Test
    void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
        assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
    }

    @Test
    void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
        // Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
        // stay green even if umlaut folding regressed. Both case-colliding aliases must match.
        personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
        personRepository.save(Person.builder().lastName("müller").alias("müller").build());

        assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
    }

    // ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───

    @Test
    void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
        personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());

        assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
        assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
    }

    @Test
    void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
        personRepository.save(Person.builder().firstName("hans").lastName("müller").build());

        assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
                .hasSize(2);
    }

    @Test
    void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
        // Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
        // NULL and pull in the institution/last-name-only row as a "sender". Proven on real
        // Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
        personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL

        assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
                .isEmpty();
    }

    // ─── findCorrespondents ───────────────────────────────────────────────────

    @Test
    void findCorrespondents_returnsPersonsWhoSharedDocumentsWith() {
        Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
        Person anna   = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
        Person clara  = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());

        // Walter sends to Anna (1 document)
        documentRepository.save(Document.builder()
                .title("Brief 1").originalFilename("brief1.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(anna)).build());

        // Walter sends to Clara (2 documents — Clara should rank higher)
        documentRepository.save(Document.builder()
                .title("Brief 2").originalFilename("brief2.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(clara)).build());
        documentRepository.save(Document.builder()
                .title("Brief 3").originalFilename("brief3.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(clara)).build());

        List<Person> correspondents = personRepository.findCorrespondents(walter.getId());

        assertThat(correspondents).extracting(Person::getFirstName)
                .containsExactly("Clara", "Anna"); // Clara ranks first (2 documents)
    }

    @Test
    void findCorrespondents_returnsEmpty_whenPersonHasNoDocuments() {
        Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());

        List<Person> correspondents = personRepository.findCorrespondents(solo.getId());

        assertThat(correspondents).isEmpty();
    }

    // ─── findCorrespondentsWithFilter ─────────────────────────────────────────

    @Test
    void findCorrespondentsWithFilter_returnsOnlyMatchingCorrespondents() {
        Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
        Person anna   = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
        Person bernd  = personRepository.save(Person.builder().firstName("Bernd").lastName("Braun").build());

        documentRepository.save(Document.builder()
                .title("Brief an Anna").originalFilename("anna.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(anna)).build());
        documentRepository.save(Document.builder()
                .title("Brief an Bernd").originalFilename("bernd.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(bernd)).build());

        List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "Anna");

        assertThat(filtered).extracting(Person::getFirstName).containsExactly("Anna");
    }

    @Test
    void findCorrespondentsWithFilter_isCaseInsensitive() {
        Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
        Person anna   = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());

        documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(anna)).build());

        List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "schmidt");

        assertThat(filtered).hasSize(1);
        assertThat(filtered.get(0).getLastName()).isEqualTo("Schmidt");
    }

    // ─── reassignSender ───────────────────────────────────────────────────────

    @Test
    void reassignSender_updatesDocumentsSenderFromSourceToTarget() {
        Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
        Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());

        documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(source).build());

        personRepository.reassignSender(source.getId(), target.getId());
        entityManager.flush();
        entityManager.clear();

        List<Document> docs = documentRepository.findBySenderId(target.getId());
        assertThat(docs).hasSize(1);
        assertThat(documentRepository.findBySenderId(source.getId())).isEmpty();
    }

    // ─── insertMissingReceiverReference ──────────────────────────────────────

    @Test
    void insertMissingReceiverReference_addsTargetWhereSourceWasReceiver() {
        Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
        Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
        Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());

        Document doc = documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(sender).receivers(Set.of(source)).build());

        personRepository.insertMissingReceiverReference(source.getId(), target.getId());
        entityManager.flush();
        entityManager.clear();

        Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
        assertThat(reloaded.getReceivers())
                .extracting(Person::getId)
                .contains(target.getId());
    }

    @Test
    void insertMissingReceiverReference_doesNotCreateDuplicate_whenTargetAlreadyReceiver() {
        Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
        Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
        Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());

        // target is already a receiver together with source
        Document doc = documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(sender).receivers(Set.of(source, target)).build());

        personRepository.insertMissingReceiverReference(source.getId(), target.getId());
        entityManager.flush();
        entityManager.clear();

        Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
        long targetCount = reloaded.getReceivers().stream()
                .filter(p -> p.getId().equals(target.getId())).count();
        assertThat(targetCount).isEqualTo(1); // no duplicate
    }

    // ─── Phase 3.2: findAllWithDocumentCount ──────────────────────────────────

    @Test
    void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() {
        Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
        Person anna   = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());

        // Walter sends 2 docs to Anna (Anna receives 2)
        documentRepository.save(Document.builder()
                .title("Brief 1").originalFilename("b1.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(anna)).build());
        documentRepository.save(Document.builder()
                .title("Brief 2").originalFilename("b2.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(walter).receivers(Set.of(anna)).build());
        // Anna also sends 1 doc to Walter
        documentRepository.save(Document.builder()
                .title("Brief 3").originalFilename("b3.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(anna).receivers(Set.of(walter)).build());

        List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();

        PersonSummaryDTO walterSummary = result.stream()
                .filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow();
        PersonSummaryDTO annaSummary = result.stream()
                .filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow();

        assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1
        assertThat(annaSummary.getDocumentCount()).isEqualTo(3);   // sent 1, received 2
    }

    @Test
    void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() {
        Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());

        List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();

        PersonSummaryDTO soloSummary = result.stream()
                .filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow();
        assertThat(soloSummary.getDocumentCount()).isEqualTo(0);
    }

    @Test
    void searchWithDocumentCount_filtersAndIncludesCount() {
        Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
        Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());

        documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(hans).receivers(Set.of(anna)).build());

        List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Hans");

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getFirstName()).isEqualTo("Hans");
        assertThat(result.get(0).getDocumentCount()).isEqualTo(1);
    }

    @Test
    void searchWithDocumentCount_isCaseInsensitive() {
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());

        List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("hans");

        assertThat(result).hasSize(1);
    }

    // ─── searchByName with aliases ───────────────────────────────────────────

    @Test
    void searchByName_findsByAliasLastName() {
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());

        List<Person> results = personRepository.searchByName("de Gruyter");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getLastName()).isEqualTo("Cram");
    }

    @Test
    void searchByName_stillFindsByCurrentLastName_afterAliasAdded() {
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());

        List<Person> results = personRepository.searchByName("Cram");

        assertThat(results).hasSize(1);
    }

    @Test
    void searchByName_doesNotReturnDuplicates_whenMultipleAliasesMatch() {
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("Gruyter-Cram").type(PersonNameAliasType.OTHER).sortOrder(1).build());

        List<Person> results = personRepository.searchByName("Gruyter");

        assertThat(results).hasSize(1);
    }

    @Test
    void searchByName_findsByAliasFirstName() {
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).firstName("Wilhelmina").lastName("de Gruyter")
                .type(PersonNameAliasType.BIRTH).sortOrder(0).build());

        List<Person> results = personRepository.searchByName("Wilhelmina");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getLastName()).isEqualTo("Cram");
    }

    @Test
    void searchByName_ordersByLastNameThenFirstName() {
        personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
        personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());

        List<Person> results = personRepository.searchByName("Cram");

        assertThat(results).extracting(Person::getFirstName)
                .containsExactly("Anna", "Bernd", "Clara");
    }

    // ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
    // The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
    // fetch query actually finds an alias-only match and feeds it into classification. These walk
    // the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.

    @Test
    void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
        PersonService personService = new PersonService(personRepository, aliasRepository);
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
        // Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
        // the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
        entityManager.flush();
        entityManager.clear();

        NameMatches matches = personService.resolveByName("Clara Cram");

        assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
    }

    @Test
    void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
        PersonService personService = new PersonService(personRepository, aliasRepository);
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).firstName("Wilhelmina").lastName("de Gruyter")
                .type(PersonNameAliasType.BIRTH).sortOrder(0).build());
        entityManager.flush();
        entityManager.clear();

        NameMatches matches = personService.resolveByName("Wilhelmina");

        assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
    }

    // ─── searchWithDocumentCount with aliases ────────────────────────────────

    @Test
    void searchWithDocumentCount_findsByAliasLastName() {
        Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        aliasRepository.save(PersonNameAlias.builder()
                .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());

        List<PersonSummaryDTO> results = personRepository.searchWithDocumentCount("de Gruyter");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getLastName()).isEqualTo("Cram");
    }

    // ─── null firstName handling ────────────────────────────────────────────

    @Test
    void searchByName_findsPersonWithNullFirstName() {
        personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());

        List<Person> result = personRepository.searchByName("Gesellschafter");

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
    }

    @Test
    void searchWithDocumentCount_findsPersonWithNullFirstName() {
        personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());

        List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Gesellschafter");

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
    }

    // ─── #671: provisional must be SELECTed in all three native projections ───
    // Adding isProvisional() to the interface compiles even if a native query forgets
    // to SELECT p.provisional — it then silently returns false. These tests are the only
    // guard against that trap, so they must run against real Postgres.

    @Test
    void findAllWithDocumentCount_projectsProvisionalTrue() {
        personRepository.save(Person.builder()
                .firstName("Inferred").lastName("Person").provisional(true).build());

        List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();

        assertThat(result).anyMatch(PersonSummaryDTO::isProvisional);
    }

    @Test
    void searchWithDocumentCount_projectsProvisionalTrue() {
        personRepository.save(Person.builder()
                .firstName("Provisorisch").lastName("Müller").provisional(true).build());

        List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Provisorisch");

        assertThat(result).hasSize(1);
        assertThat(result.get(0).isProvisional()).isTrue();
    }

    @Test
    void findTopByDocumentCount_projectsProvisionalTrue() {
        Person provisional = personRepository.save(Person.builder()
                .firstName("Top").lastName("Provisional").provisional(true).build());
        documentRepository.save(Document.builder()
                .title("Brief").originalFilename("b.pdf")
                .status(DocumentStatus.UPLOADED)
                .sender(provisional).build());

        List<PersonSummaryDTO> result = personRepository.findTopByDocumentCount(10);

        PersonSummaryDTO summary = result.stream()
                .filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
        assertThat(summary.isProvisional()).isTrue();
    }

    // ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ────────
    // The slice query (findByFilter) and the count query (countByFilter) MUST share one
    // WHERE clause so totalElements can never drift from the rendered page. These tests run
    // against real Postgres because the slice ORDER BY uses a computed alias that fails on H2.

    private void seedDirectoryFixture() {
        // Register family member, no documents — visible by reader default (familyMember)
        personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build());
        // Person with one document — visible by reader default (documentCount > 0)
        Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build());
        documentRepository.save(Document.builder().title("B").originalFilename("b.pdf")
                .status(DocumentStatus.UPLOADED).sender(hasDoc).build());
        // Provisional, zero-document, non-family — hidden by reader default
        personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build());
        // An institution with no documents, non-family, non-provisional
        personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build());
    }

    @Test
    void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, null, true, null, 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName)
                .containsExactlyInAnyOrder("Register", "Person");
    }

    @Test
    void countByFilter_readerDefault_matchesSliceSize() {
        seedDirectoryFixture();

        long count = personRepository.countByFilter(null, null, null, null, true, null);

        assertThat(count).isEqualTo(2);
    }

    @Test
    void findByFilter_showAll_returnsEveryone() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, null, false, null, 50, 0);

        assertThat(slice).hasSize(4);
    }

    @Test
    void findByFilter_typeInstitution_returnsOnlyInstitutions() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                "INSTITUTION", null, null, null, false, null, 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
    }

    @Test
    void findByFilter_familyOnly_returnsOnlyFamilyMembers() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, true, null, null, false, null, 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register");
    }

    @Test
    void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, true, null, false, null, 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person");
    }

    @Test
    void findByFilter_provisionalTrue_returnsOnlyProvisional() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, true, false, null, 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt");
    }

    @Test
    void findByFilter_combinedFilters_andTogether() {
        seedDirectoryFixture();
        // family + has-documents → intersection is empty (Register has no docs, Doku is not family)
        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, true, true, null, false, null, 50, 0);

        assertThat(slice).isEmpty();
    }

    @Test
    void findByFilter_query_combinesWithFilters() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, null, false, "Verlag", 50, 0);

        assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
    }

    @Test
    void findByFilter_pageBeyondRange_returnsEmptySlice() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, null, false, null, 50, 999 * 50);

        assertThat(slice).isEmpty();
    }

    @Test
    void findByFilter_respectsPageSize() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> firstPage = personRepository.findByFilter(
                null, null, null, null, false, null, 2, 0);
        List<PersonSummaryDTO> secondPage = personRepository.findByFilter(
                null, null, null, null, false, null, 2, 2);

        assertThat(firstPage).hasSize(2);
        assertThat(secondPage).hasSize(2);
        assertThat(firstPage).extracting(PersonSummaryDTO::getId)
                .doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList());
    }

    @Test
    void countByFilter_typeInstitution_matchesSlice() {
        seedDirectoryFixture();

        long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null);

        assertThat(count).isEqualTo(1);
    }

    @Test
    void countByFilter_query_matchesSliceSize() {
        // The whole point of the shared FILTER_WHERE is that the slice and the count can never
        // drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size
        // so a future edit to one query's LIKE clause is caught.
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, null, null, false, "Verlag", 50, 0);
        long count = personRepository.countByFilter(null, null, null, null, false, "Verlag");

        assertThat(count).isEqualTo(slice.size());
        assertThat(count).isEqualTo(1);
    }

    @Test
    void findByFilter_projectsDocumentCount() {
        seedDirectoryFixture();

        List<PersonSummaryDTO> slice = personRepository.findByFilter(
                null, null, true, null, false, null, 50, 0);

        assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
    }

    // ─── generation column (#689) ──────────────────────────────────────────────

    @Test
    void save_persistsGeneration_andFindByIdReturnsSameGeneration() {
        Person person = Person.builder()
                .firstName("Walter")
                .lastName("Raddatz")
                .generation(3)
                .build();

        Person saved = personRepository.save(person);
        entityManager.flush();
        entityManager.clear();

        Optional<Person> found = personRepository.findById(saved.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getGeneration()).isEqualTo(3);
    }

    @Test
    void save_allowsNullGeneration_existingRowsRemainNull() {
        Person person = Person.builder()
                .firstName("Anonym")
                .lastName("Person")
                .build();

        Person saved = personRepository.save(person);
        entityManager.flush();
        entityManager.clear();

        Optional<Person> found = personRepository.findById(saved.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getGeneration()).isNull();
    }

    // ─── #684: ON DELETE integrity enforced at the database layer ──────────────
    // A raw deleteById (bypassing PersonService) must keep referential integrity:
    // documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
    // transcription_block_mentioned_persons soft reference → CASCADE. These run against
    // real Postgres because the FK ON DELETE behaviour never fires on H2.

    @Test
    void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
        Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
        Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());

        Document sent = documentRepository.save(Document.builder()
                .title("Gesendet").originalFilename("sent.pdf")
                .status(DocumentStatus.UPLOADED).sender(target).build());
        Document received = documentRepository.save(Document.builder()
                .title("Empfangen").originalFilename("received.pdf")
                .status(DocumentStatus.UPLOADED).sender(bystander)
                .receivers(Set.of(target)).build());
        entityManager.flush();
        entityManager.clear();

        personRepository.deleteById(target.getId());
        entityManager.flush();
        entityManager.clear();

        assertThat(personRepository.findById(target.getId())).isEmpty();

        Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
        assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL

        Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
        assertThat(reloadedReceived.getReceivers())
                .noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row

        // Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
        // layer — both documents themselves survive. Guards against a future migration turning
        // documents.sender_id SET NULL into CASCADE and destroying historical letters.
        assertThat(documentRepository.findById(sent.getId())).isPresent();
        assertThat(documentRepository.findById(received.getId())).isPresent();
    }

    @Test
    void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
        Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
        Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
        Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());

        Document doc = documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED).sender(sender)
                .receivers(Set.of(target, coReceiver)).build());
        entityManager.flush();
        entityManager.clear();

        personRepository.deleteById(target.getId());
        entityManager.flush();
        entityManager.clear();

        Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
        assertThat(reloaded.getReceivers()).extracting(Person::getId)
                .containsExactly(coReceiver.getId()); // co-receiver untouched
    }

    @Test
    void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
        // AC-8: the trickier same-document interaction the cross-document cases don't exercise.
        Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
        Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());

        Document doc = documentRepository.save(Document.builder()
                .title("Selbstbrief").originalFilename("self.pdf")
                .status(DocumentStatus.UPLOADED).sender(target)
                .receivers(Set.of(target, coReceiver)).build());
        entityManager.flush();
        entityManager.clear();

        personRepository.deleteById(target.getId());
        entityManager.flush();
        entityManager.clear();

        Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
        assertThat(reloaded.getSender()).isNull();
        assertThat(reloaded.getReceivers()).extracting(Person::getId)
                .containsExactly(coReceiver.getId());
    }

    @Test
    void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
        // AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
        // in transcription_blocks.text and must stay visible as plain text after the person goes.
        Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
        Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
        Document doc = documentRepository.save(Document.builder()
                .title("Brief").originalFilename("brief.pdf")
                .status(DocumentStatus.UPLOADED).build());
        entityManager.flush();

        UUID annotationId = UUID.randomUUID();
        UUID blockId = UUID.randomUUID();
        entityManager.createNativeQuery(
                "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
                        + "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
                .setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
        entityManager.createNativeQuery(
                "INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
                .setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
                .setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
        // Two mention rows on the same block: the deleted person and an innocent bystander.
        entityManager.createNativeQuery(
                "INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
                        + "VALUES (?1, ?2, ?3)")
                .setParameter(1, blockId).setParameter(2, mentioned.getId())
                .setParameter(3, "Auguste Raddatz").executeUpdate();
        entityManager.createNativeQuery(
                "INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
                        + "VALUES (?1, ?2, ?3)")
                .setParameter(1, blockId).setParameter(2, survivor.getId())
                .setParameter(3, "Clara Cram").executeUpdate();
        entityManager.flush();
        entityManager.clear();

        personRepository.deleteById(mentioned.getId());
        entityManager.flush();
        entityManager.clear();

        Number mentionRows = (Number) entityManager.createNativeQuery(
                "SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
                .setParameter(1, mentioned.getId()).getSingleResult();
        assertThat(mentionRows.longValue()).isZero();

        // The cascade is scoped to the deleted person — the bystander's mention row is untouched.
        Number survivorRows = (Number) entityManager.createNativeQuery(
                "SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
                .setParameter(1, survivor.getId()).getSingleResult();
        assertThat(survivorRows.longValue()).isEqualTo(1);

        String text = (String) entityManager.createNativeQuery(
                "SELECT text FROM transcription_blocks WHERE id = ?1")
                .setParameter(1, blockId).getSingleResult();
        assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
    }

    // ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────

    @Test
    void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
        personRepository.save(Person.builder()
                .firstName("Maria").lastName("Datiert")
                .birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
                .build());
        personRepository.save(Person.builder()
                .firstName("Nora").lastName("Undatiert")
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();

        PersonSummaryDTO dated = all.stream()
                .filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
        assertThat(dated.getBirthYear()).isEqualTo(1901);
        assertThat(dated.getDeathYear()).isNull();
        PersonSummaryDTO undated = all.stream()
                .filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
        assertThat(undated.getBirthYear()).isNull();
        assertThat(undated.getDeathYear()).isNull();
    }

    @Test
    void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
        personRepository.save(Person.builder()
                .firstName("Herbert").lastName("Gruppiert")
                .birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
                .deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");

        assertThat(found).hasSize(1);
        assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
        assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
    }

    @Test
    void findByFilter_derivesYearsFromDates() {
        personRepository.save(Person.builder()
                .firstName("Filtriert").lastName("Person")
                .birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> found = personRepository.findByFilter(
                null, null, null, null, false, "Filtriert", 10, 0);

        assertThat(found).hasSize(1);
        assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
        assertThat(found.get(0).getDeathYear()).isNull();
    }

    // ─── #773 follow-up: full date + precision exposed on the summary projection ──
    // (the mention dropdown renders precise life dates from the list endpoint)

    @Test
    void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
        personRepository.save(Person.builder()
                .firstName("Maria").lastName("Praezise")
                .birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
                .deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
                .build());
        personRepository.save(Person.builder()
                .firstName("Nora").lastName("Datenlos")
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();

        PersonSummaryDTO dated = all.stream()
                .filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
        assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
        assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
        assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
        assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
        PersonSummaryDTO undated = all.stream()
                .filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
        assertThat(undated.getBirthDate()).isNull();
        assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
        assertThat(undated.getDeathDate()).isNull();
        assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
    }

    @Test
    void searchWithDocumentCount_exposesDateAndPrecisionFields() {
        personRepository.save(Person.builder()
                .firstName("Herbert").lastName("Suchbar")
                .birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
                .deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");

        assertThat(found).hasSize(1);
        assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
        assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
        assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
        assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
    }

    @Test
    void findTopByDocumentCount_exposesDateAndPrecisionFields() {
        personRepository.save(Person.builder()
                .firstName("Top").lastName("Dokumentiert")
                .birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);

        PersonSummaryDTO found = top.stream()
                .filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
        assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
        assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
        assertThat(found.getDeathDate()).isNull();
        assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
    }

    @Test
    void findByFilter_exposesDateAndPrecisionFields() {
        personRepository.save(Person.builder()
                .firstName("Gefiltert").lastName("Genau")
                .deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
                .build());
        entityManager.flush();

        List<PersonSummaryDTO> found = personRepository.findByFilter(
                null, null, null, null, false, "Gefiltert", 10, 0);

        assertThat(found).hasSize(1);
        assertThat(found.get(0).getBirthDate()).isNull();
        assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
        assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
        assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
    }
}
