"""Attaching competition medals to duty-free product_variants.

The competition network has no barcodes, so this is the one place in the app
that matches on names. It is deliberately conservative: a medal shown on the
wrong bottle is worse than a medal not shown at all. A first version of this
matcher pooled brand and name words together, which let wine entries match on
varietal words with the brand entirely absent, and let a one-word entry land on
any product of that brand. One row in three was wrong. The rules below each
close one of those holes:

- the winner's distinctive BRAND words must ALL appear on the product (varietal
  or style words alone are never enough, and neither is one brand word out of
  two: "Le Grand Courtage" once landed on Le Grand Noir, "Champagne Telmont" on
  Charles Heidsieck, "Perfetto Gin" on Beefeater, because a single shared word,
  often the category word inside the brand name, satisfied the gate);
- a brand made only of category or producer-type words ("J Vineyards" after the
  single letter is dropped) proves nothing, so it matches nothing;
- expression words ("original", "reserve", "black") and age/vintage numbers are
  kept, not discarded, and a number mismatch is a veto ("8 Year" never lands on
  the 12-year bottle);
- a one-word entry only matches a product that is exactly that word;
- a winner whose best score is shared by product variants with genuinely different
  names is ambiguous, and ambiguity means no medal;
- two entries with different names or different medals landing on one product
  in one competition-year are ambiguous the other way round (four entries named
  only "Elijah Craig" with four different medals cannot all be the Small Batch),
  and that product gets no medal for that competition-year.

Storage is keyed by the natural key `(variant_id, competition_slug, year,
medal)`: a re-import inserts what is new, updates in place what changed, and
with `rebuild=True` also removes what the file no longer produces. Ids never
churn, so a pin by natural key (`award_picker.AwardKey`) survives every run.
"""

import json
import logging
import re
import unicodedata
from collections import defaultdict
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Award, ProductVariant
from app.services.award_picker import medal_rank
from app.services.normalize import _ABV_RE, _NOISE_RE, _PUNCT_RE, _SIZE_PATTERNS

logger = logging.getLogger(__name__)

SOURCE = "competition-network"


class ProductLike(Protocol):
    id: int
    brand: str | None
    name: str


# Share of the competition entry's words that must appear in the retail
# product's brand+name. Tuned for precision; lowering it produces wrong medals.
MIN_TOKEN_OVERLAP = 0.75

# Pure glue that carries no identity. Deliberately NOT the catalogue-wide
# stopword list: for medal matching, "original", "reserve" and the category
# word ARE the identity of an expression.
_GLUE = {
    "the", "and", "of", "de", "la", "le", "el", "old", "years", "year",
    "yr", "yrs", "yo", "aged", "vol", "no",
}

# Words so common in producer names that sharing one proves nothing: producer
# types, and the drink categories entrants fold into their brand field
# ("Zaya Rum", "Champagne Telmont", "Mezcal Nucano"). They still count as
# product words; they just cannot carry the brand gate.
_GENERIC_BRAND = {
    "estate", "estates", "vineyard", "vineyards", "creek", "valley", "bay",
    "farm", "farms", "cellar", "cellars", "winery", "wines", "wine", "hills",
    "ridge", "house", "distillery", "distilleries", "distillers", "distilling",
    "brewery", "brewing", "company", "co", "spirits", "international",
    "beverages", "brands", "bodega", "bodegas", "casa", "chateau", "domaine",
    "maison", "vina", "cantina", "tenuta", "weingut", "ron", "rum", "gin",
    "whisky", "vodka", "tequila", "mezcal", "champagne", "brandy", "cognac",
    "bourbon", "liqueur", "liqueurs", "vermouth", "cava", "prosecco", "sake",
    "beer", "cider", "organic",
    # Style nationalities entrants write into the brand ("Forty Creek Canadian Whisky").
    "canadian", "irish", "scotch", "scottish", "american", "kentucky", "tennessee",
    "japanese", "french", "italian", "spanish", "mexican", "jamaican", "cuban",
    "australian", "german", "english",
}

_CANONICAL = {"whiskey": "whisky", "litre": "l", "liter": "l"}

# Words that mark a distinct expression of a bottle rather than the bottle itself. On
# either side alone they veto: "BACARDÍ Reserva Ocho" is not the Rye Cask Finish, and a
# "Cask Strength" entry is not the standard bottling, even when the plainer product is not
# in the catalogue (missing beats wrong). Category and size words are not here, because
# retailers add those; "reserve" is not here either, because retailers write "Reserva" into
# the base name of bottles the competition names without it.
_EXPRESSION_MARKERS = {
    "cask", "casks", "strength", "finish", "finished", "rye", "limited", "special",
    "sherry", "port", "peated", "smoky", "barrel", "barrels",
    "black", "blue", "green", "platinum", "label", "noir", "xo", "vs", "vsop", "xxo", "navy",
    "overproof", "spiced", "honey", "apple", "vanilla", "cherry", "coffee", "chocolate",
    "citrus", "orange", "lime", "pink", "toasted",
}
# Not markers, measured on the 9 Sep dump: "single" (malt, pot still), "batch" (distilled,
# small batch), "proof" (after a number the number already vetoes), "double"/"triple"
# (malt), "rose", "red" and "white" (a wine's type), "organic" (a retailer's description of
# the same bottle) and "edition" (travel packaging) each cost a right medal. "label" covers
# the Johnnie Walker range without making every colour a marker.

# "18yr", "12yo", "7yo": the age is the number and the suffix is glue. Left as one
# token, the number veto cannot see it, so "Highland Park 18yr old" never met
# "Highland Park 18 Year Old" and "Emin. Reserva 7yo" met the ageless "Eminente Reserva".
_AGE_SUFFIX_RE = re.compile(r"\b(\d{1,2})\s*(yo|yr|yrs)\b")


def award_tokens(text: str | None) -> set[str]:
    """Identity-bearing words of a name, for medal matching only."""
    # A typographic apostrophe is not ASCII and would vanish, gluing "Angel’s" into
    # "angels" while the brand field's "Angel's" gives "angel": the brand gate then failed
    # for every curly-quoted entry (the competition file writes them that way).
    cleaned = (text or "").replace("\u2019", "'").replace("\u2018", "'").replace("`", "'")
    cleaned = unicodedata.normalize("NFKD", cleaned).encode("ascii", "ignore").decode()
    cleaned = _AGE_SUFFIX_RE.sub(r"\1 ", cleaned.lower())
    cleaned = _NOISE_RE.sub(" ", cleaned)
    for pattern, _ in _SIZE_PATTERNS:
        cleaned = pattern.sub(" ", cleaned)
    cleaned = _ABV_RE.sub(" ", cleaned)
    words = _PUNCT_RE.sub(" ", cleaned).split()
    out: set[str] = set()
    for word in words:
        word = _CANONICAL.get(word, word)
        if word in _GLUE:
            continue
        # Single letters are noise, but single DIGITS are ages -- "8 Year Old"
        # is what tells this bottle from its 12-year sibling.
        if len(word) == 1 and not word.isdigit():
            continue
        out.add(word)
    return out


def _numbers(tokens: set[str]) -> set[str]:
    return {t for t in tokens if t.isdigit()}


def _score_candidate(
    winner_tokens: set[str],
    brand_tokens: set[str],
    distinctive_brand: set[str],
    product_tokens: set[str],
) -> float:
    """0.0 unless every precision gate passes; otherwise the token overlap."""
    if not winner_tokens:
        return 0.0
    # Brand gate, part one: no distinctive brand word means the brand cannot be
    # proved on the bottle, so nothing is proved.
    if not distinctive_brand:
        return 0.0
    matched = winner_tokens & product_tokens
    # A one-word entry may only match a product that IS that word.
    if len(winner_tokens) == 1:
        return 1.0 if product_tokens == winner_tokens else 0.0
    if len(matched) < 2:
        return 0.0
    # Brand gate, part two: every distinctive brand word must be on the product.
    # Style and varietal words alone never carry a medal, and neither does one
    # brand word out of two.
    if not distinctive_brand <= product_tokens:
        return 0.0
    # Age / vintage veto, both directions: a number on either side that the
    # other lacks means a different expression.
    if _numbers(winner_tokens) != _numbers(product_tokens):
        return 0.0
    # Contrastive expression veto: when the winner names an expression word the
    # bottle lacks AND the bottle names one the winner lacks ("Original" vs
    # "Black"), they are siblings in a range, not the same product. One-sided
    # extras are fine -- retailers abbreviate and add category words.
    winner_only = winner_tokens - product_tokens - brand_tokens
    product_only = product_tokens - winner_tokens - brand_tokens
    if winner_only and product_only:
        return 0.0
    # Expression-marker veto, either side: a one-sided "Rye Cask Finish" or "Cask Strength"
    # names a different bottling. Until 10 Sep one-sided extras were always allowed, and
    # Bacardi Reserva Ocho's Silver sat on the Rye Cask Finish because the plain Ocho was
    # not in the catalogue.
    if (winner_only | product_only) & _EXPRESSION_MARKERS:
        return 0.0
    return len(matched) / len(winner_tokens)


@dataclass(frozen=True, slots=True)
class _Hit:
    """One competition entry placed on one product, before same-year collapse."""

    name_tokens: frozenset[str]
    competition: str
    medal: str | None
    score: int | None


@dataclass(frozen=True, slots=True)
class _Desired:
    """One award row the file says should exist."""

    variant_id: int
    competition: str
    competition_slug: str | None
    year: int | None
    medal: str | None
    score: int | None

    @property
    def natural_key(self) -> tuple[int, str, int | None, str]:
        return _natural_key(self.variant_id, self.competition_slug, self.year, self.medal)


def _natural_key(
    variant_id: int, competition_slug: str | None, year: int | None, medal: str | None
) -> tuple[int, str, int | None, str]:
    return (variant_id, (competition_slug or "").lower(), year, (medal or "").lower())


def match_winners(
    product_variants: Sequence[ProductLike], winners: list[dict]
) -> tuple[list[_Desired], dict[str, int]]:
    """Place every entry on a product, or nowhere. Pure matching; writes nothing."""
    tokens_of: dict[int, set[str]] = {
        p.id: award_tokens(f"{p.brand or ''} {p.name}") for p in product_variants
    }
    by_token: dict[str, list[ProductLike]] = defaultdict(list)
    for product in product_variants:
        for token in tokens_of[product.id]:
            by_token[token].append(product)

    stats = {"winners": len(winners), "matched": 0, "ambiguous": 0, "ambiguous_same_year": 0}
    hits: dict[tuple[int, str, int | None], list[_Hit]] = defaultdict(list)

    for winner in winners:
        brand = winner.get("brand") or ""
        winner_tokens = award_tokens(f"{brand} {winner['product']}")
        brand_tokens = award_tokens(brand)
        distinctive_brand = brand_tokens - _GENERIC_BRAND
        if not winner_tokens or not distinctive_brand:
            continue

        candidates: list[ProductLike] = []
        for token in distinctive_brand:
            candidates.extend(by_token.get(token, ()))

        scored: list[tuple[float, ProductLike]] = []
        for product in dict.fromkeys(candidates):
            score = _score_candidate(
                winner_tokens, brand_tokens, distinctive_brand, tokens_of[product.id]
            )
            if score >= MIN_TOKEN_OVERLAP:
                scored.append((score, product))
        if not scored:
            continue

        best_score = max(score for score, _ in scored)
        best = [p for score, p in scored if score == best_score]
        # Products with identical token sets are size variants of one bottle;
        # genuinely different names tying at the top is ambiguity, and
        # ambiguity means no medal.
        distinct_names = {frozenset(tokens_of[p.id]) for p in best}
        if len(distinct_names) > 1:
            stats["ambiguous"] += 1
            continue
        product = min(best, key=lambda p: p.id)
        stats["matched"] += 1
        slug = (winner.get("competition_slug") or "").lower()
        hits[(product.id, slug, winner.get("year"))].append(
            _Hit(
                name_tokens=frozenset(winner_tokens),
                competition=winner["competition"],
                medal=winner.get("medal"),
                score=winner.get("score"),
            )
        )

    desired: list[_Desired] = []
    for (variant_id, slug, year), group in hits.items():
        # One product, one competition, one year: the database holds one row, so
        # the entries must agree on what was entered and what it won. A source
        # file listing the same entry twice is a duplicate; entries that differ
        # in name or medal are different bottles, one of which is not this one.
        identities = {(h.name_tokens, (h.medal or "").lower()) for h in group}
        if len(identities) > 1:
            stats["ambiguous_same_year"] += 1
            continue
        best = max(group, key=lambda h: (-(medal_rank(h.medal)), h.score or 0))
        desired.append(
            _Desired(
                variant_id=variant_id,
                competition=best.competition,
                competition_slug=slug or None,
                year=year,
                medal=best.medal,
                score=best.score,
            )
        )
    return desired, stats


def reconcile_awards(db: Session, desired: list[_Desired], *, rebuild: bool) -> dict[str, int]:
    """Make the network's rows match `desired`, by natural key, without renumbering.

    Default: add what is new; never change or remove an existing row. `rebuild=True`:
    also correct a medal or score that changed and remove rows the file no longer
    produces. Rows from other sources are never touched.
    """
    stats = {
        "created": 0, "updated": 0, "corrected": 0, "removed": 0,
        "unchanged": 0, "skipped_existing": 0,
    }
    existing = list(db.scalars(select(Award).where(Award.source == SOURCE)))
    by_key: dict[tuple, Award] = {
        _natural_key(a.variant_id, a.competition_slug, a.year, a.medal): a for a in existing
    }
    # The database's own uniqueness is (product, competition name, year): a medal
    # correction changes the natural key but must land on that same row.
    by_slot: dict[tuple[int, str, int | None], Award] = {
        (a.variant_id, a.competition, a.year): a for a in existing
    }
    # Rows other sources own may already hold a slot; the network never overwrites them.
    foreign_slots = {
        (a.variant_id, a.competition, a.year)
        for a in db.scalars(select(Award).where(Award.source != SOURCE))
    }

    keep: set[int] = set()
    for want in desired:
        slot = (want.variant_id, want.competition, want.year)
        row = by_key.get(want.natural_key)
        if row is not None:
            keep.add(row.id)
            if row.score != want.score:
                row.score = want.score
                stats["updated"] += 1
            else:
                stats["unchanged"] += 1
            continue

        same_slot = by_slot.get(slot)
        if same_slot is not None:
            keep.add(same_slot.id)
            if not rebuild:
                stats["skipped_existing"] += 1
                continue
            same_slot.medal = want.medal
            same_slot.score = want.score
            same_slot.competition_slug = want.competition_slug
            stats["corrected"] += 1
            continue

        if slot in foreign_slots:
            stats["skipped_existing"] += 1
            continue

        db.add(
            Award(
                variant_id=want.variant_id,
                competition=want.competition,
                competition_slug=want.competition_slug,
                year=want.year,
                medal=want.medal,
                score=want.score,
                is_own_competition=True,
                source=SOURCE,
            )
        )
        stats["created"] += 1

    if rebuild:
        for row in existing:
            if row.id not in keep:
                db.delete(row)
                stats["removed"] += 1

    db.commit()
    return stats


def import_awards(db: Session, winners_path: Path, *, rebuild: bool = False) -> dict[str, int]:
    """Match the winners file against the catalogue and reconcile the awards table."""
    payload = json.loads(winners_path.read_text())
    winners = payload.get("winners", [])
    desired, stats = match_winners(list(db.scalars(select(ProductVariant))), winners)
    stats.update(reconcile_awards(db, desired, rebuild=rebuild))
    logger.info("awards_imported %s", stats)
    return stats
