package org.raddatz.familienarchiv.geschichte.journeyitem;

import org.junit.jupiter.api.BeforeEach;
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.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class JourneyItemServiceTest {

    @Mock JourneyItemRepository journeyItemRepository;
    @Mock GeschichteQueryService geschichteQueryService;
    @Mock DocumentService documentService;
    @Mock AuditService auditService;
    @Mock UserService userService;

    @InjectMocks JourneyItemService journeyItemService;

    UUID geschichteId = UUID.randomUUID();
    UUID itemId = UUID.randomUUID();
    UUID docId = UUID.randomUUID();
    UUID actorId = UUID.randomUUID();

    @BeforeEach
    void setupAuth() {
        AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
        lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
        lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
        SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken("test@test.de", null,
                        List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
    }

    // ─── toSummary — name composition ────────────────────────────────────────

    @Test
    void toSummary_uses_linked_person_firstName_lastName() {
        Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
        Document doc = makeDoc(docId, sender, List.of(), null, null);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
    }

    @Test
    void toSummary_falls_back_to_senderText_when_no_person() {
        Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.senderName()).isEqualTo("Familie Müller");
    }

    @Test
    void toSummary_returns_null_senderName_when_neither_person_nor_text() {
        Document doc = makeDoc(docId, null, List.of(), null, null);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.senderName()).isNull();
    }

    @Test
    void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
        Document doc = makeDoc(docId, null, List.of(), null, null);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.receiverCount()).isEqualTo(0);
        assertThat(summary.receiverName()).isNull();
    }

    @Test
    void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
        Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
        Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
        Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.receiverCount()).isEqualTo(2);
        assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
    }

    @Test
    void toSummary_datePrecision_SEASON_roundtrips() {
        Document doc = makeDoc(docId, null, List.of(), null, null);
        doc.setMetaDatePrecision(DatePrecision.SEASON);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
    }

    @Test
    void toSummary_datePrecision_APPROX_roundtrips() {
        Document doc = makeDoc(docId, null, List.of(), null, null);
        doc.setMetaDatePrecision(DatePrecision.APPROX);

        var summary = journeyItemService.toSummary(doc);

        assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
    }

    // ─── append ──────────────────────────────────────────────────────────────

    @Test
    void append_to_empty_journey_starts_at_10() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
        JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("Note");

        JourneyItemView view = journeyItemService.append(geschichteId, dto);

        assertThat(view.position()).isEqualTo(10);
    }

    @Test
    void append_after_reorder_continues_from_max_position() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
        JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("Note");

        JourneyItemView view = journeyItemService.append(geschichteId, dto);

        assertThat(view.position()).isEqualTo(50);
    }

    @Test
    void append_returns400_when_neither_documentId_nor_note() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .hasMessageContaining("documentId or note");
    }

    @Test
    void append_returns400_when_note_trims_to_empty_and_no_document() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("   \n  ");

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class);
    }

    @Test
    void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
        // 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("x".repeat(2001));

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
    }

    @Test
    void append_accepts_note_of_exactly_2000_chars() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
        JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("x".repeat(2000));

        assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
    }

    @Test
    void append_returns404_when_documentId_does_not_exist() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
        when(documentService.findSummaryByIdInternal(docId))
                .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(docId);

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
    }

    @Test
    void append_returns409_when_100_items_exist() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("Note");

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
    }

    @Test
    void append_returns409_when_document_already_in_journey() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(docId);

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
    }

    @Test
    void append_to_STORY_type_creates_journey_item() {
        Geschichte story = story(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
        Document doc = makeDoc(docId, null, List.of(), null, null);
        when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(docId);

        JourneyItemView view = journeyItemService.append(geschichteId, dto);

        assertThat(view.position()).isEqualTo(10);
        assertThat(view.document().id()).isEqualTo(docId);
    }

    @Test
    void append_to_STORY_type_respects_capacity_cap() {
        Geschichte story = story(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(docId);

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
    }

    @Test
    void append_to_STORY_type_rejects_duplicate_document() {
        Geschichte story = story(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(docId);

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
    }

    @Test
    void cap_is_COUNT_based_not_MAX_position_based() {
        // 99 rows with MAX(position)=2000 should still accept the 100th append
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
        JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("Note");

        assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
    }

    @Test
    void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
        // Two concurrent appends can both pass the exists() pre-check; the partial
        // unique index then rejects the second INSERT at flush. The service must
        // translate that into the same friendly 409 as the pre-check.
        // Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
        when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
        PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
                PSQLState.UNIQUE_VIOLATION);
        when(journeyItemRepository.saveAndFlush(any()))
                .thenThrow(new org.springframework.dao.DataIntegrityViolationException(
                        "could not execute statement", psqlEx));

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(UUID.randomUUID());

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
    }

    @Test
    void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
        // B1: the dedup check must use PSQLException.getSQLState() == "23505", not
        // constraint-name string matching — constraint renames must not regress this.
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
        when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));

        // Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
        // wrapped by Spring's DataIntegrityViolationException.
        PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
                PSQLState.UNIQUE_VIOLATION);
        org.springframework.dao.DataIntegrityViolationException dive =
                new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
        when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(UUID.randomUUID());

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
    }

    @Test
    void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
        // An FK violation (document deleted between load and flush) must NOT be
        // translated into "already added" — only the dedup unique index (23505) earns that 409.
        // FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
        when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
        when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
        PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
        when(journeyItemRepository.saveAndFlush(any()))
                .thenThrow(new org.springframework.dao.DataIntegrityViolationException(
                        "could not execute statement", psqlEx));

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setDocumentId(UUID.randomUUID());

        assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
                .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
    }

    @Test
    void append_audits_JOURNEY_ITEM_ADDED() {
        Geschichte journey = journey(geschichteId);
        when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
        when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
        when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
        JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
        when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);

        JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
        dto.setNote("Note");
        journeyItemService.append(geschichteId, dto);

        verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
    }

    // ─── updateNote ───────────────────────────────────────────────────────────

    @Test
    void updateNote_absent_leaves_note_unchanged() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        // note is null by default — absent from JSON, no-op

        JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);

        assertThat(view.note()).isEqualTo("Original note");
        verify(journeyItemRepository, never()).save(any());
    }

    @Test
    void updateNote_null_clears_note_when_document_is_present() {
        Geschichte journey = journey(geschichteId);
        Document doc = makeDoc(docId, null, List.of(), null, null);
        JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
        JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
        when(journeyItemRepository.save(item)).thenReturn(saved);

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.empty());

        JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);

        assertThat(view.note()).isNull();
    }

    @Test
    void updateNote_string_sets_note() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, null);
        item.setNote(null);
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
        JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
        when(journeyItemRepository.save(item)).thenReturn(saved);

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.of("New note"));

        JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);

        assertThat(view.note()).isEqualTo("New note");
    }

    @Test
    void updateNote_null_returns400_when_item_has_no_document() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.empty());

        assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.VALIDATION_ERROR));
    }

    @Test
    void updateNote_whitespace_only_including_newlines_stored_as_null() {
        Geschichte journey = journey(geschichteId);
        Document doc = makeDoc(docId, null, List.of(), null, null);
        JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
        JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
        when(journeyItemRepository.save(item)).thenReturn(saved);

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.of("\n \n"));

        JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);

        assertThat(view.note()).isNull();
    }

    @Test
    void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.of("x".repeat(2001)));

        assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
    }

    @Test
    void updateNote_auditsNoteUpdate() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, null);
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
        JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
        when(journeyItemRepository.save(item)).thenReturn(saved);

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.of("New note"));
        journeyItemService.updateNote(geschichteId, itemId, dto);

        verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
    }

    @Test
    void patch_returns404_when_item_belongs_to_different_journey() {
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());

        JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
        dto.setNote(Optional.of("text"));

        assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
    }

    // ─── delete ───────────────────────────────────────────────────────────────

    @Test
    void delete_returns404_when_item_already_deleted() {
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
    }

    @Test
    void delete_no_audit_when_item_not_found() {
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
                .isInstanceOf(DomainException.class);

        verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
    }

    @Test
    void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
        Geschichte journey = journey(geschichteId);
        JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
        when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));

        journeyItemService.delete(geschichteId, itemId);

        verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
    }

    // ─── reorder ─────────────────────────────────────────────────────────────

    @Test
    void reorder_unknownGeschichteId_throws404() {
        UUID unknownId = UUID.randomUUID();
        // geschichteQueryService is not lenient-stubbed for unknownId → returns false

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of());

        assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
    }

    @Test
    void reorder_returns400_when_itemIds_contain_duplicates() {
        UUID id1 = UUID.randomUUID();
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(id1, id1));  // duplicate

        assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.VALIDATION_ERROR));
    }

    @Test
    void reorder_returns400_when_itemId_belongs_to_different_journey() {
        UUID foreignId = UUID.randomUUID();
        UUID localId = UUID.randomUUID();
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(foreignId));

        assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
                .isInstanceOf(DomainException.class)
                .satisfies(e -> assertThat(((DomainException) e).getCode())
                        .isEqualTo(ErrorCode.VALIDATION_ERROR));
    }

    @Test
    void reorder_returns400_when_ids_have_extra_items() {
        UUID id1 = UUID.randomUUID();
        UUID id2 = UUID.randomUUID();
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(id1, id2));

        assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
                .isInstanceOf(DomainException.class);
    }

    @Test
    void reorder_returns200_when_empty_on_empty_journey() {
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of());

        List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);

        assertThat(result).isEmpty();
    }

    @Test
    void reorder_returns400_when_empty_on_nonempty_journey() {
        UUID id1 = UUID.randomUUID();
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of());

        assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
                .isInstanceOf(DomainException.class);
    }

    @Test
    void reorder_returns_items_in_new_order_starting_at_10() {
        Geschichte journey = journey(geschichteId);
        UUID id1 = UUID.randomUUID();
        UUID id2 = UUID.randomUUID();
        JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
        JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
        when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
        when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(id1, id2)); // want id1 first

        List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);

        assertThat(views).hasSize(2);
        assertThat(views.get(0).id()).isEqualTo(id1);
        assertThat(views.get(0).position()).isEqualTo(10);
        assertThat(views.get(1).id()).isEqualTo(id2);
        assertThat(views.get(1).position()).isEqualTo(20);
    }

    @Test
    void reorder_identical_order_returns200() {
        Geschichte journey = journey(geschichteId);
        UUID id1 = UUID.randomUUID();
        JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
        when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
        when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(id1));

        List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);

        assertThat(views).hasSize(1);
        assertThat(views.get(0).position()).isEqualTo(10);
    }

    @Test
    void reorder_of_grandfathered_over_cap_journey_succeeds() {
        Geschichte journey = journey(geschichteId);
        // 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
        List<UUID> ids = new java.util.ArrayList<>();
        List<JourneyItem> items = new java.util.ArrayList<>();
        for (int i = 1; i <= 130; i++) {
            UUID id = UUID.randomUUID();
            ids.add(id);
            items.add(savedItem(id, journey, i * 10, null, "item " + i));
        }
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
        when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
        when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(ids);

        List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);

        assertThat(views).hasSize(130);
    }

    @Test
    void reorder_audits_JOURNEY_ITEMS_REORDERED() {
        Geschichte journey = journey(geschichteId);
        UUID id1 = UUID.randomUUID();
        JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
        when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
        when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
        when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));

        JourneyReorderDTO dto = new JourneyReorderDTO();
        dto.setItemIds(List.of(id1));
        journeyItemService.reorder(geschichteId, dto);

        verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
    }

    // ─── helpers ─────────────────────────────────────────────────────────────

    private Geschichte journey(UUID id) {
        return Geschichte.builder()
                .id(id)
                .title("Test Journey")
                .type(GeschichteType.JOURNEY)
                .status(GeschichteStatus.DRAFT)
                .build();
    }

    private Geschichte story(UUID id) {
        return Geschichte.builder()
                .id(id)
                .title("Test Story")
                .type(GeschichteType.STORY)
                .status(GeschichteStatus.DRAFT)
                .build();
    }

    private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
        return JourneyItem.builder()
                .id(id)
                .geschichte(g)
                .position(position)
                .document(null)  // no document entity to avoid LAZY issues in unit tests
                .note(note)
                .build();
    }

    private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
        JourneyItem item = JourneyItem.builder()
                .id(id)
                .geschichte(g)
                .position(position)
                .document(doc)
                .note(note)
                .build();
        return item;
    }

    private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
        Document doc = Document.builder()
                .id(id)
                .title("Test Doc")
                .originalFilename("test.pdf")
                .status(DocumentStatus.UPLOADED)
                .senderText(senderText)
                .receiverText(receiverText)
                .sender(sender)
                .build();
        doc.setReceivers(new HashSet<>(receivers));
        return doc;
    }
}
