"""Layered matching engine with confidence scoring."""

from __future__ import annotations

from rapidfuzz import fuzz

from agent_samochodowy.matcher.index import CatalogIndex
from agent_samochodowy.matcher.normalize import deduplicate_offers, expand_category_synonyms
from agent_samochodowy.models import MatchResult, Offer, PartQuery


def match(query: PartQuery, index: CatalogIndex) -> MatchResult:
    """Match a PartQuery against the catalog index using layered strategy.

    Priority: OE (0.95) → engine_code (0.80) → attributes (≤0.90) → fuzzy (≤0.50).
    First layer that produces results wins.
    """
    # Layer 1: OE number match
    if query.oe_numbers:
        offers = _match_by_oe(query.oe_numbers, index)
        if offers:
            return MatchResult(
                confidence=0.95,
                method="oe",
                offers=deduplicate_offers(offers),
                matched=True,
            )

    # Layer 2: Engine code match
    if query.engine_code:
        offers = _match_by_engine_code(query.engine_code, query, index)
        if offers:
            return MatchResult(
                confidence=0.80,
                method="engine_code",
                offers=deduplicate_offers(offers),
                matched=True,
            )

    # Layer 3: Attribute match (brand + model + category)
    if query.brand or query.model or query.category:
        offers, conf = _match_by_attributes(query, index)
        if offers:
            return MatchResult(
                confidence=conf,
                method="atrybuty",
                offers=deduplicate_offers(offers),
                matched=True,
            )

    # Layer 4: Fuzzy / text match
    offers, conf = _match_fuzzy(query, index)
    if offers:
        return MatchResult(
            confidence=conf,
            method="fuzzy",
            offers=deduplicate_offers(offers),
            matched=True,
        )

    # No match
    return MatchResult(confidence=0.0, method="brak", offers=[], matched=False)


def _match_by_oe(oe_numbers: list[str], index: CatalogIndex) -> list[Offer]:
    """Search for offers matching any of the given OE numbers."""
    results: list[Offer] = []
    seen_ids: set[str] = set()
    for oe in oe_numbers:
        for offer in index.search_by_oe(oe):
            if offer.offer_id not in seen_ids:
                seen_ids.add(offer.offer_id)
                results.append(offer)
    return results


def _match_by_engine_code(
    engine_code: str, query: PartQuery, index: CatalogIndex
) -> list[Offer]:
    """Search by engine code, optionally filtering by brand/model."""
    offers = index.search_by_engine_code(engine_code)
    if not offers:
        return []

    # If brand/model available, filter to improve precision
    if query.brand:
        filtered = [o for o in offers if o.brand and o.brand.lower() == query.brand.lower()]
        if filtered:
            return filtered
    return offers


# ---------------------------------------------------------------------------
# Attribute matching with category validation
# ---------------------------------------------------------------------------

def _offer_matches_category(offer: Offer, query_category: str) -> bool:
    """Check if an offer plausibly matches the query category via synonyms."""
    synonyms = expand_category_synonyms(query_category)
    # Check offer.category field (most reliable when present)
    if offer.category:
        cat_lower = offer.category.lower()
        if any(syn in cat_lower for syn in synonyms):
            return True
    # Check title
    title_lower = offer.title.lower()
    return any(syn in title_lower for syn in synonyms)


def _model_in_results(query_model: str, offers: list[Offer]) -> list[Offer]:
    """Return offers where the query model appears in offer model/title.

    Supports prefix matching: query "R410" matches offer model "R",
    and query "LF" matches offer model "LF45".
    """
    tokens = [t.lower() for t in query_model.split() if len(t) >= 2]
    if not tokens:
        return []
    matched = []
    for o in offers:
        offer_model = (o.model or "").lower()
        haystack = (offer_model + " " + o.title).lower()
        for tok in tokens:
            # Direct substring match in title
            if tok in haystack:
                matched.append(o)
                break
            # Prefix match: query "R410" matches catalog model "R"
            if offer_model and tok.startswith(offer_model):
                matched.append(o)
                break
    return matched


def _try_model_variants(
    index: CatalogIndex, brand: str | None, model: str, category: str | None,
) -> list[Offer]:
    """Try exact model, then base token, then series prefix.

    Examples:
    - "Sprinter w904" → try "Sprinter w904", then "Sprinter"
    - "R410" → try "R410", then "R410" (prefix matching in index handles R→R410)
    - "LF" → try "LF" (prefix matching in index handles LF→LF45/LF55)
    """
    kwargs: dict = {}
    if brand:
        kwargs["brand"] = brand
    if category:
        kwargs["category"] = category

    # Note: index.search_by_attributes now does prefix matching on model,
    # so "LF" will find "LF45"/"LF55" and "R410" will match catalog "R"
    offers = index.search_by_attributes(model=model, **kwargs)
    if offers:
        return offers

    # Try first word (e.g. "Sprinter" from "Sprinter w904")
    base = model.split()[0]
    if base.lower() != model.lower():
        offers = index.search_by_attributes(model=base, **kwargs)
        if offers:
            return offers

    # Try series prefix: "R410" → "R", "TGX470" → "TGX"
    import re
    prefix_match = re.match(r"^([A-Za-z]+)", model)
    if prefix_match:
        prefix = prefix_match.group(1)
        if prefix.lower() != model.lower() and prefix.lower() != base.lower() and len(prefix) >= 1:
            offers = index.search_by_attributes(model=prefix, **kwargs)

    return offers


def _match_by_attributes(
    query: PartQuery, index: CatalogIndex
) -> tuple[list[Offer], float]:
    """Attribute-based matching with category validation.

    Category validation: offers whose category/title doesn't match the
    query category are filtered out. This prevents "rozrusznik Iveco" from
    returning "zbiornik paliwa Iveco".

    Confidence tiers:
      0.90 — brand + model + category all confirmed
      0.85 — brand + category confirmed, model found in results
      0.65 — brand + model confirmed, category confirmed via post-filter
      0.55 — brand + category (no model to check) or model + category
      0.45 — brand + model only (no category in query)
      0.35 — brand + category but model absent from results
    """

    # --- Path 1: brand + model + category (highest precision) ---
    if query.brand and query.model and query.category:
        offers = _try_model_variants(index, query.brand, query.model, query.category)
        if offers:
            return offers, 0.90

    # --- Path 2: brand + category ---
    if query.brand and query.category:
        offers = index.search_by_attributes(brand=query.brand, category=query.category)
        if offers:
            if query.model:
                model_matched = _model_in_results(query.model, offers)
                if model_matched:
                    return model_matched, 0.85
                return offers, 0.35  # model absent → low trust
            return offers, 0.55

    # --- Path 3: model + category ---
    if query.model and query.category:
        offers = _try_model_variants(index, None, query.model, query.category)
        if offers:
            return offers, 0.55

    # --- Path 4: brand + model only ---
    if query.brand and query.model:
        offers = _try_model_variants(index, query.brand, query.model, None)
        if offers:
            if query.category:
                # Post-filter: reject offers whose category doesn't match
                filtered = [o for o in offers if _offer_matches_category(o, query.category)]
                if filtered:
                    return filtered, 0.65
                # No category match → don't return random wrong-category parts
                return [], 0.0
            return offers, 0.45

    return [], 0.0


def _match_fuzzy(
    query: PartQuery, index: CatalogIndex
) -> tuple[list[Offer], float]:
    """FTS5 search + rapidfuzz re-scoring.

    If the query has a known brand and/or category, offers that match those
    attributes are scored higher and the confidence ceiling is raised.
    """
    search_text = query.raw_text
    fts_results = index.search_fuzzy(search_text)
    if not fts_results:
        return [], 0.0

    # Build category synonym set for boosting
    cat_synonyms: set[str] = set()
    if query.category:
        cat_synonyms = {s.lower() for s in expand_category_synonyms(query.category)}

    q_brand = (query.brand or "").lower()

    # Re-score using rapidfuzz against title, with brand/category boost
    scored: list[tuple[Offer, float, bool]] = []  # (offer, ratio, brand_cat_match)
    for offer in fts_results:
        ratio = fuzz.token_set_ratio(search_text.lower(), offer.title.lower())
        if ratio < 40:
            continue

        # Check if offer matches query brand and/or category
        offer_brand_match = q_brand and offer.brand and offer.brand.lower() == q_brand
        offer_title_lower = offer.title.lower()
        offer_cat_match = any(syn in offer_title_lower for syn in cat_synonyms) if cat_synonyms else False
        brand_cat_confirmed = offer_brand_match and offer_cat_match

        scored.append((offer, ratio, brand_cat_confirmed))

    if not scored:
        return [], 0.0

    # Prefer offers with brand+category confirmation
    scored.sort(key=lambda x: (x[2], x[1]), reverse=True)
    best_ratio = scored[0][1]
    best_confirmed = scored[0][2]

    # Map rapidfuzz ratio (40-100) to confidence
    if best_confirmed:
        # Brand + category confirmed in fuzzy → higher ceiling (0.45-0.60)
        confidence = 0.45 + (best_ratio - 40) / 60 * 0.15
        confidence = min(confidence, 0.60)
    else:
        # Pure fuzzy text match → conservative (0.30-0.45)
        confidence = 0.30 + (best_ratio - 40) / 60 * 0.15
        confidence = min(confidence, 0.45)

    return [o for o, _, _ in scored[:5]], round(confidence, 2)
