package org.raddatz.familienarchiv.tag;

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.tag.TagUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;

import java.util.List;
import java.util.Map;
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.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TagServiceTest {

    @Mock TagRepository tagRepository;
    @InjectMocks TagService tagService;

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

    @Test
    void getById_throwsNotFound_whenMissing() {
        UUID id = UUID.randomUUID();
        when(tagRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.getById(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    @Test
    void getById_returnsTag_whenFound() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Familie").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));

        assertThat(tagService.getById(id)).isEqualTo(tag);
    }

    // ─── findOrCreate ─────────────────────────────────────────────────────────

    @Test
    void findOrCreate_exactCaseWins_overCaseInsensitiveSibling() {
        // "Geburt" (parent) and "geburt" (child) both exist; the edit round-trip replays the stored
        // name "geburt", which must bind to the exact-case row, not the parent.
        Tag exact = Tag.builder().id(UUID.randomUUID()).name("geburt").build();
        when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exact));

        Tag result = tagService.findOrCreate("geburt");

        assertThat(result).isEqualTo(exact);
        verify(tagRepository, never()).save(any());
    }

    @Test
    void findOrCreate_exactCaseWins_evenWhenItsIdIsNotTheLowest() {
        // Adversarial guard: exact-case must short-circuit BEFORE the lowest-id rule. Here the exact row
        // has the higher id, so a naive "always pick lowest id across all CI matches" would pick wrong.
        Tag exactHigherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000009")).name("geburt").build();
        when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exactHigherId));

        Tag result = tagService.findOrCreate("geburt");

        assertThat(result).isEqualTo(exactHigherId);
        verify(tagRepository, never()).findAllByNameIgnoreCase(any()); // exact-case wins without consulting the CI list
        verify(tagRepository, never()).save(any());
    }

    @Test
    void findOrCreate_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
        // Stored name is "Weihnachten"; a save replays "weihnachten" (no exact-case row) → bind to the
        // single case-insensitive match rather than creating a duplicate.
        Tag stored = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").build();
        when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
        when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(stored));

        Tag result = tagService.findOrCreate("weihnachten");

        assertThat(result).isEqualTo(stored);
        verify(tagRepository, never()).save(any());
    }

    @Test
    void findOrCreate_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
        // Two rows collide case-insensitively and neither equals the query exactly. Resolution must be
        // deterministic (lowest id) and never throw — proven by calling twice and getting the same id.
        Tag lowerId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).name("Reisepläne").build();
        Tag higherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).name("reisepläne").build();
        when(tagRepository.findByName("REISEPLÄNE")).thenReturn(Optional.empty());
        when(tagRepository.findAllByNameIgnoreCase("REISEPLÄNE")).thenReturn(List.of(higherId, lowerId));

        Tag first = tagService.findOrCreate("REISEPLÄNE");
        Tag second = tagService.findOrCreate("REISEPLÄNE");

        assertThat(first.getId()).isEqualTo(lowerId.getId());
        assertThat(second.getId()).isEqualTo(first.getId());
        verify(tagRepository, never()).save(any());
    }

    @Test
    void findOrCreate_createsOrphanTag_whenNameAbsent() {
        Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
        when(tagRepository.findByName("Krieg")).thenReturn(Optional.empty());
        when(tagRepository.findAllByNameIgnoreCase("Krieg")).thenReturn(List.of());
        when(tagRepository.save(any())).thenReturn(saved);

        Tag result = tagService.findOrCreate("Krieg");

        assertThat(result).isEqualTo(saved);
        verify(tagRepository).save(any());
    }

    @Test
    void findOrCreate_trimsWhitespace_thenLandsOnCaseInsensitiveChild() {
        Tag child = Tag.builder().id(UUID.randomUUID()).name("weihnachten").build();
        when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
        when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(child));

        Tag result = tagService.findOrCreate("  weihnachten  ");

        assertThat(result).isEqualTo(child);
        verify(tagRepository).findByName("weihnachten");
    }

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

    @Test
    void update_savesNewName() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Old").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));

        Tag result = tagService.update(id, new TagUpdateDTO("New", null, null));

        assertThat(result.getName()).isEqualTo("New");
    }

    @Test
    void update_savesParentId() {
        UUID id = UUID.randomUUID();
        UUID parentId = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Child").build();
        Tag parent = Tag.builder().id(parentId).name("Parent").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.findById(parentId)).thenReturn(Optional.of(parent));
        when(tagRepository.findAncestorIds(parentId)).thenReturn(List.of());
        when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));

        Tag result = tagService.update(id, new TagUpdateDTO("Child", parentId, null));

        assertThat(result.getParentId()).isEqualTo(parentId);
    }

    @Test
    void update_savesColor() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Colored").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));

        Tag result = tagService.update(id, new TagUpdateDTO("Colored", null, "sage"));

        assertThat(result.getColor()).isEqualTo("sage");
    }

    @Test
    void update_throwsNotFound_whenTagMissing() {
        UUID id = UUID.randomUUID();
        when(tagRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null)))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    // ─── color validation ─────────────────────────────────────────────────────

    @Test
    void update_throwsInvalidTagColor_whenColorNotInAllowedPalette() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Tag").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));

        assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("Tag", null, "hotpink")))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVALID_TAG_COLOR);
    }

    @Test
    void update_allowsNullColor() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Tag").color("sage").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));

        Tag result = tagService.update(id, new TagUpdateDTO("Tag", null, null));

        assertThat(result.getColor()).isNull();
    }

    // ─── cycle detection ─────────────────────────────────────────────────────

    @Test
    void update_throwsCycleDetected_whenTagIsAncestorOfProposedParent() {
        UUID tagId = UUID.randomUUID();
        UUID proposedParentId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Tag").build();
        when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag));
        // tagId appears in the ancestor chain of proposedParentId → cycle
        when(tagRepository.findAncestorIds(proposedParentId)).thenReturn(List.of(tagId));

        assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", proposedParentId, null)))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED);
    }

    @Test
    void update_throwsCycleDetected_whenTagIsSameAsProposedParent() {
        UUID tagId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Tag").build();
        when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag));

        assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", tagId, null)))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED);
    }

    // ─── getTagTree ───────────────────────────────────────────────────────────

    @Test
    void getTagTree_returnsEmptyList_whenNoTags() {
        when(tagRepository.findAll()).thenReturn(List.of());
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        assertThat(tagService.getTagTree()).isEmpty();
    }

    @Test
    void getTagTree_returnsFlatRootTags_whenNoParentRelationships() {
        UUID idA = UUID.randomUUID();
        UUID idB = UUID.randomUUID();
        List<Tag> tags = List.of(
                Tag.builder().id(idA).name("Alpha").build(),
                Tag.builder().id(idB).name("Beta").build()
        );
        when(tagRepository.findAll()).thenReturn(tags);
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        var tree = tagService.getTagTree();

        assertThat(tree).hasSize(2);
        assertThat(tree).allSatisfy(node -> assertThat(node.children()).isEmpty());
    }

    @Test
    void getTagTree_nestsChildrenUnderParent() {
        UUID parentId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag parent = Tag.builder().id(parentId).name("Parent").build();
        Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
        when(tagRepository.findAll()).thenReturn(List.of(parent, child));
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        var tree = tagService.getTagTree();

        assertThat(tree).hasSize(1);
        assertThat(tree.get(0).id()).isEqualTo(parentId);
        assertThat(tree.get(0).children()).hasSize(1);
        assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId);
    }

    // ─── getTagTree (enriched) ────────────────────────────────────────────────

    @Test
    void getTagTree_populatesParentId_onChildNode() {
        UUID parentId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag parent = Tag.builder().id(parentId).name("Parent").build();
        Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
        when(tagRepository.findAll()).thenReturn(List.of(parent, child));
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        var tree = tagService.getTagTree();

        assertThat(tree.get(0).children().get(0).parentId()).isEqualTo(parentId);
    }

    @Test
    void getTagTree_populatesDocumentCount_fromAggregateQuery() {
        UUID tagId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Tag").build();
        TagRepository.TagCount countEntry = mock(TagRepository.TagCount.class);
        when(countEntry.getTagId()).thenReturn(tagId);
        when(countEntry.getCount()).thenReturn(5L);
        when(tagRepository.findAll()).thenReturn(List.of(tag));
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        var tree = tagService.getTagTree();

        assertThat(tree.get(0).documentCount()).isEqualTo(5);
    }

    @Test
    void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() {
        when(tagRepository.findAll()).thenReturn(List.of());
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        tagService.getTagTree();

        verify(tagRepository, times(1)).findDocumentCountsPerTag();
    }

    @Test
    void getTagTree_populatesSubtreeDocumentCount_fromRollupQuery() {
        UUID tagId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Reisen").build();
        TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class);
        when(subtreeEntry.getTagId()).thenReturn(tagId);
        when(subtreeEntry.getCount()).thenReturn(7L);
        when(tagRepository.findAll()).thenReturn(List.of(tag));
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry));

        var tree = tagService.getTagTree();

        assertThat(tree.get(0).subtreeDocumentCount()).isEqualTo(7);
    }

    @Test
    void getTagTree_keepsDirectAndSubtreeCountsIndependent() {
        UUID tagId = UUID.randomUUID();
        Tag tag = Tag.builder().id(tagId).name("Reisen").build();
        TagRepository.TagCount directEntry = mock(TagRepository.TagCount.class);
        when(directEntry.getTagId()).thenReturn(tagId);
        when(directEntry.getCount()).thenReturn(2L);
        TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class);
        when(subtreeEntry.getTagId()).thenReturn(tagId);
        when(subtreeEntry.getCount()).thenReturn(7L);
        when(tagRepository.findAll()).thenReturn(List.of(tag));
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(directEntry));
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry));

        var node = tagService.getTagTree().get(0);

        assertThat(node.documentCount()).isEqualTo(2);
        assertThat(node.subtreeDocumentCount()).isEqualTo(7);
    }

    @Test
    void getTagTree_callsFindSubtreeDocumentCountsPerTag_exactlyOnce() {
        when(tagRepository.findAll()).thenReturn(List.of());
        when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
        when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());

        tagService.getTagTree();

        verify(tagRepository, times(1)).findSubtreeDocumentCountsPerTag();
    }

    // ─── resolveEffectiveColors ───────────────────────────────────────────────

    @Test
    void resolveEffectiveColors_doesNothing_whenCollectionIsEmpty() {
        tagService.resolveEffectiveColors(List.of());
        verifyNoInteractions(tagRepository);
    }

    @Test
    void resolveEffectiveColors_doesNothing_whenAllTagsHaveOwnColor() {
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").color("sage").build();

        tagService.resolveEffectiveColors(List.of(tag));

        verify(tagRepository, never()).findAllById(any());
        assertThat(tag.getColor()).isEqualTo("sage");
    }

    @Test
    void resolveEffectiveColors_doesNothing_whenTagHasNoParentAndNoColor() {
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").build();

        tagService.resolveEffectiveColors(List.of(tag));

        verify(tagRepository, never()).findAllById(any());
        assertThat(tag.getColor()).isNull();
    }

    @Test
    void resolveEffectiveColors_setsParentColor_onChildTagWithNoColor() {
        UUID parentId = UUID.randomUUID();
        Tag parent = Tag.builder().id(parentId).name("Parent").color("sage").build();
        Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build();
        when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent));

        tagService.resolveEffectiveColors(List.of(child));

        assertThat(child.getColor()).isEqualTo("sage");
    }

    @Test
    void resolveEffectiveColors_leavesChildUnchanged_whenParentHasNoColor() {
        UUID parentId = UUID.randomUUID();
        Tag parent = Tag.builder().id(parentId).name("Parent").build();
        Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build();
        when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent));

        tagService.resolveEffectiveColors(List.of(child));

        assertThat(child.getColor()).isNull();
    }

    @Test
    void resolveEffectiveColors_batchLoadsParents_inOneQuery() {
        UUID parentId1 = UUID.randomUUID();
        UUID parentId2 = UUID.randomUUID();
        Tag parent1 = Tag.builder().id(parentId1).name("P1").color("sage").build();
        Tag parent2 = Tag.builder().id(parentId2).name("P2").color("sienna").build();
        Tag child1 = Tag.builder().id(UUID.randomUUID()).name("C1").parentId(parentId1).build();
        Tag child2 = Tag.builder().id(UUID.randomUUID()).name("C2").parentId(parentId2).build();
        when(tagRepository.findAllById(Set.of(parentId1, parentId2))).thenReturn(List.of(parent1, parent2));

        tagService.resolveEffectiveColors(List.of(child1, child2));

        verify(tagRepository, times(1)).findAllById(any());
        assertThat(child1.getColor()).isEqualTo("sage");
        assertThat(child2.getColor()).isEqualTo("sienna");
    }

    // ─── resolveRootTags ───────────────────────────────────────────────────────

    @Test
    void resolveRootTags_returnsTagItself_whenTagIsRoot() {
        // REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
        Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();

        Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));

        assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
        verify(tagRepository, never()).findAncestorIds(any());
        verify(tagRepository, never()).findAllById(any());
    }

    @Test
    void resolveRootTags_walksChildToRoot_withRootColor() {
        // REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
        UUID rootId = UUID.randomUUID();
        UUID midId = UUID.randomUUID();
        Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
        Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
        Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
        when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
        when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));

        Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));

        assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
    }

    @Test
    void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
        // REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
        UUID rootId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
        Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
        Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
        when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
        when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));

        Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));

        assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
        verify(tagRepository, times(1)).findAncestorIds(childId);
        verify(tagRepository, times(1)).findAllById(any());
    }

    @Test
    void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
        // REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
        UUID rootId = UUID.randomUUID();
        Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
        Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
        when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
        when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));

        Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));

        assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
    }

    @Test
    void resolveRootTags_returnsEmptyMap_forEmptyInput() {
        assertThat(tagService.resolveRootTags(List.of())).isEmpty();
        verify(tagRepository, never()).findAncestorIds(any());
    }

    // ─── mergeTags ────────────────────────────────────────────────────────────

    @Test
    void mergeTags_throwsBadRequest_whenSelfMerge() {
        UUID id = UUID.randomUUID();

        assertThatThrownBy(() -> tagService.mergeTags(id, id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_MERGE_SELF);
    }

    @Test
    void mergeTags_throwsNotFound_whenSourceMissing() {
        UUID sourceId = UUID.randomUUID();
        UUID targetId = UUID.randomUUID();
        when(tagRepository.findById(sourceId)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    @Test
    void mergeTags_throwsNotFound_whenTargetMissing() {
        UUID sourceId = UUID.randomUUID();
        UUID targetId = UUID.randomUUID();
        Tag source = Tag.builder().id(sourceId).name("Source").build();
        when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
        when(tagRepository.findById(targetId)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    @Test
    void mergeTags_throwsBadRequest_whenTargetIsDescendantOfSource() {
        UUID sourceId = UUID.randomUUID();
        UUID targetId = UUID.randomUUID();
        Tag source = Tag.builder().id(sourceId).name("Parent").build();
        Tag target = Tag.builder().id(targetId).name("Child").parentId(sourceId).build();
        when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
        when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
        when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId, targetId));

        assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_MERGE_INVALID_TARGET);
    }

    @Test
    void mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource() {
        UUID sourceId = UUID.randomUUID();
        UUID targetId = UUID.randomUUID();
        Tag source = Tag.builder().id(sourceId).name("Source").build();
        Tag target = Tag.builder().id(targetId).name("Target").build();
        when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
        when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
        when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId));

        Tag result = tagService.mergeTags(sourceId, targetId);

        verify(tagRepository).reassignDocumentTags(sourceId, targetId);
        verify(tagRepository).deleteDocumentTagsByTagId(sourceId);
        verify(tagRepository).reparentChildren(sourceId, targetId);
        verify(tagRepository).deleteById(sourceId);
        assertThat(result).isEqualTo(target);
    }

    // ─── deleteWithDescendants ────────────────────────────────────────────────

    @Test
    void deleteWithDescendants_throwsNotFound_whenTagMissing() {
        UUID id = UUID.randomUUID();
        when(tagRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.deleteWithDescendants(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    @Test
    void deleteWithDescendants_deletesSubtreeDocTagsAndAllTags() {
        UUID id = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Root").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id, childId));

        tagService.deleteWithDescendants(id);

        verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id, childId));
        verify(tagRepository).deleteAllById(List.of(id, childId));
    }

    @Test
    void deleteWithDescendants_whenLeafTag_deletesTagAndItsOwnDocTags() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Leaf").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id));

        tagService.deleteWithDescendants(id);

        verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id));
        verify(tagRepository).deleteAllById(List.of(id));
    }

    @Test
    void deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("Tag").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
        when(tagRepository.findDescendantIds(id)).thenReturn(List.of());

        tagService.deleteWithDescendants(id);

        verify(tagRepository, never()).deleteDocumentTagsByTagIds(any());
        verify(tagRepository).deleteAllById(List.of());
    }

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

    @Test
    void delete_callsRepositoryDelete() {
        UUID id = UUID.randomUUID();
        Tag tag = Tag.builder().id(id).name("ToDelete").build();
        when(tagRepository.findById(id)).thenReturn(Optional.of(tag));

        tagService.delete(id);

        verify(tagRepository).delete(tag);
    }

    @Test
    void delete_throwsNotFound_whenTagMissing() {
        UUID id = UUID.randomUUID();
        when(tagRepository.findById(id)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> tagService.delete(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.TAG_NOT_FOUND);
    }

    // ─── search ───────────────────────────────────────────────────────────────

    @Test
    void search_includes_children_when_root_matches() {
        UUID rootId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag root = Tag.builder().id(rootId).name("Briefe").build();
        Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build();
        when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root));
        when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId));
        when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child));

        List<Tag> result = tagService.search("Brief");

        assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId);
    }

    @Test
    void search_includes_ancestors_when_child_matches() {
        UUID rootId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag root = Tag.builder().id(rootId).name("Briefe").build();
        Tag child = Tag.builder().id(childId).name("Hochzeit").parentId(rootId).build();
        when(tagRepository.findByNameContainingIgnoreCase("Hochzeit")).thenReturn(List.of(child));
        when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
        when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root));

        List<Tag> result = tagService.search("Hochzeit");

        assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId);
    }

    @Test
    void search_deduplicates_when_root_also_in_descendant_result() {
        UUID rootId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag root = Tag.builder().id(rootId).name("Briefe").build();
        Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build();
        when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root));
        // findDescendantIds includes the seed (rootId) — dedup must remove it from extras
        when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId));
        when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child));

        List<Tag> result = tagService.search("Brief");

        assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId);
        assertThat(result).hasSize(2);
    }

    @Test
    void search_callsResolveEffectiveColors_onAllResults() {
        UUID rootId = UUID.randomUUID();
        UUID childId = UUID.randomUUID();
        Tag root = Tag.builder().id(rootId).name("Briefe").color("sage").build();
        Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build();
        when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root));
        when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId));
        when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child));
        // resolveEffectiveColors will call findAllById for the child's parent color
        when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root));

        tagService.search("Brief");

        // verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
        verify(tagRepository, atLeastOnce()).findAllById(any());
    }

    // ─── findByNameContaining ─────────────────────────────────────────────────

    @Test
    void findByNameContaining_delegatesToRepository() {
        Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
        when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));

        List<Tag> result = tagService.findByNameContaining("krieg");

        assertThat(result).containsExactly(krieg);
        verify(tagRepository).findByNameContainingIgnoreCase("krieg");
    }
}
