#!/usr/bin/env python3
"""The targeted-40 beauty list: which fragrances and skincare lines to price everywhere first.

Sources of truth: the catalogue rows already collected (Paris beauty with GTINs, the Athens
rows), the Shopify feeds already fetched, the Changi slugs already seeded, and, when their
refresh logs say finished, Dublin's product sitemap and one page-1 tile read per Avolta
beauty category. Nothing here writes to the database or contacts a host on its own: the
inputs are files this script is pointed at, so it costs no requests to re-run.

Method (build plan §7): every row is folded to brand + line + size, with the concentration
(EDP, EDT, EDC, parfum, mist) as a VETO inside a fold, never part of the key, exactly as the
identity rules will treat it after migration #3. Rows are then counted by distinct store; the
list is the 20 fragrance and 20 skincare folds seen at the most stores, GTIN-bearing folds
first at equal count, with the per-store names printed beside each so a human can see what
was joined and what was not.

    python3 main/scripts/beauty-candidates.py --catalogue paris.csv --shopify feeds.json \
        --changi .logs/runs/changi-targets-2026-09-05.md --out .logs/runs/beauty-candidates-<date>.md

The catalogue CSV is `brand,name,quantity_ml,gtin,category,code` for every beauty row (one
per product per shop), e.g. from
`psql --csv -c "select pr.brand, pr.name, pr.quantity_ml, pr.gtin, pr.category, l.code from ..."`.
"""

from __future__ import annotations

import argparse
import csv
import json
import re
import sys
import unicodedata
from collections import defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from app.services.normalize import brand_key, looks_like_set, parse_concentration, parse_quantity_ml  # noqa: E402

# Words that describe the format, the audience or the packaging, never the line.
_FORMAT_WORDS = {
    "eau", "de", "parfum", "toilette", "cologne", "edp", "edt", "edc", "extrait", "perfume",
    "spray", "vapo", "vaporisateur", "vaporizador", "natural", "rechargeable", "recargable",
    "refillable", "for", "pour", "ml", "cl", "oz", "fl", "the", "le", "la", "les", "and", "&",
    "with", "travel", "exclusive", "edition", "limited", "new", "mist", "brume", "bruma",
    "body", "hair", "cheveux", "corps", "jumbo", "repack",
}
# The audience IS part of the line ("Eternity for Men" and "Eternity for Women" are
# different bottles); spellings are folded so shops that write it differently agree.
_AUDIENCE = {"homme": "men", "man": "men", "him": "men", "femme": "women", "woman": "women",
             "her": "women", "damen": "women", "herren": "men"}
_PUNCT = re.compile(r"[^a-z0-9]+")
_SIZE_TOKEN = re.compile(r"\b\d+(?:[.,]\d+)?\s*(?:ml|cl|l|oz|g|gr)\b", re.I)
_FRAGRANCE_TYPE = re.compile(
    r"(perfum|parfum|fragan|fragr|cologne|unisex|body mist|bruma|brume|eau de)", re.I
)
_SKINCARE_TYPE = re.compile(
    r"(rostro|cuerpo|capilar|cabello|labios|ojos|crema|suero|serum|t[oó]nico|locion|lotion|"
    r"exfol|mascarilla|desmaq|limpiad|cleanser|sunscreen|protecci|after sun|u[nñ]as|nail|"
    r"cuidado|shampoo|conditioner|styling|hand|body|face|skin|mano|hair|sublimage|tratamiento)",
    re.I,
)
_SLUG_ID = re.compile(r"-mp\d+$")
_FRAGRANCE_SLUG = re.compile(r"(?:^|-)(edp|edt|edc|parfum|cologne|eau-de|mist|perfume)(?:-|$)")


def ascii_lower(text: str) -> str:
    return unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode().lower()


def product_line_key(brand: str | None, name: str) -> str:
    """The product line with brand, size, concentration and format words removed.

    Bare numbers stay: once the sized tokens are gone, a number is the line
    ("Cheirosa 62", "212", "No 5"), not a measurement.
    """
    text = ascii_lower(re.sub(r"[\u2122\u00ae\u00a9]", " ", name))
    text = _SIZE_TOKEN.sub(" ", text)
    words = [_AUDIENCE.get(w, w) for w in _PUNCT.sub(" ", text).split() if w]
    brand_words = set(brand_key(brand).split()) | set(ascii_lower(brand or "").replace("'", "").split())
    kept = [w for w in words if w not in _FORMAT_WORDS and w not in brand_words]
    return " ".join(kept)


def is_set(name: str) -> bool:
    return looks_like_set(name)


def kind_of(category: str | None, product_type: str | None, name: str) -> str | None:
    """fragrance | skincare | None, from our category first, then the shop's shelf name."""
    if category in ("Fragrance", "Skincare"):
        return category.lower()
    shelf = f"{product_type or ''}"
    if _FRAGRANCE_TYPE.search(shelf) or parse_concentration(name):
        return "fragrance"
    if _SKINCARE_TYPE.search(shelf):
        return "skincare"
    return None


class Row:
    __slots__ = ("store", "brand", "name", "quantity_ml", "gtin", "kind", "concentration")

    def __init__(self, store, brand, name, quantity_ml, gtin, kind):
        self.store, self.brand, self.name, self.quantity_ml, self.gtin, self.kind = (
            store, brand, name, quantity_ml, gtin, kind,
        )
        self.concentration = parse_concentration(name)


def load_catalogue(path: Path) -> list[Row]:
    rows = []
    with path.open(newline="") as fh:
        for rec in csv.DictReader(fh):
            size = int(rec["quantity_ml"]) if rec.get("quantity_ml") else parse_quantity_ml(rec["name"])
            kind = kind_of(rec.get("category"), None, rec["name"])
            if kind is None:
                continue
            rows.append(Row(rec["code"], rec["brand"], rec["name"], size, rec.get("gtin") or None, kind))
    return rows


def load_shopify(path: Path) -> list[Row]:
    rows = []
    feeds = json.loads(path.read_text())
    for code, shop in feeds.items():
        for product in shop["product_variants"]:
            kind = kind_of(None, product.get("product_type"), product.get("title") or "")
            if kind is None:
                continue
            title = (product.get("title") or "").strip()
            for variant in product.get("variants") or []:
                vt = (variant.get("title") or "").strip()
                name = f"{title} {vt}" if vt and vt.lower() != "default title" else title
                price = variant.get("price")
                if price in (None, "", "0.00"):
                    continue
                rows.append(Row(code, product.get("vendor"), name, parse_quantity_ml(name), None, kind))
    return rows


def load_changi(path: Path, brands: list[str]) -> list[Row]:
    """Beauty slugs from the seeded targets file, brand read from the slug head."""
    rows = []
    text = path.read_text()
    section = text.split("## Beauty slugs", 1)
    if len(section) < 2:
        return rows
    known = sorted(((ascii_lower(b).replace(" ", "-").replace("'", "-"), b) for b in brands), key=lambda p: -len(p[0]))
    for line in section[1].splitlines():
        if not line.startswith("- "):
            continue
        slug = _SLUG_ID.sub("", line[2:].strip())
        brand = next((b for key, b in known if slug.startswith(key)), None)
        if brand is None:
            continue
        rest = slug[len(ascii_lower(brand).replace(" ", "-").replace("'", "-")):].strip("-")
        name = rest.replace("--", " ").replace("-", " ")
        kind = "fragrance" if _FRAGRANCE_SLUG.search(slug) else "skincare"
        rows.append(Row("SIN", brand, name, parse_quantity_ml(name), None, kind))
    return rows


def dublin_slugs(path: Path) -> list[str]:
    """Beauty product slugs from a saved sitemap. This retailer's slug omits the brand
    ("red-door-eau-de-toilette-100ml"), so these rows cannot be folded on their own; they
    attach to a fold built from branded rows when the slug carries the fold's line words
    and size (`attach_brandless`)."""
    from app.services.collectors.ari import vertical_of_url
    out = []
    for loc in re.findall(r"<loc>(.*?)</loc>", path.read_text()):
        if vertical_of_url(loc) != "beauty":
            continue
        out.append(loc.split("?", 1)[0].rsplit("/", 2)[-2])
    return out


def attach_brandless(candidates: list[dict], store: str, slugs: list[str]) -> int:
    """Add `store` to every candidate whose line words and size a brandless slug carries,
    when exactly one brand's candidate matches (an ambiguous slug attaches nowhere)."""
    added = 0
    by_size: dict[int | None, list[dict]] = defaultdict(list)
    for c in candidates:
        by_size[c["quantity_ml"]].append(c)
    for slug in slugs:
        text = slug.replace("-", " ")
        size = parse_quantity_ml(text)
        words = set(ascii_lower(text).split())
        hits = [c for c in by_size.get(size, []) if c["line_words"] <= words and any(len(w) >= 5 for w in c["line_words"])]
        if len({c["brand"] for c in hits}) != 1:
            continue
        for c in hits:
            if store not in c["stores"]:
                c["stores"].append(store)
                c["stores"].sort()
                c["names"].append(f"{store}: (slug) {slug}")
                added += 1
    return added


def fold(rows: list[Row]) -> dict[tuple, list[Row]]:
    groups: dict[tuple, list[Row]] = defaultdict(list)
    for row in rows:
        if is_set(row.name):
            continue
        bk, lk = brand_key(row.brand), product_line_key(row.brand, row.name)
        if not bk or not lk:
            continue
        groups[(row.kind, bk, lk, row.quantity_ml)].append(row)
    return groups


def split_by_concentration(rows: list[Row]) -> list[list[Row]]:
    """The veto: rows that declare different concentrations are different product_variants;
    a row that declares none joins the largest cluster."""
    clusters: dict[str | None, list[Row]] = defaultdict(list)
    for row in rows:
        clusters[row.concentration].append(row)
    unknown = clusters.pop(None, [])
    if not clusters:
        return [unknown]
    ordered = sorted(clusters.values(), key=len, reverse=True)
    ordered[0].extend(unknown)
    return ordered


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--catalogue", type=Path, required=True)
    ap.add_argument("--shopify", type=Path)
    ap.add_argument("--changi", type=Path)
    ap.add_argument("--dublin", type=Path, help="a saved product sitemap (optional)")
    ap.add_argument("--out", type=Path, required=True)
    ap.add_argument("--json", type=Path, help="machine-readable list for the targeted fetch")
    ap.add_argument("--per-kind", type=int, default=20)
    args = ap.parse_args()

    rows = load_catalogue(args.catalogue)
    brands = sorted({r.brand for r in rows if r.brand})
    if args.shopify:
        rows += load_shopify(args.shopify)
    if args.changi:
        rows += load_changi(args.changi, brands)

    candidates = []
    for (kind, bk, lk, size), group in fold(rows).items():
        for cluster in split_by_concentration(group):
            stores = sorted({r.store for r in cluster})
            gtin = next((r.gtin for r in cluster if r.gtin), None)
            candidates.append({
                "kind": kind, "brand": bk, "line": lk, "line_words": set(lk.split()), "quantity_ml": size,
                "concentration": next((r.concentration for r in cluster if r.concentration), None),
                "stores": stores, "gtin": gtin,
                "names": sorted({f"{r.store}: {r.brand} | {r.name}" for r in cluster}),
            })
    if args.dublin:
        attached = attach_brandless(candidates, "DUB", dublin_slugs(args.dublin))
        print(f"dublin: {attached} fold(s) gained the store from brandless slugs")
    for c in candidates:
        c.pop("line_words")
    candidates.sort(key=lambda c: (-len(c["stores"]), c["gtin"] is None, c["brand"], c["line"]))
    stores_seen = sorted({r.store for r in rows})
    picked = {kind: [c for c in candidates if c["kind"] == kind and c["quantity_ml"]][: args.per_kind]
              for kind in ("fragrance", "skincare")}

    lines = [f"# Beauty candidates, generated by `main/scripts/beauty-candidates.py`", ""]
    lines.append(f"Rows folded: {len(rows)} across stores {', '.join(stores_seen)}; folds: {len(candidates)}.")
    lines.append("Key = brand + line + size; concentration vetoes inside a fold and never keys.")
    lines.append("")
    for kind in ("fragrance", "skincare"):
        at3 = sum(1 for c in candidates if c["kind"] == kind and len(c["stores"]) >= 3)
        at2 = sum(1 for c in candidates if c["kind"] == kind and len(c["stores"]) >= 2)
        lines.append(f"## {kind.title()}: {at3} folds at 3+ stores, {at2} at 2+")
        lines.append("")
        lines.append("| stores | brand | line | size | conc. | GTIN | seen as |")
        lines.append("|---|---|---|---|---|---|---|")
        for c in picked[kind]:
            lines.append(
                f"| {len(c['stores'])} ({' '.join(c['stores'])}) | {c['brand']} | {c['line']} | "
                f"{c['quantity_ml'] or '?'} | {c['concentration'] or '-'} | {'yes' if c['gtin'] else 'no'} | "
                f"{'; '.join(c['names'][:4])} |"
            )
        lines.append("")
    args.out.write_text("\n".join(lines) + "\n")
    if args.json:
        args.json.write_text(json.dumps({"fragrance": picked["fragrance"], "skincare": picked["skincare"]}, indent=1))
    print(f"rows={len(rows)} folds={len(candidates)} -> {args.out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
