package org.raddatz.familienarchiv.timeline;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;

import org.springframework.orm.ObjectOptimisticLockingFailureException;
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.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

/**
 * Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own
 * services (never their repositories). All four body-returning operations return a
 * {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2).
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineEventService {

    private static final int MAX_TITLE_LENGTH = 255;

    private final TimelineEventRepository events;
    private final PersonService personService;
    private final DocumentService documentService;
    private final RelationshipService relationshipService;

    @Transactional
    public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
        DatePrecision precision = effectivePrecision(request);
        validateRangeInvariant(request, precision);
        validateTitleLength(request);

        TimelineEvent event = TimelineEvent.builder()
                .title(request.title())
                .type(request.type())
                .eventDate(normalizeEventDate(request.eventDate(), precision))
                .precision(precision)
                .eventDateEnd(request.eventDateEnd())
                .description(request.description())
                .persons(resolvePersons(request.personIds()))
                .documents(resolveDocuments(request.documentIds()))
                .createdBy(actorId)
                .updatedBy(actorId)
                .build();

        return toView(events.saveAndFlush(event));
    }

    @Transactional
    public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) {
        TimelineEvent event = events.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
                        "Timeline event not found: " + id));
        requireVersionMatch(request, event);
        DatePrecision precision = effectivePrecision(request);
        validateRangeInvariant(request, precision);
        validateTitleLength(request);
        applyUpdate(event, request, precision, actorId);

        // saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the
        // try — a bare save() flushes at commit, after this method returns, so the exception would
        // escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's.
        try {
            return toView(events.saveAndFlush(event));
        } catch (ObjectOptimisticLockingFailureException ex) {
            throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
                    "Timeline event was modified concurrently: " + id);
        }
    }

    @Transactional
    public void delete(UUID id) {
        TimelineEvent event = events.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
                        "Timeline event not found: " + id));
        events.delete(event);
    }

    /**
     * View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional:
     * the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView}
     * assembly, and under {@code open-in-view: false} a closed session there is a
     * {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent).
     */
    @Transactional(readOnly = true)
    public TimelineEventView getEvent(UUID id) {
        TimelineEvent event = events.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
                        "Timeline event not found: " + id));
        return toView(event);
    }

    // --- update mechanics: mutate the managed entity, never reassign collections ---

    private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) {
        event.setTitle(request.title());
        event.setType(request.type());
        event.setEventDate(normalizeEventDate(request.eventDate(), precision));
        event.setPrecision(precision);
        event.setEventDateEnd(request.eventDateEnd());
        event.setDescription(request.description());
        replaceLinks(event, request);
        event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
    }

    /**
     * Compares the client's concurrency token against the freshly-loaded version (the Q1
     * "last-seen version" token). A mismatch means the client edited stale data → 409.
     *
     * <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
     * flush. Setting {@code @Version} on a <em>managed</em> entity is silently ignored by Hibernate
     * for the optimistic check: it uses its own loaded-version snapshot for the
     * {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
     * {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
     * below remains the backstop for two transactions flushing concurrently; this guard is what
     * catches the human-timescale "B submitted a form based on a version A already superseded" case.
     * A null token means no check (last-write-wins) until #9 always sends it.
     */
    private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
        if (request.version() != null && !request.version().equals(event.getVersion())) {
            throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
                    "Timeline event was modified concurrently: " + event.getId());
        }
    }

    /**
     * Replaces (set semantics) the link collections. Mutates the existing managed collections —
     * Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join
     * rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links.
     */
    private void replaceLinks(TimelineEvent event, TimelineEventRequest request) {
        event.getPersons().clear();
        event.getPersons().addAll(resolvePersons(request.personIds()));
        event.getDocuments().clear();
        event.getDocuments().addAll(resolveDocuments(request.documentIds()));
    }

    // --- validation / normalization ---

    /**
     * Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and
     * additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may
     * equal but never precede {@code eventDate}. Without this guard a reversed range (end before
     * start) persists silently and renders as a negative span. Equal dates are a valid one-day
     * closed range.
     */
    private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) {
        boolean isRange = precision == DatePrecision.RANGE;
        if (request.eventDateEnd() != null && !isRange) {
            throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
                    "eventDateEnd is only valid when precision is RANGE");
        }
        if (isRange && request.eventDateEnd() == null) {
            throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
                    "A RANGE event requires a non-null eventDateEnd");
        }
        if (isRange && request.eventDateEnd().isBefore(request.eventDate())) {
            throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
                    "eventDateEnd must not precede eventDate");
        }
    }

    /**
     * Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP
     * callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255)
     * column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as
     * "duplicate validation".
     */
    private void validateTitleLength(TimelineEventRequest request) {
        if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) {
            throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG,
                    "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
        }
    }

    private DatePrecision effectivePrecision(TimelineEventRequest request) {
        return request.precision() != null ? request.precision() : DatePrecision.YEAR;
    }

    private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) {
        return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate;
    }

    // --- link resolution (fail-closed, dedupe-first) ---

    private Set<Person> resolvePersons(List<UUID> ids) {
        if (ids == null || ids.isEmpty()) {
            return new HashSet<>();
        }
        // Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the
        // resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious
        // mismatch.
        Set<UUID> distinct = new LinkedHashSet<>(ids);
        List<Person> resolved = personService.getAllById(new ArrayList<>(distinct));
        if (resolved.size() != distinct.size()) {
            throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found");
        }
        return new HashSet<>(resolved);
    }

    private Set<Document> resolveDocuments(List<UUID> ids) {
        if (ids == null || ids.isEmpty()) {
            return new HashSet<>();
        }
        // Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free
        // DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this
        // write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch.
        Set<Document> resolved = new HashSet<>();
        for (UUID documentId : new LinkedHashSet<>(ids)) {
            resolved.add(documentService.getDocumentById(documentId));
        }
        return resolved;
    }

    // --- derived event assembly ---

    /**
     * Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
     * PersonRelationship data. Computed on read, never persisted.
     *
     * <p>Derived events are computed, never persisted, and cannot be mutated via the events API
     * (enforced in #5). Ids produced by this method are structurally non-UUID
     * ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
     * write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
     * independently enforce {@code READ_ALL} authorization before invoking this method
     * (see ADR-043).
     */
    @Transactional(readOnly = true)
    public List<TimelineEntryDTO> assembleDerivedEvents() {
        List<Person> persons = personService.findAllFamilyMembers();
        List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();

        List<TimelineEntryDTO> result = new ArrayList<>();
        result.addAll(buildBirthEvents(persons));
        result.addAll(buildDeathEvents(persons));
        result.addAll(buildMarriageEvents(spouseEdges));

        log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
        return result;
    }

    private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
        return persons.stream()
                .filter(p -> p.getBirthDate() != null)
                .map(p -> new TimelineEntryDTO(
                        Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
                        p.getBirthDate(), null,
                        p.getDisplayName(), EventType.PERSONAL,
                        null, null, List.of(p.getId()), DerivedEventType.BIRTH,
                        null, null, null, null))
                .toList();
    }

    private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
        return persons.stream()
                .filter(p -> p.getDeathDate() != null)
                .map(p -> new TimelineEntryDTO(
                        Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
                        p.getDeathDate(), null,
                        p.getDisplayName(), EventType.PERSONAL,
                        null, null, List.of(p.getId()), DerivedEventType.DEATH,
                        null, null, null, null))
                .toList();
    }

    private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
        // DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
        // in-memory dedup on relationship row id is a defensive assertion.
        Set<UUID> seen = new HashSet<>();
        List<TimelineEntryDTO> result = new ArrayList<>();
        for (PersonRelationship r : spouseEdges) {
            if (seen.add(r.getId())) {
                // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
                // The marriage date is the relationship's from_date at its stored precision
                // (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
                LocalDate eventDate = r.getFromDate();
                DatePrecision precision = r.getFromDatePrecision();
                String title = r.getPerson().getDisplayName()
                        + " & " + r.getRelatedPerson().getDisplayName();
                result.add(new TimelineEntryDTO(
                        Kind.EVENT, precision, true, "", "",
                        eventDate, null,
                        title, EventType.PERSONAL,
                        null, null,
                        List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
                        DerivedEventType.MARRIAGE,
                        null, null, null, null));
            }
        }
        return result;
    }

    // --- view assembly (explicit allow-list; never the raw entity) ---

    private TimelineEventView toView(TimelineEvent event) {
        List<PersonView> persons = event.getPersons().stream()
                .map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
                .toList();
        List<DocumentRef> documents = event.getDocuments().stream()
                .map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate()))
                .toList();
        return new TimelineEventView(
                event.getId(), event.getTitle(), event.getType(), event.getEventDate(),
                event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(),
                event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(),
                persons, documents);
    }
}
