package org.raddatz.familienarchiv.person;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.lang.Nullable;

import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class PersonService {

    private final PersonRepository personRepository;
    private final PersonNameAliasRepository aliasRepository;

    public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
        return personRepository.findTopByDocumentCount(limit);
    }

    /**
     * Filtered, paginated directory query. The slice and the total are derived from one
     * shared WHERE clause (see {@link PersonRepository#FILTER_WHERE}) so totalElements can
     * never drift from the rendered page. {@code type} is passed as the enum name because the
     * native query compares against the string column.
     */
    public PersonSearchResult search(PersonFilter filter, int page, int size, String q) {
        String type = filter.type() == null ? null : filter.type().name();
        String query = (q == null || q.isBlank()) ? null : q.trim();
        int offset = page * size;

        List<PersonSummaryDTO> items = personRepository.findByFilter(
                type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
                filter.readerDefault(), query, size, offset);
        long total = personRepository.countByFilter(
                type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
                filter.readerDefault(), query);

        return PersonSearchResult.paged(items, page, size, total);
    }

    /**
     * Clears the {@code provisional} flag — a deliberate state transition exposed as
     * {@code PATCH /api/persons/{id}/confirm}, never as a mass-assignable DTO field (CWE-915).
     */
    @Transactional
    public Person confirmPerson(UUID id) {
        Person person = getById(id);
        person.setProvisional(false);
        return personRepository.save(person);
    }

    /**
     * Hard-deletes a person used by triage. Detaches the person from any documents they
     * sent (nulls sender_id) and from any received-document references first, so the delete
     * cannot orphan an FK and fail with a 500.
     */
    @Transactional
    public void deletePerson(UUID id) {
        getById(id);
        personRepository.reassignSenderToNull(id);
        personRepository.deleteReceiverReferences(id);
        personRepository.deleteById(id);
    }

    public Person getById(UUID id) {
        return personRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
    }

    public long count() {
        return personRepository.count();
    }

    public List<Person> findCorrespondents(UUID personId, String q) {
        if (q != null && !q.isBlank()) {
            return personRepository.findCorrespondentsWithFilter(personId, q);
        }
        return personRepository.findCorrespondents(personId);
    }

    public List<Person> getAllById(List<UUID> ids) {
        return personRepository.findAllById(ids);
    }

    public List<Person> findAllFamilyMembers() {
        return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
    }

    @Transactional
    public Person setFamilyMember(UUID personId, boolean familyMember) {
        Person person = getById(personId);
        person.setFamilyMember(familyMember);
        return personRepository.save(person);
    }

    public Optional<Person> findByName(String firstName, String lastName) {
        return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
    }

    /** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
    public Optional<Person> findBySourceRef(String sourceRef) {
        return personRepository.findBySourceRef(sourceRef);
    }

    @Nullable
    @Transactional
    public Person findOrCreateByAlias(String rawName) {
        String alias = rawName.trim();
        PersonType type = PersonTypeClassifier.classify(alias);
        if (type == PersonType.SKIP) return null;

        return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
            if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
                return personRepository.save(Person.builder()
                        .alias(alias)
                        .lastName(alias)
                        .personType(type)
                        .build());
            }

            PersonNameParser.SplitName split = PersonNameParser.split(alias);
            Person person = personRepository.save(Person.builder()
                    .alias(alias)
                    .firstName(split.firstName())
                    .lastName(split.lastName())
                    .build());
            if (split.maidenName() != null) {
                int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
                aliasRepository.save(PersonNameAlias.builder()
                        .person(person)
                        .lastName(split.maidenName())
                        .type(PersonNameAliasType.MAIDEN_NAME)
                        .sortOrder(nextSortOrder)
                        .build());
            }
            return person;
        });
    }

    /**
     * Idempotent upsert keyed on {@code sourceRef} (the normalizer person_id) for the
     * canonical importer (Phase 3, ADR-025). On first import the canonical fields are
     * written verbatim. On re-import the human-edit-preserve precedence applies:
     * a non-blank existing field is never overwritten, and {@code provisional} never
     * flips back to true once a human has confirmed the person.
     */
    @Transactional
    public Person upsertBySourceRef(PersonUpsertCommand cmd) {
        return personRepository.findBySourceRef(cmd.sourceRef())
                .map(existing -> personRepository.save(mergeCanonical(existing, cmd)))
                .orElseGet(() -> fromCanonical(cmd));
    }

    private Person fromCanonical(PersonUpsertCommand cmd) {
        Person person = personRepository.save(Person.builder()
                .sourceRef(cmd.sourceRef())
                .firstName(blankToNull(cmd.firstName()))
                .lastName(cmd.lastName())
                .notes(blankToNull(cmd.notes()))
                .birthYear(cmd.birthYear())
                .deathYear(cmd.deathYear())
                .generation(cmd.generation())
                .familyMember(cmd.familyMember())
                .personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
                .provisional(cmd.provisional())
                .build());
        String maiden = blankToNull(cmd.maidenName());
        if (maiden != null) {
            int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
            aliasRepository.save(PersonNameAlias.builder()
                    .person(person)
                    .lastName(maiden)
                    .type(PersonNameAliasType.MAIDEN_NAME)
                    .sortOrder(nextSortOrder)
                    .build());
        }
        return person;
    }

    private Person mergeCanonical(Person existing, PersonUpsertCommand cmd) {
        existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
        existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
        existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
        existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
        existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
        existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
        if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
            existing.setPersonType(cmd.personType());
        }
        // provisional is monotonic-downward: once it is false it never reverts to true.
        // This also pins the cross-loader precedence (ADR-025): a register/tree person is
        // loaded before documents and already false, so a later document row that references
        // the same source_ref (provisional=true) can never flip it provisional — the guard
        // below only fires while existing is still provisional. Order of document rows is
        // therefore irrelevant.
        if (existing.isProvisional()) {
            existing.setProvisional(cmd.provisional());
        }
        return existing;
    }

    // preferHuman keeps an existing human-entered value and only falls back to the canonical
    // value when the existing one is absent — the single idiom for every fill-blank field.
    private static String preferHuman(String existing, String canonical) {
        return (existing == null || existing.isBlank()) ? blankToNull(canonical) : existing;
    }

    private static Integer preferHuman(Integer existing, Integer canonical) {
        return existing != null ? existing : canonical;
    }

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

    @Transactional
    public Person createPerson(String firstName, String lastName, String alias) {
        Person person = Person.builder()
                .firstName(firstName)
                .lastName(lastName)
                .alias(alias == null || alias.isBlank() ? null : alias.trim())
                .build();
        return personRepository.save(person);
    }

    @Transactional
    public Person createPerson(PersonUpdateDTO dto) {
        if (dto.getPersonType() == PersonType.SKIP) {
            throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
        }
        validateYears(dto.getBirthYear(), dto.getDeathYear());
        Person person = Person.builder()
                .personType(dto.getPersonType())
                .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
                .firstName(dto.getFirstName())
                .lastName(dto.getLastName())
                .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
                .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
                .birthYear(dto.getBirthYear())
                .deathYear(dto.getDeathYear())
                .generation(dto.getGeneration())
                .build();
        return personRepository.save(person);
    }

    private void validateYears(Integer birthYear, Integer deathYear) {
        if (birthYear != null && birthYear <= 0) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
        }
        if (deathYear != null && deathYear <= 0) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
        }
        if (birthYear != null && deathYear != null && birthYear > deathYear) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
        }
    }

    @Transactional
    public Person updatePerson(UUID id, PersonUpdateDTO dto) {
        if (dto.getPersonType() == PersonType.SKIP) {
            throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
        }
        validateYears(dto.getBirthYear(), dto.getDeathYear());
        Person person = personRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
        person.setPersonType(dto.getPersonType());
        person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
        person.setFirstName(dto.getFirstName());
        person.setLastName(dto.getLastName());
        person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
        person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
        person.setBirthYear(dto.getBirthYear());
        person.setDeathYear(dto.getDeathYear());
        // Form path: a human can clear generation back to null. Unlike the importer
        // which routes through preferHuman, we write the DTO value verbatim.
        person.setGeneration(dto.getGeneration());
        return personRepository.save(person);
    }

    @Transactional
    public void mergePersons(UUID sourceId, UUID targetId) {
        if (sourceId.equals(targetId)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein");
        }
        personRepository.findById(sourceId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Source person not found: " + sourceId));
        personRepository.findById(targetId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Target person not found: " + targetId));

        // Reassign sender references
        personRepository.reassignSender(sourceId, targetId);

        // Add target as receiver where source is receiver but target is not yet
        personRepository.insertMissingReceiverReference(sourceId, targetId);

        // Remove all remaining source receiver references (duplicates already handled)
        personRepository.deleteReceiverReferences(sourceId);

        personRepository.deleteById(sourceId);
    }

    // ─── Alias management ───────────────────────────────────────────────────

    public List<PersonNameAlias> getAliases(UUID personId) {
        getById(personId);
        return aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId);
    }

    @Transactional
    public PersonNameAlias addAlias(UUID personId, PersonNameAliasDTO dto) {
        Person person = getById(personId);
        int nextSortOrder = aliasRepository.findMaxSortOrder(personId) + 1;
        PersonNameAlias alias = PersonNameAlias.builder()
                .person(person)
                .lastName(dto.lastName())
                .firstName(dto.firstName())
                .type(dto.type())
                .sortOrder(nextSortOrder)
                .build();
        return aliasRepository.save(alias);
    }

    @Transactional
    public void removeAlias(UUID personId, UUID aliasId) {
        PersonNameAlias alias = aliasRepository.findById(aliasId)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.ALIAS_NOT_FOUND, "Alias not found: " + aliasId));
        if (!alias.getPerson().getId().equals(personId)) {
            throw DomainException.forbidden("Alias does not belong to this person");
        }
        aliasRepository.delete(alias);
    }
}
