"""
Motor de matching de líneas de factura contra el knowledge base de insumos.

Pipeline:
  1. normalize()      -> minúsculas, sin acentos, abreviaturas expandidas
  2. extract_pack()   -> cantidad, unidad, formato de envase (12x1L, caja 5kg...)
  3. extract_facets() -> forma, procesado, conservacion, preparacion, calidad
  4. match_ingredient() -> ingrediente canónico (+ corte anatómico si aplica)
  5. validate()       -> descarta facetas incompatibles con la familia
  6. score()          -> confianza y motivo

Sin dependencias externas obligatorias. Usa rapidfuzz si está instalado.

Uso:
    python matcher.py "MERLUZA FILETE S/PIEL CONG. CAJA 5KG"
    python matcher.py --test        # ejecuta la batería de tests
"""

from __future__ import annotations

import json
import math
import re
import sys
import unicodedata
from dataclasses import dataclass, field, asdict
from difflib import SequenceMatcher
from functools import lru_cache
from pathlib import Path
from typing import Any

try:
    from rapidfuzz import fuzz as _rf

    def _ratio(a: str, b: str) -> float:
        return _rf.ratio(a, b) / 100.0
except ImportError:  # fallback stdlib
    def _ratio(a: str, b: str) -> float:
        return SequenceMatcher(None, a, b).ratio()


DATA = Path(__file__).resolve().parent.parent / "data"

# Abreviaturas de proveedor -> forma larga. Se expanden ANTES de todo lo demás.
ABBREV = {
    "cong": "congelado", "congel": "congelado", "ultracong": "ultracongelado",
    "refrig": "refrigerado", "fresc": "fresco",
    "s/": "sin ", "c/": "con ", "sn": "sin", "s.": "sin",
    "filet": "filete", "fil": "filete",
    "trit": "triturado", "molid": "molido",
    "und": "unidad", "uds": "unidades", "ud": "unidad",
    "cja": "caja", "cj": "caja", "bdj": "bandeja", "blsa": "bolsa",
    "env": "envase", "pqt": "paquete", "bt": "botella", "btl": "botella",
    "ib": "iberico", "eco": "ecologico", "nat": "natural",
    "aove": "aceite de oliva virgen extra",
    "hg": "eviscerado descabezado", "h&g": "eviscerado descabezado",
    "pbo": "sin espinas", "iqf": "congelado",
    "1a": "primera", "1ª": "primera", "2ª": "segunda",
}

STOPWORDS = {
    "de", "del", "la", "el", "los", "las", "en", "con", "sin", "y", "a", "al",
    "por", "para", "of", "the", "in", "with", "and",
}


# --------------------------------------------------------------------------- #
# 1. Normalización
# --------------------------------------------------------------------------- #
def strip_accents(s: str) -> str:
    return "".join(
        c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn"
    )


def normalize(text: str) -> str:
    t = strip_accents(text.lower())
    t = t.replace("/", " / ").replace("+", " + ")
    t = re.sub(r"[^\w\s/%.,x&-]", " ", t)
    t = re.sub(r"\s+", " ", t).strip()

    out = []
    for tok in t.split():
        clean = tok.strip(".,-")
        out.append(ABBREV.get(clean, tok))
    t = " ".join(out)
    # "s / piel" -> "sin piel"
    t = re.sub(r"\bs\s*/\s*", "sin ", t)
    t = re.sub(r"\bc\s*/\s*", "con ", t)
    t = re.sub(r"\s+", " ", t).strip()
    return t


# --------------------------------------------------------------------------- #
# 2. Carga del KB
# --------------------------------------------------------------------------- #
@dataclass
class KB:
    facets: dict[str, dict] = field(default_factory=dict)
    families: dict[str, dict] = field(default_factory=dict)
    ingredients: list[dict] = field(default_factory=list)
    alias_index: list[tuple[str, str, str]] = field(default_factory=list)  # (alias, ing_id, kind)
    cut_index: list[tuple[str, str, str]] = field(default_factory=list)    # (alias, cut_id, parent_id)
    regional: dict[str, list[dict]] = field(default_factory=dict)          # alias -> entradas regionales
    country_names: dict[str, str] = field(default_factory=dict)
    by_id: dict[str, dict] = field(default_factory=dict)

    @classmethod
    def load(cls, data_dir: Path = DATA) -> "KB":
        kb = cls()
        for f in sorted((data_dir / "facets").glob("*.json")):
            d = json.loads(f.read_text(encoding="utf-8"))
            kb.facets[d["facet_id"]] = d

        fam = json.loads((data_dir / "families.json").read_text(encoding="utf-8"))
        kb.families = fam["families"]
        # resolver herencia entre familias
        for name, spec in kb.families.items():
            parent = spec.get("inherits")
            if parent and parent in kb.families:
                merged = {**kb.families[parent], **spec}
                for facet in ("forma", "procesado", "conservacion", "preparacion", "calidad"):
                    extra = spec.get(f"{facet}_extra", [])
                    merged[facet] = sorted(
                        set(kb.families[parent].get(facet, [])) | set(spec.get(facet, [])) | set(extra)
                    )
                kb.families[name] = merged

        for f in sorted((data_dir / "ingredients").glob("*.json")):
            d = json.loads(f.read_text(encoding="utf-8"))
            default_family = d.get("family", "")
            for ing in d["ingredients"]:
                ing.setdefault("family", default_family)
                kb.ingredients.append(ing)

        def _variants(name: str) -> list[str]:
            """'Ternera / vacuno' -> ['ternera', 'vacuno']; 'Aceite de oliva' -> [.., 'aceite oliva']"""
            out: list[str] = []
            for part in re.split(r"\s*/\s*", normalize(name)):
                part = part.strip()
                if not part:
                    continue
                out.append(part)
                bare = " ".join(t for t in part.split() if t not in STOPWORDS)
                if bare and bare != part:
                    out.append(bare)
            return out

        for ing in kb.ingredients:
            iid = ing["id"]
            for n in [ing["canonical_es"], ing.get("canonical_en", "")]:
                for v in _variants(n) if n else []:
                    kb.alias_index.append((v, iid, "canonical"))
            for n in ing.get("aliases_es", []) + ing.get("aliases_en", []):
                for v in _variants(n):
                    kb.alias_index.append((v, iid, "alias"))
            for n in ing.get("abbrev", []):
                kb.alias_index.append((normalize(n), iid, "abbrev"))
            for cut in ing.get("cortes", []):
                for n in [cut.get("es", ""), cut.get("en", "")] + cut.get("syn_es", []) + cut.get("syn_en", []):
                    for v in _variants(n) if n else []:
                        kb.cut_index.append((v, cut["id"], iid))
                        # el nombre de un corte también identifica al animal
                        kb.alias_index.append((v, iid, "corte"))
        # capa de alias regionales (LATAM/ES): mismo id canónico, nombre por país
        reg_path = data_dir / "regional_aliases.json"
        if reg_path.exists():
            reg = json.loads(reg_path.read_text(encoding="utf-8"))
            kb.country_names = reg.get("_paises", {})
            for entry in reg["aliases"]:
                a = normalize(entry["alias"])
                kb.alias_index.append((a, entry["ingrediente"], "alias"))
                kb.regional.setdefault(a, []).append(entry)

        # alias más largos primero: "nuez pecana" antes que "nuez"
        kb.alias_index.sort(key=lambda x: -len(x[0]))
        kb.cut_index.sort(key=lambda x: -len(x[0]))
        kb.by_id = {i["id"]: i for i in kb.ingredients}
        return kb

    def get_ingredient(self, iid: str) -> dict | None:
        return self.by_id.get(iid)


# --------------------------------------------------------------------------- #
# 3. Extracción de cantidad / unidad / envase
# --------------------------------------------------------------------------- #
PACK_PATTERNS = [
    # 12x1L, 6 x 500 g, 24X33CL
    re.compile(r"(?P<n>\d+)\s*[x*]\s*(?P<q>[\d.,]+)\s*(?P<u>kgs?|kilos?|grs?|gramos?|g|mgs?|ts?|lts?|litros?|l|mls?|cls?|cc|uds?|unidades?)\b"),
    # 5 kg, 500 g, 1,5 L
    re.compile(r"(?P<q>[\d.,]+)\s*(?P<u>kgs?|kilos?|grs?|gramos?|g|mgs?|lts?|litros?|l|mls?|cls?|cc|uds?|unidades?|docenas?)\b"),
]


def _to_float(s: str) -> float:
    s = s.replace(".", "").replace(",", ".") if s.count(",") == 1 and s.count(".") >= 1 else s.replace(",", ".")
    try:
        return float(s)
    except ValueError:
        return 0.0


def extract_pack(kb: KB, text: str) -> tuple[dict, str]:
    """Devuelve (info_pack, texto_restante)."""
    info: dict[str, Any] = {}
    residual = text
    for pat in PACK_PATTERNS:
        m = pat.search(residual)
        if not m:
            continue
        gd = m.groupdict()
        unit_raw = gd["u"]
        unit = _resolve_unit(kb, unit_raw)
        info["cantidad"] = _to_float(gd["q"])
        info["unidad"] = unit["code"] if unit else unit_raw
        if gd.get("n"):
            info["piezas_por_pack"] = int(gd["n"])
        if unit:
            base_qty = info["cantidad"] * unit["factor"] * info.get("piezas_por_pack", 1)
            info["cantidad_base"] = round(base_qty, 6)
            info["unidad_base"] = unit["base"]
        residual = (residual[: m.start()] + " " + residual[m.end():]).strip()
        break
    return info, re.sub(r"\s+", " ", residual)


def _resolve_unit(kb: KB, raw: str) -> dict | None:
    for v in kb.facets["unidad"]["values"]:
        for p in v["patterns"]:
            if re.fullmatch(p.strip("\\b"), raw) or re.search(p, f" {raw} "):
                return v
    return None


# --------------------------------------------------------------------------- #
# 4. Extracción de facetas
# --------------------------------------------------------------------------- #
FACET_ORDER = ["conservacion", "preparacion", "procesado", "forma", "calidad", "envase"]

# Si aparece la clave, se elimina el valor genérico que ya implica.
SUBSUMES = {
    "virgen_extra": ["extra"],
    "iberico": ["extra"],
    "sin_sal": ["salado"],
    "deshidratado": ["crudo"],
    "precocinado": ["crudo"],
}


def extract_facets(kb: KB, text: str) -> tuple[dict, str, list[str]]:
    found: dict[str, list[str]] = {}
    evidence: list[str] = []
    consumed: list[tuple[int, int]] = []

    for facet_id in FACET_ORDER:
        facet = kb.facets[facet_id]
        multi = facet.get("cardinality") == "multi"
        hits: list[tuple[str, int, int]] = []
        for v in facet["values"]:
            for p in v.get("patterns", []):
                for m in re.finditer(p, text):
                    if any(s <= m.start() < e or s < m.end() <= e for s, e in consumed):
                        continue
                    hits.append((v["code"], m.start(), m.end()))
                    break
        if not hits:
            continue
        hits.sort(key=lambda h: (h[1], -(h[2] - h[1])))
        seen: list[str] = []
        for code, s, e in hits:
            if code in seen:
                continue
            if not multi and seen:
                break
            seen.append(code)
            consumed.append((s, e))
            evidence.append(f"{facet_id}={code} <- '{text[s:e]}'")
        for code in list(seen):
            for redundant in SUBSUMES.get(code, []):
                if redundant in seen:
                    seen.remove(redundant)
        found[facet_id] = seen

    residual = text
    for s, e in sorted(consumed, reverse=True):
        residual = residual[:s] + " " + residual[e:]
    residual = " ".join(t for t in residual.split() if t not in STOPWORDS)
    return found, re.sub(r"\s+", " ", residual).strip(), evidence


# --------------------------------------------------------------------------- #
# 5. Matching del ingrediente
# --------------------------------------------------------------------------- #
WEIGHT = {"canonical": 1.0, "alias": 0.96, "corte": 0.94, "abbrev": 0.88}

ACOMPANAMIENTO = re.compile(r"\b(en|con|al|a la|sobre|relleno de|salsa de|base de)\s+$")


@lru_cache(maxsize=8192)
def _alias_re(alias: str):
    return re.compile(rf"\b{re.escape(alias)}\b")

# Palabras demasiado genéricas para identificar un insumo por sí solas.
# Sin esto, "QUINOA BLANCA" matchea pimienta por el alias "blanca".
GENERIC = {
    "blanca", "blanco", "negra", "negro", "verde", "roja", "rojo", "rosa",
    "natural", "fresco", "fresca", "dulce", "amargo", "grande", "pequeno",
    "fino", "fina", "gorda", "entero", "entera", "real", "extra", "suave",
}


def _disambiguation_bonus(ing: dict, text: str) -> float:
    """+0.10 si aparece una pista propia del insumo.
    Resuelve 'SOLOMILLO IBERICO' (corte compartido por ternera y cerdo)."""
    for d in ing.get("disambiguators", []):
        if re.search(rf"\b{re.escape(normalize(d))}\b", text):
            return 0.10
    return 0.0


def match_exact(kb: KB, text: str) -> tuple[dict | None, float, str, tuple[int, int] | None]:
    """Busca el alias más específico sobre el TEXTO COMPLETO, antes de extraer facetas.

    El orden importa: si las facetas se extraen primero, 'lomo' se consume como
    forma y 'lomo vetado' deja de existir como nombre de insumo. Identificamos el
    insumo, enmascaramos su texto y solo entonces buscamos facetas en lo que queda.
    """
    best = (None, 0.0, "", None, 0.0)
    for alias, iid, kind in kb.alias_index:
        if not alias or alias in GENERIC:
            continue
        # prefiltro barato: \balias\b exige que el literal esté presente.
        # Descarta el 99% de los 3.500 alias sin tocar el motor de regex.
        if alias not in text:
            continue
        m = _alias_re(alias).search(text)
        if not m:
            continue
        ing = kb.get_ingredient(iid)
        # ranking: favorece el alias más largo y más al principio de la línea
        score = WEIGHT[kind] * min(1.0, 0.6 + 0.4 * len(alias) / max(len(text), 1))
        score += _disambiguation_bonus(ing, text)
        score -= 0.15 * (m.start() / max(len(text), 1))
        # calidad: cuánto fiarse del match, con independencia de lo larga que sea la línea
        quality = WEIGHT[kind]
        if ACOMPANAMIENTO.search(text[:m.start()][-14:]):
            score *= 0.75
            quality *= 0.75
        if len(alias) <= 4:
            quality *= 0.85          # alias muy corto: más riesgo de falso positivo
        if score > best[1]:
            best = (ing, score, f"alias exacto '{alias}' ({kind})", m.span(), quality)
    return best


def match_fuzzy(kb: KB, residual: str):
    """Red de seguridad para typos, sobre lo que no consumió ninguna faceta."""
    best = (None, 0.0, "", None, 0.0)
    for tok in [t for t in residual.split() if len(t) > 3 and t not in GENERIC]:
        for alias, iid, kind in kb.alias_index:
            if alias in GENERIC or abs(len(alias) - len(tok)) > 4:
                continue
            r = _ratio(tok, alias)
            if r > 0.86:
                score = r * WEIGHT[kind] * 0.92
                if score > best[1]:
                    best = (kb.get_ingredient(iid), score,
                            f"fuzzy '{tok}'~'{alias}' ({r:.2f})", None, r * 0.9)
    return best


def parent_of(kb: KB, ing: dict) -> dict | None:
    """Un corte (lomo vetado, trutro, panceta) es un insumo propio con precio propio,
    pero apunta al animal del que sale. Devuelve esa referencia."""
    pid = ing.get("parent")
    if not pid:
        return None
    p = kb.get_ingredient(pid)
    return {"id": pid, "nombre": p["canonical_es"] if p else pid}


# --------------------------------------------------------------------------- #
# 6. Validación contra reglas de familia
# --------------------------------------------------------------------------- #
def validate(kb: KB, ing: dict | None, facets: dict) -> tuple[dict, list[str]]:
    warnings: list[str] = []
    if not ing:
        return facets, ["sin ingrediente: no se validan facetas"]
    fam = kb.families.get(ing["family"])
    if not fam:
        return facets, [f"familia '{ing['family']}' no definida"]

    clean: dict[str, list[str]] = {}
    for facet_id, codes in facets.items():
        if facet_id in ("envase", "unidad"):
            clean[facet_id] = codes
            continue
        allowed = set(fam.get(facet_id, []))
        keep, drop = [], []
        for c in codes:
            (keep if not allowed or c in allowed else drop).append(c)
        clean[facet_id] = keep
        for c in drop:
            warnings.append(
                f"'{c}' descartado: incompatible con familia '{ing['family']}' (faceta {facet_id})"
            )
    return clean, warnings


def suggest_sku(ing: dict, facets: dict) -> dict | None:
    """Empareja con un SKU frecuente del catálogo si encaja."""
    best, best_hits = None, 0
    for sku in ing.get("common_skus", []):
        hits = 0
        for k, v in sku.items():
            if k in ("label_es", "note", "corte"):
                continue
            got = facets.get(k, [])
            want = v if isinstance(v, list) else [v]
            hits += len(set(want) & set(got))
        if hits > best_hits:
            best, best_hits = sku, hits
    return {"label_es": best["label_es"], "coincidencias": best_hits} if best and best_hits else None


# --------------------------------------------------------------------------- #
# Orquestador
# --------------------------------------------------------------------------- #
def match_line(kb: KB, raw: str) -> dict:
    norm = normalize(raw)
    pack, after_pack = extract_pack(kb, norm)

    # 1) insumo primero, sobre el texto íntegro
    ing, ing_score, why, span, quality = match_exact(kb, after_pack)
    # 2) se enmascara su nombre para que no contamine la extracción de facetas
    masked = after_pack
    if span:
        masked = after_pack[:span[0]] + " " * (span[1] - span[0]) + after_pack[span[1]:]
    facets, residual, evidence = extract_facets(kb, masked)
    # 3) si no hubo alias exacto, red de seguridad difusa sobre el residual
    if ing is None:
        ing, ing_score, why, _, quality = match_fuzzy(kb, residual)
    padre = parent_of(kb, ing) if ing else None
    facets, warnings = validate(kb, ing, facets)

    # ¿el nombre usado es un regionalismo? -> pista del mercado del proveedor
    region: dict | None = None
    for alias, entries in kb.regional.items():
        if alias in norm and _alias_re(alias).search(norm):
            for e in entries:
                if not ing or e["ingrediente"] == ing["id"]:
                    region = {
                        "termino": alias,
                        "paises": e["paises"],
                        "colision": e.get("_nota"),
                    }
                    break
        if region:
            break

    n_facets = sum(len(v) for v in facets.values())
    # cuánto del texto quedó sin explicar por ingrediente, facetas o pack
    explicado = 1 - len(residual) / max(len(after_pack), 1)
    confidence = min(1.0, 0.62 * quality + 0.05 * n_facets
                     + 0.05 * (1 if padre else 0) + 0.22 * explicado)
    if warnings and ing:
        confidence *= 0.9
    # un solo redondeo al final, medio hacia arriba, idéntico al motor JS
    confidence = math.floor(confidence * 1000 + 0.5) / 1000

    return {
        "input": raw,
        "normalizado": norm,
        "ingrediente": {
            "id": ing["id"], "nombre": ing["canonical_es"], "familia": ing["family"],
        } if ing else None,
        "parte_de": padre,
        "regionalismo": region,
        "facetas": {k: v for k, v in facets.items() if v},
        "pack": pack,
        "sku_sugerido": suggest_sku(ing, facets) if ing else None,
        "confianza": confidence,
        "necesita_revision": confidence < 0.75 or ing is None,
        "evidencia": evidence + ([why] if why else []),
        "avisos": warnings,
        "residual_no_interpretado": residual,
    }


# --------------------------------------------------------------------------- #
def main() -> None:
    kb = KB.load()
    if len(sys.argv) > 1 and sys.argv[1] == "--test":
        cases = json.loads((Path(__file__).parent.parent / "tests" / "lineas_factura.json").read_text(encoding="utf-8"))
        ok = 0
        for c in cases:
            r = match_line(kb, c["linea"])
            got = r["ingrediente"]["id"] if r["ingrediente"] else None
            hit = got == c["esperado_ingrediente"]
            ok += hit
            flag = "OK " if hit else "FAIL"
            print(f"[{flag}] {c['linea']}")
            print(f"       -> {got} | {r['facetas']} | conf={r['confianza']}")
            if not hit:
                print(f"       esperado: {c['esperado_ingrediente']} | residual='{r['residual_no_interpretado']}'")
        print(f"\n{ok}/{len(cases)} ingredientes correctos ({ok/len(cases)*100:.0f}%)")
        return

    line = " ".join(sys.argv[1:]) or "MERLUZA FILETE S/PIEL CONG. CAJA 5KG"
    print(json.dumps(match_line(kb, line), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
