package org.raddatz.familienarchiv.person.relationship;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.dao.DataIntegrityViolationException;

import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Felix Brandt — TDD red for RelationshipService domain rules.
 *
 * <p>Required by the plan (Nora blockers 1 + 2):
 * <ul>
 *   <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
 *   <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
 * </ul>
 * Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
 * and ownership permitted from either side.
 */
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {

    @Mock PersonRelationshipRepository relationshipRepository;
    @Mock PersonService personService;
    @Mock RelationshipInferenceService inferenceService;
    @InjectMocks RelationshipService service;

    Person alice;
    Person bob;
    Person charlie;

    @BeforeEach
    void seed() {
        alice = person("Alice");
        bob = person("Bob");
        charlie = person("Charlie");
    }

    // --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
    @Test
    void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = parentOf(alice, bob, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));

        assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
        verify(relationshipRepository, never()).delete(any());
    }

    // --- Nora blocker 2 ---
    @Test
    void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
        // alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);

        var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
        assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
        when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
        assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
    }

    @Test
    void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
        var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
        assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
                LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
                LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
        assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
                LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
        assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
                null, DatePrecision.DAY, null, null, null);
        assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void addRelationship_persists_with_storage_truth() {
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
        when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
            PersonRelationship r = inv.getArgument(0);
            r.setId(UUID.randomUUID());
            r.setCreatedAt(Instant.now());
            return r;
        });

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
                LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
        var result = service.addRelationship(alice.getId(), dto);

        assertThat(result.personId()).isEqualTo(alice.getId());
        assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
        assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
        assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
        assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
        assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
        assertThat(result.notes()).isEqualTo("first born");
    }

    @Test
    void addRelationship_marks_both_endpoints_as_family_member_when_type_is_family() {
        // Creating a family-graph edge (PARENT_OF / SPOUSE_OF / SIBLING_OF) must mark both
        // endpoints as family members so they appear in findAllFamilyMembers and the network.
        // This is what makes the canonical importer's relationships actually show up in the UI.
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
        when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
            PersonRelationship r = inv.getArgument(0);
            r.setId(UUID.randomUUID());
            r.setCreatedAt(Instant.now());
            return r;
        });

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
        service.addRelationship(alice.getId(), dto);

        verify(personService).setFamilyMember(alice.getId(), true);
        verify(personService).setFamilyMember(bob.getId(), true);
    }

    @Test
    void addRelationship_does_not_flip_family_member_for_non_family_type() {
        // FRIEND / COLLEAGUE / EMPLOYER / DOCTOR / NEIGHBOR / OTHER are NOT family-graph
        // edges (see getFamilyNetwork's filter), so addRelationship must leave family_member
        // alone — a doctor of the family is not a family member.
        when(personService.getById(alice.getId())).thenReturn(alice);
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
            PersonRelationship r = inv.getArgument(0);
            r.setId(UUID.randomUUID());
            r.setCreatedAt(Instant.now());
            return r;
        });

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
        service.addRelationship(alice.getId(), dto);

        verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
        verify(personService, never()).setFamilyMember(eq(bob.getId()), anyBoolean());
    }

    @Test
    void deleteRelationship_succeeds_when_viewpoint_is_object() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = parentOf(alice, bob, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));

        // Bob is the storage related_person; deleting from his viewpoint should work.
        service.deleteRelationship(bob.getId(), relId);
        verify(relationshipRepository).delete(rel);
    }

    @Test
    void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
        UUID relId = UUID.randomUUID();
        when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
    }

    // --- updateRelationship (REQ-004/006/007/008/009/010/013) ---

    @Test
    void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
        UUID relId = UUID.randomUUID();
        when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
        assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = parentOf(alice, bob, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
        assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));

        var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
        assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
                LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
                LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
        assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
        assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
        verify(relationshipRepository, never()).saveAndFlush(any());
    }

    @Test
    void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
        assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
    }

    @Test
    void updateRelationship_updates_fields_and_returns_dto() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
                LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
        var result = service.updateRelationship(alice.getId(), relId, dto);

        assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
        assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
        assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
        assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
        assertThat(result.notes()).isEqualTo("wedding day");
    }

    @Test
    void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
        UUID relId = UUID.randomUUID();
        PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
        when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
        when(personService.getById(bob.getId())).thenReturn(bob);
        when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));

        var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
        service.updateRelationship(alice.getId(), relId, dto);

        verify(personService).setFamilyMember(alice.getId(), true);
        verify(personService).setFamilyMember(bob.getId(), true);
    }

    @Test
    void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
        // alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
        // Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
        // Only the alice→bob edge must appear in the returned NetworkDTO.
        UUID aliceBobRelId = UUID.randomUUID();
        UUID aliceCharlieRelId = UUID.randomUUID();
        PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
        PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);

        when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
        when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));

        NetworkDTO result = service.getFamilyNetwork();

        assertThat(result.nodes()).hasSize(2);
        assertThat(result.edges()).hasSize(1);
        assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
        assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
    }

    @Test
    void getFamilyNetwork_populates_generation_on_PersonNodeDTO() {
        Person walter = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
                .familyMember(true).generation(2).build();
        Person clara = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
                .familyMember(true).generation(3).build();
        when(personService.findAllFamilyMembers()).thenReturn(List.of(walter, clara));
        when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of());

        NetworkDTO result = service.getFamilyNetwork();

        assertThat(result.nodes()).hasSize(2);
        assertThat(result.nodes().stream().map(n -> n.generation()).toList())
                .containsExactlyInAnyOrder(2, 3);
    }

    // --- helpers ---

    private static Person person(String name) {
        return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
    }

    private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
        return relOf(parent, child, RelationType.PARENT_OF, id);
    }

    private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
        return PersonRelationship.builder()
                .id(id)
                .person(subject)
                .relatedPerson(object)
                .relationType(type)
                .createdAt(Instant.now())
                .build();
    }
}
