#!/usr/bin/env python3
"""Does it ANIMATE, or does it switch? — compare a film, never two stills.

    tools/motion-census <dir-of-frames> [--interval 0.05] [--rows] [--roi x,y,w,h]
    tools/motion-census --selftest

Point it at a directory of PNGs captured in order during a REAL run — the port's
`--film`, or a capture harness's frame dump. It reports where the picture moves
and where it is frozen, plus how many distinct states it ever took.

Why this exists
---------------
🔴 A human played the port and said *"the logos just switch, there is no
animation at all."* Every check either agent had said the splash was correct.
Three instruments agreed, and all three were blind in the same way:

  * a **frozen sweep** — step the clock by hand, render a still per unit. That
    proves the renderer CAN draw pose N. It never runs the animation.
  * a **settled comparison** — correlate the resting pose against a capture. A
    screen that is frozen 84 % of the time matches a settled reference
    perfectly; that is what being frozen MEANS.
  * an **achieved-fps counter** — frames DRAWN per second. Drawing the same
    pixels 25 times a second scores identically to animating at 25 fps.

The common defect: **every one measured throughput or a pose, and none measured
CHANGE.** So this measures change and nothing else.

  measured 2026-09-02 on the boot splash: moving 1.30 s of 7.95 s (16.4 %),
  publisher frozen for 3.20 s, developer for 2.40 s, 26 distinct luma values.

What the numbers mean
---------------------
A fade is ONE LONG RUN of small non-zero deltas. A switch is isolated one-frame
spikes with flat nothing between them. The `distinct states` count is the blunt
version of the same question: a 45-unit ramp cannot be drawn in 26 states.

⚠️ It cannot tell you the animation is CORRECT — only that something moved. A
wrong ramp that moves every frame passes here. Pair it with a comparison against
the oracle; this is the liveness half, which is the half that was missing.
"""

from __future__ import annotations

import sys
from pathlib import Path

try:
    from PIL import Image
except ImportError:
    sys.exit("motion-census: needs Pillow (pip install pillow)")

# Below this, two frames are the same picture. Chosen as a floor, not tuned: PNG
# frames of an unchanged scene differ by exactly 0.000, so anything above noise
# works and a bigger number would only hide small fades.
MOVED = 0.05

# Downsample before comparing. A fade moves every pixel a little, so it survives
# scaling; scaling also stops one stray cursor pixel reading as motion.
GRID = (160, 90)


def load(path: Path, roi=None) -> list[int]:
    im = Image.open(path).convert("L")
    if roi:
        x, y, w, h = roi
        im = im.crop((x, y, x + w, y + h))
    return list(im.resize(GRID).getdata())


def census(frames: list[Path], interval: float, roi=None):
    prev, rows = None, []
    for i, f in enumerate(frames):
        px = load(f, roi)
        lum = sum(px) / len(px)
        delta = 0.0 if prev is None else sum(abs(a - b) for a, b in zip(px, prev)) / len(px)
        rows.append((i * interval, delta, lum))
        prev = px
    return rows


def segments(rows):
    segs, cur, start = [], None, 0.0
    for t, delta, _ in rows[1:]:
        state = "MOVING" if delta > MOVED else "static"
        if state != cur:
            if cur is not None:
                segs.append((cur, start, t))
            cur, start = state, t
    if cur is not None:
        segs.append((cur, start, rows[-1][0]))
    return segs


def report(rows, interval: float, show_rows: bool) -> int:
    total = rows[-1][0]
    segs = segments(rows)

    if show_rows:
        print(f"{'t(s)':>7} {'Δ prev':>8} {'luma':>7}  bar")
        for t, delta, lum in rows:
            print(f"{t:>7.2f} {delta:>8.3f} {lum:>7.2f}  {'#' * min(60, int(delta * 6))}")
        print()

    print(f"{len(rows)} frames @{interval}s = {total:.2f}s\n")
    print(f"{'state':<8} {'from':>7} {'to':>7} {'dur':>7}   luma")
    for state, a, b in segs:
        lums = [l for t, _, l in rows if a <= t <= b]
        flag = "   <-- FROZEN" if state == "static" and (b - a) > 0.5 else ""
        print(f"{state:<8} {a:>7.2f} {b:>7.2f} {b-a:>7.2f}   "
              f"{min(lums):.2f}..{max(lums):.2f}{flag}")

    moving = sum(b - a for s, a, b in segs if s == "MOVING")
    pct = 100 * moving / total if total else 0.0
    states = len({round(l, 2) for _, _, l in rows})
    print(f"\nmoving  {moving:.2f}s of {total:.2f}s = {pct:.1f}%")
    print(f"distinct luma states: {states}")
    longest = max((b - a for s, a, b in segs if s == "static"), default=0.0)
    print(f"longest frozen stretch: {longest:.2f}s")
    print()
    if pct < 50:
        print("🔴 This is a SWITCH, not an animation. Most of the run is one still")
        print("   picture. A frozen sweep and a settled comparison both pass on this.")
        return 1
    print("moves for most of its length — liveness only; correctness is a separate")
    print("question this tool cannot answer.")
    return 0


def selftest() -> int:
    """Synthetic controls, executed. A detector that cannot tell a fade from a
    switch would report the same green line on both, and its verdict on the real
    film would mean nothing."""
    import tempfile

    ok = 0
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)

        def write(name, values):
            sub = root / name
            sub.mkdir()
            for i, v in enumerate(values):
                Image.new("L", (320, 180), int(v)).save(sub / f"f{i:04d}.png")
            return sorted(sub.glob("*.png"))

        # A fade: every frame differs from the last.
        fade = write("fade", [10 + i * 4 for i in range(40)])
        rows = census(fade, 0.05)
        pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
        good = pct > 90
        print(f"  {'a real fade reads as MOVING':<40} {pct:5.1f}%  {'✅' if good else '🔴'}")
        ok |= 0 if good else 1

        # A switch: one value, then another, held. This is the shape the port
        # actually produced, and the case the old instruments could not see.
        sw = write("switch", [10] * 20 + [200] * 20)
        rows = census(sw, 0.05)
        pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
        good = pct < 20
        print(f"  {'a switch reads as STATIC':<40} {pct:5.1f}%  {'✅' if good else '🔴'}")
        ok |= 0 if good else 1

        # Fully frozen: the degenerate case must not read as motion.
        fr = write("frozen", [42] * 30)
        rows = census(fr, 0.05)
        pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
        good = pct == 0
        print(f"  {'a frozen film reads as 0% motion':<40} {pct:5.1f}%  {'✅' if good else '🔴'}")
        ok |= 0 if good else 1

    print()
    print("the census separates a fade from a switch" if not ok
          else "🔴 the census cannot tell a fade from a switch — it asserts nothing")
    return ok


def main(argv: list[str]) -> int:
    if "--selftest" in argv:
        return selftest()
    args = [a for a in argv if not a.startswith("--")]
    if not args:
        sys.exit(__doc__.split("\n\n")[1])
    interval = 0.05
    roi = None
    for i, a in enumerate(argv):
        if a == "--interval" and i + 1 < len(argv):
            interval = float(argv[i + 1])
        if a == "--roi" and i + 1 < len(argv):
            roi = tuple(int(v) for v in argv[i + 1].split(","))
    d = Path(args[0])
    frames = sorted(d.glob("*.png"))
    if len(frames) < 3:
        sys.exit(f"motion-census: {d} holds {len(frames)} PNG(s) — nothing to compare.\n"
                 "   Exit 2: the harness is broken, not the animation.")
    return report(census(frames, interval, roi), interval, "--rows" in argv)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
