#!/usr/bin/env python3
"""Automated spacing QC for deterministic mograph engines.
Renders key frames for every beat x aspect and analyzes the vertical layout
from pixels: flags touching elements, interior dead bands, and top/bottom
imbalance. Usage: qc_spacing.py <engine.py> [aspects...]
"""
import importlib.util, sys
from PIL import Image, ImageDraw

def load(engine, aspect):
    sys.argv = [engine, aspect]
    spec = importlib.util.spec_from_file_location("fm_" + aspect, engine)
    fm = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = fm          # engines may read sys.modules[__name__]
    spec.loader.exec_module(fm)
    return fm

BOXES = []

def instrument(fm):
    """Monkey-patch the engine's draw helpers to record element bboxes (design px)."""
    from PIL import Image as _Image
    BOXES.clear()
    def rec(kind, x0, y0, x1, y1, alpha=1.0):
        if alpha and alpha > 0.25:
            BOXES.append((kind, x0, y0, x1, y1))
    if not hasattr(fm, "_qc_orig"):
        fm._qc_orig = {}
        for name in ("pill", "icon", "text_c", "text_stagger", "photo_card", "mark_at", "dot", "rrect"):
            if hasattr(fm, name): fm._qc_orig[name] = getattr(fm, name)
    o = fm._qc_orig
    _ratio = {}
    def ratio(path):
        if path not in _ratio:
            im = _Image.open(path); _ratio[path] = im.width / im.height
        return _ratio[path]
    if "pill" in o:
        def pill(dr, cx, cy, s2, alpha=1.0, **kw):
            w = len(s2) * 20 + 56; h = 58
            rec("pill", cx - w/2, cy - h/2, cx + w/2, cy + h/2, alpha)
            return o["pill"](dr, cx, cy, s2, alpha, **kw)
        fm.pill = pill
    if "icon" in o:
        def icon(base, name, cx, cy, h_design, alpha=1.0, rise=0.0):
            import os
            p = f"{fm.ICON_DIR}/{name}.png"
            r = ratio(p) if os.path.exists(p) else 1.0
            w = h_design * r
            rec("icon", cx - w/2, cy - h_design/2, cx + w/2, cy + h_design/2, alpha)
            return o["icon"](base, name, cx, cy, h_design, alpha, rise)
        fm.icon = icon
    if "text_c" in o:
        def text_c(dr, cx, cy, s2, font, fill, alpha=1.0, tracking=0):
            sz = font.size / fm.SS; w = len(s2) * sz * 0.62
            rec("text", cx - w/2, cy - sz*0.6, cx + w/2, cy + sz*0.6, alpha)
            return o["text_c"](dr, cx, cy, s2, font, fill, alpha, tracking)
        fm.text_c = text_c
    if "text_stagger" in o:
        def text_stagger(dr, cx, cy, s2, font, fill, prog, **kw):
            if prog > 0.3:
                sz = font.size / fm.SS; w = len(s2) * sz * 0.62
                rec("text", cx - w/2, cy - sz*0.6, cx + w/2, cy + sz*0.6, 1.0)
            return o["text_stagger"](dr, cx, cy, s2, font, fill, prog, **kw)
        fm.text_stagger = text_stagger
    if "photo_card" in o:
        def photo_card(base, dr, name, cx, cy, w_design, alpha=1.0, rise=0.0, radius=34):
            p = f"{fm.BRAND_DIR}/photos/{name}"
            import os
            h = w_design / (ratio(p) if os.path.exists(p) else 1.6)
            rec("photo", cx - w_design/2, cy - h/2, cx + w_design/2, cy + h/2, alpha)
            return o["photo_card"](base, dr, name, cx, cy, w_design, alpha, rise, radius)
        fm.photo_card = photo_card
    if "mark_at" in o:
        def mark_at(base, cx, cy, h_design, alpha=1.0):
            r = fm.mark_img.width / fm.mark_img.height
            w = h_design * r
            rec("mark", cx - w/2, cy - h_design/2, cx + w/2, cy + h_design/2, alpha)
            return o["mark_at"](base, cx, cy, h_design, alpha)
        fm.mark_at = mark_at
    if "dot" in o:
        def dot(dr, cx, cy, r, color, alpha=1.0):
            if r >= 10: rec("dot", cx - r, cy - r, cx + r, cy + r, alpha)
            return o["dot"](dr, cx, cy, r, color, alpha)
        fm.dot = dot
    if "rrect" in o:
        def rrect(dr, x, y, w, h, r, fill, alpha=1.0, shadow=True):
            if w > 60 and h > 70: rec("card", x, y, x + w, y + h, alpha)
            return o["rrect"](dr, x, y, w, h, r, fill, alpha, shadow)
        fm.rrect = rrect

def overlap_issues():
    issues = []
    for i in range(len(BOXES)):
        for j in range(i + 1, len(BOXES)):
            k1, a0, a1, a2, a3 = BOXES[i]
            k2, b0, b1, b2, b3 = BOXES[j]
            ix = max(0, min(a2, b2) - max(a0, b0))
            iy = max(0, min(a3, b3) - max(a1, b1))
            inter = ix * iy
            if inter <= 0: continue
            ar1 = (a2 - a0) * (a3 - a1); ar2 = (b2 - b0) * (b3 - b1)
            small = min(ar1, ar2)
            if small <= 0: continue
            cont = inter / small
            if cont >= 0.88: continue          # full containment = intentional (text in card)
            if k1 == "card" and k2 == "card": continue  # stacked/split cards animate
            if k1 == "dot" and k2 == "dot": continue    # markers may coincide on a shared line
            if {k1, k2} == {"text"}: continue  # stagger vs caption estimates
            strict = {frozenset(p) for p in (("pill","icon"),("pill","photo"),("pill","mark"),("text","dot"),("text","icon"),("text","photo"),("pill","text"))}
            if cont > 0.12 or (frozenset((k1, k2)) in strict and cont > 0.02):
                issues.append(f"overlap {k1}/{k2} at y={int(min(a1, b1))} ({int(cont*100)}% of smaller)")
    return issues

def render_frame(fm, bi, bt):
    base = Image.new("RGB", (fm.CW, fm.CH), fm.IVORY)
    dr = ImageDraw.Draw(base, "RGBA")
    if hasattr(fm, "BEAT_DURS"):
        fm.BEAT_FNS[bi](base, dr, bt, fm.BEAT_DURS[bi])
    else:
        fm.BEAT_FNS[bi](base, dr, bt)
    fm.progress(dr, bi, bt)
    fm.corner_mark(base)
    return base.resize((fm.Wd, fm.Hd), Image.LANCZOS)

def analyze(im, bg, mode="16x9"):
    W, H = im.size
    px = im.load()
    tol = 20
    occupied = []
    fracs = []                               # per sampled row: occupied columns / all columns
    ncols = len(range(0, W, 4))
    for y in range(0, H, 2):
        cnt = 0
        for x in range(0, W, 4):
            p = px[x, y]
            if abs(p[0]-bg[0]) + abs(p[1]-bg[1]) + abs(p[2]-bg[2]) > tol:
                cnt += 1
        occupied.append(cnt > ncols * 0.02)   # ignore lone decor dots
        fracs.append(cnt / ncols)
    blocks = []
    y0 = None
    for i, v in enumerate(occupied):
        if v and y0 is None: y0 = i
        if not v and y0 is not None:
            blocks.append((y0 * 2, (i - 1) * 2)); y0 = None
    if y0 is not None: blocks.append((y0 * 2, (len(occupied) - 1) * 2))
    blocks = [b for b in blocks if b[1] - b[0] >= 8]
    # how much of the width a band ever fills — a glyph fragment (the dot of an "i" in a
    # display-size wordmark, plus the one narrow ascender beside it) is a sliver; a pill,
    # a card or a line of copy is not
    def dense(b):
        return max(fracs[b[0] // 2:b[1] // 2 + 1] or [0])
    merged = []
    for b in blocks:
        if merged and b[0] - merged[-1][1] < 10:
            merged[-1] = (merged[-1][0], b[1])
        else:
            merged.append(list(b) if isinstance(b, tuple) else b)
    blocks = [tuple(b) for b in merged]
    # coalesce bottom chrome (progress dots + corner mark) into one block
    chrome_y = H * 0.92
    body = [b for b in blocks if b[0] < chrome_y]
    chrome = [b for b in blocks if b[0] >= chrome_y]
    if chrome:
        body.append((min(c[0] for c in chrome), max(c[1] for c in chrome)))
    blocks = body
    # merge thin artifacts (glyph descenders, icon fragments) across small gaps
    merged2 = []
    def sliver(a, other):
        # a sparse band much thinner than its neighbour is part of the neighbour's glyphs:
        # "Linear" at 200px put the i-dot 14px above the x-height with only the L's stem
        # between them, and the hook beat read as "touching" for every brand with an i
        # (2026-08-29). A pill or a caption is neither sparse nor that thin.
        ha, ho = a[1] - a[0], other[1] - other[0]
        return dense(a) < 0.08 and ha < 0.4 * ho
    for b in blocks:
        if merged2:
            gap = b[0] - merged2[-1][1]
            thin = (b[1] - b[0] < 16) or (merged2[-1][1] - merged2[-1][0] < 16)
            if gap < 22 and (thin or sliver(merged2[-1], b) or sliver(b, merged2[-1])):
                merged2[-1] = (merged2[-1][0], b[1]); continue
        merged2.append(list(b))
    blocks = [tuple(b) for b in merged2]
    issues = []
    if not blocks: return ["EMPTY-FRAME"]
    top = blocks[0][0]; bot = H - blocks[-1][1]
    for i in range(len(blocks) - 1):
        g = blocks[i + 1][0] - blocks[i][1]
        ymid = blocks[i][1]
        thr = {"9x16": 0.22, "1x1": 0.19}.get(mode, 0.17)
        if g > H * thr: issues.append(f"dead-band {g}px after y={ymid}")
        elif g < H * 0.016: issues.append(f"touching gap={g}px at y={ymid}")
    if top > H * (0.20 if mode == "9x16" else 0.22): issues.append(f"top-empty {top}px")
    if bot > H * 0.22: issues.append(f"bottom-empty {bot}px")
    return issues

def main():
    engine = sys.argv[1]
    aspects = sys.argv[2:] or ["16x9", "9x16", "1x1"]
    total_issues = 0
    for aspect in aspects:
        fm = load(engine, aspect)
        for bi in range(fm.BEATS):
            worst = []
            durs = getattr(fm, "BEAT_DURS", None)
            times = (durs[bi] * 0.55, durs[bi] * 0.82) if durs else (3.2, 4.6)
            minimal = bi in getattr(fm, "QC_MINIMAL", set())
            for bt in times:   # mid + late: everything should be on screen
                instrument(fm)
                im = render_frame(fm, bi, bt)
                flags = analyze(im, fm.IVORY, mode=aspect)
                if minimal:
                    flags = [x for x in flags if "touching" in x]
                worst += [f"t={bt}: {s}" for s in flags]
                worst += [f"t={bt}: {s}" for s in sorted(set(overlap_issues()))]
            if worst:
                total_issues += len(worst)
                print(f"[{aspect}] b{bi+1}: " + " | ".join(worst))
    print(f"QC {'CLEAN' if total_issues == 0 else f'{total_issues} issue(s)'}")
    return 0 if total_issues == 0 else 1

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