#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Superium — product data + image pipeline
========================================

Builds ``assets/data/products.json`` plus the WebP image set that every product
card in the template is generated from. Three interchangeable sources, one
output shape — swapping source is a single command and no page needs editing.

    --source snapp   real snapp.market catalogue: live Persian titles, real
                     prices and discounts, manufacturer photography.
                     Highest fidelity. See the rights note below.

    --source off     Open Food Facts / Open Beauty Facts / Open Products Facts,
                     paired with the Persian catalogue in tools/catalogue.py.
                     Real photography under CC-BY-SA (credited per file), but
                     crowd-sourced: expect phone snapshots with shop
                     backgrounds and uneven lighting rather than studio packs.

    --source art     Generated product art. No third-party content at all, so
                     nothing to attribute and nothing to clear. The safe
                     default if you are redistributing the template.

Rights note
-----------
snapp.market images are manufacturer product photography served from a
third-party CDN — the best-looking option, but not yours to redistribute.
Fine for local prototyping and client demos; before you sell or ship the
package, regenerate with ``--source art``, which produces artwork you own
outright. ``--source off`` is the middle road: real photos, open licence,
but visibly inconsistent quality.

Usage
-----
    python tools/fetch_products.py                     # snapp (default)
    python tools/fetch_products.py --source off
    python tools/fetch_products.py --source art
    python tools/fetch_products.py --check             # coverage report only
    python tools/fetch_products.py --limit 3           # quick dev run

Requires: requests, Pillow.
"""

from __future__ import annotations

import argparse
import io
import json
import math
import random
import re
import sys
import time
import unicodedata
from datetime import date
from pathlib import Path
from urllib.parse import quote

import requests
from PIL import Image, ImageDraw, ImageFilter

sys.path.insert(0, str(Path(__file__).resolve().parent))
from catalogue import CATEGORIES  # noqa: E402

# --------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------

ROOT = Path(__file__).resolve().parent.parent
IMG_DIR = ROOT / "assets" / "images" / "products"
CAT_DIR = ROOT / "assets" / "images" / "categories"
DATA_DIR = ROOT / "assets" / "data"
DOCS_DIR = ROOT / "docs"

TARGET_PER_CATEGORY = 20

UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)

# --------------------------------------------------------------------------
# snapp.market
# --------------------------------------------------------------------------

SNAPP_API = "https://svc.snapp.market/mobile/v3/product-vendors/search"
SNAPP_PARAMS = {
    "client": "WEBSITE",
    "appVersion": "8.1.1",
    "UDID": "superium-template-build",
    "lat": "35.7",
    "long": "51.4",
    "page_size": "20",
}
SNAPP_HEADERS = {
    "User-Agent": UA,
    "Referer": "https://snapp.market/",
    "Accept": "application/json, text/plain, */*",
}
SNAPP_PAGES = 4
# The endpoint throttles bursts hard (whole-host timeout for several minutes).
# One request every 1.2s has proven safe.
SNAPP_DELAY = 1.2

# --------------------------------------------------------------------------
# Open Food Facts family
# --------------------------------------------------------------------------

OFF_HEADERS = {
    "User-Agent": "Superium-Template/1.0 (RTL grocery HTML template build)",
    "Accept": "application/json",
}
OFF_DELAY = 1.5  # OFF asks clients to stay gentle

# Pet food and similar noise — never wanted in a household supermarket demo.
TITLE_BLOCKLIST = re.compile(r"(بچه گربه|گربه|سگ |حیوان|پت |طوطی|آکواریوم|دام و طیور)")

# Utensils and small appliances. A query like "ماکارونی" also matches
# "قاشق ماکارونی", and "آبمیوه" matches "آبمیوه‌گیری" — correct for the
# retailer, wrong for a food shelf. Allowed through in the "home" category,
# which is where this sort of thing belongs.
GADGET_BLOCKLIST = re.compile(
    r"(قاشق|چنگال|آبمیوه ?گیری|چای ?ساز|قهوه ?ساز|همزن|سرخ ?کن|کتری|ماگ|"
    r"رنده|آبکش|درب ?باز ?کن|بند ?رخت|جا ?ادویه|فلاسک|پارچ|سینی|قابلمه|"
    r"ماهیتابه|چاقو|تخته ?خرد|شیکر|بطری ?آب|لیوان|فنجان|اسکوپ|ملاقه|قیف|سوت ?زن|صافی|ترازو|چرخ ?گوشت)"
)

# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------


def log(msg: str = "") -> None:
    print(msg, flush=True)


LATIN_TO_PERSIAN = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")


def clean_title(title: str) -> str:
    """
    Normalise a supplier title for a Persian shelf: Arabic letters to their
    Persian equivalents, collapsed whitespace, and Latin numerals converted to
    Persian ones so "برنج هندی 500 گرمی" reads "برنج هندی ۵۰۰ گرمی" — matching
    the prices, which are Persian everywhere else in the UI.
    """
    title = title.replace("ي", "ی").replace("ك", "ک")
    title = re.sub(r"\s+", " ", title).strip()
    return title.translate(LATIN_TO_PERSIAN)


def to_persian_digits(value) -> str:
    return f"{int(value):,}".translate(str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹"))


def session_no_proxy() -> requests.Session:
    """
    This machine has a system-wide SOCKS proxy that requests cannot use
    (no PySocks) and that these hosts do not need. Ignore the environment.
    """
    s = requests.Session()
    s.trust_env = False
    return s


def synth_price(rnd: random.Random) -> tuple[int, int, int]:
    """A plausible Toman price with an occasional discount."""
    price = rnd.choice(
        [29, 35, 45, 58, 68, 89, 105, 118, 135, 155, 188, 219, 265, 320, 398]
    ) * 1000
    ratio = rnd.choice([0, 0, 0, 0, 5, 8, 10, 12, 16, 22, 28])
    discount = round(price * ratio / 100 / 500) * 500
    return price, discount, ratio


# --------------------------------------------------------------------------
# Source: snapp.market
# --------------------------------------------------------------------------


def snapp_search(session: requests.Session, query: str, page: int) -> list[dict]:
    params = dict(SNAPP_PARAMS, query=query, page=str(page))
    url = SNAPP_API + "?" + "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
    try:
        res = session.get(url, headers=SNAPP_HEADERS, timeout=25)
    except requests.RequestException:
        return []
    if res.status_code != 200 or not res.content:
        return []
    try:
        return res.json().get("items", []) or []
    except ValueError:
        return []


def collect_snapp(session: requests.Session, want: int) -> list[dict]:
    products: list[dict] = []
    seen_ids: set[int] = set()
    seen_titles: set[str] = set()
    dead_streak = 0

    for cat in CATEGORIES:
        got = 0
        log(f"\n  {cat['icon']}  {cat['title']}  ({cat['slug']})")

        # Give every query its own slice of the quota. Without this the first
        # query fills the category on its own — six kinds of rice and no pasta.
        per_query = max(2, -(-want // len(cat["queries"])))

        for qi, query in enumerate(cat["queries"]):
            if got >= want:
                break
            # Later queries may take what earlier ones left unused.
            ceiling = min(want, per_query * (qi + 1))

            for page in range(SNAPP_PAGES):
                if got >= ceiling:
                    break

                items = snapp_search(session, query, page)
                time.sleep(SNAPP_DELAY)

                if not items:
                    dead_streak += 1
                    # 12 empty responses in a row means the host has cut us off;
                    # continuing just burns time.
                    if dead_streak >= 12:
                        log("      ! snapp.market is not responding — aborting.")
                        log("        Retry later, or use --source off / art.")
                        return products
                    continue
                dead_streak = 0

                for item in items:
                    if got >= ceiling:
                        break
                    pid = item.get("id")
                    title = clean_title(item.get("title") or "")
                    price = item.get("price") or 0
                    images = item.get("images") or []

                    if not pid or pid in seen_ids or not title or price <= 0:
                        continue
                    if not images or TITLE_BLOCKLIST.search(title):
                        continue
                    if cat["slug"] != "home" and GADGET_BLOCKLIST.search(title):
                        continue
                    key = title[:26]
                    if key in seen_titles:
                        continue
                    src = images[0].get("main") or images[0].get("thumb")
                    if not src:
                        continue

                    seen_ids.add(pid)
                    seen_titles.add(key)
                    got += 1
                    discount = int(item.get("discount") or 0)
                    products.append(
                        {
                            "id": f"{cat['slug']}-{pid}",
                            "title": title,
                            "category": cat["slug"],
                            "categoryTitle": cat["title"],
                            "price": int(price),
                            "discount": discount,
                            "discountRatio": int(item.get("discountRatio") or 0),
                            "final": int(price) - discount,
                            "_src": src,
                        }
                    )

        log(f"      collected {got}")

    return products


# --------------------------------------------------------------------------
# Source: Open Food Facts family
# --------------------------------------------------------------------------


def off_search(session: requests.Session, host: str, tag: str) -> list[dict]:
    url = (
        f"https://{host}/api/v2/search?categories_tags_en={quote(tag)}"
        "&fields=code,product_name,brands,image_front_url"
        "&page_size=40&sort_by=unique_scans_n"
    )
    try:
        res = session.get(url, headers=OFF_HEADERS, timeout=35)
    except requests.RequestException:
        return []
    if res.status_code != 200:
        return []
    try:
        return res.json().get("products", []) or []
    except ValueError:
        return []


def collect_off(session: requests.Session, want: int) -> list[dict]:
    """OFF supplies the photography; catalogue.py supplies the Persian copy."""
    products: list[dict] = []
    rnd = random.Random(20260806)
    used_images: set[str] = set()

    for cat in CATEGORIES:
        host = cat.get("off_host", "world.openfoodfacts.org")
        names = cat["items"][:want]
        pool: list[dict] = []

        log(f"\n  {cat['icon']}  {cat['title']}  ({cat['slug']})")

        for tag in cat.get("off", []):
            if len(pool) >= len(names):
                break
            for p in off_search(session, host, tag):
                img = p.get("image_front_url")
                if not img or img in used_images:
                    continue
                used_images.add(img)
                pool.append({"img": img, "code": p.get("code", "")})
            time.sleep(OFF_DELAY)

        if not pool:
            log("      ! no images returned for this category")
            continue

        for i, name in enumerate(names):
            shot = pool[i % len(pool)]
            price, discount, ratio = synth_price(rnd)
            products.append(
                {
                    "id": f"{cat['slug']}-{i + 1:02d}",
                    "title": name,
                    "category": cat["slug"],
                    "categoryTitle": cat["title"],
                    "price": price,
                    "discount": discount,
                    "discountRatio": ratio,
                    "final": price - discount,
                    "_src": shot["img"],
                    "_ref": f"https://{host}/product/{shot['code']}",
                }
            )

        log(f"      collected {min(len(names), len(names))} (pool of {len(pool)} photos)")

    return products


# --------------------------------------------------------------------------
# Source: generated art
# --------------------------------------------------------------------------

ART_PALETTES = {
    "dairy": ((246, 250, 255), (86, 132, 199)),
    "produce": ((240, 250, 240), (72, 158, 88)),
    "drinks": ((240, 249, 252), (36, 145, 175)),
    "snacks": ((253, 246, 236), (196, 118, 46)),
    "pantry": ((252, 248, 238), (176, 143, 62)),
    "breakfast": ((254, 247, 235), (214, 148, 44)),
    "cleaning": ((240, 250, 251), (55, 160, 162)),
    "baby": ((253, 243, 248), (203, 106, 152)),
    "frozen": ((240, 247, 253), (78, 129, 190)),
    "home": ((246, 247, 249), (114, 123, 138)),
}


def draw_art(slug: str, seed: int, size: int = 600) -> Image.Image:
    """
    A soft, on-brand package silhouette. Not a photograph and not pretending to
    be one — it reads as deliberate placeholder art at card size.
    """
    bg, ink = ART_PALETTES.get(slug, ((245, 246, 248), (120, 128, 140)))
    rnd = random.Random(seed)
    img = Image.new("RGB", (size, size), bg)
    d = ImageDraw.Draw(img, "RGBA")

    # Backdrop disc
    r = int(size * 0.36)
    c = size // 2
    d.ellipse((c - r, c - r, c + r, c + r), fill=(*ink, 22))

    shape = rnd.choice(["bottle", "carton", "tub", "bag", "box"])
    w = int(size * rnd.uniform(0.26, 0.34))
    h = int(size * rnd.uniform(0.40, 0.52))
    x0, y0 = c - w // 2, c - h // 2 + int(size * 0.03)
    x1, y1 = x0 + w, y0 + h
    body = (*ink, 235)

    if shape == "bottle":
        neck_w = int(w * 0.34)
        d.rounded_rectangle(
            (c - neck_w // 2, y0 - int(h * 0.20), c + neck_w // 2, y0 + int(h * 0.10)),
            radius=neck_w // 3,
            fill=body,
        )
        d.rounded_rectangle((x0, y0, x1, y1), radius=int(w * 0.22), fill=body)
    elif shape == "carton":
        d.polygon(
            [(x0, y0 + int(h * 0.16)), (c, y0 - int(h * 0.04)), (x1, y0 + int(h * 0.16))],
            fill=body,
        )
        d.rectangle((x0, y0 + int(h * 0.16), x1, y1), fill=body)
    elif shape == "tub":
        d.polygon([(x0, y0), (x1, y0), (x1 - w * 0.10, y1), (x0 + w * 0.10, y1)], fill=body)
        d.rounded_rectangle(
            (x0 - w * 0.06, y0 - h * 0.09, x1 + w * 0.06, y0 + h * 0.05),
            radius=int(w * 0.10),
            fill=body,
        )
    elif shape == "bag":
        d.rounded_rectangle((x0, y0, x1, y1), radius=int(w * 0.30), fill=body)
        d.rectangle((x0 + w * 0.18, y0 - h * 0.06, x1 - w * 0.18, y0 + h * 0.06), fill=body)
    else:
        d.rounded_rectangle((x0, y0, x1, y1), radius=int(w * 0.10), fill=body)

    # Label band + two rules, so the silhouette reads as packaging.
    band_y = y0 + int(h * rnd.uniform(0.42, 0.56))
    d.rectangle((x0, band_y, x1, band_y + int(h * 0.20)), fill=(255, 255, 255, 210))
    d.rectangle(
        (x0 + w * 0.16, band_y + h * 0.06, x1 - w * 0.16, band_y + h * 0.085),
        fill=(*ink, 150),
    )
    d.rectangle(
        (x0 + w * 0.26, band_y + h * 0.115, x1 - w * 0.26, band_y + h * 0.135),
        fill=(*ink, 95),
    )

    # Contact shadow
    shadow = Image.new("RGBA", img.size, (0, 0, 0, 0))
    ImageDraw.Draw(shadow).ellipse(
        (c - w * 0.62, y1 - h * 0.03, c + w * 0.62, y1 + h * 0.09), fill=(*ink, 60)
    )
    shadow = shadow.filter(ImageFilter.GaussianBlur(size * 0.022))
    img = Image.alpha_composite(img.convert("RGBA"), shadow).convert("RGB")

    return img


def collect_art(want: int) -> list[dict]:
    rnd = random.Random(20260806)
    products: list[dict] = []
    for cat in CATEGORIES:
        log(f"  {cat['icon']}  {cat['title']}  ({cat['slug']})")
        for i, name in enumerate(cat["items"][:want]):
            price, discount, ratio = synth_price(rnd)
            products.append(
                {
                    "id": f"{cat['slug']}-{i + 1:02d}",
                    "title": name,
                    "category": cat["slug"],
                    "categoryTitle": cat["title"],
                    "price": price,
                    "discount": discount,
                    "discountRatio": ratio,
                    "final": price - discount,
                    "_art": True,
                }
            )
    return products


# --------------------------------------------------------------------------
# Images
# --------------------------------------------------------------------------


def save_pair(img: Image.Image, category: str, index: int) -> None:
    """
    Write 600px and 300px square WebP, product centred on white, into
    assets/images/products/<category>/NN.webp — a browsable library where
    swapping a photo means dropping a file at the same path.
    """
    if img.mode in ("RGBA", "LA", "P"):
        img = img.convert("RGBA")
        flat = Image.new("RGB", img.size, (255, 255, 255))
        flat.paste(img, mask=img.split()[-1])
        img = flat
    else:
        img = img.convert("RGB")

    folder = IMG_DIR / category
    folder.mkdir(parents=True, exist_ok=True)
    for size, suffix in ((600, ""), (300, "-sm")):
        copy = img.copy()
        copy.thumbnail((size, size), Image.LANCZOS)
        square = Image.new("RGB", (size, size), (255, 255, 255))
        square.paste(copy, ((size - copy.width) // 2, (size - copy.height) // 2))
        square.save(folder / f"{index:02d}{suffix}.webp", "WEBP", quality=80, method=5)


def build_images(
    session: requests.Session, products: list[dict], credits: list[dict]
) -> list[dict]:
    IMG_DIR.mkdir(parents=True, exist_ok=True)
    kept: list[dict] = []
    total = len(products)
    seq: dict[str, int] = {}

    for i, p in enumerate(products, 1):
        cat = p["category"]
        src = p.pop("_src", None)
        ref = p.pop("_ref", None)
        is_art = p.pop("_art", False)

        index = seq.get(cat, 0) + 1
        ok = False

        if is_art:
            save_pair(draw_art(cat, abs(hash(p["id"])) % 10**6), cat, index)
            ok = True
        else:
            try:
                res = session.get(src, headers={"User-Agent": UA}, timeout=35)
                if res.status_code == 200 and res.content:
                    img = Image.open(io.BytesIO(res.content))
                    img.load()
                    save_pair(img, cat, index)
                    ok = True
            except Exception:  # noqa: BLE001 — any failure is just a skip
                ok = False
            time.sleep(0.2)

        if not ok:
            continue

        # Only advance the sequence for images that actually landed, so the
        # library never has gaps.
        seq[cat] = index
        rel = f"assets/images/products/{cat}/{index:02d}"
        credits.append(
            {
                "file": f"{cat}/{index:02d}.webp",
                "source": ref or src or "generated by tools/fetch_products.py",
                "fetched": str(date.today()),
            }
        )
        p["image"] = f"{rel}.webp"
        p["thumb"] = f"{rel}-sm.webp"
        kept.append(p)

        if i % 25 == 0 or i == total:
            log(f"    images {i}/{total}")

    return kept


def interleave(products: list[dict]) -> list[dict]:
    """
    Spread each category's products so neighbours differ.

    Queries return in blocks — six kinds of rice, then six kinds of pasta —
    which makes a horizontal rail look like a mistake. Grouping by the first
    word of the title and dealing round-robin fixes that without shuffling
    away the ranking inside each group.
    """
    out: list[dict] = []

    for cat in CATEGORIES:
        rows = [p for p in products if p["category"] == cat["slug"]]
        groups: dict[str, list[dict]] = {}
        for p in rows:
            groups.setdefault(p["title"].split(" ")[0], []).append(p)

        buckets = list(groups.values())
        while any(buckets):
            for bucket in buckets:
                if bucket:
                    out.append(bucket.pop(0))

    # Anything in an unknown category still ships, just at the end.
    known = {p["id"] for p in out}
    out.extend(p for p in products if p["id"] not in known)
    return out


def prune_orphans(products: list[dict]) -> int:
    """
    Delete image files no longer referenced by the catalogue. Without this,
    every re-crawl leaves the previous run's photos behind and the package
    grows without bound.
    """
    keep = set()
    for p in products:
        keep.add(Path(p["image"]).relative_to("assets/images/products").as_posix())
        keep.add(Path(p["thumb"]).relative_to("assets/images/products").as_posix())

    removed = 0
    for f in IMG_DIR.rglob("*.webp"):
        if f.relative_to(IMG_DIR).as_posix() not in keep:
            f.unlink()
            removed += 1
    return removed


def build_category_images(products: list[dict]) -> None:
    """
    The category tile reuses a product photo. Each category names a canonical
    item in catalogue.py ("tile"); we take the plainest product matching it, so
    the shelf reads as milk, tomatoes, rice — not whichever branded oddity
    happened to sort first.
    """
    CAT_DIR.mkdir(parents=True, exist_ok=True)
    for cat in CATEGORIES:
        rows = [p for p in products if p["category"] == cat["slug"]]
        if not rows:
            continue
        keyword = cat.get("tile")
        candidates = [p for p in rows if keyword and keyword in p["title"]] or rows
        # Shortest matching title is the plainest version of the canonical item.
        pick = min(candidates, key=lambda p: len(p["title"]))
        src = ROOT / pick["image"]
        if src.exists():
            Image.open(src).save(CAT_DIR / f"{cat['slug']}.webp", "WEBP", quality=82, method=5)


# --------------------------------------------------------------------------
# Output
# --------------------------------------------------------------------------

RIGHTS_NOTE = {
    "snapp": [
        "> **هشدار حقوقی.** این تصاویر، عکاسی محصولِ تولیدکنندگان است و از CDN",
        "> اسنپ‌مارکت دریافت شده. برای نمونه‌سازی و ارائه به کارفرما بی‌اشکال است،",
        "> اما پیش از **فروش یا انتشار مجدد** قالب، مجموعه تصاویر را بازتولید کنید:",
        ">",
        "> ```bash",
        "> python tools/fetch_products.py --source art",
        "> ```",
        "",
        "> **Legal note.** Manufacturer product photography retrieved from",
        "> snapp.market's CDN. Fine for prototyping; regenerate with",
        "> `--source art` before redistributing.",
    ],
    "off": [
        "تصاویر از پروژه‌های Open Food Facts / Open Beauty Facts / Open Products",
        "Facts دریافت شده‌اند و تحت لایسنس **CC-BY-SA 3.0** هستند. لینک صفحه‌ی",
        "هر محصول در جدول زیر آمده است. در صورت استفاده‌ی تجاری، شرط",
        "«اشتراک‌گذاری همانند» را رعایت کنید.",
        "",
        "Images from the Open Food Facts family, licensed CC-BY-SA 3.0.",
        "Per-file product page links below; honour the share-alike term.",
    ],
    "art": [
        "تمام تصاویر توسط `tools/fetch_products.py` تولید شده‌اند. هیچ محتوای",
        "شخص ثالثی در پکیج نیست و نیازی به ذکر منبع یا تسویه‌ی حقوقی ندارد.",
        "",
        "All artwork generated by `tools/fetch_products.py`. No third-party",
        "content — nothing to attribute, nothing to clear.",
    ],
}


def write_outputs(products: list[dict], credits: list[dict], source: str) -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    DOCS_DIR.mkdir(parents=True, exist_ok=True)

    payload = {
        "generated": str(date.today()),
        "source": source,
        "currency": "toman",
        "note": (
            "price / discount / final are Toman. Templates render them with "
            "Persian numerals and thousands separators."
        ),
        "categories": [
            {"slug": c["slug"], "title": c["title"], "icon": c["icon"]}
            for c in CATEGORIES
        ],
        "products": products,
    }
    (DATA_DIR / "products.json").write_text(
        json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
    )

    lines = [
        "# اعتبار تصاویر — Image credits",
        "",
        f"تولید: {date.today()}  ·  منبع: `{source}`  ·  {len(credits)} فایل",
        "",
    ]
    lines += RIGHTS_NOTE[source]
    lines += ["", "| فایل | منبع | تاریخ |", "|---|---|---|"]
    lines += [f"| `{c['file']}` | {c['source']} | {c['fetched']} |" for c in credits]
    (DOCS_DIR / "image-credits.md").write_text("\n".join(lines) + "\n", encoding="utf-8")


def report_coverage() -> int:
    path = DATA_DIR / "products.json"
    if not path.exists():
        log("✗ assets/data/products.json missing — run the pipeline first.")
        return 1

    data = json.loads(path.read_text(encoding="utf-8"))
    products = data["products"]
    log(f"source: {data['source']}   generated: {data['generated']}")
    log(f"products: {len(products)}\n")

    problems = 0
    for cat in CATEGORIES:
        rows = [p for p in products if p["category"] == cat["slug"]]
        missing = [p for p in rows if not (ROOT / p["image"]).exists()]
        flag = "ok " if len(rows) >= 6 and not missing else "LOW"
        if flag == "LOW":
            problems += 1
        extra = f"   {len(missing)} missing images" if missing else ""
        log(f"  [{flag}] {cat['title']:<22} {len(rows):>3} products{extra}")

    files = list(IMG_DIR.rglob("*.webp"))
    mb = sum(f.stat().st_size for f in files) / 1_048_576
    log(f"\nimage set: {len(files)} files, {mb:.1f} MB")
    return 1 if problems else 0


# --------------------------------------------------------------------------
# Entry point
# --------------------------------------------------------------------------


def main() -> int:
    ap = argparse.ArgumentParser(description="Superium product / image pipeline")
    ap.add_argument(
        "--source",
        choices=("snapp", "off", "art"),
        default="snapp",
        help="where product data and photography come from (default: snapp)",
    )
    ap.add_argument("--check", action="store_true", help="coverage report only")
    ap.add_argument(
        "--reorder",
        action="store_true",
        help="re-interleave the existing catalogue and rebuild category tiles "
        "— local only, no network",
    )
    ap.add_argument("--limit", type=int, help="products per category (dev runs)")
    args = ap.parse_args()

    if args.check:
        return report_coverage()

    if args.reorder:
        path = DATA_DIR / "products.json"
        if not path.exists():
            log("✗ assets/data/products.json missing.")
            return 1
        data = json.loads(path.read_text(encoding="utf-8"))
        before = len(data["products"])
        data["products"] = [
            p
            for p in data["products"]
            if not TITLE_BLOCKLIST.search(p["title"])
            and (p["category"] == "home" or not GADGET_BLOCKLIST.search(p["title"]))
        ]
        for row in data["products"]:
            row["title"] = clean_title(row["title"])
        dropped = before - len(data["products"])
        if dropped:
            log(f"  dropped {dropped} product(s) now caught by the blocklists")
        data["products"] = interleave(data["products"])
        path.write_text(
            json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
        )
        prune_orphans(data["products"])
        build_category_images(data["products"])
        log(f"✓ reordered {len(data['products'])} products, rebuilt category tiles")
        return 0

    want = args.limit or TARGET_PER_CATEGORY
    log(f"Superium product pipeline — source: {args.source}, {want}/category")

    session = session_no_proxy()

    if args.source == "snapp":
        products = collect_snapp(session, want)
    elif args.source == "off":
        products = collect_off(session, want)
    else:
        products = collect_art(want)

    if not products:
        log("\n✗ nothing collected — outputs left untouched.")
        return 1

    log(f"\n  {len(products)} products; building images ...")
    credits: list[dict] = []
    products = build_images(session, products, credits)

    if not products:
        log("\n✗ no images could be built — outputs left untouched.")
        return 1

    products = interleave(products)
    orphans = prune_orphans(products)
    if orphans:
        log(f"  pruned {orphans} orphaned image(s) from earlier runs")
    build_category_images(products)
    write_outputs(products, credits, args.source)

    files = list(IMG_DIR.rglob("*.webp"))
    mb = sum(f.stat().st_size for f in files) / 1_048_576
    log(f"\n✓ {len(products)} products · {len(files)} images · {mb:.1f} MB")
    log("  assets/data/products.json")
    log("  docs/image-credits.md")
    return 0


if __name__ == "__main__":
    sys.exit(main())
