#!/usr/bin/env python3
"""Mechanical gate for the icon-sheet approval checklist.

references/icon-sheet-prompt.md ends with a four-line checklist an operator is
supposed to eyeball at the icons gate: layered (a pale offset behind the body),
white interior detail rather than a silhouette, one small accent per subject, and
subjects that read at a uniform thumbnail size. Eyeballing it is how a bare black
shield reached a delivered film, and how a subject that crossed its cell boundary
got decapitated by the slicer while its severed crown became an orphan slice.

Thresholds are DERIVED from the shipped fleet, not guessed. Measuring the 72
icons behind eight delivered films showed the style rules are matters of degree —
a sun, a moon, a bolt and a flag legitimately carry no white interior, and
2-of-9 accent-less subjects shipped more than once. So style findings are
ADVISORY and always printed with their numbers; only provable damage blocks:

  ERROR    a missing/empty slice, a size fragment, an aspect sliver, or a
           beat-used subject that is a flat single-plane blob on every axis at
           once (no accent AND no interior AND a weak offset). Across the whole
           fleet exactly one subject trips that last one: the shield an
           independent design review had already called the film's worst icon.
  advisory anything softer — so the reviewer reads a number instead of a hunch.

Usage:  check_icons.py [<icon-dir>] [<pack>]
Defaults to $BRAND_PROJECT/assets/icons and $BRAND_PACK.
"""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from packlib import parse_pack, hex_rgb  # noqa: E402

from PIL import Image  # noqa: E402

OPAQUE = 200            # alpha above this counts as ink
WHITE = 235             # every channel above this counts as white
NEAR = 40               # per-channel tolerance when matching a brand hex
ACCENT_MIN = 0.0015     # a real accent dot is ~0.2%+ of the subject
ACCENT_MAX = 0.15       # "used very sparingly" — a dot, not a second body colour
INTERIOR_MIN = 0.01     # under 1% white and the subject reads as a silhouette
OFFSET_MIN = 0.02       # under 2% pale and the subject reads single-plane
BLOB_OFFSET = 0.15      # the flat-blob error needs a weak offset too
SIZE_OUTLIER = 0.6      # under 0.6x the median longest edge = a slicer fragment
MAX_ASPECT = 3.2        # a 3.2:1 sliver is an orphan, not a spot illustration
ORPHAN_FRAC = 0.20      # a component under 0.20x the largest is bleed, not design


def near(rgb, ref):
    return all(abs(rgb[i] - ref[i]) < NEAR for i in range(3))


def components(path):
    """Opaque connected components, largest first, as fractions of the largest.

    WHY THIS EXISTS (2026-08-03, cdnjs). The size and aspect checks above are
    per-FILE: they catch a slice that is itself a fragment. They cannot see an
    orphan that lands INSIDE another subject's bounding box, and that is the
    failure that got through.

    A go-bananas sheet is sliced at W//3, H//3 and each cell cropped to its
    content bbox. If the sheet is not square — gemini-pro-image returns 1408x768
    even when asked for 1:1 — those cell boundaries do not line up with the grid
    the model actually drew, so a subject near an edge clips into its neighbour.
    The neighbour then contains a few hundred stray pixels at its far edge, the
    bbox stretches to the full cell width, and the icon's ASPECT is silently
    wrong: `globe` measured 469x207 (2.27:1) when the real circle was 240x207
    (1.16:1), and `vault` 469x211 when it was 212x211. Both passed every check
    here — 2.27 is under MAX_ASPECT and neither was a size outlier — and both
    then rendered at roughly half their intended size, because icon_h scales the
    whole bbox including the transparent air the orphan propped open.

    0.20 is the threshold because a subject may legitimately be several pieces:
    the same sheet's `drawers` is two drawers at 57% / 43% of each other, while
    the real orphans measured 4.7%, 2.5%, 1.7% and 0.1%. Anything between those
    bands is a judgement call the reviewer should make, so it is reported.
    """
    im = Image.open(path).convert("RGBA")
    w, h = im.size
    px = im.load()
    seen = bytearray(w * h)
    sizes = []
    for sy in range(h):
        for sx in range(w):
            if seen[sy * w + sx] or px[sx, sy][3] < OPAQUE:
                continue
            seen[sy * w + sx] = 1
            stack, n = [(sx, sy)], 0
            while stack:
                x, y = stack.pop()
                n += 1
                for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < w and 0 <= ny < h and not seen[ny * w + nx] \
                            and px[nx, ny][3] >= OPAQUE:
                        seen[ny * w + nx] = 1
                        stack.append((nx, ny))
            sizes.append(n)
    sizes.sort(reverse=True)
    return [s / sizes[0] for s in sizes] if sizes else []


def measure(path):
    """Opaque-pixel totals plus coarse colour buckets, in one pass."""
    im = Image.open(path).convert("RGBA")
    w, h = im.size
    px = im.load()
    ink = white = 0
    buckets = {}
    for y in range(h):
        for x in range(w):
            r, g, b, a = px[x, y]
            if a < OPAQUE:
                continue
            ink += 1
            if min(r, g, b) > WHITE:
                white += 1
            key = (r // 24, g // 24, b // 24)
            buckets[key] = buckets.get(key, 0) + 1
    return w, h, ink, white, buckets


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("-")]
    icon_dir = args[0] if args else os.path.join(os.environ.get("BRAND_PROJECT", "."), "assets", "icons")
    pack_path = args[1] if len(args) > 1 else os.environ.get("BRAND_PACK")
    if not pack_path:
        sys.exit("check_icons: no pack (pass one or set BRAND_PACK)")
    pack = parse_pack(pack_path)
    accent_rgb = hex_rgb(pack["COLOR_ACCENT"])
    secondary_rgb = hex_rgb(pack["COLOR_SECONDARY"])
    names = [n for n in (pack.get("ICON_SHEET_NAMES") or "").split("|") if n]
    if not names:
        sys.exit("check_icons: pack has no ICON_SHEET_NAMES")
    used = {v.split("|")[0] for k, v in pack.items() if k.startswith("BEAT_")}

    errors, advisories, rows = [], [], []
    for name in names:
        path = os.path.join(icon_dir, f"{name}.png")
        if not os.path.exists(path):
            errors.append(f"{name}: no {name}.png in {icon_dir} — the slicer dropped it")
            continue
        w, h, ink, white, buckets = measure(path)
        if not ink:
            errors.append(f"{name}: fully transparent slice")
            continue
        hit = lambda ref: sum(n for b, n in buckets.items() if near([c * 24 + 12 for c in b], ref))  # noqa: E731
        rows.append({
            "name": name, "w": w, "h": h, "ink": ink,
            "accent": hit(accent_rgb) / ink,
            "white": white / ink,
            "offset": hit(secondary_rgb) / ink,
            "used": name in used,
            "comps": components(path),
        })

    med = sorted(max(r["w"], r["h"]) for r in rows)[len(rows) // 2] if rows else 0
    for r in rows:
        n, tag = r["name"], "" if r["used"] else " (unused by any beat)"
        # --- provable damage: the slicer cut a subject apart -------------------
        edge = max(r["w"], r["h"])
        if med and edge < med * SIZE_OUTLIER:
            errors.append(f"{n}: {r['w']}x{r['h']} is {edge/med:.2f}x the median {med}px — a slicer fragment, not a "
                          f"whole subject. A subject that crosses its cell boundary splits in two.")
        aspect = edge / max(min(r["w"], r["h"]), 1)
        if aspect > MAX_ASPECT:
            errors.append(f"{n}: aspect {aspect:.1f}:1 — an orphan sliver; it scales to fit a box it cannot fill")
        # an orphan INSIDE this bbox: bleed from the neighbouring cell. Fatal because
        # it silently inflates the bbox, so icon_h then scales mostly transparent air.
        orphans = [f for f in r["comps"][1:] if f < ORPHAN_FRAC]
        if orphans:
            errors.append(
                f"{n}: {len(orphans)} orphan component(s) at "
                f"{', '.join(f'{100*f:.1f}%' for f in orphans)} of the main shape — bleed from a "
                f"neighbouring cell, not part of the subject. It inflates the bbox, so this icon "
                f"renders smaller than icon_h implies. Usually means the sheet was not square "
                f"(check it: a 3x3 grid must be sliced from a SQUARE sheet). Drop components under "
                f"{100*ORPHAN_FRAC:.0f}% of the largest and re-crop, or regenerate the sheet at 1:1.")
        elif len(r["comps"]) > 1:
            advisories.append(
                f"{n}: {len(r['comps'])} components at "
                f"{', '.join(f'{100*f:.0f}%' for f in r['comps'])} — intentional if the subject is "
                f"genuinely several pieces, bleed if not. Look at it.")
        # --- provable style failure: flat on every axis at once ----------------
        if r["used"] and r["accent"] < ACCENT_MIN and r["white"] < INTERIOR_MIN and r["offset"] < BLOB_OFFSET:
            errors.append(f"{n}: a flat single-plane blob — no {pack['COLOR_ACCENT']} accent, "
                          f"{100*r['white']:.2f}% white interior and only {100*r['offset']:.1f}% offset. "
                          f"The house style is a layered illustration, not a silhouette.")
        # --- advisories: matters of degree, reported with their numbers --------
        if r["accent"] < ACCENT_MIN:
            advisories.append(f"{n}: no {pack['COLOR_ACCENT']} accent ({100*r['accent']:.2f}%) — "
                              f"the style calls for one small accent detail per illustration{tag}")
        elif r["accent"] > ACCENT_MAX:
            advisories.append(f"{n}: accent is {100*r['accent']:.1f}% of the subject — sparingly means a dot{tag}")
        if r["white"] < INTERIOR_MIN:
            advisories.append(f"{n}: {100*r['white']:.2f}% white interior — reads as a silhouette; a knockout, "
                              f"screen, slot or seam gives it a second plane{tag}")
        if r["offset"] < OFFSET_MIN:
            advisories.append(f"{n}: {100*r['offset']:.1f}% {pack['COLOR_SECONDARY']} offset — single-plane{tag}")

    for r in rows:
        print(f"  {r['name']:12s} {r['w']:4d}x{r['h']:<4d} ink={r['ink']:6d} accent={100*r['accent']:5.2f}% "
              f"white={100*r['white']:5.2f}% offset={100*r['offset']:5.1f}%{'' if r['used'] else '  (unused)'}")
    beat_rows = [r for r in rows if r["used"]]
    if beat_rows:
        print(f"  on-screen subjects: {sum(1 for r in beat_rows if r['accent'] < ACCENT_MIN)}/{len(beat_rows)} "
              f"carry no accent, {sum(1 for r in beat_rows if r['white'] < INTERIOR_MIN)}/{len(beat_rows)} "
              f"read as silhouettes")
    for a in advisories:
        print(f"advisory: {a}")
    if errors:
        print()
        for e in errors:
            print(f"ERROR: {e}")
        sys.exit(f"\nicon sheet fails the house-style checklist ({len(errors)} error(s)) — regenerate per "
                 f"references/icon-sheet-prompt.md, re-slice, re-run")
    print(f"icons OK: {len(rows)} subjects, {len(advisories)} advisory")


main()
