package org.raddatz.familienarchiv.person;

import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;

import org.springframework.mock.web.MockMultipartFile;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;

import java.util.Set;
import java.util.UUID;

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class PersonServiceIntegrationTest {

    @MockitoBean S3Client s3Client;
    @Autowired PersonService personService;
    @Autowired PersonRepository personRepository;
    @Autowired DocumentRepository documentRepository;
    @Autowired DocumentService documentService;

    @PersistenceContext EntityManager entityManager;

    @Test
    void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
        Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");

        assertThat(result).isNull();
        assertThat(personRepository.findAll()).isEmpty();
    }

    @Test
    void findOrCreateByAlias_institutionStoresFullNameInLastName() {
        Person result = personService.findOrCreateByAlias("Arthur Collignon GmbH");

        assertThat(result).isNotNull();
        assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
        assertThat(result.getFirstName()).isNull();
        assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
        assertThat(result.getDisplayName()).isEqualTo("Arthur Collignon GmbH");
    }

    @Test
    void findOrCreateByAlias_groupStoresFullNameInLastName() {
        Person result = personService.findOrCreateByAlias("Geschwister de Gruyter");

        assertThat(result).isNotNull();
        assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
        assertThat(result.getFirstName()).isNull();
        assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
    }

    @Test
    void findOrCreateByAlias_personSplitsNameNormally() {
        Person result = personService.findOrCreateByAlias("Clara Cram");

        assertThat(result).isNotNull();
        assertThat(result.getPersonType()).isEqualTo(PersonType.PERSON);
        assertThat(result.getFirstName()).isEqualTo("Clara");
        assertThat(result.getLastName()).isEqualTo("Cram");
    }

    // ─── #731: case-colliding alias resolution against real Postgres ───────────
    // The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
    // plain-ASCII test would stay green while umlaut aliases regressed.

    @Test
    void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
        Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
        Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
        UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();

        // No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
        // candidates and must pick the lowest id, never throwing NonUniqueResultException.
        Person resolved = personService.findOrCreateByAlias("MÜLLER");

        assertThat(resolved.getId()).isEqualTo(expected);
    }

    @Test
    void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
        personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
        personRepository.save(Person.builder().lastName("müller").alias("müller").build());

        Person first = personService.findOrCreateByAlias("MÜLLER");
        Person second = personService.findOrCreateByAlias("MÜLLER");

        assertThat(second.getId()).isEqualTo(first.getId());
    }

    // ─── #731: filename-based sender resolution against real Postgres ──────────

    @Test
    void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
        Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());

        Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();

        assertThat(doc.getSender()).isNotNull();
        assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
    }

    @Test
    void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
        Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());

        // Filename folds to "hans müller"; the only stored person is "Hans Müller".
        Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();

        assertThat(doc.getSender()).isNotNull();
        assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
    }

    @Test
    void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
        // Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
        // neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
        personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
        personRepository.save(Person.builder().firstName("hans").lastName("müller").build());

        Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();

        assertThat(doc.getSender()).isNull();
    }

    @Test
    void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
        // A last-name-only filename never resolves to a sender (the parser yields no parsed name).
        personRepository.save(Person.builder().lastName("Müller").build());

        Document doc = uploadNamed("1965-03-12_Müller.pdf").document();

        assertThat(doc.getSender()).isNull();
    }

    @Test
    void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
        // Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
        // and pick up the last-name-only row.
        personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL

        assertThat(personService.findByName(null, "Müller")).isEmpty();
    }

    private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
        return documentService.storeDocument(file, null);
    }

    // ─── #667: confirm round-trip + reader-default semantics ──────────────────

    @Test
    void search_readerDefault_hidesProvisionalZeroDocumentPerson() {
        personRepository.save(Person.builder()
                .firstName("Unbe").lastName("Staetigt").provisional(true).build());

        PersonSearchResult result = personService.search(PersonFilter.cleanDefault(), 0, 50, null);

        assertThat(result.items()).noneMatch(p -> p.getLastName().equals("Staetigt"));
        assertThat(result.totalElements()).isEqualTo(result.items().size());
    }

    @Test
    void search_showAll_includesProvisionalZeroDocumentPerson() {
        personRepository.save(Person.builder()
                .firstName("Unbe").lastName("Staetigt").provisional(true).build());

        PersonSearchResult result = personService.search(PersonFilter.showAll(), 0, 50, null);

        assertThat(result.items()).anyMatch(p -> p.getLastName().equals("Staetigt"));
    }

    @Test
    void confirmPerson_clearsProvisional_andShowAllTreatsItAsConfirmed() {
        Person provisional = personRepository.save(Person.builder()
                .firstName("Bald").lastName("Bestaetigt").provisional(true).build());

        personService.confirmPerson(provisional.getId());

        Person reloaded = personRepository.findById(provisional.getId()).orElseThrow();
        assertThat(reloaded.isProvisional()).isFalse();

        PersonSearchResult showAll = personService.search(PersonFilter.showAll(), 0, 50, null);
        assertThat(showAll.items())
                .filteredOn(p -> p.getId().equals(provisional.getId()))
                .allMatch(p -> !p.isProvisional());
    }

    @Test
    void deletePerson_removesPerson() {
        Person target = personRepository.save(Person.builder()
                .firstName("Weg").lastName("Person").provisional(true).build());

        personService.deletePerson(target.getId());

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

    // ─── generation full-stack round-trip (#689) ──────────────────────────────

    @Test
    void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
        // Sara's QA concern: pin the full PUT→DB→GET round-trip for the
        // null-clear path. Without this we only have the WebMvcTest mocked
        // boundary; nothing proved the JPA flush actually wrote SQL NULL.
        Person seeded = personRepository.save(Person.builder()
                .firstName("Hans").lastName("Raddatz")
                .personType(PersonType.PERSON).generation(3).build());
        entityManager.flush();
        entityManager.clear();

        PersonUpdateDTO dto = new PersonUpdateDTO();
        dto.setPersonType(PersonType.PERSON);
        dto.setFirstName("Hans");
        dto.setLastName("Raddatz");
        dto.setGeneration(null);

        personService.updatePerson(seeded.getId(), dto);
        entityManager.flush();
        entityManager.clear();

        Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
        assertThat(reloaded.getGeneration()).isNull();
    }

    @Test
    void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
        // Pin the G 0 case end-to-end. The form-action spec covers that 0
        // doesn't get spread-dropped at the SvelteKit boundary; this test
        // covers that the controller + service + JPA chain preserves the
        // primitive zero (not coerced to null somewhere along the way).
        Person seeded = personRepository.save(Person.builder()
                .firstName("Walter").lastName("Raddatz")
                .personType(PersonType.PERSON).build());
        entityManager.flush();
        entityManager.clear();

        PersonUpdateDTO dto = new PersonUpdateDTO();
        dto.setPersonType(PersonType.PERSON);
        dto.setFirstName("Walter");
        dto.setLastName("Raddatz");
        dto.setGeneration(0);

        personService.updatePerson(seeded.getId(), dto);
        entityManager.flush();
        entityManager.clear();

        Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
        assertThat(reloaded.getGeneration()).isEqualTo(0);
    }

    @Test
    void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
        // A person referenced as BOTH a document sender and a document receiver must delete
        // cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
        // constraints null the sender_id FK and drop the receiver join row, so there is no FK
        // orphan and the documents themselves survive.
        Person target = personRepository.save(Person.builder()
                .firstName("Weg").lastName("Person").provisional(true).build());
        Person bystander = personRepository.save(Person.builder()
                .firstName("Bleibt").lastName("Hier").build());

        Document sent = documentRepository.save(Document.builder()
                .title("Sent letter").originalFilename("sent.pdf")
                .status(DocumentStatus.UPLOADED).sender(target).build());
        Document received = documentRepository.save(Document.builder()
                .title("Received letter").originalFilename("received.pdf")
                .status(DocumentStatus.UPLOADED).sender(bystander)
                .receivers(new java.util.HashSet<>(Set.of(target))).build());

        // Persist the fixture and detach everything so the delete operates on the database
        // directly without the persistence context holding stale references.
        entityManager.flush();
        entityManager.clear();

        personService.deletePerson(target.getId());

        // The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
        // cache so the asserting reads observe the post-delete database state, not stale
        // managed entities still holding the dropped sender/receiver associations.
        entityManager.flush();
        entityManager.clear();

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

        Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
        assertThat(reloadedSent.getSender()).isNull();

        Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
        assertThat(reloadedReceived.getReceivers())
                .noneMatch(p -> p.getId().equals(target.getId()));
        // The other person and the documents themselves survive the delete.
        assertThat(personRepository.findById(bystander.getId())).isPresent();
    }

    @Test
    void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
        // AC-7: merging a source who is sender of A and receiver of B into a target leaves the
        // target as sender of A and receiver of B, drops the source's leftover receiver row via
        // V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
        Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
        Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").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 docA = documentRepository.save(Document.builder()
                .title("Von Anna").originalFilename("a.pdf")
                .status(DocumentStatus.UPLOADED).sender(source).build());
        Document docB = documentRepository.save(Document.builder()
                .title("An Anna").originalFilename("b.pdf")
                .status(DocumentStatus.UPLOADED).sender(sender)
                .receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
        entityManager.flush();
        entityManager.clear();

        personService.mergePersons(source.getId(), target.getId());
        entityManager.flush();
        entityManager.clear();

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

        Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
        assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());

        Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
        assertThat(reloadedB.getReceivers()).extracting(Person::getId)
                .containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
    }
}
