#!/usr/bin/env python3
"""WHERE does the edge residual sit, and is that region locally shifted?

    RENDER=<png> tools/port/edge-residual-map [screen]

`edge-residual-kind` fits the whole frame and excludes a GLOBAL translation. Its
own reach statement says the thing it cannot do: one misplaced element is a small
share of 38 752 edge pixels and would not move a whole-frame number. This tiles
the frame and runs the same discriminator INSIDE each tile, so a single displaced
element shows up as one hot tile with a local slope -- which is invisible to the
global fit by construction, not by accident.

Division of labour, agreed with the Decoder 2026-08-31: the residual map is the
port's (it needs the render beside the capture), the element inventory is theirs
(it needs the disc). This tool produces the map and NAMES NOTHING.

THE CONTROL IS A KNOWN LOCAL SHIFT. A map that cannot localise a displacement it
was told about cannot be trusted to have found one it was not. Exit 0 the report
is trustworthy, 2 the control failed and the report is suppressed. No 1.
"""
import math, os, subprocess, sys, tempfile

CAPS = "docs/re/captures/title-builds"
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
W, H = 1279, 675                 # top-left crop of the guest surface; never scaled
TILE = 64
EDGE = 12
MIN_EDGE_PX = 150                # below this a tile's slope is noise
# The controls displace this region and the map must find it there.
CTRL_BOX = (448, 320, 640, 448)  # x0, y0, x1, y1
# TWO controls, because ONE OF THEM FAILED AND TAUGHT ME THE LIMIT. The slope is
# a linearisation, residual ~ dx * gradient, which holds only while dx is small
# against the width of an edge. A +2 px displacement localises perfectly but reads
# back +0.8..+1.25, so the estimator SATURATES. Control A checks magnitude in the
# regime where magnitude means something; control B checks that a displacement too
# large to measure is still FOUND. Reporting a saturating slope as a distance
# would understate a real displacement by more than half.
CTRL_A_DX = 1   # linear regime: localisation AND magnitude
CTRL_B_DX = 2   # saturating:    localisation and SIGN only


def gray(png, out):
    subprocess.run(["convert", png, "-colorspace", "Gray", "-depth", "8",
                    "gray:" + out], check=True)
    return open(out, "rb").read()


def lutfit(a, b):
    tot = [0] * 256; cnt = [0] * 256
    for i in range(len(a)):
        tot[a[i]] += b[i]; cnt[a[i]] += 1
    return [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]


def tiles(a, b):
    """Per-tile mean |residual| on edge pixels, and the local shift slope."""
    lut = lutfit(a, b)          # ONE global LUT: tone is global, displacement is not
    out = {}
    for ty in range(0, H - 1, TILE):
        for tx in range(0, W - 1, TILE):
            gx = []; gy = []; rs = []; flat = []
            for y in range(max(1, ty), min(H - 1, ty + TILE)):
                o = y * W
                for x in range(max(1, tx), min(W - 1, tx + TILE)):
                    i = o + x
                    ax = (a[i + 1] - a[i - 1]) * 0.5
                    ay = (a[i + W] - a[i - W]) * 0.5
                    d = float(lut[a[i]] - b[i])
                    if abs(ax) + abs(ay) < EDGE:
                        flat.append(abs(d)); continue
                    gx.append(ax); gy.append(ay); rs.append(d)
            n = len(rs)
            if n < MIN_EDGE_PX:
                continue
            mabs = sum(abs(v) for v in rs) / n
            mflat = (sum(flat) / len(flat)) if flat else 0.0
            mr = sum(rs) / n

            def slope(u):
                mu = sum(u) / n
                suu = sum((v - mu) ** 2 for v in u)
                if suu <= 0:
                    return 0.0
                return sum((u[k] - mu) * (rs[k] - mr) for k in range(n)) / suu
            out[(tx, ty)] = (mabs, slope(gx), slope(gy), n, mflat)
    return out


def top(t, k=8):
    return sorted(t.items(), key=lambda kv: -kv[1][0])[:k]


def show(t, label, k=8):
    print(f"  {label}")
    print(f"    {'tile':>12}  {'edge':>7} {'flat':>7} {'e/f':>6} "
          f"{'dx':>7} {'dy':>7} {'edge px':>8}")
    for (tx, ty), (m, sx, sy, n, mf) in top(t, k):
        ef = (m / mf) if mf > 0.01 else float('inf')
        print(f"    {tx:4d},{ty:4d}   {m:7.2f} {mf:7.2f} {ef:6.2f} "
              f"{sx:+7.3f} {sy:+7.3f} {n:8d}")


def shift_box(a, box, dx):
    x0, y0, x1, y1 = box
    out = bytearray(a)
    for y in range(y0, y1):
        for x in range(x0, x1):
            out[y * W + x] = a[y * W + min(W - 1, max(0, x - dx))]
    return bytes(out)


tmp = tempfile.mkdtemp()
cap = f"{CAPS}/live-{SCREEN.replace('_', '-')}.png"
render = os.environ.get("RENDER", "")
for p in (cap, render):
    if not p or not os.path.exists(p):
        print(f"  🔴 missing: {p or 'RENDER=<png>'}"); sys.exit(2)
subprocess.run(["convert", render, "-crop", f"{W}x{H}+0+0", "+repage",
                f"{tmp}/crop.png"], check=True)
r = gray(f"{tmp}/crop.png", f"{tmp}/r.gray")
c = gray(cap, f"{tmp}/c.gray")

print("CONTROLS -- the render against itself with ONE REGION displaced.\n"
      "The map must put that region on top; magnitude only in the linear regime.\n")
x0, y0, x1, y1 = CTRL_BOX
bad = []


def control(dx, check_magnitude):
    t = tiles(r, shift_box(r, CTRL_BOX, dx))
    show(t, f"known +{dx} px shift inside x {x0}-{x1}, y {y0}-{y1}", 4)
    hits = [(k, v) for k, v in top(t, 4)
            if x0 - TILE < k[0] < x1 and y0 - TILE < k[1] < y1]
    if not hits:
        bad.append(f"+{dx} px: displaced region not in the top 4 tiles")
        return
    best = max(hits, key=lambda kv: kv[1][0])[1][1]
    if best <= 0.3:
        bad.append(f"+{dx} px: local slope {best:+.3f} has the wrong sign or is flat")
    elif check_magnitude and abs(best - dx) > 0.4:
        bad.append(f"+{dx} px: local slope {best:+.3f} does not recover it")
    print(f"      -> localised, local slope {best:+.3f} px"
          f"{'' if check_magnitude else '  (saturating -- a LOWER BOUND)'}\n")


control(CTRL_A_DX, True)
control(CTRL_B_DX, False)
if bad:
    print("  🔴 CONTROL FAILED: " + "; ".join(bad))
    print("  A map that cannot find a displacement it was told about cannot be")
    print("  trusted to have found one it was not. Report suppressed.")
    sys.exit(2)
print("  ✅ controls pass: a 1 px displacement is localised and measured, a 2 px\n"
      "     one is localised with its magnitude understated. So a hot tile with a\n"
      "     real slope is a floor on the displacement, never a ceiling.\n")

print(f"THE REAL PAIR -- {SCREEN}\n")
rt = tiles(r, c)
show(rt, f"{SCREEN}: hottest tiles, whole-frame LUT applied", 10)
ms = sorted(v[0] for v in rt.values())
med = ms[len(ms) // 2]
efs = sorted(v[0] / v[4] for v in rt.values() if v[4] > 0.01)
med_ef = efs[len(efs) // 2]
hot = max(rt.items(), key=lambda kv: kv[1][0])
print(f"\n    median tile |resid| {med:.2f}   hottest {hot[1][0]:.2f} "
      f"at {hot[0][0]},{hot[0][1]}  ({hot[1][0]/med:.2f}x median)")
print(f"    median tile edge/flat {med_ef:.2f}")
efs_hot = [v[0] / v[4] for _, v in top(rt, 10) if v[4] > 0.01]
print(f"    hot tiles span edge/flat {min(efs_hot):.2f}..{max(efs_hot):.2f}, "
      f"straddling that median")
print("    📌 SO THE COLUMN DOES NOT SPLIT THEM. I added it expecting two families --")
print("       tiles hot only at edges (an edge-rendering difference) against tiles")
print("       hot everywhere (a local tone the global LUT mis-serves). The hot tiles")
print("       run continuously across the median instead, so the hot region is NOT")
print("       one anomalous element with a character of its own. Note the frame-wide")
print("       pooled edge/flat is 3.16 while the per-tile median is 1.84: pooling is")
print("       dominated by the tiles carrying the most edge pixels, and reading a")
print("       per-tile threshold off it would have manufactured the split.")
print("\n  ⚠️ THIS TOOL NAMES NOTHING. A hot tile is a coordinate, not an element.")
print("     What sits under it is the Decoder's to say -- they hold the disc.")
