package org.raddatz.familienarchiv.tag;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

import org.raddatz.familienarchiv.tag.TagTreeNodeDTO;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class TagService {

    // These 10 color tokens are the fixed palette.
    // Keep in sync with the --c-tag-* tokens defined in frontend/src/routes/layout.css.
    static final Set<String> ALLOWED_TAG_COLORS = Set.of(
            "sage", "sienna", "amber", "slate", "violet",
            "rose", "cobalt", "moss", "sand", "coral"
    );

    private final TagRepository tagRepository;

    public List<Tag> search(String query) {
        List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query);
        if (matched.isEmpty()) return matched;
        return enrichWithRelatives(matched);
    }

    public List<Tag> findByNameContaining(String fragment) {
        return tagRepository.findByNameContainingIgnoreCase(fragment);
    }

    public Tag getById(UUID id) {
        return tagRepository.findById(id)
                .orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
    }

    /** Lookup by the canonical tag_path — used by the canonical importer to attach a document's tag. */
    public Optional<Tag> findBySourceRef(String sourceRef) {
        return tagRepository.findBySourceRef(sourceRef);
    }

    /**
     * Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
     * collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
     * parent and its same-named lowercase child), so resolution prefers an exact-case match, then
     * falls back to the lowest-id case-insensitive match, then creates. See #730.
     */
    public Tag findOrCreate(String name) {
        String cleanName = name.trim();
        Optional<Tag> exact = tagRepository.findByName(cleanName);
        if (exact.isPresent()) return exact.get();                          // exact-case wins (edit round-trip replays the stored name)
        List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
        if (!caseInsensitive.isEmpty()) {
            return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
        }
        return tagRepository.save(Tag.builder().name(cleanName).build());   // create-when-absent (orphan tag: null sourceRef/parentId)
    }

    /**
     * Idempotent upsert keyed on {@code sourceRef} (the canonical tag_path) for the
     * Phase-3 importer (ADR-025). On first import the canonical name and parent are
     * written; on re-import a human-renamed tag name is preserved (the source_ref is the
     * stable identity, the name is a human-editable label).
     */
    @Transactional
    public Tag upsertBySourceRef(String sourceRef, String name, UUID parentId) {
        return tagRepository.findBySourceRef(sourceRef)
                .map(existing -> {
                    existing.setParentId(parentId);
                    return tagRepository.save(existing);
                })
                .orElseGet(() -> tagRepository.save(Tag.builder()
                        .sourceRef(sourceRef)
                        .name(name)
                        .parentId(parentId)
                        .build()));
    }

    @Transactional
    public Tag update(UUID id, TagUpdateDTO dto) {
        Tag tag = getById(id);

        if (dto.parentId() != null) {
            validateNoSelfReference(id, dto.parentId());
            validateNoAncestorCycle(id, dto.parentId());
            getById(dto.parentId()); // ensure parent exists
        }

        if (dto.color() != null) {
            validateColor(dto.color());
        }

        tag.setName(dto.name());
        tag.setParentId(dto.parentId());
        tag.setColor(dto.color());
        return tagRepository.save(tag);
    }

    @Transactional
    public void delete(UUID id) {
        tagRepository.delete(getById(id));
    }

    @Transactional
    public Tag mergeTags(UUID sourceId, UUID targetId) {
        validateNotSelf(sourceId, targetId);
        Tag source = getById(sourceId);
        Tag target = getById(targetId);
        log.info("Merging tag '{}' ({}) into '{}' ({})", source.getName(), sourceId, target.getName(), targetId);
        validateNotDescendant(sourceId, targetId);
        transferDocuments(sourceId, targetId);
        tagRepository.reparentChildren(sourceId, targetId);
        tagRepository.deleteById(sourceId);
        return target;
    }

    @Transactional
    public void deleteWithDescendants(UUID id) {
        log.info("Deleting subtree rooted at {}", id);
        getById(id);
        List<UUID> ids = tagRepository.findDescendantIds(id);
        if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids);
        tagRepository.deleteAllById(ids);
        log.info("Deleted subtree rooted at {}, {} nodes", id, ids.size());
    }

    /**
     * Sets the effective (inherited) color on child tags that have no color of their own.
     * Colors are stored only on root-level tags; children inherit the parent's color.
     * Parent tags are batch-loaded in a single query. Safe to call on detached entities.
     */
    public void resolveEffectiveColors(Collection<Tag> tags) {
        if (tags == null || tags.isEmpty()) return;

        Set<UUID> parentIdsNeeded = tags.stream()
                .filter(t -> t.getColor() == null && t.getParentId() != null)
                .map(Tag::getParentId)
                .collect(Collectors.toSet());

        if (parentIdsNeeded.isEmpty()) return;

        Map<UUID, String> parentColors = tagRepository.findAllById(parentIdsNeeded)
                .stream()
                .filter(p -> p.getColor() != null)
                .collect(Collectors.toMap(Tag::getId, Tag::getColor));

        tags.forEach(tag -> {
            if (tag.getColor() == null && tag.getParentId() != null) {
                String resolved = parentColors.get(tag.getParentId());
                if (resolved != null) {
                    tag.setColor(resolved);
                }
            }
        });
    }

    /**
     * Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
     * token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
     * ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
     * non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
     * letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
     * from the resolved root's stored token (null when the root has none). Safe on detached tags.
     */
    public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
        if (tags == null || tags.isEmpty()) return Map.of();

        Map<UUID, Tag> distinct = new LinkedHashMap<>();
        for (Tag tag : tags) {
            if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
        }

        Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
        Set<UUID> idsToLoad = new HashSet<>();
        for (Tag tag : distinct.values()) {
            if (tag.getParentId() == null) continue;
            List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
            ancestorIdsByTagId.put(tag.getId(), ancestorIds);
            idsToLoad.addAll(ancestorIds);
        }

        Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
                : tagRepository.findAllById(idsToLoad).stream()
                        .collect(Collectors.toMap(Tag::getId, t -> t));

        Map<UUID, RootTag> result = new HashMap<>();
        for (Tag tag : distinct.values()) {
            Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
            result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
        }
        return result;
    }

    private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
        if (tag.getParentId() == null) return tag;
        if (ancestorIds != null) {
            for (UUID ancestorId : ancestorIds) {
                Tag ancestor = ancestorsById.get(ancestorId);
                if (ancestor != null && ancestor.getParentId() == null) return ancestor;
            }
        }
        // No null-parent ancestor surfaced — the parent is orphaned or the chain is deeper than the
        // findAncestorIds CTE's depth guard. Fall back to the tag as its own root, but surface it:
        // a silently mislabeled root would otherwise be invisible. UUIDs only (no tag names logged).
        log.warn("Tag {} has parent {} but no root surfaced from its ancestry; "
                + "treating it as its own root.", tag.getId(), tag.getParentId());
        return tag;
    }

    /**
     * For each tag name, returns the set of that tag's ID plus all descendant IDs.
     * Used by DocumentService to expand selected filter tags before applying AND/OR logic.
     */
    public List<Set<UUID>> expandTagNamesToDescendantIdSets(List<String> tagNames) {
        if (tagNames == null || tagNames.isEmpty()) return List.of();
        return tagNames.stream()
                .filter(StringUtils::hasText)
                .map(name -> (Set<UUID>) new HashSet<>(tagRepository.findDescendantIdsByName(name.trim())))
                .toList();
    }

    /**
     * Returns all tags assembled into a tree, each node carrying two counts:
     * {@code documentCount} — documents tagged with that exact tag (direct) — and
     * {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
     * (subtree rollup). Each count comes from one aggregate query (no N+1).
     * NOTE: counts are global per tag, not scoped to any search filter.
     * Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the
     * subtree rollup) as well as the admin sidebar and tag operation previews (which read the
     * direct count).
     */
    public List<TagTreeNodeDTO> getTagTree() {
        List<Tag> all = tagRepository.findAll();
        Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
        Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
        return buildTree(all, counts, subtreeCounts);
    }

    private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
        return counts.stream().collect(Collectors.toMap(
                TagRepository.TagCount::getTagId,
                TagRepository.TagCount::getCount
        ));
    }

    // ─── private helpers ─────────────────────────────────────────────────────

    // Each matched tag issues 1 CTE query (findDescendantIds or findAncestorIds) + 1 batch
    // fetch for extras. Typical queries match 1–3 tags at depth ≤ 4, so 3–5 queries total.
    private List<Tag> enrichWithRelatives(List<Tag> matched) {
        Set<UUID> matchedIds = matched.stream().map(Tag::getId).collect(Collectors.toSet());
        Set<UUID> extraIds = new HashSet<>();

        for (Tag tag : matched) {
            if (tag.getParentId() == null) {
                extraIds.addAll(tagRepository.findDescendantIds(tag.getId()));
            } else {
                extraIds.addAll(tagRepository.findAncestorIds(tag.getId()));
            }
        }
        extraIds.removeAll(matchedIds);

        List<Tag> result = new ArrayList<>(matched);
        if (!extraIds.isEmpty()) {
            result.addAll(tagRepository.findAllById(extraIds));
        }
        resolveEffectiveColors(result);
        return result;
    }

    private void validateNotSelf(UUID sourceId, UUID targetId) {
        if (sourceId.equals(targetId)) {
            throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF,
                    "Source and target must not be the same tag: " + sourceId);
        }
    }

    private void validateNotDescendant(UUID sourceId, UUID targetId) {
        List<UUID> descendants = tagRepository.findDescendantIds(sourceId);
        if (descendants.contains(targetId)) {
            throw DomainException.badRequest(ErrorCode.TAG_MERGE_INVALID_TARGET,
                    "Target " + targetId + " is a descendant of source " + sourceId);
        }
    }

    private void transferDocuments(UUID sourceId, UUID targetId) {
        tagRepository.reassignDocumentTags(sourceId, targetId);
        tagRepository.deleteDocumentTagsByTagId(sourceId);
    }

    private void validateNoSelfReference(UUID tagId, UUID proposedParentId) {
        if (tagId.equals(proposedParentId)) {
            throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED,
                    "A tag cannot be its own parent: " + tagId);
        }
    }

    private void validateNoAncestorCycle(UUID tagId, UUID proposedParentId) {
        // TOCTOU note: concurrent admin writes could both pass this check and create a
        // multi-node cycle. This is intentionally not locked because: (a) the endpoint
        // requires ADMIN_TAG permission so concurrency is rare, (b) the DB-level
        // CHECK (parent_id != id) prevents infinite self-loops as a hard backstop,
        // and (c) the window is microseconds. Do NOT add a pessimistic lock here.
        List<UUID> ancestors = tagRepository.findAncestorIds(proposedParentId);
        if (ancestors.contains(tagId)) {
            throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED,
                    "Assigning parent " + proposedParentId + " to tag " + tagId + " would create a cycle");
        }
    }

    private void validateColor(String color) {
        if (!ALLOWED_TAG_COLORS.contains(color)) {
            throw DomainException.badRequest(ErrorCode.INVALID_TAG_COLOR,
                    "Color '" + color + "' is not in the allowed palette");
        }
    }

    private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
                                           Map<UUID, Long> subtreeCounts) {
        Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
        for (Tag tag : tags) {
            int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
            int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
            nodeById.put(tag.getId(), new TagTreeNodeDTO(
                    tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
                    new ArrayList<>(), tag.getParentId()
            ));
        }
        for (TagTreeNodeDTO node : nodeById.values()) {
            if (node.parentId() != null) {
                TagTreeNodeDTO parent = nodeById.get(node.parentId());
                if (parent != null) parent.children().add(node);
            }
        }
        return nodeById.values().stream().filter(n -> n.parentId() == null).toList();
    }
}
