#!/usr/bin/env python3
"""Builds supabase/seed/product_index.csv from the Open Beauty Facts export (docs/dispatch/AH).

    curl -O https://static.openbeautyfacts.org/data/openbeautyfacts-products.jsonl.gz
    python3 scripts/build_product_index.py openbeautyfacts-products.jsonl.gz [--stats stats.json]

Stdlib only. What it does, in order:
1. Reads every OBF row. Keeps face and skin care that maps to one of the app's categories
   (cleanser, toner, essence, serum, eye, moisturizer, face_oil, sunscreen, spot) and drops hair,
   body, hands, makeup, fragrance, oral, shaving, masks, wipes and food. The classifier reads the
   product name, the generic name and the category tags; OBF tags are sparse (about 4 in 10 rows
   carry any), so names do most of the work. Rows with no brand or no name are dropped.
2. Parses actives from the ingredient list with scripts/inci_actives.py (the app's
   core/rules/inci_actives.dart is the same parser). actives_source is `inci` when the list was
   readable, else `none`: unknown, never "no actives".
3. Dedups with the same normalisation the app uses to match reads (scripts/shelf_identity.py,
   ShelfIdentity in Dart): one row per normalised brand and name-token set. The row kept is the one
   with a readable ingredient list, then the most complete, then the most recently edited.
4. Drops OBF rows that are the same product as a curated catalog row (data/catalog.csv): the
   curated row is in the index already (the migration copies catalog_products) and wins.

5. Cleans what is shown (ticket 0027): HTML entities decoded, junk before the first word dropped
   ("&gt;&gt;BRINGGREEN"), a brand stored as a product line mapped to its maker (BRAND_ALIASES),
   known brands in their own casing (BRAND_DISPLAY), and all-caps brands and names title-cased.
   Search is case-insensitive, so casing is display only.
6. A sunscreen with no readable ingredient list gets actives ["sunscreen"] and actives_source
   `category`: a sunscreen carries UV filters by definition. No other category implies actives.

The CSV has OBF data only, which is ODbL: keep it in product_index, `source = 'obf'`, with the OBF
code, and never merge it into the curated catalog. scripts/load_product_index.sh loads it.
"""
import argparse
import collections
import csv
import gzip
import html
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import inci_actives  # noqa: E402
import shelf_identity  # noqa: E402

REPO = Path(__file__).resolve().parent.parent
OUT = REPO / "supabase" / "seed" / "product_index.csv"
CATALOG = REPO / "data" / "catalog.csv"
MAX_INGREDIENTS = 4000

fold = shelf_identity.fold


def rx(*words):
    return re.compile("|".join(words))


# Checked against the name, generic name and category tags, folded.
EXCLUDE = rx(
    r"\bhair\b", r"cheveu", r"\bhaar", r"shampo", r"conditioner", r"apres-?shampo", r"capillaire", r"coloration",
    r"hair-dyes", r"hair-care", r"\bdeo", r"antiperspirant", r"anti-perspirant", r"toothpaste", r"dentifrice",
    r"tandpasta", r"zahnpasta", r"mouthwash", r"bain de bouche", r"\btooth", r"dental", r"eau de parfum",
    r"eau de toilette", r"perfume", r"cologne", r"en:perfumes", r"mascara", r"lipstick", r"\blips?\b", r"lip ?balm", r"levres",
    r"lippen", r"gloss", r"foundation", r"fond de teint", r"concealer", r"eyeshadow", r"eye shadow", r"eyeliner",
    r"\bkohl", r"\bblush", r"bronzer", r"highlighter", r"\bprimer\b", r"\bbb cream", r"\bcc cream", r"powder",
    r"poudre", r"\bnail", r"vernis", r"ongles", r"cuticle", r"makeup(?![- ]remover)", r"make-up(?![- ]remover)",
    r"maquillage", r"\bshav", r"rasage", r"aftershave", r"after shave", r"after-shave", r"\bbeard", r"barbe",
    r"wipes", r"lingette", r"doekjes", r"intim", r"\bmask", r"masque", r"masker", r"maske\b", r"after ?sun",
    r"after-sun", r"apres[- ]soleil", r"self[- ]tan", r"autobronz", r"\bhand", r"\bmains?\b", r"\bfoot\b",
    r"\bfeet\b", r"\bpieds?\b", r"en:plant-based", r"en:dietary", r"en:dairies", r"en:fats", r"en:beverages",
    r"en:condoms", r"en:foods", r"supplement", r"\bdiaper", r"\bcouche", r"tattoo", r"\bbaby\b", r"\bbebe\b",
    r"gumm", r"chewable", r"throat", r"tablet", r"capsule", r"physiologique", r"\blash", r"\bcils\b", r"\bbrow",
    r"wimper", r"kirpik",
)
# Body words exclude a row only when nothing says face: "face and body" creams stay.
BODY = rx(r"\bbody", r"\bcorps\b", r"korper", r"lichaam", r"bodylotion", r"shower", r"douche", r"\bbath",
          r"\bbain\b", r"dusch", r"\bbad\b", r"\bsoap", r"savon", r"seife", r"\bzeep", r"\blegs?\b")
FACE = rx(r"\bface\b", r"\bfacial", r"visage", r"gezicht", r"gesicht", r"rostro", r"\bviso\b", r"\bcara\b",
          r"skin ?care", r"en:face", r"facial-creams", r"day-creams", r"night-creams", r"face-lotions",
          r"anti-aging-face-care", r"anti-wrinkles-creams", r"huidverzorging", r"gezichts")

SUNSCREEN = rx(r"\bspf ?\d", r"sunscreen", r"sun ?screen", r"sun (?:cream|lotion|fluid|milk|spray|stick|gel)",
               r"sunblock", r"solaire", r"\becran\b", r"zonnebrand", r"zonnecreme", r"sonnencreme", r"sonnenschutz",
               r"sonnenmilch", r"protector solar", r"protecao solar", r"fotoprotector", r"\buv ?(?:fluid|defen|protect)",
               r"en:sunscreen", r"en:in-sun-protections")
CLEANSER = rx(r"cleans", r"face wash", r"facial wash", r"\bwash\b", r"micell", r"mizell", r"nettoyant", r"demaquill",
              r"make-?up remover", r"reinigung", r"reiniging", r"limpiador", r"limpeza", r"gel lavant",
              r"mousse nettoyante", r"lavant", r"purif(?:ying|iant) (?:gel|foam)", r"gel purifiant", r"gel moussant", r"\bfoam", r"\bscrub", r"gommage", r"peeling", r"en:cleansers",
              r"en:cleansing-waters", r"en:micellar-water", r"gezichtsreiniging")
EYE = rx(r"\beyes?\b", r"\byeux\b", r"oogcreme", r"ooggel", r"augen", r"under[- ]eye", r"en:eye-creams")
SPOT = rx(r"(?<!dark )\bspot\b", r"pimple", r"blemish", r"acne (?:gel|patch|treatment)", r"anti-?bouton",
          r"\bbouton", r"puistje", r"pickel")
TONER = rx(r"\btoner", r"\btonic\b", r"tonique", r"tonico", r"gesichtswasser", r"gezichtstonic", r"toning",
           r"exfoliant", r"face mist", r"\bmist\b", r"\bbrume\b", r"eau thermale", r"thermal (?:spring )?water")
ESSENCE = rx(r"\bessence\b", r"treatment lotion")
FACE_OIL = rx(r"face oil", r"facial oil", r"huile (?:de soin )?visage", r"gezichtsolie", r"gesichtsol",
              r"aceite facial", r"rosehip (?:seed )?oil", r"squalane")
SERUM = rx(r"\bserum", r"ampoule", r"\bampul", r"booster", r"concentrate", r"\bconcentre\b", r"\bdrops\b",
           r"gezichtsserum", r"\belixir")
MOISTURIZER = rx(r"moistur", r"hydrat", r"feuchtigkeit", r"hydraterend", r"dagcreme", r"nachtcreme",
                 r"tagescreme", r"nachtpflege", r"tagespflege", r"soin (?:de )?(?:jour|nuit)", r"day (?:cream|care)",
                 r"night (?:cream|care)", r"sleeping", r"en:facial-creams", r"en:day-creams", r"en:night-creams",
                 r"en:face-lotions", r"anti-wrinkles-creams", r"anti-aging-face-care")
# A cream, lotion, gel or balm is a moisturizer only when something says face or day/night care.
GENERIC_CREAM = rx(r"\bcream", r"\bcreme", r"\blotion", r"\bemulsion", r"\bfluid", r"\bgel\b", r"\bbalm",
                   r"\bbaume", r"\bcrema", r"\bkrem")
CARE_CONTEXT = rx(r"\bday\b", r"\bnight\b", r"\bjour\b", r"\bnuit\b", r"anti-?(?:age|aging|ageing|wrinkle|rides)",
                  r"wrinkle", r"\brides\b", r"firming", r"repair", r"barrier")

# A makeup brand whose name collides with a category word, and a placeholder brand.
EXCLUDE_BRANDS = {"essence", "test"}

# --- Not skincare, and brands that are not the brand (docs/dispatch/AL item 3) --------------------
# The classifier above works from names and sparse tags, so a stir-fry sauce with "honey" and
# "cleanser"-shaped words, a laundry liquid or a body wash could still land in a face category.
# These run after it, on the brand, the display name and the ingredient list, folded, and each one
# names why a row went, for the report.

# Named food outright: nobody's skincare is called a gravy.
FOOD_NAME = rx(r"\bsauce\b", r"\bgravy\b", r"seasoning", r"stir-?fry", r"marinade", r"sausage", r"\bflavou?red\b",
               r"bouillon", r"tea blend", r"herbal tea\b", r"ketchup")
# An ingredient list that reads like a food label.
# Not "may contain" or "allergens": cosmetic labels use both, for pigments and fragrance allergens.
FOOD_INGREDIENTS = rx(r"soy sauce", r"wheat flour", r"partially hydrogenated", r"corn syrup solids",
                      r"per 100 ?(?:g|ml)", r"nutrition", r"caseinat", r"proteines? de lait", r"milk protein concentrate")
# The first ingredient of something eaten, or of a sugar or salt scrub.
FOOD_FIRST = {"sugar", "sucre", "azucar", "zucker", "sucrose", "corn syrup", "glucose syrup", "syrup", "creamer",
              "flour", "maltodextrin", "salt", "sea salt", "sel", "sal", "sal marina", "honey"}
SCRUB = rx(r"scrub", r"gommage", r"exfolia", r"peeling", r"polish")
# Laundry and fabric care, read from the name (a brand can say "laundry": Skin Laundry). Not the
# Italian "detergente", which is a cleanser, and "adoucissant" only as a fabric softener refill: on a
# toner it means softening.
HOUSEHOLD = rx(r"assouplissant", r"adoucissant (?:recharge|concentr)", r"\blavages\b", r"fabric softener", r"\blessive\b", r"laundry", r"\bdetergent\b",
               r"liquid detergent", r"\bbucato\b",
               r"easy wash", r"magic wash", r"agents? de surface cationique")
HOUSEHOLD_BRANDS = {"soupline", "surfexcel"}
# Hair, by product line or brand (the classifier's "hair" words miss a Norwegian "balsam").
HAIR = rx(r"\bh ?& ?s balsam", r"shoulders? balsam", r"colorista", r"head ?(?:&|and) ?shoulders?", r"\bh ?& ?s\b", r"pantene", r"shiny drops",
          r"\baussie\b", r"high dive", r"damage therapy")
HAIR_BRANDS = {"pantene", "aussie", "headshoulders"}
# Made for the body, the ear or a foot, whatever else the name says, unless the name also says face.
BODY_ONLY = rx(r"body ?(?:wash|lotion|milk|butter|scrub|oil|mist|cream|spray)", r"fragrance mist",
               r"head[- ]to[- ]toe", r"top[- ]to[- ]toe", r"feminine", r"\bcorporal\b", r"\bkorper", r"ear drops",
               r"exfoliant sock", r"\bsock\b", r"cleansing cloths?", r"\bcloths\b", r"\bcorpo\b", r"pferdebalsam",
               r"\bcorps\b", r"bodycr", r"body ?(?:care|gel|love)")
# Not "creme lavante": Effaclar H and most French cleansing creams are for the face.
FACE_IN_NAME = rx(r"\bface\b", r"\bfacial", r"visage", r"gezicht", r"gesicht", r"rostro", r"\bviso\b", r"\bcara\b",
                  r"\byuz\b", r"\blip")


def first_ingredient(text):
    head = re.split(r"[,;(\n]", fold(text or ""), maxsplit=1)[0]
    head = re.sub(r"^(?:ingredients?|ingredientes|ingredienti|zutaten|ingr)\s*[:/]?\s*", "", head.strip())
    return re.sub(r"\s*\d+(?:[.,]\d+)?\s*%.*$", "", head).strip(" .*:")


def not_skincare(brand, name, ingredients_text):
    """Why a row that passed the classifier is still not face or skin care, or None."""
    b = shelf_identity.normalise_brand(brand)
    n = fold(name)
    text = fold(f"{brand} {name}")
    face = bool(FACE_IN_NAME.search(n))
    if b in HOUSEHOLD_BRANDS or HOUSEHOLD.search(n) or HOUSEHOLD.search(fold(ingredients_text or "")[:200]):
        return "household"
    if b in HAIR_BRANDS or HAIR.search(text):
        return "hair"
    if FOOD_NAME.search(n) or FOOD_INGREDIENTS.search(fold(ingredients_text or "")):
        return "food"
    first = first_ingredient(ingredients_text)
    if first in FOOD_FIRST:
        if SCRUB.search(n):
            # A sugar face scrub is skincare; a sugar or salt body scrub is not.
            return None if face else "body scrub"
        return None if face else "food"
    if BODY_ONLY.search(n) and not face:
        return "body"
    return None


# Brand fields that name the maker's parent company or distributor, or a phrase that is not a
# brand at all. Keyed by normalised brand. The product's own brand is usually the start of the
# name ("Unilever | Pond's Bright Beauty Face Wash"), which is what repair_brand takes.
PARENT_BRANDS = {
    "unilever", "unileverno", "unilevernorgeas", "hindustanunileverlimited", "coralunilever", "henkel",
    "schwarzkopfhenkel", "beiersdorf", "beiersdorfag", "brynildgruppenas", "johnsonjohnson", "johnsonandjohnson",
    "johnsonjohnsonsbf", "johnsonjohnsonconsumernordic", "johnsonsandjohnson", "proctergamble",
    "proctergamblenorgeas", "protcterandgamble", "lorealnorgeas", "lorealnorgeasslt", "orklahealthas",
    "cosmaxinc", "cmslabinc", "jincostechco", "absorblabcoltd", "benowinc", "beautyselectioncoltd",
    "thebootscompanyplc", "mentholatumaustptyltd", "ellebasicas",
}
GENERIC_BRANDS = {
    "sensitiveskin", "skincare", "skin", "care", "beauty", "kbeauty", "personalcare", "corporal", "cleanbeauty",
}
# Brands a name may start with, beyond the ones the index itself holds twice or more.
SUB_BRANDS = ["Aveeno", "Neutrogena", "Nivea", "Nivea Sun", "Nivea Men", "L'Oréal", "Pond's", "Vaseline", "Garnier", "Diadermine", "Dove",
              "Simple", "Celimax", "Numbuzin", "Biodance", "Cell Fusion C", "Dear Klairs", "Klairs", "Madeca",
              "Beauty of Joseon", "Soltan", "Ambre Solaire", "Oxy", "Natusan", "Salvequick", "Sunsilk", "Lifebuoy",
              "Fair & Lovely", "Glow & Lovely", "Lakme", "Clean & Clear", "Olay"]


# Brand words too common to match a name on: "Derma Spa" is a Dove line, not the Derma brand.
TOO_GENERIC_TO_MATCH = {"derma", "skin", "beauty", "care", "natural", "organic", "pure", "clean"}


def known_brands(rows):
    """Brands a name can be matched against, in their most used spelling: every brand the index
    holds on two rows or more, the curated catalog's, and [SUB_BRANDS] (which win), never a parent,
    a generic phrase or a word too common to match on."""
    spellings = collections.defaultdict(collections.Counter)
    for r in rows:
        spellings[shelf_identity.normalise_brand(r["brand"])][r["brand"]] += 1
    spelled = {k: c.most_common(1)[0][0] for k, c in spellings.items() if sum(c.values()) >= 2}
    with CATALOG.open(newline="", encoding="utf-8") as f:
        for r in csv.DictReader(f):
            spelled.setdefault(shelf_identity.normalise_brand(r["brand"]), r["brand"])
    for b in SUB_BRANDS:
        spelled[shelf_identity.normalise_brand(b)] = b
    skip = PARENT_BRANDS | GENERIC_BRANDS | TOO_GENERIC_TO_MATCH
    return {k: display_brand(v) for k, v in spelled.items() if k and k not in skip}


def repair_brand(brand, name, known):
    """(brand, name) with a parent company or a generic phrase replaced by the brand the name
    starts with, taken off the front of the name. Unchanged when the brand is fine or the name
    does not start with a known brand. The name can come back empty ("sensitive skin | Aveeno"):
    the caller drops that row, a brand with no product."""
    b = shelf_identity.normalise_brand(brand)
    if b not in PARENT_BRANDS and b not in GENERIC_BRANDS:
        return brand, name
    words = name.split()
    best = None
    for k in range(min(len(words), 5), 0, -1):
        head = shelf_identity.normalise_brand(" ".join(words[:k]))
        if head in known:
            best = (known[head], " ".join(words[k:]).strip(" -,:"))
            break
    return best if best else (brand, name)


# --- Display cleanup (ticket 0027) ---------------------------------------------------------------

# OBF rows that store a product line, not the maker, as the brand. Keyed by normalised brand.
BRAND_ALIASES = {"1025": "Round Lab"}  # Round Lab's 1025 Dokdo line

# Brands whose own casing title-casing would break. Keyed by normalised brand.
BRAND_DISPLAY = {
    "cerave": "CeraVe", "cosrx": "COSRX", "eltamd": "EltaMD", "skin1004": "SKIN1004",
    "larocheposay": "La Roche-Posay", "paulaschoice": "Paula's Choice", "ordinary": "The Ordinary",
    "elf": "e.l.f.", "roundlab": "Round Lab",
    # Short enough to be kept in capitals as an acronym otherwise.
    "olay": "Olay", "nuxe": "Nuxe", "mixa": "Mixa", "hipp": "HiPP",
}

# Kept in capitals when title-casing an all-caps name.
ACRONYMS = {"spf", "uv", "uva", "uvb", "pa", "aha", "bha", "pha", "ha", "am", "pm", "sos", "bb", "cc", "dd",
            "xl", "ii", "iii", "iv", "lsf", "fps", "ip", "ph", "dna", "pdrn", "q10", "b3", "b5", "b12", "c", "e",
            "npa", "ac", "ai", "dx", "ds", "k", "h", "r", "mela"}

# Words with their own casing.
SPECIAL_CASE = {"ph": "pH"}

# A brand this short in capitals is usually an acronym (NYX, SVR, QV): left as it is.
SHORT_BRAND = 4

_JUNK_PREFIX = re.compile(r"^[^\w'\"(\[]+")
_SPACES = re.compile(r"\s+")


def clean_text(s):
    """Entities decoded (twice, for double-encoded rows), non-breaking spaces, junk before the
    first word dropped, whitespace collapsed."""
    t = html.unescape(html.unescape(s or "")).replace("\u00a0", " ")
    t = _JUNK_PREFIX.sub("", t)
    return _SPACES.sub(" ", t).strip()


def all_caps(s):
    letters = [c for c in s if c.isalpha()]
    return len(letters) >= 2 and all(c.isupper() for c in letters)


def _title_word(w):
    core = re.sub(r"[^\w]", "", w).lower()
    if core in SPECIAL_CASE:
        return w.lower().replace(core, SPECIAL_CASE[core])
    if core in ACRONYMS or any(ch.isdigit() for ch in w):
        return w
    out, up = [], True
    for i, ch in enumerate(w.lower()):
        out.append(ch.upper() if up and ch.isalpha() else ch)
        if ch.isalpha():
            up = False
        # A new word after a hyphen, slash or bracket; after the French elision l' or d'.
        if ch in "-/([" or (ch in "'\u2019" and i == 1 and w[0].lower() in "ld"):
            up = True
    return "".join(out)


def title_case(s):
    return " ".join(_title_word(w) for w in s.split(" "))


def display_brand(brand):
    known = BRAND_DISPLAY.get(shelf_identity.normalise_brand(brand))
    if known:
        return known
    if all_caps(brand) and sum(c.isalpha() for c in brand) > SHORT_BRAND:
        return title_case(brand)
    return brand


_BRAND_IN_NAME = [(re.compile(r"(?<![\w])" + re.escape(d) + r"(?![\w])", re.IGNORECASE), d)
                  for d in sorted(set(BRAND_DISPLAY.values()), key=len, reverse=True)]


def display_name(name):
    """All caps title-cased; a known brand inside the name in its own casing ("Cosrx" is COSRX)."""
    out = title_case(name) if all_caps(name) else name
    for pattern, display in _BRAND_IN_NAME:
        out = pattern.sub(display, out)
    return out


# First match wins. Face oil is last so "squalane gel moisturizer" is a moisturizer and "retinoid
# in squalane" is still an oil.
ORDER = [("sunscreen", SUNSCREEN), ("cleanser", CLEANSER), ("eye", EYE), ("spot", SPOT), ("toner", TONER),
         ("essence", ESSENCE), ("serum", SERUM), ("moisturizer", MOISTURIZER), ("face_oil", FACE_OIL)]


def classify(name, generic, tags):
    """The app category for an OBF row, or None when it is not face or skin care."""
    text = fold(" ".join([name, generic, " ".join(tags)]))
    if EXCLUDE.search(text):
        return None
    face = bool(FACE.search(text))
    if BODY.search(text) and not face:
        return None
    for category, pattern in ORDER:
        if pattern.search(text):
            return category
    if GENERIC_CREAM.search(text) and (face or CARE_CONTEXT.search(text)):
        return "moisturizer"
    return None


def first_brand(brands):
    """The first listed brand, cleaned, with a product-line brand mapped to its maker."""
    b = clean_text((brands or "").split(",")[0])
    return BRAND_ALIASES.get(shelf_identity.normalise_brand(b), b)


def product_name(row):
    return clean_text(row.get("product_name_en") or row.get("product_name") or "")


def ingredients(row):
    return (row.get("ingredients_text_en") or row.get("ingredients_text") or "").strip()


def identity_key(brand, name):
    b = shelf_identity.normalise_brand(brand)
    tokens = shelf_identity.name_tokens(name, brand)
    if not b or not tokens:
        return None
    return (b, frozenset(tokens))


def curated_keys():
    with CATALOG.open(newline="", encoding="utf-8") as f:
        return {k for r in csv.DictReader(f) if (k := identity_key(r["brand"], r["name"]))}


def rank(row):
    """Higher is better: a readable list, then completeness, then the latest edit, then the code."""
    return (row["actives_source"] == "inci", row["completeness"], row["last_modified_t"], row["source_code"])


def build(path):
    stats = collections.OrderedDict(raw=0, with_brand_and_name=0, filtered=0, filtered_with_ingredients=0,
                                    filtered_with_readable_list=0, by_category=collections.Counter(),
                                    by_category_with_ingredients=collections.Counter(), no_identity=0,
                                    not_skincare_dropped=0, duplicates_dropped=0, same_as_curated_dropped=0,
                                    brand_repaired=0, dropped_after_repair=0, kept=0,
                                    kept_by_category=collections.Counter(), kept_with_actives=0,
                                    kept_by_actives_source=collections.Counter(), cleaned_entities=0,
                                    cleaned_junk_prefix=0, brand_aliased=0, recased_brand=0, recased_name=0,
                                    sunscreen_from_category=0, sunscreen_inci_without_filter=0,
                                    top_brands=collections.Counter())
    groups = {}
    curated = curated_keys()
    opener = gzip.open if str(path).endswith(".gz") else open
    with opener(path, "rt", encoding="utf-8") as f:
        for line in f:
            stats["raw"] += 1
            d = json.loads(line)
            brand, name = first_brand(d.get("brands")), product_name(d)
            raw_brand = (d.get("brands") or "").split(",")[0].strip()
            raw_name = (d.get("product_name_en") or d.get("product_name") or "").strip()
            if not brand or not name:
                continue
            stats["with_brand_and_name"] += 1
            if shelf_identity.normalise_brand(brand) in EXCLUDE_BRANDS:
                continue
            category = classify(name, d.get("generic_name_en") or d.get("generic_name") or "", d.get("categories_tags") or [])
            if category is None:
                continue
            if not_skincare(brand, name, ingredients(d)):
                stats["not_skincare_dropped"] += 1
                continue
            stats["filtered"] += 1
            stats["by_category"][category] += 1
            text = ingredients(d)
            if text:
                stats["filtered_with_ingredients"] += 1
                stats["by_category_with_ingredients"][category] += 1
            parsed = inci_actives.parse(text)
            if parsed is not None:
                stats["filtered_with_readable_list"] += 1
            key = identity_key(brand, name)
            if key is None:
                stats["no_identity"] += 1
                continue
            actives_source = "inci" if parsed is not None else "none"
            actives = sorted(parsed) if parsed else []
            if category == "sunscreen" and parsed is None:
                actives, actives_source = ["sunscreen"], "category"
            row = {
                "source": "obf",
                "source_code": str(d.get("code") or d.get("_id")),
                "brand": display_brand(brand),
                "name": display_name(name),
                "category": category,
                "actives": json.dumps(actives),
                "actives_source": actives_source,
                "raw_brand": raw_brand,
                "raw_name": raw_name,
                "clean_brand": brand,
                "clean_name": name,
                "ingredients_text": text[:MAX_INGREDIENTS],
                "completeness": float(d.get("completeness") or 0),
                "last_modified_t": int(d.get("last_modified_t") or 0),
            }
            if key in curated:
                stats["same_as_curated_dropped"] += 1
                continue
            if key in groups:
                stats["duplicates_dropped"] += 1
                if rank(row) > rank(groups[key]):
                    groups[key] = row
            else:
                groups[key] = row
    rows, changes = cleanup(list(groups.values()), curated)
    stats["brand_repaired"] = len(changes["repaired"])
    stats["dropped_after_repair"] = len(changes["removed"])
    stats["kept"] = len(rows)
    for r in rows:
        stats["kept_by_category"][r["category"]] += 1
        stats["kept_by_actives_source"][r["actives_source"]] += 1
        stats["top_brands"][r["brand"]] += 1
        if r["actives"] != "[]":
            stats["kept_with_actives"] += 1
        if html.unescape(r["raw_brand"] + r["raw_name"]) != r["raw_brand"] + r["raw_name"]:
            stats["cleaned_entities"] += 1
        if _JUNK_PREFIX.match(html.unescape(r["raw_brand"])) or _JUNK_PREFIX.match(html.unescape(r["raw_name"])):
            stats["cleaned_junk_prefix"] += 1
        if shelf_identity.normalise_brand(r["clean_brand"]) != shelf_identity.normalise_brand(clean_text(r["raw_brand"])):
            stats["brand_aliased"] += 1
        stats["recased_brand"] += r["brand"] != r["clean_brand"]
        stats["recased_name"] += r["name"] != r["clean_name"]
        if r["actives_source"] == "category":
            stats["sunscreen_from_category"] += 1
        if r["category"] == "sunscreen" and r["actives_source"] == "inci" and "sunscreen" not in r["actives"]:
            stats["sunscreen_inci_without_filter"] += 1
    stats["top_brands"] = collections.Counter(dict(stats["top_brands"].most_common(30)))
    stats["short_or_numeric_brands"] = sorted({r["brand"] for r in rows
                                               if len(r["brand"]) < 3 or r["brand"].isdigit()})
    return rows, stats


def cleanup(rows, curated):
    """The AL item 3 pass over finished rows: drops what is not skincare, repairs brands, then
    drops what the repair made a duplicate of another row or of a curated product. Returns the
    rows, sorted, and what changed: {"removed": [(why, row)], "repaired": [(row before, row after)]}."""
    known = known_brands(rows)
    changes = {"removed": [], "repaired": []}
    kept = []
    for r in rows:
        why = not_skincare(r["brand"], r["name"], r.get("ingredients_text", ""))
        if why:
            changes["removed"].append((why, r))
            continue
        brand, name = repair_brand(r["brand"], r["name"], known)
        if (brand, name) != (r["brand"], r["name"]):
            if not name:
                changes["removed"].append(("brand only, no product name", r))
                continue
            fixed = {**r, "brand": brand, "name": display_name(name)}
            changes["repaired"].append((r, fixed))
            r = fixed
        kept.append(r)
    seen = {}
    out = []
    for r in sorted(kept, key=lambda r: (r["actives_source"] != "inci", r["source_code"])):
        key = identity_key(r["brand"], r["name"])
        if key in curated:
            changes["removed"].append(("same as a curated product after repair", r))
        elif key in seen:
            changes["removed"].append(("duplicate after repair", r))
        else:
            seen[key] = r
            out.append(r)
    out.sort(key=lambda r: (fold(r["brand"]), fold(r["name"]), r["source_code"]))
    return out, changes


def refilter(path):
    """Applies [cleanup] to an already built product_index.csv, so the filter can change without a
    new OBF download mixing new rows into the before and after."""
    with Path(path).open(newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    out, changes = cleanup(rows, curated_keys())
    return rows, out, changes


COLUMNS = ["source", "source_code", "brand", "name", "category", "actives", "actives_source", "ingredients_text"]


def write(rows):
    OUT.parent.mkdir(parents=True, exist_ok=True)
    with OUT.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=COLUMNS, extrasaction="ignore", lineterminator="\n")
        w.writeheader()
        w.writerows(rows)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("export", nargs="?", help="openbeautyfacts-products.jsonl(.gz)")
    ap.add_argument("--stats", help="write the counts as JSON here too")
    ap.add_argument("--refilter", action="store_true",
                    help="re-apply the cleanup to the committed product_index.csv instead of building from an export")
    ap.add_argument("--changes", help="with --refilter: write every removed and repaired row here as JSON")
    args = ap.parse_args()
    if args.refilter:
        before, rows, changes = refilter(OUT)
        write(rows)
        summary = {"before": len(before), "after": len(rows),
                   "removed_by_reason": dict(collections.Counter(why for why, _ in changes["removed"]).most_common()),
                   "repaired": len(changes["repaired"])}
        print(json.dumps(summary, indent=1))
        if args.changes:
            Path(args.changes).write_text(json.dumps({
                "summary": summary,
                "removed": [{"why": why, "source_code": r["source_code"], "brand": r["brand"], "name": r["name"],
                             "category": r["category"]} for why, r in changes["removed"]],
                "repaired": [{"source_code": a["source_code"], "before": [a["brand"], a["name"]],
                              "after": [b["brand"], b["name"]]} for a, b in changes["repaired"]],
            }, indent=1, ensure_ascii=False))
        return
    if not args.export:
        ap.error("an export file, or --refilter")
    rows, stats = build(args.export)
    write(rows)
    printable = {k: (dict(v.most_common()) if isinstance(v, collections.Counter) else v) for k, v in stats.items()}
    print(json.dumps(printable, indent=1))
    if args.stats:
        Path(args.stats).write_text(json.dumps(printable, indent=1))
    print(f"wrote {OUT.relative_to(REPO)}: {len(rows)} rows, {OUT.stat().st_size / 1e6:.1f} MB", file=sys.stderr)


if __name__ == "__main__":
    main()
