#!/usr/bin/env python3
"""Which ELEMENT carries the disagreement with the capture? Rank them by suppression.

    tools/port/element-residual [screen]        # default main_menu

`edge-residual-map` gives hot COORDINATES, and turning those into elements needs
the design-space -> capture transform, which is a convention I would have to
assume. This needs no transform: the port has a mod tree, so shadow an element's
sprite with a transparent PNG, render, and diff the port's OWN two renders. The
pixels that change ARE the element, already in the comparison frame.

Reports, per element, on the pixels it actually paints:
  * mean |residual| against the capture, after ONE global tone LUT
  * the SIGN -- is the port drawing this element too dark or too bright
  * edge versus flat -- an outline problem or a body problem

⚠️ SUPPRESSION IS BY SPRITE PATH, so elements sharing a sprite are suppressed
together and are reported as one row. `ptloop01` draws `pteff03.png`; the id and
the file are not the same thing.

EXIT 0 the report is trustworthy, 2 a control failed. No 1: this ranks, it does
not judge. A brightness difference here is NOT licence to brighten the element --
blend mode is undecoded (`screen.rs`), and tuning until the two agree is exactly
what the mission forbids.
"""
import json, os, subprocess, sys, tempfile

CAPS = "docs/re/captures/title-builds"
POSE = {                                    # same poses as verify-capture
    "main_menu":         (f"{CAPS}/live-main-menu.png", ["--menu=main_menu"]),
    "extras":            (f"{CAPS}/live-extras.png", ["--menu=extras"]),
    "main_menu_options": (f"{CAPS}/live-main-menu-options-focused.png",
                          ["--menu=main_menu_options", "--focus=ptbtn04"]),
}
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
if SCREEN not in POSE:
    print(f"  🔴 no pose for {SCREEN}; known: {', '.join(POSE)}"); sys.exit(2)
CAP, ARGS = POSE[SCREEN]
W, H = 1279, 675
BASE = ["--loop-phase=0", "--leaf-time=0", "--script=wait"]
tmp = tempfile.mkdtemp()


def render(png, mods=None):
    env = dict(os.environ)
    if mods: env["SYLPHEED_MODS"] = mods
    else: env.pop("SYLPHEED_MODS", None)
    r = subprocess.run(["xvfb-run", "-a", "timeout", "300", "godot", "--path", "port",
                        "--"] + BASE + ARGS + [f"--capture={png}"],
                       env=env, capture_output=True, text=True)
    return r.stdout + r.stderr


def gray(png, out):
    subprocess.run(["convert", png, "-crop", f"{W}x{H}+0+0", "+repage",
                    "-colorspace", "Gray", "-depth", "8", "gray:" + out], check=True)
    return open(out, "rb").read()


sd = json.load(open(f"export/screens/{'title'}/{SCREEN}.json")) if os.path.exists(
    f"export/screens/title/{SCREEN}.json") else None
if sd is None:
    for root, _, files in os.walk("export/screens"):
        if f"{SCREEN}.json" in files:
            sd = json.load(open(os.path.join(root, f"{SCREEN}.json"))); break
sprites = {}
for e in sd["elements"]:
    s = e.get("sprite", "")
    if s: sprites.setdefault(s, []).append(e["id"])

render(f"{tmp}/base.png")
base = gray(f"{tmp}/base.png", f"{tmp}/base.gray")
cap = gray(CAP, f"{tmp}/cap.gray")

# CONTROL 1 -- the metric's own zero. The render against ITSELF must be exactly 0.
tot = [0] * 256; cnt = [0] * 256
for i in range(len(base)): tot[base[i]] += base[i]; cnt[base[i]] += 1
idlut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
z = max(abs(idlut[base[i]] - base[i]) for i in range(0, len(base), 97))
# CONTROL 2 -- a mod that shadows NOTHING must move no pixels, or a footprint
# below is the harness rather than the element.
noop = f"{tmp}/noop"; os.makedirs(noop + "/sprites/title", exist_ok=True)
subprocess.run(["convert", "-size", "8x8", "xc:red", f"{noop}/sprites/title/zzz-not-an-asset.png"],
               check=True)
render(f"{tmp}/noop.png", noop)
nb = gray(f"{tmp}/noop.png", f"{tmp}/noop.gray")
moved = sum(1 for i in range(len(base)) if base[i] != nb[i])
print(f"  control -- metric zero on identity : {z}  (must be 0)")
print(f"  control -- mod shadowing nothing   : {moved} px moved  (must be 0)")
if z != 0 or moved != 0:
    print("\n  🔴 CONTROL FAILED. Every row below would be unattributable. Suppressed.")
    sys.exit(2)
print("  ✅ controls pass\n")

tot = [0] * 256; cnt = [0] * 256
for i in range(len(base)): tot[base[i]] += cap[i]; cnt[base[i]] += 1
lut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
resid = [abs(lut[base[i]] - cap[i]) for i in range(len(base))]
N = len(base); frame_mean = sum(resid) / N


def isedge(i):
    x, y = i % W, i // W
    if x < 1 or y < 1 or x >= W - 1 or y >= H - 1: return False
    return abs(base[i + 1] - base[i - 1]) + abs(base[i + W] - base[i - W]) >= 12


rows = []
for rel, ids in sprites.items():
    d = f"{tmp}/m_{len(rows)}"; os.makedirs(os.path.dirname(f"{d}/{rel}"), exist_ok=True)
    src = f"export/{rel}"
    if not os.path.exists(src): continue
    dim = subprocess.run(["identify", "-format", "%wx%h", src],
                         capture_output=True, text=True).stdout
    subprocess.run(["convert", "-size", dim, "xc:none", f"PNG32:{d}/{rel}"], check=True)
    log = render(f"{tmp}/o.png", d)
    if "mod: " + rel not in log:
        print(f"  ⚠️ {rel}: the override was never read -- skipped rather than "
              f"reported as an empty footprint"); continue
    o = gray(f"{tmp}/o.png", f"{tmp}/o.gray")
    m = [i for i in range(N) if abs(base[i] - o[i]) > 2]
    if not m:
        rows.append((",".join(ids), rel, 0, 0.0, 0.0, 0.0, 0.0)); continue
    mi = sum(resid[i] for i in m) / len(m)
    sg = sum(lut[base[i]] - cap[i] for i in m) / len(m)
    ed = [resid[i] for i in m if isedge(i)]; fl = [resid[i] for i in m if not isedge(i)]
    rows.append((",".join(ids), rel, len(m), mi,
                 sum(ed) / len(ed) if ed else 0.0, sum(fl) / len(fl) if fl else 0.0, sg))

print(f"{SCREEN}: frame mean |resid| {frame_mean:.2f}\n")
print(f"  {'element(s)':<22} {'foot %':>7} {'|resid|':>8} {'xmean':>6} "
      f"{'edge':>7} {'flat':>7} {'signed':>8}")
for ids, rel, n, mi, ed, fl, sg in sorted(rows, key=lambda r: -r[3]):
    if n == 0:
        print(f"  {ids:<22} {'0.00':>7} {'--':>8} {'--':>6} {'--':>7} {'--':>7} "
              f"{'--':>8}   paints nothing at this pose")
        continue
    flag = "  <- BODY" if fl > ed else ""
    print(f"  {ids:<22} {100*n/N:7.2f} {mi:8.2f} {mi/frame_mean:6.2f} "
          f"{ed:7.2f} {fl:7.2f} {sg:+8.2f}{flag}")
print("\n  signed = render - capture after the LUT; NEGATIVE means the port draws it")
print("  DARKER than the game. 'BODY' marks flat residual above edge residual --")
print("  an intensity difference rather than an outline one.")
print("  ⚠️ This is not licence to brighten anything: blend mode is undecoded.")
