package org.raddatz.familienarchiv.person.relationship;

import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
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.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
 * Owns the {@code person_relationships} table and the family_member flag.
 * Always orchestrates {@link PersonService} for cross-domain access — never
 * touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
 */
@Service
@RequiredArgsConstructor
public class RelationshipService {

    // Single source of truth for which relationship types are part of the family graph.
    // Consulted by addRelationship (to set family_member on both endpoints) and by
    // getFamilyNetwork (to filter the edges returned). FRIEND/COLLEAGUE/etc. are excluded.
    private static final List<RelationType> FAMILY_RELATION_TYPES =
            List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF);

    private final PersonRelationshipRepository relationshipRepository;
    private final PersonService personService;
    private final RelationshipInferenceService inferenceService;

    public List<RelationshipDTO> getRelationships(UUID personId) {
        personService.getById(personId);
        List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
        return rels.stream().map(RelationshipService::toDTO).toList();
    }

    public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
        personService.getById(personId);
        return inferenceService.findAllFor(personId);
    }

    public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
        personService.getById(a);
        personService.getById(b);
        return inferenceService.infer(a, b);
    }

    public NetworkDTO getFamilyNetwork() {
        // Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
        List<Person> familyMembers = personService.findAllFamilyMembers();
        Set<UUID> familyIds = new HashSet<>(familyMembers.size());
        List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
        for (Person p : familyMembers) {
            familyIds.add(p.getId());
            nodes.add(new PersonNodeDTO(
                    p.getId(), p.getDisplayName(),
                    yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
                    p.getGeneration(), true));
        }

        List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
                FAMILY_RELATION_TYPES);

        List<RelationshipDTO> edges = new ArrayList<>();
        for (PersonRelationship r : familyEdges) {
            UUID p = r.getPerson().getId();
            UUID rp = r.getRelatedPerson().getId();
            if (familyIds.contains(p) && familyIds.contains(rp)) {
                edges.add(toDTO(r));
            }
        }
        return new NetworkDTO(nodes, edges);
    }

    /**
     * Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
     * Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
     * without per-edge N+1 queries.
     */
    public List<PersonRelationship> findAllSpouseEdges() {
        return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
    }

    @Transactional
    public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
        requireNotSelf(personId, dto.relatedPersonId());
        Person person = personService.getById(personId);
        Person relatedPerson = personService.getById(dto.relatedPersonId());

        validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
        requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());

        PersonRelationship rel = PersonRelationship.builder()
                .person(person)
                .relatedPerson(relatedPerson)
                .relationType(dto.relationType())
                .fromDate(dto.fromDate())
                .fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
                .toDate(dto.toDate())
                .toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
                .notes(blankToNull(dto.notes()))
                .build();

        PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
        flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
        return toDTO(saved);
    }

    @Transactional
    public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
        PersonRelationship rel = loadOwnedRelationship(personId, relId);

        // The other party from {personId}'s viewpoint cannot be {personId} itself.
        requireNotSelf(personId, dto.relatedPersonId());
        validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());

        // Preserve the directed orientation: {personId} keeps whichever role (subject or
        // object) it already holds on the row, and the edited "related person" takes the
        // other role. So a PARENT_OF edge stays parent→child whether the curator edits it
        // from the parent's page or the child's.
        boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
        Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
        Person other = personService.getById(dto.relatedPersonId());
        Person newSubject = viewpointIsSubject ? viewpoint : other;
        Person newObject = viewpointIsSubject ? other : viewpoint;

        requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());

        rel.setPerson(newSubject);
        rel.setRelatedPerson(newObject);
        rel.setRelationType(dto.relationType());
        rel.setFromDate(dto.fromDate());
        rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
        rel.setToDate(dto.toDate());
        rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
        rel.setNotes(blankToNull(dto.notes()));

        PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
        flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
        return toDTO(saved);
    }

    // --- shared create/update invariants ---------------------------------------------

    // A person cannot be related to themselves, from either viewpoint.
    private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
        if (viewpointId.equals(relatedPersonId)) {
            throw DomainException.badRequest(
                    ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
        }
    }

    // A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
    // that would be a cycle. No-op for every other relation type.
    private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
        if (type == RelationType.PARENT_OF
                && relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
                        objectId, subjectId, RelationType.PARENT_OF)) {
            throw DomainException.conflict(
                    ErrorCode.CIRCULAR_RELATIONSHIP,
                    "Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
        }
    }

    // saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
    // inside the @Transactional boundary, not at commit time as a raw 500.
    private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
        try {
            return relationshipRepository.saveAndFlush(rel);
        } catch (DataIntegrityViolationException e) {
            throw DomainException.conflict(
                    ErrorCode.DUPLICATE_RELATIONSHIP,
                    "Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
        }
    }

    // Family-graph edges imply both endpoints are family members. Idempotent (the setter is
    // a no-op when already flagged, so re-imports stay clean) and additive — an edit never
    // auto-unflags.
    private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
        if (FAMILY_RELATION_TYPES.contains(type)) {
            personService.setFamilyMember(subjectId, true);
            personService.setFamilyMember(objectId, true);
        }
    }

    @Transactional
    public void deleteRelationship(UUID personId, UUID relId) {
        PersonRelationship rel = loadOwnedRelationship(personId, relId);
        relationshipRepository.delete(rel);
    }

    // Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
    // (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
    // belonging to people they cannot see. Shared by update + delete for consistency.
    private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
        PersonRelationship rel = relationshipRepository.findById(relId)
                .orElseThrow(() -> DomainException.notFound(
                        ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
        UUID storageSubject = rel.getPerson().getId();
        UUID storageObject = rel.getRelatedPerson().getId();
        if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
            throw DomainException.notFound(
                    ErrorCode.RELATIONSHIP_NOT_FOUND,
                    "Relationship " + relId + " does not belong to person " + personId);
        }
        return rel;
    }

    @Transactional
    public Person setFamilyMember(UUID personId, boolean familyMember) {
        return personService.setFamilyMember(personId, familyMember);
    }

    private static String blankToNull(String s) {
        return (s == null || s.isBlank()) ? null : s.trim();
    }

    // Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
    // for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
    // so RelationshipInferenceService shares the same derivation.
    static Integer yearOf(LocalDate date) {
        return date != null ? date.getYear() : null;
    }

    // Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
    // user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
    // Coherence is shared with the person domain (DatePrecisionValidation); only the order
    // check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
    private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
                                                  LocalDate toDate, DatePrecision toPrecision) {
        DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
        DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
        if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
            throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
                    "toDate " + toDate + " is before fromDate " + fromDate);
        }
    }

    private static RelationshipDTO toDTO(PersonRelationship r) {
        Person p = r.getPerson();
        Person rp = r.getRelatedPerson();
        return new RelationshipDTO(
                r.getId(),
                p.getId(),
                rp.getId(),
                p.getDisplayName(),
                yearOf(p.getBirthDate()),
                yearOf(p.getDeathDate()),
                rp.getDisplayName(),
                yearOf(rp.getBirthDate()),
                yearOf(rp.getDeathDate()),
                r.getRelationType(),
                r.getFromDate(),
                r.getFromDatePrecision(),
                r.getToDate(),
                r.getToDatePrecision(),
                r.getNotes());
    }
}
