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.tag.RootTag;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;

/**
 * Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
 * derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
 *
 * <p>Cross-domain data is reached exclusively through domain services (PersonService,
 * DocumentService). The only repository injected directly is {@link TimelineEventRepository}
 * (same domain — constitution §1.3).
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineService {

    /** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
    static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
            Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
                    .thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
                    .thenComparing(e -> e.title() != null ? e.title() : "")
                    .thenComparing(e -> {
                        if (e.eventId() != null) return e.eventId().toString();
                        if (e.documentId() != null) return e.documentId().toString();
                        return "";
                    });

    private final TimelineEventRepository eventRepository;
    private final TimelineEventService timelineEventService;
    private final DocumentService documentService;
    private final PersonService personService;
    private final TagService tagService;

    /**
     * Assembles the timeline for the given filter. All filters are ANDed.
     * Throws {@link DomainException} (bad request) when fromYear &gt; toYear.
     * Throws {@link DomainException} (not found) when personId refers to an unknown person.
     *
     * <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
     * this method accesses lazy collections ({@link TimelineEvent#getPersons()},
     * {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
     * repository sub-transaction closes. Without this annotation those accesses throw
     * {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
     */
    @Transactional(readOnly = true)
    public TimelineDTO assemble(TimelineFilter filter) {
        if (filter.fromYear() != null && filter.toYear() != null
                && filter.fromYear() > filter.toYear()) {
            throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
                    "toYear must not be before fromYear");
        }

        // Resolve generation person IDs once — used across all three layers
        Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());

        // Fetch curated events once — reused for both the event entries below and the
        // batched letter→event link resolution (resolveLetterEventLinks), so the
        // membership pass costs no extra query. REQ-009.
        List<TimelineEvent> allEvents = eventRepository.findAll();

        // ── curated events ───────────────────────────────────────────────────
        List<TimelineEntryDTO> entries = new ArrayList<>();
        for (TimelineEvent ev : allEvents) {
            if (!passesTypeFilter(ev.getType(), filter.type())) continue;
            if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
            if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
            if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
            entries.add(mapEvent(ev));
        }

        // ── derived events ───────────────────────────────────────────────────
        for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
            if (!passesTypeFilter(derived.type(), filter.type())) continue;
            if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
            if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
            if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
            entries.add(derived);
        }

        // ── letters ─────────────────────────────────────────────────────────
        List<Document> letters = new ArrayList<>();
        for (Document doc : fetchDocuments(filter.personId())) {
            if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
            if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
            letters.add(doc);
        }
        Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
        Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
        for (Document doc : letters) {
            entries.add(mapDocument(doc, rootByDocId, eventByDocId));
        }

        return bucket(entries);
    }

    // ─── Bucketing ───────────────────────────────────────────────────────────

    Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
        Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
        for (TimelineEntryDTO e : entries) {
            if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
            map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
        }
        return map;
    }

    private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
        List<TimelineEntryDTO> undated = entries.stream()
                .filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
                .sorted(WITHIN_BAND_ORDER)
                .toList();

        Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
        List<TimelineYearDTO> years = byYear.entrySet().stream()
                .map(e -> new TimelineYearDTO(e.getKey(),
                        e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
                .toList();

        return new TimelineDTO(years, undated);
    }

    // ─── Document fetch (global vs personId path) ────────────────────────────

    private List<Document> fetchDocuments(UUID personId) {
        if (personId == null) {
            return documentService.getAllForTimeline();
        }
        // personId path: validate existence, then union sender+receiver (dedup by id)
        personService.getById(personId);
        Map<UUID, Document> seen = new LinkedHashMap<>();
        for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
        for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
        return new ArrayList<>(seen.values());
    }

    // ─── Filter predicates ───────────────────────────────────────────────────

    private boolean passesTypeFilter(EventType entryType, EventType filterType) {
        return filterType == null || filterType == entryType;
    }

    private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
        if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
        int year = date.getYear();
        if (filter.fromYear() != null && year < filter.fromYear()) return false;
        if (filter.toYear() != null && year > filter.toYear()) return false;
        return true;
    }

    private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
        if (personId == null) return true;
        return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
    }

    private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
        if (personId == null) return true;
        return linkedIds != null && linkedIds.contains(personId);
    }

    private Set<UUID> resolveGenerationPersonIds(Integer generation) {
        if (generation == null) return null;
        return personService.getPersonsByGeneration(generation).stream()
                .map(Person::getId)
                .collect(Collectors.toSet());
    }

    private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
        if (genPersonIds == null) return true;
        if (persons == null || persons.isEmpty()) return false;
        return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
    }

    private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
        if (genPersonIds == null) return true;
        if (linkedIds == null || linkedIds.isEmpty()) return false;
        return linkedIds.stream().anyMatch(genPersonIds::contains);
    }

    private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
        if (genPersonIds == null) return true;
        Person sender = doc.getSender();
        if (sender != null && genPersonIds.contains(sender.getId())) return true;
        Set<Person> receivers = doc.getReceivers();
        if (receivers != null) {
            return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
        }
        return false;
    }

    // ─── Mapping ─────────────────────────────────────────────────────────────

    private TimelineEntryDTO mapEvent(TimelineEvent ev) {
        List<UUID> personIds = ev.getPersons() == null ? List.of()
                : ev.getPersons().stream().map(Person::getId).toList();
        return new TimelineEntryDTO(
                Kind.EVENT,
                ev.getPrecision(),
                false,
                "",
                "",
                ev.getEventDate(),
                ev.getEventDateEnd(),
                ev.getTitle(),
                ev.getType(),
                ev.getId(),
                null,
                personIds,
                null,
                null,
                null,
                null,
                null
        );
    }

    private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
                                         Map<UUID, UUID> eventByDocId) {
        RootTag root = rootByDocId.get(doc.getId());
        return new TimelineEntryDTO(
                Kind.LETTER,
                doc.getMetaDatePrecision(),
                false,
                resolveSenderName(doc),
                resolveReceiverName(doc),
                doc.getDocumentDate(),
                null,
                doc.getTitle(),
                null,
                null,
                doc.getId(),
                List.of(),
                null,
                root == null ? null : root.id(),
                root == null ? null : root.name(),
                root == null ? null : root.color(),
                eventByDocId.get(doc.getId())
        );
    }

    /**
     * Resolves each letter's linked curated event in one batched pass, keyed by document id: the
     * event whose {@code documents} set contains the letter (REQ-009). A single doc→event map is
     * built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
     * carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
     * event, the earliest-dated event wins (then lowest {@code eventId}); the pass runs over a
     * stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on
     * the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The
     * map is built from <em>all</em> events (not just the year/type-filtered ones) so the link is a
     * stable property of the data; the frontend's filter-then-cluster decides whether the linked
     * event is actually on screen (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
     */
    private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
        Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
        if (letterDocIds.isEmpty()) return Map.of();

        // Stable order so a multi-event letter links deterministically: earliest event date
        // (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009).
        List<TimelineEvent> ordered = events.stream()
                .sorted(Comparator
                        .comparing(TimelineEvent::getEventDate,
                                Comparator.nullsLast(Comparator.naturalOrder()))
                        .thenComparing(TimelineEvent::getId))
                .toList();

        Map<UUID, UUID> eventByDocId = new HashMap<>();
        for (TimelineEvent ev : ordered) {
            Set<Document> linkedDocs = ev.getDocuments();
            if (linkedDocs == null) continue;
            for (Document linked : linkedDocs) {
                if (letterDocIds.contains(linked.getId())) {
                    eventByDocId.putIfAbsent(linked.getId(), ev.getId());
                }
            }
        }
        return eventByDocId;
    }

    /**
     * Resolves each letter's primary root tag in one batched pass, keyed by document id — no
     * per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
     * so the {@code min()} scan over a letter's tag set runs exactly once here (not again at map
     * time), and {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
     */
    private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
        Map<UUID, Tag> primaryByDocId = new LinkedHashMap<>();
        for (Document doc : letters) {
            Tag primary = primaryTag(doc);
            if (primary != null) primaryByDocId.put(doc.getId(), primary);
        }
        if (primaryByDocId.isEmpty()) return Map.of();

        Map<UUID, RootTag> rootByTagId =
                tagService.resolveRootTags(new ArrayList<>(primaryByDocId.values()));
        Map<UUID, RootTag> rootByDocId = new HashMap<>();
        primaryByDocId.forEach((docId, primary) -> {
            RootTag root = rootByTagId.get(primary.getId());
            if (root != null) rootByDocId.put(docId, root);
        });
        return rootByDocId;
    }

    /** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
    private static Tag primaryTag(Document doc) {
        Set<Tag> tags = doc.getTags();
        if (tags == null || tags.isEmpty()) return null;
        return tags.stream()
                .filter(t -> t.getName() != null)
                .min(Comparator.comparing(Tag::getName))
                .orElse(null);
    }

    private String resolveSenderName(Document doc) {
        if (doc.getSender() != null) return doc.getSender().getDisplayName();
        String text = doc.getSenderText();
        return (text != null && !text.isBlank()) ? text : "";
    }

    private String resolveReceiverName(Document doc) {
        Set<Person> receivers = doc.getReceivers();
        if (receivers != null && !receivers.isEmpty()) {
            return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
        }
        String text = doc.getReceiverText();
        return (text != null && !text.isBlank()) ? text : "";
    }

    private static int precisionRank(DatePrecision precision) {
        if (precision == null) return 0;
        return switch (precision) {
            case DAY -> 5;
            case MONTH -> 4;
            case SEASON -> 3;
            case YEAR -> 2;
            case APPROX -> 1;
            default -> 0;
        };
    }
}
