"""Tag search engine — string matching with semantic fallback."""

import logging
import math
import threading
from pathlib import Path

from .data import CooccurrenceIndex, TagRegistry, tokenize, flatten

logger = logging.getLogger("BooruGacha")

CACHE_DIR = Path(__file__).parent / "cache"
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"

# Tiered content filter presets for Roll / Search nodes.
CONTENT_FILTER_OPTIONS = ["all", "sfw", "sfw+nudity", "sfw+nudity+sexual"]
CONTENT_FILTER_SETS: dict[str, set[str] | None] = {
    "all": None,
    "sfw": {"sfw"},
    "sfw+nudity": {"sfw", "nudity"},
    "sfw+nudity+sexual": {"sfw", "nudity", "sexual"},
}


class TagSearchEngine:
    """Finds the best matching Danbooru tag for arbitrary user input."""

    def __init__(self, registry: TagRegistry):
        self.registry = registry
        self._model = None
        self._embeddings = None  # torch.Tensor | None

    # === public API ===================================================

    @staticmethod
    def _tag_label(tag: dict) -> str:
        """Build a ``group/subgroup/category`` debug label."""
        parts = [tag["group"]]
        if tag.get("subgroup"):
            parts.append(tag["subgroup"])
        if tag.get("category"):
            parts.append(tag["category"])
        return "/".join(parts)

    def find_one(
        self,
        query: str,
        threshold: float = 0.3,
        exclude: set[str] | None = None,
        allowed_filtering: set[str] | None = None,
    ) -> tuple[str, str, float] | None:
        """Find the single best matching tag for *query*.

        Returns ``(danbooru_name, category_label, score)`` or ``None``.
        Tags whose **name** is in *exclude* are skipped.
        Tags whose **filtering** value is not in *allowed_filtering*
        are skipped (when the set is provided).
        """
        q = query.strip()
        if not q:
            return None

        def _excluded(tag: dict) -> bool:
            if exclude and (
                tag["name"] in exclude or tag["danbooru_name"] in exclude
            ):
                return True
            if allowed_filtering and tag.get("filtering") not in allowed_filtering:
                return True
            return False

        # Normalise spaces → underscores for index lookups
        q_norm = q.lower().replace(" ", "_")

        # 1. Exact match (fast path) -----------------------------------
        for key, index in (
            (q_norm, self.registry.danbooru_to_idx),
            (q_norm, self.registry.name_to_idx),
        ):
            if key in index:
                tag = self.registry.tags[index[key]]
                if not _excluded(tag):
                    return (tag["danbooru_name"], self._tag_label(tag), 1.0)

        # 2. String scoring on candidates ------------------------------
        candidates = self._get_candidates(q_norm)
        string_scores: dict[int, float] = {}
        for idx in candidates:
            if _excluded(self.registry.tags[idx]):
                continue
            score = self._string_score(q_norm, idx)
            if score > 0.05:
                string_scores[idx] = score

        # Strong string match — return immediately
        if string_scores:
            best_idx = max(string_scores, key=string_scores.get)
            if string_scores[best_idx] >= 0.8:
                tag = self.registry.tags[best_idx]
                return (tag["danbooru_name"], self._tag_label(tag), string_scores[best_idx])

        # 3. Semantic scoring ------------------------------------------
        semantic_scores = self._semantic_scores(q)

        # 4. Blend and pick the best -----------------------------------
        all_idxs: set[int] = set(string_scores.keys())

        if semantic_scores is not None:
            top_k_vals, top_k_idxs = semantic_scores.topk(
                min(50, len(semantic_scores))
            )
            for i in range(len(top_k_idxs)):
                idx = top_k_idxs[i].item()
                if not _excluded(self.registry.tags[idx]):
                    all_idxs.add(idx)

        best_match = None
        best_combined = 0.0
        for idx in all_idxs:
            s = string_scores.get(idx, 0.0)
            e = semantic_scores[idx].item() if semantic_scores is not None else 0.0

            combined = max(
                0.4 * e + 0.6 * s,
                s * 0.9,  # strong string match can't be dragged down
            )
            if combined > best_combined:
                best_combined = combined
                best_match = idx

        if best_match is not None and best_combined >= threshold:
            tag = self.registry.tags[best_match]
            return (tag["danbooru_name"], self._tag_label(tag), best_combined)

        return None

    # === candidate generation =========================================

    def _get_candidates(self, q_norm: str) -> set[int]:
        candidates: set[int] = set()

        # Token index — any tag sharing a token with the query
        for token in tokenize(q_norm):
            hits = self.registry.token_index.get(token)
            if hits:
                candidates.update(hits)

        # Flat form exact lookup
        q_flat = flatten(q_norm)
        hits = self.registry.flat_index.get(q_flat)
        if hits:
            candidates.update(hits)

        return candidates

    # === string scoring ===============================================

    def _string_score(self, q_norm: str, tag_idx: int) -> float:
        tag = self.registry.tags[tag_idx]
        best = 0.0
        for target in {tag["name"], tag["danbooru_name"]}:
            score = max(
                self._flat_score(q_norm, target),
                self._token_f1(q_norm, target),
            )
            if score > best:
                best = score
        return best

    @staticmethod
    def _flat_score(query: str, target: str) -> float:
        q = flatten(query)
        t = flatten(target)
        if not q or not t:
            return 0.0
        if q == t:
            return 1.0
        if q in t:
            return len(q) / len(t)
        if t in q:
            return len(t) / len(q)
        return 0.0

    def _token_f1(self, query: str, target: str) -> float:
        q_tokens = tokenize(query)
        t_tokens = tokenize(target)
        if not q_tokens or not t_tokens:
            return 0.0

        matched_q: set[str] = set()
        matched_t: set[int] = set()
        for qt in q_tokens:
            for i, tt in enumerate(t_tokens):
                if i in matched_t:
                    continue
                if qt == tt or qt + "s" == tt or tt + "s" == qt:
                    matched_q.add(qt)
                    matched_t.add(i)
                    break

        if not matched_q:
            return 0.0

        # Weight tokens by how prevalent they are in the tag vocabulary.
        # Common domain tokens (e.g. "skin", "hair") carry more weight
        # than rare/niche ones, so partial matches on meaningful concept
        # tokens outscore coincidental hits on obscure names.
        def _tw(token: str) -> float:
            count = len(self.registry.token_index.get(token, set()))
            return math.log2(2 + count)

        matched_t_tokens = [t_tokens[i] for i in matched_t]
        w_matched_tag = sum(_tw(t) for t in matched_t_tokens)
        w_all_tag = sum(_tw(t) for t in t_tokens)
        w_matched_query = sum(_tw(q) for q in matched_q)
        w_all_query = sum(_tw(q) for q in q_tokens)

        precision = w_matched_tag / w_all_tag
        recall = w_matched_query / w_all_query
        if precision + recall == 0:
            return 0.0
        f1 = 2 * precision * recall / (precision + recall)

        # Penalise extra unmatched tokens in the target
        extra = len(t_tokens) - len(matched_t)
        f1 *= 0.8**extra

        return f1

    # === semantic scoring =============================================

    def _semantic_scores(self, query: str):
        """Cosine similarity of *query* against all tag embeddings."""
        self._ensure_model()
        if self._model is None:
            return None

        import torch
        from sentence_transformers import util

        query_emb = self._model.encode(query, convert_to_tensor=True)
        if query_emb.device != self._embeddings.device:
            query_emb = query_emb.to(self._embeddings.device)
        scores = util.cos_sim(query_emb, self._embeddings)[0]
        return scores

    def _ensure_model(self):
        if self._model is not None:
            return
        try:
            import numpy as np
            import torch
            from sentence_transformers import SentenceTransformer
        except ImportError:
            logger.warning(
                "[BooruGacha] sentence-transformers not installed — "
                "semantic search disabled"
            )
            return

        logger.info("[BooruGacha] Loading sentence-transformer model …")
        CACHE_DIR.mkdir(exist_ok=True)
        self._model = SentenceTransformer(MODEL_NAME, cache_folder=str(CACHE_DIR))

        cache_file = CACHE_DIR / f"tag_embeddings_{len(self.registry)}.npy"

        if cache_file.exists():
            data = np.load(str(cache_file))
            if data.shape[0] == len(self.registry):
                self._embeddings = torch.tensor(data, dtype=torch.float32)
                logger.info(
                    f"[BooruGacha] Loaded cached embeddings "
                    f"({len(self.registry)} tags)"
                )
                return
            logger.info("[BooruGacha] Embedding cache stale, rebuilding …")

        # Build enriched text for each tag
        tag_texts: list[str] = []
        for tag in self.registry.tags:
            readable = tag["name"].replace("_", " ")
            cat = tag["category"] or tag["subgroup"] or tag["group"]
            if cat:
                tag_texts.append(f"{readable} ({cat.replace('_', ' ')})")
            else:
                tag_texts.append(readable)

        logger.info(
            f"[BooruGacha] Building embeddings for {len(tag_texts)} tags …"
        )
        self._embeddings = self._model.encode(
            tag_texts,
            convert_to_tensor=True,
            show_progress_bar=True,
            batch_size=256,
        )
        np.save(str(cache_file), self._embeddings.cpu().numpy())
        self._embeddings = self._embeddings.cpu()
        logger.info(f"[BooruGacha] Embeddings cached → {cache_file}")


# ── singleton ────────────────────────────────────────────────────────

_lock = threading.Lock()
_engine: TagSearchEngine | None = None


def get_engine() -> TagSearchEngine:
    global _engine
    if _engine is None:
        with _lock:
            if _engine is None:
                registry = TagRegistry()
                _engine = TagSearchEngine(registry)
    return _engine


_cooccurrence: CooccurrenceIndex | None = None


def get_cooccurrence() -> CooccurrenceIndex:
    global _cooccurrence
    if _cooccurrence is None:
        with _lock:
            if _cooccurrence is None:
                _cooccurrence = CooccurrenceIndex()
    return _cooccurrence
