package org.raddatz.familienarchiv.timeline;

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.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
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 java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TimelineServiceTest {

    @Mock TimelineEventRepository eventRepository;
    @Mock TimelineEventService timelineEventService;
    @Mock DocumentService documentService;
    @Mock PersonService personService;
    @Mock TagService tagService;

    @InjectMocks TimelineService timelineService;

    // ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────

    @Test
    void within_band_order_day_precision_sorts_before_year() {
        var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
        var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");

        var sorted = List.of(yearEntry, dayEntry).stream()
                .sorted(TimelineService.WITHIN_BAND_ORDER)
                .toList();

        assertThat(sorted).containsExactly(dayEntry, yearEntry);
    }

    @Test
    void within_band_order_same_precision_and_date_sorts_alphabetically() {
        var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
        var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");

        var sorted = List.of(entryZ, entryA).stream()
                .sorted(TimelineService.WITHIN_BAND_ORDER)
                .toList();

        assertThat(sorted).containsExactly(entryA, entryZ);
    }

    @Test
    void within_band_order_same_title_uses_document_id_as_tiebreak() {
        UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
        UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
        var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
                LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
                null, null, null, null);
        var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
                LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
                null, null, null, null);

        var sorted = List.of(e2, e1).stream()
                .sorted(TimelineService.WITHIN_BAND_ORDER)
                .toList();

        assertThat(sorted.get(0).documentId()).isEqualTo(id1);
    }

    // ─── Assembly tests (issue-spec order) ──────────────────────────────────

    @Test
    void test1_empty_archive_returns_empty_dto() {
        // REQ-013, REQ-007
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).isEmpty();
    }

    @Test
    void test2_one_year_letter_returns_one_year_band() {
        // REQ-007
        var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).year()).isEqualTo(1914);
        assertThat(result.years().get(0).entries()).hasSize(1);
        assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
        assertThat(result.undated()).isEmpty();
    }

    @Test
    void test3a_null_date_letter_goes_to_undated() {
        // REQ-003
        var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).hasSize(1);
    }

    @Test
    void test3b_unknown_precision_letter_goes_to_undated() {
        // REQ-003
        var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).hasSize(1);
    }

    @Test
    void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
        // REQ-005
        var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(DatePrecision.YEAR)
                .documentDate(LocalDate.of(1914, 1, 1))
                .build(); // no sender, no senderText, no receivers, no receiverText
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        TimelineDTO result = timelineService.assemble(noFilters());

        var entry = result.years().get(0).entries().get(0);
        assertThat(entry.senderName()).isEqualTo("");
        assertThat(entry.receiverName()).isEqualTo("");
    }

    @Test
    void test5_day_precision_sorts_before_year_in_same_year_band() {
        // REQ-002
        var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
        var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));

        TimelineDTO result = timelineService.assemble(noFilters());

        var entries = result.years().get(0).entries();
        assertThat(entries).hasSize(2);
        assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
        assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
    }

    @Test
    void test6_same_precision_same_date_sorted_alphabetically_by_title() {
        // REQ-002
        var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
        var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));

        TimelineDTO result = timelineService.assemble(noFilters());

        var entries = result.years().get(0).entries();
        assertThat(entries).hasSize(2);
        assertThat(entries.get(0).title()).isEqualTo("Adler");
        assertThat(entries.get(1).title()).isEqualTo("Zimmer");
    }

    @Test
    void test7a_range_event_placed_only_in_start_year_band() {
        // REQ-004
        var rangeEvent = event("WW1", EventType.HISTORICAL,
                LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
        when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).year()).isEqualTo(1914);
        assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
    }

    @Test
    void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
        // REQ-004
        var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
                LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
        when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
    }

    @Test
    void test8_range_event_excluded_when_start_year_before_fromYear() {
        // REQ-004
        var rangeEvent = event("WW1", EventType.HISTORICAL,
                LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
        when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        // fromYear=1915 → start year 1914 is outside → excluded
        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));

        assertThat(result.years()).isEmpty();
    }

    @Test
    void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
        // REQ-009
        var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
        var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
                LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
        var personalEvent = event("Geburt", EventType.PERSONAL,
                LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
        when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        // filter: only HISTORICAL events
        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));

        long letters = result.years().stream().flatMap(y -> y.entries().stream())
                .filter(e -> e.kind() == Kind.LETTER).count();
        long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
                .filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
        assertThat(letters).isEqualTo(1);
        assertThat(personalEvents).isEqualTo(0);
    }

    @Test
    void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
        // REQ-010
        var sender = Person.builder().id(UUID.randomUUID())
                .lastName("Mustermann").firstName("Max").generation(2).build();
        var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
                .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
                .sender(sender).build();
        var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
        when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).entries()).hasSize(1);
        assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
    }

    @Test
    void test9c_fromYear_toYear_inclusive_single_year_window() {
        // REQ-011
        var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
        var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
        var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).year()).isEqualTo(1914);
        assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
    }

    @Test
    void test10_adversarial_and_logic_neither_event_passes_both_filters() {
        // REQ-012 — type AND year must both pass
        var wrongType = event("Personal", EventType.PERSONAL,
                LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
        var wrongYear = event("Historical outside", EventType.HISTORICAL,
                LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
        when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).isEmpty();
    }

    @Test
    void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
        // REQ-008
        UUID personId = UUID.randomUUID();
        var person = Person.builder().id(personId).lastName("Mustermann").build();
        var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
                .sender(person)
                .receivers(Set.of(person))
                .build();
        when(personService.getById(personId)).thenReturn(person);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
        when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));

        TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));

        long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
                + result.undated().size();
        assertThat(total).isEqualTo(1);
    }

    @Test
    void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
        // REQ-012
        UUID personId = UUID.randomUUID();
        var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
        var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
                .sender(person).build();
        var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
        when(personService.getById(personId)).thenReturn(person);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
        when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
        when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2

        TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).isEmpty();
    }

    @Test
    void test13_null_generation_sender_not_returned_by_generation_filter() {
        // REQ-020 — both sender and receiver have null generation → excluded
        var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
        var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
                .sender(nullGenSender).build();
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
        when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));

        assertThat(result.years()).isEmpty();
        assertThat(result.undated()).isEmpty();
    }

    @Test
    void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
        var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
        when(eventRepository.findAll()).thenReturn(List.of(ev));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        TimelineDTO result = timelineService.assemble(noFilters());

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).entries()).hasSize(1);
        assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
    }

    @Test
    void test15_range_event_start_year_equal_to_fromYear_is_included() {
        // REQ-004 — inclusive lower bound
        var rangeEvent = event("WW1", EventType.HISTORICAL,
                LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
        when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of());

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));

        assertThat(result.years()).hasSize(1);
        assertThat(result.years().get(0).year()).isEqualTo(1914);
    }

    @Test
    void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
        // REQ-011
        var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
        var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
        var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));

        TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));

        assertThat(result.years()).hasSize(2);
        assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
    }

    @Test
    void fromYear_greater_than_toYear_throws_bad_request() {
        // REQ-016 (service-layer guard)
        assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
                .isInstanceOf(DomainException.class);
    }

    // ─── Helpers ─────────────────────────────────────────────────────────────

    // ─── root-tag chip enrichment (#835) ─────────────────────────────────────

    @Test
    void letter_with_tags_carries_its_primary_root_tag() {
        // REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
        // assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
        UUID kriegId = UUID.randomUUID();
        Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
        Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
        Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
        when(tagService.resolveRootTags(anyList()))
                .thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));

        TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));

        assertThat(entry.rootTagId()).isEqualTo(kriegId);
        assertThat(entry.rootTagName()).isEqualTo("Krieg");
        assertThat(entry.rootTagColor()).isEqualTo("sienna");
    }

    @Test
    void untagged_letter_has_no_root_tag_fields() {
        // REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
        Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));

        TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));

        assertThat(entry.rootTagId()).isNull();
        assertThat(entry.rootTagName()).isNull();
        assertThat(entry.rootTagColor()).isNull();
        verify(tagService, never()).resolveRootTags(anyList());
    }

    @Test
    void letter_primary_root_without_color_yields_null_color() {
        // REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
        UUID rootId = UUID.randomUUID();
        Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
        Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
        when(tagService.resolveRootTags(anyList()))
                .thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));

        TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));

        assertThat(entry.rootTagId()).isEqualTo(rootId);
        assertThat(entry.rootTagName()).isEqualTo("Allgemein");
        assertThat(entry.rootTagColor()).isNull();
    }

    @Test
    void root_tags_resolved_in_a_single_batched_pass() {
        // REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
        UUID kriegId = UUID.randomUUID();
        Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
        Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
        Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
        Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
        Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
        when(eventRepository.findAll()).thenReturn(List.of());
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
        when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
                kriegId, new RootTag(kriegId, "Krieg", "sienna"),
                weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));

        timelineService.assemble(noFilters());

        verify(tagService, times(1)).resolveRootTags(anyList());
    }

    // ─── letter→event link (#850, REQ-009) ───────────────────────────────────

    @Test
    void letter_in_a_curated_events_documents_carries_that_events_id() {
        // REQ-009: linkedEventId = the curated event whose documents set contains the letter.
        Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
        UUID eventId = UUID.randomUUID();
        TimelineEvent event = TimelineEvent.builder().id(eventId)
                .title("Briefe von der Front").type(EventType.PERSONAL)
                .documents(new HashSet<>(Set.of(letterDoc)))
                .build(); // no eventDate → event lands undated, leaving the year band to the letter
        when(eventRepository.findAll()).thenReturn(List.of(event));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));

        TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));

        assertThat(entry.linkedEventId()).isEqualTo(eventId);
    }

    @Test
    void letter_in_no_curated_event_has_null_linkedEventId() {
        // REQ-009: a letter referenced by no curated event → linkedEventId null; the frontend
        // then renders it as a loose chronological letter (REQ-006).
        Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
        TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
                .title("Anderes Ereignis").type(EventType.PERSONAL)
                .documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
                .build();
        when(eventRepository.findAll()).thenReturn(List.of(event));
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));

        TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));

        assertThat(entry.linkedEventId()).isNull();
    }

    @Test
    void multi_event_letter_links_deterministically_to_the_earliest_event() {
        // REQ-009: a document referenced by >1 curated event links to the earliest-dated event
        // (then lowest id), independent of repository iteration order — not a coin-flip on
        // findAll()'s undefined order.
        Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
        TimelineEvent earlier = TimelineEvent.builder()
                .id(UUID.fromString("00000000-0000-0000-0000-000000000001"))
                .title("Frühes Ereignis").type(EventType.PERSONAL)
                .eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH)
                .documents(new HashSet<>(Set.of(shared)))
                .build();
        TimelineEvent later = TimelineEvent.builder()
                .id(UUID.fromString("00000000-0000-0000-0000-000000000002"))
                .title("Spätes Ereignis").type(EventType.PERSONAL)
                .eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH)
                .documents(new HashSet<>(Set.of(shared)))
                .build();
        when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
        when(documentService.getAllForTimeline()).thenReturn(List.of(shared));

        // `later` first in iteration: a naive putIfAbsent would wrongly pick it.
        when(eventRepository.findAll()).thenReturn(List.of(later, earlier));
        assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
                .isEqualTo(earlier.getId());

        // Reversed order yields the same winner — the link is order-independent.
        when(eventRepository.findAll()).thenReturn(List.of(earlier, later));
        assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
                .isEqualTo(earlier.getId());
    }

    private static TimelineEntryDTO theLetter(TimelineDTO result) {
        return java.util.stream.Stream.concat(
                        result.years().stream().flatMap(y -> y.entries().stream()),
                        result.undated().stream())
                .filter(e -> e.kind() == Kind.LETTER)
                .findFirst().orElseThrow();
    }

    private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
        assertThat(result.years()).hasSize(1);
        return result.years().get(0).entries().get(0);
    }

    private static TimelineFilter noFilters() {
        return new TimelineFilter(null, null, null, null, null);
    }

    private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
        return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
                date, null, title, null, null, UUID.randomUUID(), List.of(), null,
                null, null, null, null);
    }

    private static Document docWithDate(LocalDate date, DatePrecision precision) {
        return Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(precision).documentDate(date).build();
    }

    private static Document docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
        return Document.builder().id(UUID.randomUUID()).title("Brief")
                .metaDatePrecision(precision).documentDate(date)
                .tags(new HashSet<>(Set.of(tags))).build();
    }

    private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
        return Document.builder().id(UUID.randomUUID()).title(title)
                .metaDatePrecision(precision).documentDate(date).build();
    }

    private static TimelineEvent event(String title, EventType type, LocalDate date,
                                       DatePrecision precision, LocalDate endDate) {
        return TimelineEvent.builder().id(UUID.randomUUID())
                .title(title).type(type)
                .eventDate(date).precision(precision).eventDateEnd(endDate)
                .build();
    }
}
