"""Weighted fuzzy ranker using rapidfuzz.

Combines four rapidfuzz scorers (WRatio, token_sort_ratio, token_set_ratio,
partial_ratio) with configurable weights, plus optional attribute penalties
for colour/dimension mismatches and numeric pattern bonuses.

The ranker **never** rejects a match — it scores and ranks candidates so
the caller (or a review UI) can decide what to do with low-confidence results.

Requires the ``[matching]`` extra::

    uv add entr-adapter-core[matching]
"""

from __future__ import annotations

import re
from typing import Dict, List, Optional, Tuple

try:
    from rapidfuzz import fuzz
except ImportError as exc:
    raise ImportError(
        "The matching engine requires the 'rapidfuzz' package. "
        "Install it with: uv add entr-adapter-core[matching]"
    ) from exc


# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def find_best_match(
    normalized_query: str,
    choices: List[str],
    normalized_choices: List[str],
    original_query: str = "",
    original_choices: Optional[List[str]] = None,
    id_values: Optional[List[str]] = None,
    scorer_weights: Optional[Dict[str, float]] = None,
) -> Optional[Tuple[int, float]]:
    """Find the best match for *normalized_query* among *normalized_choices*.

    Args:
        normalized_query: Pre-normalized query string.
        choices: Original (un-normalized) choice strings for display.
        normalized_choices: Normalized choice strings for scoring.
        original_query: Raw query string for attribute extraction
            (colour/dimension penalties). Optional.
        original_choices: Raw choice strings for attribute extraction.
            Defaults to *choices* if not provided.
        id_values: Optional list of entity IDs, parallel to *choices*.
            Used for numeric pattern bonuses.
        scorer_weights: Optional weight dict with keys ``wratio``,
            ``token_sort``, ``token_set``, ``partial``. Defaults to
            ``{wratio: 0.30, token_sort: 0.25, token_set: 0.35, partial: 0.10}``.

    Returns:
        A tuple of ``(best_index, score)`` or ``None`` when no choices
        are provided. The index maps back into the original *choices* list.
    """
    if not normalized_choices or not normalized_query:
        return None

    weights = scorer_weights or {
        "wratio": 0.30,
        "token_sort": 0.25,
        "token_set": 0.35,
        "partial": 0.10,
    }

    raw_choices = original_choices if original_choices is not None else choices
    query_colours = _extract_colours(original_query) if original_query else set()
    query_dims = _extract_dimensions(original_query) if original_query else set()
    query_numeric = _extract_numeric_patterns(original_query) if original_query else []

    best_index: int = -1
    best_score: float = 0.0

    for i, norm_choice in enumerate(normalized_choices):
        base = _weighted_score(normalized_query, norm_choice, weights)

        raw_choice = raw_choices[i] if i < len(raw_choices) else ""
        adjusted = _apply_attribute_penalties(
            base, original_query, raw_choice, query_colours, query_dims,
        )

        if id_values and i < len(id_values) and query_numeric:
            adjusted = _apply_numeric_pattern_bonus(adjusted, query_numeric, id_values[i])

        if adjusted > best_score:
            best_score = adjusted
            best_index = i

    if best_index < 0:
        return None

    return best_index, best_score


# ------------------------------------------------------------------
# Scoring helpers
# ------------------------------------------------------------------

def _weighted_score(query: str, choice: str, weights: Dict[str, float]) -> float:
    """Combine four rapidfuzz scorers with the given weights."""
    if not query or not choice:
        return 0.0

    w_ratio = fuzz.WRatio(query, choice)
    token_sort = fuzz.token_sort_ratio(query, choice)
    token_set = fuzz.token_set_ratio(query, choice)
    partial = fuzz.partial_ratio(query, choice)

    return min(
        w_ratio * weights.get("wratio", 0.30)
        + token_sort * weights.get("token_sort", 0.25)
        + token_set * weights.get("token_set", 0.35)
        + partial * weights.get("partial", 0.10),
        100.0,
    )


def _apply_attribute_penalties(
    base_score: float,
    query: str,
    choice: str,
    query_colours: set,
    query_dims: set,
) -> float:
    """Penalise colour, dimension, and token-count mismatches."""
    if base_score < 50:
        return base_score

    adjusted = base_score

    # Colour mismatch
    if query_colours:
        c_colours = _extract_colours(choice)
        if c_colours and not query_colours & c_colours:
            adjusted -= min(25, base_score * 0.30)

    # Dimension mismatch
    if query_dims:
        c_dims = _extract_dimensions(choice)
        if c_dims and not query_dims & c_dims:
            adjusted -= min(20, base_score * 0.25)

    # Big token-count difference
    qt = len(query.split())
    ct = len(choice.split())
    if qt > 2 and ct > 2:
        ratio = min(qt, ct) / max(qt, ct)
        if ratio < 0.5:
            adjusted -= min(15, base_score * 0.15)

    return max(adjusted, 0.0)


def _apply_numeric_pattern_bonus(
    base_score: float,
    patterns: List[str],
    entity_id: str,
) -> float:
    """Boost score when entity ID matches numeric patterns from the query."""
    if not patterns or not entity_id:
        return base_score

    bonus = 0.0
    for pat in patterns:
        if entity_id.startswith(pat):
            bonus = max(bonus, min(15, len(pat) * 2))
            break
        elif pat in entity_id:
            bonus = max(bonus, min(10, len(pat) * 1.5))

    if bonus > 0:
        return min(base_score + bonus, 100.0)
    return base_score


# ------------------------------------------------------------------
# Attribute extractors
# ------------------------------------------------------------------

_COLOUR_RE = re.compile(
    r"\b("
    r"wit|white|zwart|black|grijs|gray|grey|rood|red|blauw|blue|"
    r"groen|green|geel|yellow|bruin|brown|beige|creme|cream|"
    r"oranje|orange|transparant|transp|tr"
    r")\b",
    re.IGNORECASE,
)

_DIM_RE = re.compile(r"\b(\d+\s*x\s*\d+(?:\s*x\s*\d+)?)\b", re.IGNORECASE)


def _extract_colours(text: str) -> set:
    return {m.lower() for m in _COLOUR_RE.findall(text)}


def _extract_dimensions(text: str) -> set:
    return {re.sub(r"\s", "", m).lower() for m in _DIM_RE.findall(text)}


def _extract_numeric_patterns(query: str) -> List[str]:
    """Extract 3-9 digit numbers that could be entity-ID prefixes."""
    matches = re.findall(r"(?<!\d)(\d{3,9})(?!\d)", query)
    filtered = []
    for m in matches:
        if 1900 <= int(m) <= 2099 and len(m) == 4:
            continue
        if len(m) >= 10:
            continue
        filtered.append(m)
    unique = list(set(filtered))
    unique.sort(key=len, reverse=True)
    return unique
