package org.raddatz.familienarchiv.geschichte;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
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 java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class GeschichteServiceTest {

    @Mock GeschichteRepository geschichteRepository;
    @Mock PersonService personService;
    @Mock DocumentService documentService;
    @Mock UserService userService;
    @Mock JourneyItemService journeyItemService;

    @InjectMocks GeschichteService geschichteService;

    AppUser writer;
    AppUser reader;

    @BeforeEach
    void setUp() {
        SecurityContextHolder.clearContext();
        writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
        reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
    }

    @AfterEach
    void tearDown() {
        SecurityContextHolder.clearContext();
    }

    // ─── getById ──────────────────────────────────────────────────────────────

    @Test
    void getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID id = UUID.randomUUID();
        Geschichte draft = draft(id);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));

        assertThatThrownBy(() -> geschichteService.getById(id))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
    }

    @Test
    void getById_returns_draft_when_user_has_BLOG_WRITE() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte draft = draft(id);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));

        Geschichte result = geschichteService.getById(id);

        assertThat(result.getId()).isEqualTo(id);
        assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
    }

    @Test
    void getById_returns_published_to_anyone_authenticated() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));

        Geschichte result = geschichteService.getById(id);

        assertThat(result.getId()).isEqualTo(id);
        assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
    }

    @Test
    void getById_throws_NOT_FOUND_when_id_unknown() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> geschichteService.getById(id))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
    }

    // ─── getView ──────────────────────────────────────────────────────────────

    @Test
    void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);
        JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
        when(journeyItemService.getItems(id)).thenReturn(List.of(item));

        GeschichteView view = geschichteService.getView(id);

        assertThat(view.id()).isEqualTo(id);
        assertThat(view.items()).containsExactly(item);
        verify(journeyItemService).getItems(id);
    }

    @Test
    void getView_throws_NOT_FOUND_when_id_unknown() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> geschichteService.getView(id))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
    }

    @Test
    void toView_author_displayName_uses_firstName_lastName() {
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);
        published.setAuthor(AppUser.builder()
                .id(UUID.randomUUID()).email("author@test")
                .firstName("Hans").lastName("Raddatz").build());

        GeschichteView result = geschichteService.toView(published, List.of());

        assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
    }

    @Test
    void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);
        published.setAuthor(AppUser.builder()
                .id(UUID.randomUUID()).email("anon@test").build());

        GeschichteView result = geschichteService.toView(published, List.of());

        assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
    }

    @Test
    void toView_author_email_is_not_in_author_view() {
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);
        published.setAuthor(AppUser.builder()
                .id(UUID.randomUUID()).email("secret@test")
                .firstName("Max").lastName("M").build());

        GeschichteView result = geschichteService.toView(published, List.of());

        // AuthorView exposes only id + displayName — no email field at all
        assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
        assertThat(result.author().displayName()).doesNotContain("secret@test");
    }

    @Test
    void toView_persons_are_mapped_to_PersonView() {
        UUID id = UUID.randomUUID();
        UUID personId = UUID.randomUUID();
        Geschichte published = published(id);
        published.setPersons(new HashSet<>(List.of(
                Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
        )));

        GeschichteView result = geschichteService.toView(published, List.of());

        assertThat(result.persons()).hasSize(1);
        GeschichteView.PersonView pv = result.persons().iterator().next();
        assertThat(pv.id()).isEqualTo(personId);
        assertThat(pv.firstName()).isEqualTo("Franz");
        assertThat(pv.lastName()).isEqualTo("Raddatz");
    }

    @Test
    void toView_items_are_passed_through() {
        UUID id = UUID.randomUUID();
        Geschichte published = published(id);

        GeschichteView result = geschichteService.toView(published, List.of());

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

    // ─── list ─────────────────────────────────────────────────────────────────

    @Test
    void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
        authenticateAs(reader, Permission.READ_ALL);
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(), null, 50);

        verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
    }

    @Test
    void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID personId = UUID.randomUUID();
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(personId), null, 50);

        verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
    }

    @Test
    void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID a = UUID.randomUUID();
        UUID b = UUID.randomUUID();
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(a, b), null, 50);

        verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
    }

    @Test
    void list_passes_documentId_to_repository_as_journey_item_filter() {
        authenticateAs(reader, Permission.READ_ALL);
        UUID documentId = UUID.randomUUID();
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(), documentId, 50);

        verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
    }

    @Test
    void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
        // B2: when personIds is empty/null the service must pass a sentinel NIL UUID
        // so the IN() predicate is skipped without producing invalid empty-IN() SQL.
        authenticateAs(reader, Permission.READ_ALL);
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(), null, 50);

        UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
        verify(geschichteRepository).findSummaries(
                any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
    }

    @Test
    void list_caps_limit_at_max_when_caller_passes_huge_value() {
        authenticateAs(reader, Permission.READ_ALL);
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of(mock(GeschichteSummary.class)));

        List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);

        assertThat(out).hasSizeLessThanOrEqualTo(200);
    }

    @Test
    @DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
    void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(null, List.of(), null, 50);

        verify(geschichteRepository).findSummaries(
                eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
    }

    @Test
    @DisplayName("security: DRAFT status scopes to current user only")
    void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
                .thenReturn(List.of());

        geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);

        verify(geschichteRepository).findSummaries(
                eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
    }

    // ─── create ──────────────────────────────────────────────────────────────

    @Test
    void create_sets_status_to_DRAFT_by_default() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("My Story");
        dto.setBody("<p>plain text</p>");

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
        assertThat(saved.publishedAt()).isNull();
        assertThat(saved.author().id()).isEqualTo(writer.getId());
    }

    @Test
    void create_sanitizes_body_HTML_dropping_disallowed_tags() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("XSS attempt");
        dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.body())
                .contains("<p>safe</p>")
                .doesNotContain("<script>")
                .doesNotContain("onerror")
                .doesNotContain("<img");
    }

    @Test
    void create_keeps_allowed_tags_strong_em_h2_h3_ul_ol_li() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("Rich");
        dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
                + "<ul><li>one</li></ul><ol><li>first</li></ol>");

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.body())
                .contains("<h2>Heading</h2>")
                .contains("<strong>bold</strong>")
                .contains("<em>italic</em>")
                .contains("<ul>")
                .contains("<ol>")
                .contains("<li>one</li>");
    }

    @Test
    void create_resolves_personIds_via_PersonService() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        UUID personId = UUID.randomUUID();
        Person person = Person.builder().id(personId).build();
        when(personService.getAllById(List.of(personId))).thenReturn(List.of(person));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("Linked");
        dto.setPersonIds(List.of(personId));

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
    }

    @Test
    void create_throws_BAD_REQUEST_when_title_blank() {
        authenticateAs(writer, Permission.BLOG_WRITE);

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("   ");
        dto.setBody("<p>x</p>");

        assertThatThrownBy(() -> geschichteService.create(dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    @Test
    void create_preserves_JOURNEY_type_from_dto() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("My Journey");
        dto.setType(GeschichteType.JOURNEY);

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
    }

    @Test
    void create_defaults_to_STORY_when_type_is_null() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("My Story");

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
    }

    @Test
    void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
        // The journey intro is plain text: JourneyReader renders it via Svelte text
        // interpolation (never {@html}), so the OWASP sanitizer's entity encoding
        // would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
        // re-encode cumulatively on every editor round-trip.
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("Winterbriefe");
        dto.setType(GeschichteType.JOURNEY);
        dto.setBody("Müller & Söhne, Temperatur < 0");

        GeschichteView saved = geschichteService.create(dto);

        assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
    }

    @Test
    void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.JOURNEY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setBody("Temperatur < 0 & Schnee");

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
    }

    @Test
    void update_still_sanitizes_STORY_body() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.STORY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setBody("<p>ok</p><script>alert(1)</script>");

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
    }

    // ─── length caps ─────────────────────────────────────────────────────────

    @Test
    void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
        authenticateAs(writer, Permission.BLOG_WRITE);

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("x".repeat(256));

        assertThatThrownBy(() -> geschichteService.create(dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
    }

    @Test
    void create_accepts_title_of_exactly_255_chars() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("x".repeat(255));

        assertThat(geschichteService.create(dto).title()).hasSize(255);
    }

    @Test
    void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("x".repeat(256));

        assertThatThrownBy(() -> geschichteService.update(id, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
    }

    @Test
    void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
        authenticateAs(writer, Permission.BLOG_WRITE);

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("Winterbriefe");
        dto.setType(GeschichteType.JOURNEY);
        dto.setBody("x".repeat(4001));

        assertThatThrownBy(() -> geschichteService.create(dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
    }

    @Test
    void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("Winterbriefe");
        dto.setType(GeschichteType.JOURNEY);
        dto.setBody("x".repeat(4000));

        assertThat(geschichteService.create(dto).body()).hasSize(4000);
    }

    @Test
    void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.JOURNEY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setBody("x".repeat(4001));

        assertThatThrownBy(() -> geschichteService.update(id, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
    }

    @Test
    void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
        // STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
        // the 4000-char cap exists for the verbatim JOURNEY intro path only.
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.STORY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setBody("<p>" + "x".repeat(4001) + "</p>");

        assertThat(geschichteService.update(id, dto).body()).contains("<p>");
    }

    // ─── update ──────────────────────────────────────────────────────────────

    @Test
    void update_sets_publishedAt_when_status_transitions_to_PUBLISHED() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setPublishedAt(null);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setStatus(GeschichteStatus.PUBLISHED);

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
        assertThat(saved.publishedAt()).isNotNull();
    }

    @Test
    void update_clears_publishedAt_when_status_transitions_back_to_DRAFT() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = published(id);
        existing.setPublishedAt(LocalDateTime.now().minusDays(1));
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setStatus(GeschichteStatus.DRAFT);

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
        assertThat(saved.publishedAt()).isNull();
    }

    @Test
    void update_sanitizes_body_on_save() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setBody("<p>ok</p><script>alert(1)</script>");

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
    }

    @Test
    void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.STORY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setType(GeschichteType.JOURNEY);

        assertThatThrownBy(() -> geschichteService.update(id, dto))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
    }

    @Test
    void update_accepts_dto_carrying_the_unchanged_type() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        Geschichte existing = draft(id);
        existing.setType(GeschichteType.STORY);
        when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
        when(geschichteRepository.save(any(Geschichte.class)))
                .thenAnswer(inv -> inv.getArgument(0));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setType(GeschichteType.STORY);
        dto.setTitle("Unverändert getypt");

        GeschichteView saved = geschichteService.update(id, dto);

        assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
        assertThat(saved.title()).isEqualTo("Unverändert getypt");
    }

    @Test
    void update_throws_NOT_FOUND_when_geschichte_unknown() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> geschichteService.update(id, new GeschichteUpdateDTO()))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
    }

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

    @Test
    void delete_calls_repository_deleteById() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.existsById(id)).thenReturn(true);

        geschichteService.delete(id);

        verify(geschichteRepository).deleteById(id);
    }

    @Test
    void delete_throws_NOT_FOUND_when_unknown() {
        authenticateAs(writer, Permission.BLOG_WRITE);
        UUID id = UUID.randomUUID();
        when(geschichteRepository.existsById(id)).thenReturn(false);

        assertThatThrownBy(() -> geschichteService.delete(id))
                .isInstanceOf(DomainException.class)
                .extracting("code")
                .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
        verify(geschichteRepository, never()).deleteById(any());
    }

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

    private void authenticateAs(AppUser user, Permission... permissions) {
        var authorities = List.of(permissions).stream()
                .map(p -> new SimpleGrantedAuthority(p.name()))
                .collect(Collectors.toList());
        SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
    }

    private Geschichte draft(UUID id) {
        return Geschichte.builder()
                .id(id)
                .title("Draft")
                .body("<p>body</p>")
                .status(GeschichteStatus.DRAFT)
                .persons(new HashSet<>())
                .items(new ArrayList<>())
                .build();
    }

    private Geschichte published(UUID id) {
        return Geschichte.builder()
                .id(id)
                .title("Published")
                .body("<p>body</p>")
                .status(GeschichteStatus.PUBLISHED)
                .publishedAt(LocalDateTime.now().minusHours(1))
                .persons(new HashSet<>())
                .items(new ArrayList<>())
                .build();
    }
}
