"""Fuzzy ingredient→grocery_item matcher.

Ingredient-centric: for each ingredient, finds the best-matching grocery item.
Scores combine partial_token_sort_ratio with a precision term
(ingredient sig-words / grocery sig-words) so long branded product names
that contain an ingredient word incidentally rank lower than items whose
primary purpose IS that ingredient.

Manual matches (source='manual') are preserved across runs.
"""
from __future__ import annotations

import re
import uuid as _uuid_mod
from dataclasses import dataclass
from decimal import Decimal
from uuid import UUID

from rapidfuzz import fuzz, process
from sqlalchemy.dialects.postgresql import insert as _pg_insert
from sqlalchemy.orm import Session

from app.models import (
    GroceryItem,
    Ingredient,
    IngredientGroceryMatch,
    IngredientMatchSource,
)

# Words that describe quantity/preparation but don't identify the ingredient itself.
# Filtering these from sig-word sets keeps "Chicken Thighs, Boneless Skinless"
# from requiring "boneless" to appear in the grocery name.
_STOP_WORDS = frozenset({
    "fresh", "organic", "whole", "large", "small", "medium",
    "low", "free", "light", "dark", "raw", "dried", "frozen", "canned",
    "extra", "virgin", "pure", "natural", "classic", "style",
    "boneless", "skinless", "lean",
    "grain", "long", "jarred", "roasted", "smoked", "cooked",
    "and", "with", "for", "the",
})

def _singularize(word: str) -> str:
    """Best-effort English singularization for grocery product names.

    Handles 'tortillas'→'tortilla', 'thighs'→'thigh', 'chickpeas'→'chickpea'.
    Requires length > 3 to avoid mangling short words like 'has', 'was'.
    """
    if len(word) > 4 and word.endswith("ies"):
        return word[:-3] + "y"          # 'berries' → 'berry'
    if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
        return word[:-1]                # 'tortillas' → 'tortilla'
    return word


# If any of these words appear in a grocery item's sig-words but NOT in the
# ingredient's sig-words, the match is rejected outright.  Prevents category
# cross-contamination: "Garlic" must not match "Garlic Bread", "Lime" must not
# match "Lime Margarita", etc.
_EXCLUSION_WORDS_RAW = frozenset({
    # Baked goods / bread products
    "bread", "loaf", "roll", "rolls", "bun", "buns", "croissant",
    "cracker", "crackers", "cookie", "cookies", "cake", "cupcake", "muffin", "bagel",
    # Chips / snack foods
    "chip", "chips",
    # Pasta / noodles
    "pasta", "noodle", "noodles", "vermicelli", "spaghetti", "linguine",
    "fettuccine", "penne", "rigatoni", "macaroni", "rotini", "orzo",
    # Alcoholic / mixed beverages
    "margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt",
    "wine", "spirits", "liquor",
    "vodka", "tequila", "whiskey", "rum", "gin", "bourbon",
    "lemonade", "limeade", "seltzer", "soda", "juice",
    # Butter / spreads (prevents "Garlic & Herb Butter Spread" matching "Garlic")
    "butter", "spread", "margarine",
    # Prepared proteins / seafood-in-oil (prevents "Tuna in Olive Oil" matching "Olive Oil")
    "tuna", "tonno", "salmon", "sardine", "anchovy",
    # Prepared poultry (prevents "Garlic Herb Rotisserie Chicken" matching "Garlic")
    "rotisserie",
    # Baby / personal care (belt-and-suspenders after stop-word rework)
    "baby", "wipe", "wipes", "diaper",
})
# Pre-normalize exclusion words so they match the singularized sig-word sets.
_EXCLUSION_WORDS = frozenset(_singularize(w) for w in _EXCLUSION_WORDS_RAW)


@dataclass
class MatchResult:
    ingredient_id: UUID
    grocery_item_id: UUID
    confidence: float


def _sig_words(text: str) -> frozenset:
    """Lowercase alpha tokens >2 chars, stop-words removed, plurals normalized."""
    tokens = re.sub(r"[^a-z ]", " ", text.lower()).split()
    return frozenset(
        _singularize(t)
        for t in tokens
        if len(t) > 2 and t not in _STOP_WORDS
    )


def run_match_job(
    db: Session,
    *,
    source_filter: str = "lucky_california",
    threshold: float = 0.82,
) -> int:
    """Refresh AUTO ingredient_grocery_match rows for grocery items from `source_filter`.

    For each ingredient the scorer is:
        combined = partial_token_sort_ratio × (overlap / grocery_sig_count)

    where overlap = ingredient sig-words that appear in the grocery sig-words.
    This means a product like "Milton's Olive Oil Crackers" (6 sig-words)
    scores half of "Bertolli Olive Oil" (3 sig-words) for the ingredient
    "Olive Oil", so the simpler/more specific product wins.

    100% recall is required: every significant ingredient word must appear
    in the grocery name. This eliminates cross-category noise such as
    "Ginger, Fresh" → "Pampers Complete Clean Baby Fresh Scent Wipes".

    Manual matches (source='manual') are NOT touched.
    Returns number of AUTO rows written.
    """
    grocery_rows = (
        db.query(GroceryItem)
        .filter(GroceryItem.source == source_filter)
        .all()
    )
    if not grocery_rows:
        return 0

    grocery_names_lower = [gi.name.lower() for gi in grocery_rows]
    grocery_sig = [_sig_words(gi.name) for gi in grocery_rows]
    grocery_ids = [gi.id for gi in grocery_rows]

    # Purge existing AUTO matches for this source's items before re-matching.
    db.query(IngredientGroceryMatch).filter(
        IngredientGroceryMatch.grocery_item_id.in_([gi.id for gi in grocery_rows]),
        IngredientGroceryMatch.source == IngredientMatchSource.AUTO,
    ).delete(synchronize_session=False)
    db.flush()

    # Build a lowercase-trimmed name → index map for O(1) exact-match lookup.
    gi_exact: dict[str, int] = {
        gi.name.strip().lower(): idx for idx, gi in enumerate(grocery_rows)
    }

    ingredients = db.query(Ingredient).all()
    written = 0

    for ingredient in ingredients:
        ing_sig = _sig_words(ingredient.name)
        if not ing_sig:
            continue

        # Include aliases as additional query variants.
        queries = [ingredient.name.lower()] + [
            a.lower() for a in (ingredient.aliases or []) if a
        ]

        # Fast path: exact name match beats all fuzzy candidates.
        exact_idx = gi_exact.get(ingredient.name.strip().lower())
        if exact_idx is not None:
            stmt = (
                _pg_insert(IngredientGroceryMatch.__table__)
                .values(
                    id=_uuid_mod.uuid4(),
                    ingredient_id=ingredient.id,
                    grocery_item_id=grocery_ids[exact_idx],
                    confidence=Decimal("1.000"),
                    source=IngredientMatchSource.AUTO,
                )
                .on_conflict_do_nothing(index_elements=["ingredient_id", "grocery_item_id"])
            )
            db.execute(stmt)
            written += 1
            continue

        best_idx: int | None = None
        best_combined = 0.0

        for query in queries:
            results = process.extract(
                query,
                grocery_names_lower,
                scorer=fuzz.partial_token_sort_ratio,
                limit=100,
            )
            for _text, score, idx in results:
                if score < threshold * 100:
                    continue
                gsig = grocery_sig[idx]
                if not gsig:
                    continue
                # 100% recall: every ingredient sig-word must appear in the grocery name.
                if not ing_sig.issubset(gsig):
                    continue
                # Category exclusion: reject if grocery has a disqualifying word
                # (e.g. "bread", "chips", "margarita") absent from the ingredient.
                bad_words = (gsig & _EXCLUSION_WORDS) - ing_sig
                if bad_words:
                    continue
                # Precision penalises grocery items with many extra words.
                precision = len(ing_sig) / len(gsig)
                # Hard floor: grocery must not dwarf the ingredient in sig-word count.
                # 0.30 allows "Bacon" (1 sig-word) → "Wright Brand Bacon" (3 sig-words)
                # while exclusion words still block "Garlic Herb Rotisserie Chicken".
                if precision < 0.30:
                    continue
                combined = (score / 100.0) * precision
                if combined > best_combined:
                    best_combined = combined
                    best_idx = idx

        if best_idx is None:
            continue

        # ON CONFLICT DO NOTHING preserves any existing MANUAL match for the same pair.
        stmt = (
            _pg_insert(IngredientGroceryMatch.__table__)
            .values(
                id=_uuid_mod.uuid4(),
                ingredient_id=ingredient.id,
                grocery_item_id=grocery_ids[best_idx],
                confidence=Decimal(str(round(best_combined, 3))),
                source=IngredientMatchSource.AUTO,
            )
            .on_conflict_do_nothing(index_elements=["ingredient_id", "grocery_item_id"])
        )
        db.execute(stmt)
        written += 1

    db.commit()
    return written
