"""In-memory fuzzy matching engine for entity resolution.

Orchestrates the full matching pipeline: tokenize query → collect
candidates progressively → rank with rapidfuzz → return best match.

The engine is data-source agnostic — it accepts entity data as a list
of dicts and uses configurable field names to extract IDs and descriptions.

Usage::

    from entr_adapter_core.utils.matching import MatchingEngine, MatchConfig, MatchResult

    config = MatchConfig(
        id_field="ItemCode",
        description_field="Description",
        score_threshold=90.0,
    )
    engine = MatchingEngine(config)

    # Load entity data (from any source — database, API, CSV, etc.)
    entities = [
        {"ItemCode": "A001", "Description": "Blue bubble wrap 50x60"},
        {"ItemCode": "A002", "Description": "Brown cardboard box 40x30"},
    ]

    result = engine.match("bubbel folie blauw", entities)
    if result:
        print(result.entity_id, result.score, result.confidence)
"""

from __future__ import annotations

import logging
import re
import unicodedata
from itertools import combinations
from typing import Any, Dict, List, Optional, Set

from pydantic import BaseModel, Field

from entr_adapter_core.utils.matching.config import MatchConfig
from entr_adapter_core.utils.matching.normalizer import normalize_text
from entr_adapter_core.utils.matching.ranker import find_best_match

logger = logging.getLogger(__name__)


class MatchResult(BaseModel):
    """Result of a fuzzy match operation.

    Attributes:
        entity_id: The unique identifier of the best-matched entity.
        score: The fuzzy match score (0.0 - 100.0).
        confidence: Human-readable confidence level based on score
            and configured thresholds.
        matched_entity: The full entity dict of the best match.
        candidate_count: Number of candidates considered during matching.
        all_candidates: All candidate entities that were evaluated.
            Useful for review UIs that show alternatives.
    """

    entity_id: str = Field(description="Unique identifier of the matched entity")
    score: float = Field(ge=0.0, le=100.0, description="Fuzzy match score (0-100)")
    confidence: str = Field(description="Confidence level: very_high, high, medium, or low")
    matched_entity: Dict[str, Any] = Field(description="Full entity dict of the best match")
    candidate_count: int = Field(description="Number of candidates evaluated")
    all_candidates: List[Dict[str, Any]] = Field(
        default_factory=list,
        description="All candidate entities considered during matching",
    )


class MatchingEngine:
    """In-memory fuzzy matching engine for entity resolution.

    Accepts a ``MatchConfig`` that controls field mappings, thresholds,
    synonym/stopword normalization, and scorer weights. Entity data is
    passed per-call so the engine is stateless and thread-safe.

    Args:
        config: A ``MatchConfig`` instance with matching parameters.

    Example::

        config = MatchConfig(id_field="code", description_field="name")
        engine = MatchingEngine(config)
        result = engine.match("blue widget", entities)
    """

    def __init__(self, config: MatchConfig) -> None:
        self.config = config

    def match(
        self,
        query: str,
        entities: List[Dict[str, Any]],
        *,
        include_candidates: bool = False,
    ) -> Optional[MatchResult]:
        """Run the full matching pipeline for *query* against *entities*.

        Args:
            query: The text to match (e.g., a product description from
                an invoice).
            entities: List of entity dicts. Each dict must contain at
                least the ``id_field`` and ``description_field`` keys
                specified in the config.
            include_candidates: If True, populate ``all_candidates`` in
                the result. Useful for review UIs but increases memory.

        Returns:
            A ``MatchResult`` with the best match, or ``None`` if no
            viable match was found (e.g., too few tokens or empty entities).
        """
        if not query or not entities:
            return None

        cfg = self.config

        # 1. Tokenize query
        search_tokens = self._tokenize(query)
        if len(search_tokens) < cfg.min_tokens_to_match:
            # Allow single-token queries if only one token exists
            if len(search_tokens) == 1:
                pass  # Proceed with single token
            else:
                logger.warning(
                    "Not enough tokens (%d) to meet minimum of %d",
                    len(search_tokens), cfg.min_tokens_to_match,
                )
                return None

        # 2. Collect candidates progressively
        candidates, seen_ids = self._collect_candidates(search_tokens, entities)

        # 3. Broaden search if too few candidates
        if len(candidates) < 5 and search_tokens:
            for level in range(cfg.min_tokens_to_match - 1, 0, -1):
                new_cands = self._candidates_at_level(level, search_tokens, entities, seen_ids)
                candidates.extend(new_cands)
                if len(candidates) >= 10:
                    break

        # 4. Fallback to all entities if nothing found
        if not candidates and search_tokens:
            logger.info("No candidates found — falling back to full entity list")
            candidates = entities

        if not candidates:
            return None

        # 5. Rank candidates
        descriptions = [
            str(c.get(cfg.description_field, "")) for c in candidates
        ]
        id_values = [str(c.get(cfg.id_field, "")) for c in candidates]

        normalized_query = normalize_text(query, cfg.synonym_map, cfg.stopwords)
        normalized_choices = [
            normalize_text(d, cfg.synonym_map, cfg.stopwords) for d in descriptions
        ]

        result = find_best_match(
            normalized_query=normalized_query,
            choices=descriptions,
            normalized_choices=normalized_choices,
            original_query=query,
            original_choices=descriptions,
            id_values=id_values,
            scorer_weights=cfg.scorer_weights,
        )

        if result is None:
            if candidates:
                # Return first candidate as fallback with score 0
                best = candidates[0]
                return MatchResult(
                    entity_id=str(best.get(cfg.id_field, "")),
                    score=0.0,
                    confidence="low",
                    matched_entity=best,
                    candidate_count=len(candidates),
                    all_candidates=candidates if include_candidates else [],
                )
            return None

        best_index, score = result
        best_entity = candidates[best_index]
        confidence = self._confidence_level(score)

        logger.info(
            "MATCH: '%s' (id=%s) score=%.1f (%s)",
            best_entity.get(cfg.description_field, ""),
            best_entity.get(cfg.id_field, ""),
            score,
            confidence,
        )

        return MatchResult(
            entity_id=str(best_entity.get(cfg.id_field, "")),
            score=score,
            confidence=confidence,
            matched_entity=best_entity,
            candidate_count=len(candidates),
            all_candidates=candidates if include_candidates else [],
        )

    # ------------------------------------------------------------------
    # Candidate collection
    # ------------------------------------------------------------------

    def _tokenize(self, query: str) -> List[str]:
        """Tokenize and filter the query string."""
        raw = query.lower().split()
        filtered = [t for t in raw if t not in self.config.stopwords and len(t) > 1]
        unique = list(set(filtered))
        # Sort by length descending (most specific tokens first)
        unique.sort(key=len, reverse=True)
        return unique[: self.config.max_tokens]

    def _collect_candidates(
        self,
        tokens: List[str],
        entities: List[Dict[str, Any]],
    ) -> tuple[List[Dict[str, Any]], Set[str]]:
        """Progressively collect candidates from N tokens down to min_tokens_to_match."""
        cfg = self.config
        candidates: List[Dict[str, Any]] = []
        seen_ids: Set[str] = set()

        start = len(tokens)
        end = max(cfg.min_tokens_to_match - 1, 0)

        for level in range(start, end, -1):
            new_cands = self._candidates_at_level(level, tokens, entities, seen_ids)
            candidates.extend(new_cands)

        return candidates, seen_ids

    def _candidates_at_level(
        self,
        level: int,
        tokens: List[str],
        entities: List[Dict[str, Any]],
        seen_ids: Set[str],
    ) -> List[Dict[str, Any]]:
        """Find entities matching *level* tokens from the query."""
        cfg = self.config
        if level > len(tokens) or level < 1:
            return []

        combos = list(combinations(tokens, level))
        if len(combos) > cfg.max_combinations_per_level:
            combos = combos[: cfg.max_combinations_per_level]

        new_candidates: List[Dict[str, Any]] = []

        for combo in combos:
            for entity in entities:
                eid = str(entity.get(cfg.id_field, ""))
                if eid in seen_ids:
                    continue

                # Build searchable text from description + search_fields
                parts = [str(entity.get(cfg.description_field, ""))]
                for sf in cfg.search_fields:
                    parts.append(str(entity.get(sf, "")))
                combined = " ".join(parts).lower()

                # Accent-insensitive matching
                combined_norm = "".join(
                    c
                    for c in unicodedata.normalize("NFKD", combined)
                    if unicodedata.category(c) != "Mn"
                )

                if all(
                    "".join(
                        c
                        for c in unicodedata.normalize("NFKD", t)
                        if unicodedata.category(c) != "Mn"
                    )
                    in combined_norm
                    for t in combo
                ):
                    new_candidates.append(entity)
                    seen_ids.add(eid)

        return new_candidates

    # ------------------------------------------------------------------
    # Confidence classification
    # ------------------------------------------------------------------

    def _confidence_level(self, score: float) -> str:
        """Map a numeric score to a confidence label based on config thresholds."""
        cfg = self.config
        if score >= cfg.high_confidence_threshold:
            return "very_high"
        if score >= cfg.score_threshold:
            return "high"
        if score >= cfg.low_confidence_threshold:
            return "medium"
        return "low"
