"""Consumer-facing product categories.

Retailer feeds label products inconsistently -- one calls everything "Spirits",
another returns packaging words like "Twinpack". Neither is what a shopper
browses by, so products are classified into one shared vocabulary here.

The vocabulary is taken from the client's own wireframe, so the categories a
shopper sees are the ones they asked for rather than whatever a feed emitted.
"""

import re

# Order matters: the first pattern to match wins, so narrower categories that
# share words with broader ones (an "agave spirit" is Tequila, not Liqueur) are
# listed first.
CATEGORY_RULES: list[tuple[str, re.Pattern[str]]] = [
    ("Whisky", re.compile(r"\b(whisk[ey]y?|whiskies|scotch|bourbon|rye|single malt|"
                          r"blended malt|islay|speyside|tennessee)\b", re.I)),
    ("Cognac & Brandy", re.compile(r"\b(cognac|brandy|armagnac|calvados|pisco|grappa|"
                                   r"xo|vsop|vs\b)", re.I)),
    ("Tequila & Mezcal", re.compile(r"\b(tequila|mezcal|mescal|agave|anejo|a[nñ]ejo|"
                                    r"reposado|blanco)\b", re.I)),
    ("Rum", re.compile(r"\b(rum|rhum|ron\b|cacha[cç]a)\b", re.I)),
    ("Gin", re.compile(r"\bgin\b", re.I)),
    ("Vodka", re.compile(r"\b(vodka|wodka)\b", re.I)),
    ("Champagne & Sparkling", re.compile(r"\b(champagne|prosecco|cava|spumante|cr[eé]mant|"
                                         r"sparkling|brut)\b", re.I)),
    ("Wine", re.compile(r"\b(wine|vino|merlot|shiraz|syrah|malbec|rioja|chianti|"
                        r"sauvignon|chardonnay|pinot|cabernet|riesling|port\b|sherry|"
                        r"tempranillo|zinfandel|ros[eé]\b)\b", re.I)),
    ("Beer & Cider", re.compile(r"\b(beer|lager|ale\b|stout|pilsner|cider)\b", re.I)),
    ("Liqueurs & Aperitifs", re.compile(r"\b(liqueurs?|liquer|liq\.|aperitifs?|aperitiv|aperitivo|amaro|amaretto|korn|"
                                        r"vermouth|vermut|schnapps|schnaps|sambuca|limoncello|limoncino|"
                                        r"ouzo|raki|bitters?|triple sec|curacao|cream liqueur|irish cream|"
                                        r"advocaat|absinthe|akvavit|aquavit|soju|sake|baijiu|pastis|"
                                        r"jagertee|j[aä]germeister|eggnog|creme de)\b", re.I)),
    # Perfume, not Fragrance: the shopper's word and the searched one (19x the
    # volume, Mark, 7 Sep; Adam, 10 Sep: "Search is always our driver"). Cologne
    # and eau de toilette sit beneath it; the trade word survives only in the
    # concentration attribute and the collectors' walk names.
    ("Perfume", re.compile(r"\b(eau de parfum|eau de toilette|eau de cologne|\bedp\b|\bedt\b|\bedc\b|cologne|"
                             r"parfum|fragrance|perfume|body mist|hair mist|perfume mist|after[- ]shave)\b", re.I)),
    ("Makeup", re.compile(r"\b(lipstick|lip gloss|lip liner|lip plumper|mascara|eye ?liner|kajal|"
                          r"eye ?shadow|foundation|concealer|pressed powder|loose powder|blush|bronzer|"
                          r"brow|nail (?:lacquer|polish|colou?r)|primer|highlighter|palette|make-?up)\b", re.I)),
    ("Skincare", re.compile(r"\b(serum|moisturi[sz]er|moisturi[sz]ing|cleanser|cleansing|cr[eè]me de la mer|"
                            r"skincare|skin care|face cream|eye cream|night cream|day cream|body cream|body butter|"
                            r"body lotion|body milk|hand cream|hand creams|lotion|balm|toner|tonic|mask|masque|"
                            r"exfoliat|peel|scrub|sunscreen|sun care|after sun|spf\s*\d*\+*|tanning|shave gel|shaving|"
                            r"deodorant|body wash|shower gel|micellar|essence|emulsion)\b", re.I)),
    ("Confectionery", re.compile(r"\b(chocolate|praline|truffle|candy|liquorice|licorice|"
                                 r"gummi|gum\b|toffee|nougat|biscuit)\b", re.I)),
    ("Tobacco", re.compile(r"\b(cigar|cigarette|tobacco|cigarillo)\b", re.I)),
]

# Feed values that are packaging or format, never a category a shopper browses by.
NON_CATEGORY_VALUES = {
    "bottle", "box", "gift box", "gift set", "gift pack", "tin", "carton", "case",
    "can", "tube", "miniature", "miniature pack", "twinpack", "twin pack", "pack",
    "set", "spirits", "liquor", "other", "others", "50cl bottles", "national spirits",
    # Packaging and format labels seen in retailer feeds. None of these is a
    # category a shopper browses by, so they fall through to name matching.
    "beverage can", "pet bottle", "value added pack", "magnum+", "magnum",
    "multipack", "wooden box", "miscellaneous", "blended", "gift", "sleeve",
    "shrink pack", "display", "assortment",
}


# Houses whose name settles the category when nothing else does, keyed by the
# brand fold key (`normalize.brand_key`). Only single-category houses belong
# here: a maker of both brandy and wine (Torres) or liqueur and prosecco
# (Bottega) is left to the name. Used by `backfill categories` and, after it,
# by `classify` as the last resort before None.
BRAND_CATEGORIES: dict[str, str] = {
    **dict.fromkeys(["jack daniels", "jim beam", "chivas", "chivas regal", "johnnie walker", "loch lomond",
                     "glenfiddich", "bruichladdich", "bushmills", "famous grouse", "nikka", "floki",
                     "highland park", "benriach", "midleton", "grants", "aberlour", "ardbeg", "dalmore",
                     "tullamore dew", "jameson", "macallan", "glenlivet", "glenmorangie", "talisker",
                     "lagavulin", "laphroaig", "balvenie", "dalwhinnie", "oban", "singleton",
                     "monkey shoulder", "makers mark", "woodford reserve", "bulleit", "wild turkey",
                     "yamazaki", "hakushu", "hibiki", "chita", "toki", "whistler",
                     "redbreast", "teeling", "powers", "cardhu", "mortlach", "clynelish", "cragganmore",
                     "glenkinchie", "caol ila", "royal salute", "ballantines", "dewars", "haig club",
                     "buchanans", "old parr", "black bottle", "auchentoshan", "bowmore", "glen grant",
                     "glen moray", "tomatin", "tamdhu", "glendronach", "arran", "jura", "kilchoman",
                     "agitator", "crown royal", "canadian club", "gentleman jack", "four roses",
                     "buffalo trace", "knob creek", "michters", "kavalan", "paul john", "amrut"], "Whisky"),
    **dict.fromkeys(["bacardi", "planteray", "plantation", "havana club", "stroh", "flor de cana",
                     "don papa", "botran", "captain morgan", "kraken", "diplomatico", "zacapa",
                     "ron zacapa", "appleton estate", "mount gay", "brugal", "bumbu", "angostura",
                     "santa teresa", "abuelo", "ron abuelo", "matusalem", "el dorado", "pampero",
                     "cacique", "malibu", "sailor jerry", "pussers", "chairmans reserve", "dictador",
                     "barcelo", "ron barcelo", "medellin", "ron medellin", "viejo de caldas"], "Rum"),
    **dict.fromkeys(["grey goose", "danzka", "finlandia", "absolut", "smirnoff", "ciroc", "belvedere",
                     "ketel one", "stolichnaya", "beluga", "russian standard", "crystal head",
                     "koskenkorva", "reyka", "haku", "tito's", "titos", "chopin", "zubrowka",
                     "wyborowa", "nemiroff", "khortytsa", "skyy", "three sixty"], "Vodka"),
    **dict.fromkeys(["whitley neill", "bombay sapphire", "bombay", "tanqueray", "hendricks",
                     "beefeater", "gordons", "roku", "monkey 47", "the botanist", "botanist",
                     "malfy", "aviation", "sipsmith", "nordes", "gin mare", "brockmans", "bulldog",
                     "citadelle", "g'vine", "gvine", "plymouth", "martin millers", "opihr",
                     "larios", "puerto de indias", "seagrams"], "Gin"),
    **dict.fromkeys(["jose cuervo", "sierra", "1800", "patron", "don julio", "casamigos", "clase azul",
                     "herradura", "olmeca", "espolon", "cazadores", "el jimador", "gran centenario",
                     "zignum", "maestro dobel", "avion", "volcan", "codigo 1530", "corralejo",
                     "400 conejos", "montelobos", "del maguey", "ojo de tigre"], "Tequila & Mezcal"),
    **dict.fromkeys(["hennessy", "remy martin", "martell", "courvoisier", "camus", "hine", "bisquit",
                     "meukow", "otard", "baron otard", "cardenal mendoza",
                     "carlos i", "metaxa", "st remy", "vecchia romagna", "asbach", "ararat"], "Cognac & Brandy"),
    **dict.fromkeys(["jagermeister", "baileys", "cointreau", "licor 43", "gammel dansk", "underberg",
                     "martini", "disaronno", "coole swan", "kahlua", "tia maria", "grand marnier",
                     "amarula", "frangelico", "chambord", "drambuie", "fireball", "molinari", "luxardo",
                     "aperol", "campari", "galliano", "sheridans", "southern comfort", "pimms", "ricard",
                     "pernod", "st germain", "italicus", "limoncello di capri", "berliner luft",
                     "kleiner feigling", "sure fisk", "sma", "sma series", "ga jol", "opal", "opal olgerdin",
                     "freihof", "bols", "de kuyper", "marie brizard", "cynar", "fernet branca", "averna",
                     "montenegro", "ramazzotti", "jagermeister", "unicum", "becherovka", "minttu"], "Liqueurs & Aperitifs"),
    **dict.fromkeys(["moet chandon", "veuve clicquot", "dom perignon", "laurent perrier", "bollinger",
                     "ruinart", "taittinger", "perrier jouet", "piper heidsieck", "nicolas feuillatte",
                     "pommery", "mumm", "g h mumm", "krug", "armand de brignac", "louis roederer",
                     "billecart salmon", "freixenet", "codorniu", "mionetto", "la marca"], "Champagne & Sparkling"),
    **dict.fromkeys(["grahams", "kopke", "masi", "faustino", "concha y toro", "penfolds", "baron de ley",
                     "el coto", "louis jadot", "quinta da boeira", "tommasi", "marchesi di barolo",
                     "castellani", "mare magnum", "leitz", "bread butter", "taylors", "fonseca", "sandeman",
                     "cockburns", "dows", "warres", "croft", "ramos pinto", "offley", "calem", "ferreira",
                     "marques de riscal", "marques de caceres", "campo viejo", "muga", "cvne", "ramon bilbao",
                     "protos", "vega sicilia", "antinori", "frescobaldi", "gaja", "tignanello", "banfi",
                     "zonin", "villa antinori", "cloudy bay", "oyster bay", "yellow tail", "jacobs creek",
                     "wolf blass", "casillero del diablo", "santa rita", "catena", "trapiche", "kendall jackson",
                     "robert mondavi", "beringer", "chateau ste michelle", "inniskillin",
                     "pillitteri", "peller estates", "jackson triggs", "mateus", "blue nun", "black tower"], "Wine"),
    **dict.fromkeys(["heineken", "corona", "stella artois", "carlsberg", "tuborg", "guinness", "budweiser",
                     "amstel", "tiger", "peroni", "birra moretti", "san miguel", "estrella damm", "mahou",
                     "paulaner", "erdinger", "warsteiner", "becks", "beck's", "hoegaarden", "leffe", "duvel",
                     "chimay", "somersby", "rekorderlig", "briska", "kopparberg", "strongbow", "bulmers",
                     "magners", "kronenbourg", "1664", "asahi", "sapporo", "kirin", "efes", "mythos",
                     "fix", "alfa", "viking", "gull", "einstok", "thule"], "Beer & Cider"),
}


def classify_by_brand(brand: str | None) -> str | None:
    """The category a single-category house settles, or None."""
    from app.services.normalize import brand_key
    return BRAND_CATEGORIES.get(brand_key(brand)) if brand else None


def classify(
    name: str,
    brand: str | None = None,
    feed_categories: str | list[str] | None = None,
) -> str | None:
    """Best consumer category for a product, or None when nothing fits.

    A feed's own category names are the strongest signal -- many bottles never
    say what they are ("Johnnie Walker Blue Label" contains no word for whisky) --
    so they are matched first, then the product name.

    None is a deliberate outcome: no category reads better than a wrong one.
    """
    if isinstance(feed_categories, str):
        feed_categories = [feed_categories]
    hints = [c.strip() for c in (feed_categories or []) if c and c.strip()]

    for haystack in (" ".join(hints), f"{brand or ''} {name}"):
        if not haystack.strip():
            continue
        for category, pattern in CATEGORY_RULES:
            if pattern.search(haystack):
                return category

    # No fallback to raw feed strings: they leaked navigation labels ("View
    # All", "Summer Drinks") into the category dropdowns. A single-category
    # house settles it ("Bacardi Carta Blanca" says nothing about rum); when
    # nothing matches, no category reads better than a junk one.
    return classify_by_brand(brand)


# A vertical is the category FAMILY a product belongs to: the thing a
# collector walks (a drinks tree, a beauty tree) and the thing identity rules
# will one day be scoped by. Categories map to exactly one.
VERTICAL_OF_CATEGORY: dict[str, str] = {
    "Whisky": "liquor", "Cognac & Brandy": "liquor", "Tequila & Mezcal": "liquor",
    "Rum": "liquor", "Gin": "liquor", "Vodka": "liquor", "Champagne & Sparkling": "liquor",
    "Wine": "liquor", "Beer & Cider": "liquor", "Liqueurs & Aperitifs": "liquor",
    "Perfume": "beauty", "Skincare": "beauty", "Makeup": "beauty",
    "Confectionery": "confectionery",
    "Tobacco": "tobacco",
}

# Categories we collect but do not show (rian, 11 Sep): out of the launch scope, so
# they exist nowhere on the storefront (shelves, search, counts, sitemap, brand pages),
# the way a hidden airport does; their collectors keep running and their product
# pages still open from a direct link. Perfume and Makeup stay in scope.
HIDDEN_CATEGORIES: frozenset[str] = frozenset({"Skincare", "Confectionery"})


def is_shown(category: str | None) -> bool:
    """Is a product of this category shown on the storefront? Uncategorised is shown."""
    return (category or "") not in HIDDEN_CATEGORIES


# The shopper's word for each vertical: the heading a family gets wherever the
# site groups categories (the mega menu's shelves). Typed here once and sent
# with each CategoryCount, never in the SPA, so a new vertical is one line.
FAMILY_LABEL: dict[str, str] = {
    "liquor": "Drinks",
    "beauty": "Beauty",
    "confectionery": "Confectionery",
    "tobacco": "Tobacco",
}

# What a retailer's own family label looks like, for collectors that know
# which tree they walked but not our category. First match wins.
_VERTICAL_HINTS: list[tuple[str, re.Pattern[str]]] = [
    ("liquor", re.compile(r"\b(liquor|liqueur|spirits?|wines?|alcohol|beverages?|drinks?|champagne|beer|whisk)", re.I)),
    ("beauty", re.compile(r"\b(beauty|fragrances?|perfumes?|parfum|skin\s?care|cosmetics?|make-?up|toiletr)", re.I)),
    ("confectionery", re.compile(r"\b(confectioner|chocolates?|sweets|candy|food|gourmet|delicatess)", re.I)),
    ("tobacco", re.compile(r"\b(tobacco|cigar|vap)", re.I)),
]


def vertical_of(category: str | None) -> str | None:
    """The family of one of our categories, or None for an unclassified product."""
    return VERTICAL_OF_CATEGORY.get(category or "")


def family_label(category: str | None) -> str | None:
    """The shopper's word for a category's family ("Whisky" -> "Drinks"), or None."""
    vertical = vertical_of(category)
    return FAMILY_LABEL.get(vertical) if vertical else None


def categories_of(vertical: str | None) -> list[str]:
    """The shown categories of one family: what "All drinks" browses. Hidden
    categories stay out, so a family view never lists a shelf the storefront hides."""
    return [c for c, v in VERTICAL_OF_CATEGORY.items() if v == vertical and is_shown(c)]


def majority_family(category_counts: dict[str | None, int]) -> str | None:
    """The family most of a brand's products sit in, from its per-category counts.
    A house that sells both (a fashion label with one whisky) belongs where its
    shelf is; a tie goes to the family named first alphabetically, so the answer
    never depends on row order. None when no product has a known family."""
    totals: dict[str, int] = {}
    for category, n in category_counts.items():
        vertical = vertical_of(category)
        if vertical:
            totals[vertical] = totals.get(vertical, 0) + n
    if not totals:
        return None
    return min(totals, key=lambda v: (-totals[v], v))


def vertical_from_hints(*texts: str | None) -> str | None:
    """The family a retailer's own labels (a category path, a feed type) point at."""
    haystack = " ".join(t for t in texts if t)
    for vertical, pattern in _VERTICAL_HINTS:
        if pattern.search(haystack):
            return vertical
    return None
