"""The client's picture folder and a brand-owner's product export, classified from names alone into a reviewable manifest (Stream AW3.1).

Pure: nothing here reads a picture's bytes or the network. `classify_file` reads a path and a
file name; `classify_csv_row` reads one row of the Pernod Ricard USA export; `match` resolves a
classified picture to a brand, a product line and a product variant on the catalogue, precision
first; `build` chooses one picture per target and writes every decision, taken or refused, with
its reason, so a person can flip `chosen` on the JSON before `images stage` (AW3.3) copies
anything. Empty beats guessed: an ambiguous name, a brand that resolves to nothing, or two
candidates of equal quality all leave the target empty with the reason recorded.

Why names alone: the folder holds 73 files up to 14 MB and the export 437 addresses; reading
pixels to decide what a picture depicts would be a judgement the reviewer cannot audit, while a
name is a fact the supplier wrote. What the names say was checked against ten opened files on 19
Sep (`.logs/handoff.md`): `BOTTLE_TUBE` is the tube alone, `BWC` is bottle with canister, `GTR`
a clean bottle, `RACK` a bottle in an open box; the rules below encode the brief's readings.
"""

from __future__ import annotations

import csv
import re
import unicodedata
from collections import Counter
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Iterable

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Brand, ProductLine, ProductVariant
from app.services import keying, product_lines
from app.services.normalize import clean_gtin

#: What a picture depicts: a brand mark, or a bottle (a product variant, usable for its line).
LEVELS = ("brand", "line", "variant")
#: Best first: a cut-out bottle, a bottle with its box or tube, a plain shop-style bottle, a scene.
QUALITY_RANK = {"transparent": 4, "boxed": 3, "ecommerce": 2, "lifestyle": 1}

#: The words a supplier's file name uses for the shot, none of which names a product line.
_VIEW_WORDS = frozenset({
    "bottle", "bottles", "shot", "front", "back", "transparent", "tube", "box", "pack", "carton",
    "close", "up", "closeup", "group", "icons", "lay", "side", "angle", "open", "with", "floor",
    "fop", "bwc", "label", "gtr", "usa", "copy", "of", "on", "and", "noshadow", "rack", "image",
    "lifestyle", "hand", "sash", "wrap", "sleeve", "gift", "the",
})
_UNUSABLE_WORDS = {
    "back": "a back view",
    "closeup": "a close-up",
    "icons": "the icons panel",
    "lay": "a lay-back shot",
    "group": "a group shot",
    "bwc": "the BWC view (not a front bottle shot by name)",
}
_PACKAGING_WORDS = frozenset({"box", "pack", "carton", "tube"})
_WITH_WORDS = frozenset({"and", "with"})
_VARIANT_ONLY_WORDS = frozenset({"hand", "sash", "wrap", "sleeve", "gift"})
_TRANSPARENT_WORDS = frozenset({"transparent", "noshadow"})

_SIZE_RE = re.compile(r"(?<![0-9.])(\d+(?:[.,]\d+)?)\s*(ml|cl|l)(?![a-z])", re.I)
_DIGITS_RE = re.compile(r"(?<!\d)(\d{12,14})(?!\d)")
_DIMENSIONS_RE = re.compile(r"\b\d+x\d+\b", re.I)
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
_SPLIT_RE = re.compile(r"[^a-z0-9]+")
_AGE_TOKEN_RE = re.compile(r"^(\d{1,2})yo$")
_IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"})
_ML = {"ml": 1, "cl": 10, "l": 1000}

#: The supplier a folder's top segment names, by a word in that segment (folded); the default is
#: the client's own upload. The vocabulary is `admin:<supplier-slug>` (the brief's provenance).
SUPPLIERS: tuple[tuple[str, str, str], ...] = (
    ("professor imagery", "william-grant-via-adam", "William Grant & Sons, supplied by the client"),
    ("bottle images", "bruichladdich-via-adam", "Bruichladdich, supplied by the client"),
)
DEFAULT_SUPPLIER = ("client-via-adam", "Supplied by the client")
CSV_SUPPLIER = ("pernod-ricard-via-adam", "Pernod Ricard USA product export, supplied by the client")
LICENCE = "Brand-supplied; permission held by the client"

#: The export's columns that are read. Nothing else in the file is used.
CSV_BRAND = "Core Data - Marketing Brand (Computed)"
CSV_TITLE = "Product Title"
CSV_UPC = "Core Data - UPC"
CSV_GTIN = "Core Data - GTIN"
CSV_NET = "Net Content (Computed)"
CSV_FRONT = "Digital Assets - Front Bottle Image"
CSV_LOGO = "Digital Assets - Brand Logo"


@dataclass(frozen=True)
class Classified:
    """One picture read from its name: what it depicts, how well, and the facts the name states."""

    source_path: str | None = None
    source_url: str | None = None
    level: str = "variant"
    view: str | None = None
    quality: str | None = None
    gtin: str | None = None
    alt_gtins: tuple[str, ...] = ()
    quantity_ml: int | None = None
    brand_words: str = ""
    #: Texts to read a product line from, tried in order: the line folder's name, then the file's.
    line_words: tuple[str, ...] = ()
    usable: bool = False
    reason: str | None = None
    #: A clean bottle serves its line; a hand, a sash or a travel wrap makes it variant-only.
    line_ok: bool = True
    supplier: str = DEFAULT_SUPPLIER[0]
    attribution: str = DEFAULT_SUPPLIER[1]

    @property
    def image_source(self) -> str:
        return f"admin:{self.supplier}"

    @property
    def source(self) -> str:
        return self.source_path or self.source_url or ""

    @property
    def stem(self) -> str:
        return PurePosixPath(self.source_path).stem if self.source_path else self.source_url or ""


@dataclass
class Match:
    """Where a classified picture lands on the catalogue, and why it stopped where it did."""

    brand: Brand | None = None
    line: ProductLine | None = None
    variant: ProductVariant | None = None
    by_gtin: str | None = None
    reason: str | None = None


# ------------------------------------------------------------------------ names, and only names


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


def _tokens(text: str) -> list[str]:
    return [t for t in _SPLIT_RE.split(_fold(text).replace("&", " and ")) if t]


def quantity_ml_of(text: str | None) -> int | None:
    """The millilitre size a name or a net-content cell states ("750ML", "1L", "20cl", "1.75 L");
    None for none or for a unit the catalogue does not size in (ounces)."""
    if not text:
        return None
    m = _SIZE_RE.search(text)
    if not m:
        return None
    value = float(m.group(1).replace(",", "."))
    return int(round(value * _ML[m.group(2).lower()]))


def gtins_of(text: str | None) -> tuple[str, ...]:
    """Every barcode a name states, normalised: a 12-digit UPC pads to 13, an EAN-13 stands, a
    GTIN-14 counts only with a leading 0 (a leading 1 is the case, not the bottle)."""
    out: list[str] = []
    for digits in _DIGITS_RE.findall(text or ""):
        if len(digits) == 14 and not digits.startswith("0"):
            continue
        cleaned = clean_gtin(digits)
        if cleaned and cleaned not in out:
            out.append(cleaned)
    return tuple(out)


def _supplier_of(top: str) -> tuple[str, str]:
    folded = _fold(top)
    for needle, slug, attribution in SUPPLIERS:
        if needle in folded:
            return slug, attribution
    return DEFAULT_SUPPLIER


def _is_noise(token: str) -> bool:
    """A view word, a size, a pixel count, or a long number (a barcode or a date)."""
    return bool(token in _VIEW_WORDS or _DIMENSIONS_RE.fullmatch(token) or _SIZE_RE.fullmatch(token)
                or (token.isdigit() and len(token) >= 5))


def _line_words(tokens: list[str]) -> str:
    """The stem without the view words, sizes, barcodes, dimensions and version suffixes.

    "FRONT_04", "Front_750ml-2" and "TRANSPARENT (2) (6)" end in a version, never an age: a
    short number in the trailing run of noise and short numbers is dropped when a view or size
    word comes before it in that run. "14 - FRONT ON SHOT" and "Port Charlotte_10_1L" keep
    their 14 and 10, which come before any view or size word."""
    kept = [t for t in tokens if not _is_noise(t)]
    tail = len(tokens)
    while tail and (_is_noise(tokens[tail - 1]) or (tokens[tail - 1].isdigit() and len(tokens[tail - 1]) <= 2)):
        tail -= 1
    versions: list[str] = []
    seen_noise = False
    for t in tokens[tail:]:
        if _is_noise(t):
            seen_noise = True
        elif seen_noise:
            versions.append(t)
    for v in reversed(versions):
        if kept and kept[-1] == v:
            kept.pop()
    return " ".join(kept)


def classify_file(path: str | Path) -> Classified:
    """Read one file's path and name. `path` is relative to the folder root; its first segment
    names the supplier, a segment between the supplier's inner root and the file names the
    product line the client filed it under (`<top>/<inner>/<line folder>/<file>`)."""
    rel = PurePosixPath(str(path).replace("\\", "/"))
    parts = rel.parts
    supplier, attribution = _supplier_of(parts[0] if parts else "")
    base = dict(source_path=str(rel), supplier=supplier, attribution=attribution)
    if rel.suffix.lower() not in _IMAGE_SUFFIXES:
        return Classified(**base, usable=False, reason=f"not a picture ({rel.suffix or 'no extension'})")
    stem = rel.stem
    line_folder = parts[-2] if len(parts) >= 4 else None
    tokens = _tokens(stem)
    words = _line_words(tokens)
    if _UUID_RE.match(stem) or not any(t.isalpha() for t in tokens):
        return Classified(**base, usable=False, reason="the name says nothing about what it depicts",
                          brand_words=line_folder or "", line_words=(line_folder,) if line_folder else ())
    gtins = gtins_of(stem)
    quantity_ml = quantity_ml_of(stem) or quantity_ml_of(line_folder)
    brand_words = line_folder or words
    line_words = tuple(t for t in (line_folder, words) if t)
    common = dict(base, gtin=gtins[0] if gtins else None, alt_gtins=gtins[1:], quantity_ml=quantity_ml,
                  brand_words=brand_words, line_words=line_words)

    present = set(tokens)
    if "close" in present and "up" in present:
        present.add("closeup")
    for word, why in _UNUSABLE_WORDS.items():
        if word in present:
            return Classified(**common, view=word, usable=False, reason=f"{why}: not a front bottle shot")
    packaging = present & _PACKAGING_WORDS
    with_bottle = bool(present & _WITH_WORDS)
    if packaging and not with_bottle:
        return Classified(**common, view=next(iter(sorted(packaging))), usable=False,
                          reason=f"the {sorted(packaging)[0]} alone, without the bottle")
    if packaging:
        quality, view = "boxed", "bottle with " + sorted(packaging)[0]
    elif present & _TRANSPARENT_WORDS:
        quality, view = "transparent", "transparent"
    elif "lifestyle" in present:
        quality, view = "lifestyle", "lifestyle"
    else:
        quality, view = "ecommerce", "front"
    line_ok = not (present & _VARIANT_ONLY_WORDS)
    return Classified(**common, view=view, quality=quality, usable=True, line_ok=line_ok,
                      reason=None if line_ok else "a hand, sash or wrap in shot: variant only")


def classify_csv_row(row: dict[str, str]) -> list[Classified]:
    """One export row: the front bottle image as the variant's picture (shop-style quality), and
    the brand logo as the brand's picture. The brand is the marketing brand; the line words are the
    title before its first comma; the size is the net content; the barcodes are the UPC and, when
    its first digit is 0, the GTIN. Nothing else in the row is read."""
    brand = (row.get(CSV_BRAND) or "").strip()
    title = (row.get(CSV_TITLE) or "").split(",", 1)[0].strip()
    gtins: list[str] = []
    for raw, case_aware in ((row.get(CSV_UPC) or "", False), (row.get(CSV_GTIN) or "", True)):
        digits = re.sub(r"\D", "", raw)
        if not digits or (case_aware and not digits.startswith("0")):
            continue
        cleaned = clean_gtin(digits)
        if cleaned and cleaned not in gtins:
            gtins.append(cleaned)
    out: list[Classified] = []
    front = (row.get(CSV_FRONT) or "").strip()
    supplier, attribution = CSV_SUPPLIER
    if front:
        out.append(Classified(source_url=front, level="variant", view="front", quality="ecommerce",
                              gtin=gtins[0] if gtins else None, alt_gtins=tuple(gtins[1:]),
                              quantity_ml=quantity_ml_of(row.get(CSV_NET)), brand_words=brand, line_words=(title,) if title else (),
                              usable=bool(brand), reason=None if brand else "no marketing brand on the row",
                              supplier=supplier, attribution=attribution))
    logo = (row.get(CSV_LOGO) or "").strip()
    if logo:
        out.append(Classified(source_url=logo, level="brand", view="logo", quality="transparent",
                              brand_words=brand, usable=bool(brand), reason=None if brand else "no marketing brand on the row",
                              supplier=supplier, attribution=attribution))
    return out


def read_csv(path: str | Path) -> list[Classified]:
    with open(path, encoding="utf-8-sig", newline="") as fh:
        return [c for row in csv.DictReader(fh) for c in classify_csv_row(row)]


def walk_folder(root: str | Path) -> list[Classified]:
    """Every file under the folder, classified by its path relative to the root, sorted."""
    root = Path(root)
    return [classify_file(p.relative_to(root).as_posix()) for p in sorted(root.rglob("*")) if p.is_file()]


# ------------------------------------------------------------------------------ the catalogue


def _reduced(text: str, **kw: Any) -> frozenset[str]:
    """A line key's words with the category nouns and format words gone (the "c" boundary) and
    every age token folded to its number, order ignored, so "14 Year Old Single Malt" and "14yo"
    compare equal, and "DoubleWood 12 Year Old" meets "12yo doublewood single malt", while
    "15yo sherry cask" and "15yo" do not: a word that names an expression stays on both sides."""
    key = product_lines.product_line_key(text, boundary="c", **kw)
    return frozenset(_AGE_TOKEN_RE.sub(r"\1", t) for t in key.split())


def resolve_brand(maps: keying.Maps, text: str) -> tuple[Brand | None, str]:
    """The brand row the longest leading word sequence of `text` resolves to (aliases followed),
    and the spelling that resolved it. Four words at most: "Chateau Sainte Marguerite Symphonie"."""
    words = (text or "").split()
    found: tuple[Brand | None, str] = (None, "")
    for n in range(1, min(4, len(words)) + 1):
        spelling = " ".join(words[:n])
        row = maps.brand_of(listed_brand=spelling)
        if row is not None:
            found = (row, spelling)
    return found


def _live_lines(db: Session, maps: keying.Maps, brand: Brand) -> list[ProductLine]:
    """The brand's own lines that hold a live variant: an empty line (the review prunes them)
    cannot show a picture, and counting it made "Grande Couronne" a tie between a line with a
    variant and its empty duplicate."""
    rows = [ln for ln in maps.lines.values() if ln.brand_id == brand.id and not ln.alias_of_id]
    if not rows:
        return []
    live = set(db.scalars(
        select(ProductVariant.product_line_id).where(
            ProductVariant.merged_into_id.is_(None), ProductVariant.product_line_id.in_([r.id for r in rows])
        ).distinct()
    ))
    return [r for r in rows if r.id in live]


def match(db: Session, classified: Classified, maps: keying.Maps | None = None) -> Match:
    """Precision first. A barcode equal to a live variant's wins outright and names its line and
    brand. Else the brand resolves from the leading words; under it, a line whose key equals the
    words' key, else the one live line whose reduced key equals theirs; a variant under that line
    with the stated size. Two candidates at any step, or a brand resolving to nothing, is a stop
    with the reason, and the picture stays unassigned."""
    maps = maps or keying.load_maps(db)
    m = Match()
    if not classified.usable:
        m.reason = classified.reason
        return m
    for gtin in (classified.gtin, *classified.alt_gtins):
        if not gtin:
            continue
        variant = db.scalar(select(ProductVariant).where(ProductVariant.gtin == gtin, ProductVariant.merged_into_id.is_(None)))
        if variant is not None:
            m.variant, m.by_gtin = variant, gtin
            m.line = product_lines.resolve_alias(maps.lines, variant.product_line_id) if variant.product_line_id else None
            m.brand = maps.brand_of(brand_id=variant.brand_id, listed_brand=variant.brand)
            return m
    brand, spelling = resolve_brand(maps, classified.brand_words)
    if brand is None:
        m.reason = f"brand not found: {classified.brand_words!r}"
        return m
    m.brand = brand
    if classified.level == "brand":
        return m
    lines = _live_lines(db, maps, brand)
    reduced_of = {ln.id: _reduced(ln.key) for ln in lines}
    tried: list[str] = []
    for text in classified.line_words:
        key = product_lines.product_line_key(text, listed_brand=spelling, brand=brand.name)
        tried.append(key)
        exact = [ln for ln in lines if ln.key == key]
        if len(exact) == 1:
            m.line = exact[0]
            break
        if not key:
            continue  # only the namesake line answers to no words, and it did not
        wanted = _reduced(text, listed_brand=spelling, brand=brand.name)
        near = [ln for ln in lines if reduced_of[ln.id] == wanted]
        if len(near) == 1:
            m.line = near[0]
            break
        if len(near) > 1:
            m.reason = (f"{len(near)} product lines under {brand.name} read as {' '.join(sorted(wanted))!r}: "
                        + ", ".join(sorted(ln.key for ln in near)[:4]))
            return m
    if m.line is None:
        m.reason = f"no product line under {brand.name} for " + " / ".join(repr(t) for t in tried)
        return m
    if classified.quantity_ml:
        sized = [v for v in db.scalars(select(ProductVariant).where(
            ProductVariant.product_line_id == m.line.id, ProductVariant.merged_into_id.is_(None),
            ProductVariant.quantity_ml == classified.quantity_ml))]
        if len(sized) == 1:
            m.variant = sized[0]
        elif sized:
            m.reason = f"{len(sized)} product variants of {m.line.name} at {classified.quantity_ml} ml"
        else:
            m.reason = f"no product variant of {m.line.name} at {classified.quantity_ml} ml"
    else:
        m.reason = "no size in the name: the line only"
    return m


# --------------------------------------------------------------------------------- the manifest


@dataclass
class Candidate:
    classified: Classified
    match: Match
    digest: str | None = None


@dataclass
class Entry:
    source_path: str | None
    source_url: str | None
    level: str
    target: str | None
    chosen: bool
    reason: str
    image_source: str
    licence: str
    attribution: str
    gtin: str | None = None
    quality: str | None = None
    quantity_ml: int | None = None

    def as_json(self) -> dict[str, Any]:
        out: dict[str, Any] = {}
        if self.source_path:
            out["source_path"] = self.source_path
        if self.source_url:
            out["source_url"] = self.source_url
        out.update(level=self.level, target=self.target, chosen=self.chosen, reason=self.reason)
        if self.gtin:
            out["gtin"] = self.gtin
        if self.quality:
            out["quality"] = self.quality
        if self.quantity_ml:
            out["quantity_ml"] = self.quantity_ml
        out.update(image_source=self.image_source, licence=self.licence, attribution=self.attribution)
        return out


def _entry(c: Classified, level: str, target: str | None, chosen: bool, reason: str, gtin: str | None = None) -> Entry:
    return Entry(source_path=c.source_path, source_url=c.source_url, level=level, target=target, chosen=chosen,
                 reason=reason, image_source=c.image_source, licence=LICENCE, attribution=c.attribution,
                 gtin=gtin, quality=c.quality, quantity_ml=c.quantity_ml)


def _prefer_png(cands: list[Candidate]) -> list[Candidate]:
    """The same shot in two formats (one stem, .jpg and .png) is one candidate: the PNG, which
    may carry alpha. Two different shots stay two candidates."""
    by_stem: dict[str, Candidate] = {}
    for cand in cands:
        key = cand.classified.stem.lower()
        held = by_stem.get(key)
        if held is None or (cand.classified.source_path or "").lower().endswith(".png"):
            by_stem[key] = cand
    return list(by_stem.values())


def _dedupe(cands: list[Candidate]) -> dict[str, str]:
    """Byte duplicates by digest: the shortest-named file stands (a copy's name grows) and the
    others are named after it; when the twins' names disagree on usability or quality the picture
    is refused (the names lie). Twins that are all unusable keep their own reasons."""
    verdict: dict[str, str] = {}
    groups: dict[str, list[Candidate]] = {}
    for cand in cands:
        if cand.digest:
            groups.setdefault(cand.digest, []).append(cand)
    for members in groups.values():
        if len(members) < 2 or not any(m.classified.usable for m in members):
            continue
        readings = {(m.classified.usable, m.classified.quality) for m in members}
        members.sort(key=lambda m: (len(PurePosixPath(m.classified.source).name), m.classified.source))
        if len(readings) > 1:
            names = ", ".join(PurePosixPath(m.classified.source).name for m in members)
            for m in members:
                verdict[m.classified.source] = f"byte duplicates whose names disagree: {names}"
        else:
            for m in members[1:]:
                verdict[m.classified.source] = f"byte duplicate of {PurePosixPath(members[0].classified.source).name}"
    return verdict


def _rank(cand: Candidate, size_pref: Callable[[int | None], int] | None = None) -> tuple:
    q = QUALITY_RANK.get(cand.classified.quality or "", 0)
    pref = size_pref(cand.classified.quantity_ml) if size_pref else 0
    return (-q, pref)


def _pick(cands: list[Candidate], size_pref: Callable[[int | None], int] | None = None) -> tuple[Candidate | None, str]:
    """The best candidate, or none with the reason when the best is a tie."""
    cands = _prefer_png(cands)
    if not cands:
        return None, "no candidate"
    ranked = sorted(cands, key=lambda c: (_rank(c, size_pref), c.classified.source))
    best = _rank(ranked[0], size_pref)
    tied = [c for c in ranked if _rank(c, size_pref) == best]
    if len(tied) > 1:
        return None, f"{len(tied)} candidates of equal quality: " + ", ".join(PurePosixPath(c.classified.source).name for c in tied)
    return ranked[0], "best of %d" % len(cands)


def _lost(chosen: Candidate | None, why: str) -> str:
    """Why an entry is not the one: outranked by a named winner, or part of a tie."""
    return f"outranked by {PurePosixPath(chosen.classified.source).name}" if chosen is not None else why


def _quantity_preference(rep_ml: int | None) -> Callable[[int | None], int]:
    def pref(quantity_ml: int | None) -> int:
        if rep_ml and quantity_ml == rep_ml:
            return 0
        if quantity_ml in (700, 750):
            return 1
        return 2
    return pref


def build(db: Session, classifieds: Iterable[Classified], *, digests: dict[str, str] | None = None,
          representative_ml: Callable[[ProductLine], int | None] | None = None,
          maps: keying.Maps | None = None) -> tuple[list[Entry], dict[str, Any]]:
    """Match every classified picture and choose one per target: the brand's logo once per brand,
    a variant's picture per variant, and the line's picture as the best-quality front bottle
    shot among its variants' candidates, preferring the representative variant's size, else 700
    or 750 ml. A tie at the top is nobody's choice: the target stays empty and every tied
    candidate is written unchosen with the tie as its reason, for a person to settle."""
    maps = maps or keying.load_maps(db)
    digests = digests or {}
    seen: set[tuple[str, str, str]] = set()
    unique: list[Classified] = []
    for c in classifieds:
        key = (c.level, c.source, c.brand_words)
        if key not in seen:  # the export lists one address on several rows; one candidate each
            seen.add(key)
            unique.append(c)
    cands = [Candidate(c, match(db, c, maps), digests.get(c.source)) for c in unique]
    dupes = _dedupe(cands)
    entries: list[Entry] = []
    by_brand: dict[int, list[Candidate]] = {}
    by_line: dict[int, list[Candidate]] = {}
    by_variant: dict[int, list[Candidate]] = {}
    for cand in cands:
        c, m = cand.classified, cand.match
        why = dupes.get(c.source)
        if why:
            entries.append(_entry(c, c.level, None, False, why))
            continue
        if not c.usable:
            entries.append(_entry(c, c.level, None, False, c.reason or "unusable"))
            continue
        if c.level == "brand":
            if m.brand is None:
                entries.append(_entry(c, "brand", None, False, m.reason or "unassigned"))
            else:
                by_brand.setdefault(m.brand.id, []).append(cand)
            continue
        if m.variant is not None:
            by_variant.setdefault(m.variant.id, []).append(cand)
        elif m.line is None:
            entries.append(_entry(c, "variant", None, False, m.reason or "unassigned"))
            continue
        else:
            entries.append(_entry(c, "variant", None, False, m.reason or "no product variant matched"))
        if m.line is not None and c.line_ok:
            by_line.setdefault(m.line.id, []).append(cand)

    for brand_id, group in sorted(by_brand.items()):
        brand = maps.brands[brand_id]
        urls = sorted({c.classified.source for c in group})
        if len(urls) == 1:
            entries.append(_entry(group[0].classified, "brand", f"brand:{brand.slug}", True, f"the brand logo for {brand.name}"))
        else:
            for c in group:
                entries.append(_entry(c.classified, "brand", f"brand:{brand.slug}", False, f"{len(urls)} different logos for {brand.name}"))
    for variant_id, group in sorted(by_variant.items()):
        chosen, why = _pick(group)
        variant = group[0].match.variant
        for cand in group:
            c, m = cand.classified, cand.match
            picked = chosen is not None and c.source == chosen.classified.source
            reason = (f"{c.view or 'front'} shot of {variant.name}" + (" (by barcode)" if m.by_gtin else "")) if picked else _lost(chosen, why)
            entries.append(_entry(c, "variant", f"variant:{variant.id}", picked, reason, gtin=m.by_gtin))
    for product_line_id, group in sorted(by_line.items()):
        line = maps.lines[product_line_id]
        rep = representative_ml(line) if representative_ml else None
        chosen, why = _pick(group, _quantity_preference(rep))
        for cand in group:
            c = cand.classified
            picked = chosen is not None and c.source == chosen.classified.source
            size = f"{c.quantity_ml} ml" if c.quantity_ml else "unsized"
            reason = (f"best front bottle shot for the line ({c.quality}, {size}"
                      + (", the representative variant's size" if rep and c.quantity_ml == rep else "") + ")") if picked else _lost(chosen, why)
            entries.append(_entry(c, "line", f"line:{line.slug}", picked, reason, gtin=cand.match.by_gtin))
    entries.sort(key=lambda e: (e.source_path or "~" + (e.source_url or ""), LEVELS.index(e.level)))
    return entries, summarise(entries)


_FAMILY_RE = re.compile(r"\s+(?:for|under|at|by|whose|without)\s.*$|\s+of\s+(?!equal\b).*$|[:(].*$")


def _family(reason: str) -> str:
    """A reason without its particulars ("no product line under X for 'y'" is "no product line"),
    so the summary counts kinds of refusal, not sentences."""
    return _FAMILY_RE.sub("", reason).strip()


def summarise(entries: list[Entry]) -> dict[str, Any]:
    """Per level: pictures usable (a target found), chosen, and unassigned with the top reasons."""
    out: dict[str, Any] = {}
    for level in LEVELS:
        rows = [e for e in entries if e.level == level]
        chosen = [e for e in rows if e.chosen]
        unassigned = [e for e in rows if not e.chosen]
        reasons = Counter(_family(e.reason) for e in unassigned)
        out[level] = {
            "entries": len(rows), "with_target": sum(1 for e in rows if e.target), "chosen": len(chosen),
            "targets": len({e.target for e in chosen}), "unassigned": len(unassigned),
            "top_reasons": [{"reason": r, "count": n} for r, n in reasons.most_common(3)],
        }
    return out


def manifest_document(entries: list[Entry], summary: dict[str, Any], *, folder: str | None, csv_path: str | None) -> dict[str, Any]:
    return {
        "schema": "dfp-image-manifest/1",
        "generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
        "folder": folder, "csv": csv_path, "licence": LICENCE,
        "note": ("A person may flip `chosen` on any entry; `images stage` copies chosen entries into uploads/ and "
                 "writes `url` and `thumb_url` on them; `images import` sets the pictures in the database."),
        "summary": summary,
        "entries": [e.as_json() for e in entries],
    }
