"""Read-only, bounded comparisons for the TVGEP STD2 exhibit; Python 3.10+.
Keep check_sources.py in the same folder.
Usage: python check_relationships.py DECODED_CAG_DIRECTORY source-manifest.json report.json
Requires already decoded members. Writes only a new JSON report.
Does not execute the game, validate UVs, recreate a renderer or prove universal non-use.
"""
import argparse
import json
import math
from pathlib import Path
import re
import struct
import sys
sys.dont_write_bytecode = True
from check_sources import sha

def mesh(data):
    chunks = {}
    p = 12
    if data[:4] != b"FORM" or data[8:12] != b"LWOB":
        raise ValueError("Expected LWOB")
    while p < len(data):
        n = struct.unpack_from(">I", data, p + 4)[0]
        chunks[data[p:p+4]] = data[p+8:p+8+n]
        p += 8 + n + n % 2
    pts = list(struct.iter_unpack(">fff", chunks[b"PNTS"]))
    polys = chunks[b"POLS"]
    triangles = []
    p = 0
    while p < len(polys):
        n = struct.unpack_from(">H", polys, p)[0]
        ids = struct.unpack_from(">" + str(n) + "H", polys, p+2)
        surface = struct.unpack_from(">h", polys, p+2+n*2)[0]
        if surface < 0:
            raise ValueError("Detail polygons unsupported")
        triangles.extend((ids[0], ids[i], ids[i+1]) for i in range(1, n-1))
        p += 4 + n*2
    return pts, triangles, chunks

def geometry_sets(points, triangles, reflected=False):
    coords = [tuple(round(v * (-1 if reflected and i == 0 else 1), 5)
                    for i, v in enumerate(point)) for point in points]
    return set(coords), {tuple(sorted(coords[i] for i in tri)) for tri in triangles}

def keys(data, object_name):
    text = data.decode("cp1252").replace("\r\n", "\n")
    blocks = re.split(r"(?m)(?=^(?:AddNullObject|LoadObject) )", text)
    block = next(b for b in blocks if b.splitlines()[0].lower() == "addnullobject " + object_name.lower())
    m = re.search(r"ObjectMotion[^\n]*\n\s*9\n\s*(\d+)\n", block)
    if not m:
        raise ValueError("Expected nine-channel ObjectMotion")
    lines = block[m.end():].splitlines()
    result = []
    for i in range(int(m[1])):
        values = list(map(float, lines[i*2].split()))
        if len(values) != 9:
            raise ValueError("Unexpected motion channel count")
        result.append((float(lines[i*2+1].split()[0]), values))
    return result

def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("decoded_directory", type=Path)
    ap.add_argument("manifest", type=Path)
    ap.add_argument("report", type=Path)
    args = ap.parse_args()
    if args.report.exists():
        ap.error("Report exists; choose a new filename.")
    names = [
        "edpieces/raptorbox.lwo", "edpieces/Sbox.lwo",
        "edpieces/jump_ow.lwo", "edpieces/jumpwater.lwo",
        "edpieces/straight_wat.lwo", "edpieces/straight_wapl.lwo",
        "edpieces/mound.lwo", "edpieces/hills.lwo",
        "edpieces/Lturn.lwo", "edpieces/Rturn.lwo", "edpieces/S90turn2.lwo",
        "edpieces/Bjump.lwo", "edpieces/olly.lwo",
        "edpieces/bunker.lwo", "edpieces/Odip.lwo",
        "game/garden.lws", "game/gardenbk.lws",
        "game/creek.lws", "game/creekbk.lws",
        "game/pond.lws", "game/Copy of pond.lws",
        "edpieces/uedit.txt",
    ]
    inventory = json.loads(args.manifest.read_text(encoding="utf-8"))["members"]
    by_name = {f'{r["archive"]}/{r["name"]}'.lower(): r for r in inventory}
    sources = {name: by_name[name.lower()] for name in names}
    wanted = {r["sha256"] for r in sources.values()}
    sizes = {r["size"] for r in sources.values()}
    found = {}
    for p in args.decoded_directory.rglob("*"):
        if not p.is_file() or p.is_symlink() or p.stat().st_size not in sizes:
            continue
        b = p.read_bytes()
        h = sha(b)
        if h in wanted:
            found[h] = b
    if wanted - found.keys():
        ap.error("Missing source identities; run check_sources.py for a complete report.")
    def data(name):
        return found[sources[name]["sha256"]]
    checks = {}
    for a, b in [
        ("raptorbox", "Sbox"), ("jump_ow", "jumpwater"),
        ("straight_wat", "straight_wapl"), ("mound", "hills"),
    ]:
        x, y = mesh(data(f"edpieces/{a}.lwo"))[2], mesh(data(f"edpieces/{b}.lwo"))[2]
        result = {k.decode(): x[k] == y[k] for k in [b"PNTS", b"POLS"]}
        assert all(result.values()), (a, b)
        checks[f"{a}/{b} geometry chunks"] = result
    l = mesh(data("edpieces/Lturn.lwo"))
    r = mesh(data("edpieces/Rturn.lwo"))
    s = mesh(data("edpieces/S90turn2.lwo"))
    checks["turn coordinate sets at five decimals"] = {
        "Lturn_equals_reflected_Rturn": geometry_sets(*l[:2]) == geometry_sets(*r[:2], reflected=True),
        "Lturn_equals_S90turn2": geometry_sets(*l[:2]) == geometry_sets(*s[:2]),
    }
    assert all(checks["turn coordinate sets at five decimals"].values())
    b = mesh(data("edpieces/Bjump.lwo"))
    o = mesh(data("edpieces/olly.lwo"))
    assert b[1] == o[1] and len(b[0]) == len(o[0])
    # Check the published fixed relationship, rather than silently refitting it.
    residuals = [math.dist((x, y * 1.249999996 - 0.002473538, z), q)
                 for (x, y, z), q in zip(b[0], o[0])]
    assert max(residuals) < 3e-7
    checks["Bjump to olly published transform"] = {
        "vertices": len(b[0]), "ordered_triangle_indices_equal": True,
        "y_scale": 1.249999996, "y_offset": -0.002473538,
        "max_euclidean_residual": max(residuals),
        "method": "Fixed published scale/offset; not a new affine fit or chronology proof.",
    }
    p = mesh(data("edpieces/bunker.lwo"))[0]
    q = mesh(data("edpieces/Odip.lwo"))[0]
    same = len(set(p) & set(q))
    nearest = max(min(math.dist(v, w) for w in q) for v in p)
    assert same == 249 and abs(nearest - 0.100106) < 1e-6
    checks["Bunker/Odip vertices"] = {"exact_shared": same, "max_nearest_distance": nearest}
    for course, factors, count in [
        ("garden", (1e-10, 1e-18, 1e-12), 162), ("creek", (3, 3, 3), 140),
    ]:
        backup = keys(data(f"game/{course}bk.lws"), "CAM LOCONull")
        selected = keys(data(f"game/{course}.lws"), "CAM LOCONull")
        assert len(backup) == len(selected) == count
        assert [t for t, _ in backup] == [t for t, _ in selected]
        errors = [[], [], []]
        for (_, a), (_, b) in zip(backup, selected):
            for i in range(3):
                recovered = b[i] / factors[i]
                error = abs(recovered - a[i])
                # Explicit asymmetric tolerance in backup-coordinate units.
                assert error <= 2e-6 + 2e-6 * abs(a[i])
                errors[i].append(error)
        checks[f"{course} camera positions"] = {
            "keys": count, "identical_key_times": True, "divide_selected_by": factors,
            "maximum_absolute_errors": [max(e) for e in errors],
            "absolute_tolerance": 2e-6, "relative_tolerance": 2e-6,
            "comparison_frame": "backup coordinates", "matched_components": count * 3,
        }
    copy = keys(data("game/Copy of pond.lws"), "carnull1")
    selected = dict(keys(data("game/pond.lws"), "carnull1"))
    matches, missing, differing = 0, 0, 0
    for t, v in copy:
        control = selected.get(t - 80)
        if control is None:
            missing += 1
        elif all(abs(v[i] - control[i]) < .0001 for i in [4, 5]):
            matches += 1
        else:
            differing += 1
    assert (len(copy), matches, missing, differing) == (111, 102, 6, 3)
    checks["pond pitch/bank at minus 80 frames"] = {
        "copy_keys": len(copy), "matched": matches,
        "missing_shifted_key": missing, "differing_pair": differing,
        "channels": ["pitch", "bank"], "absolute_tolerance_degrees": .0001,
        "interpolation": "none; exact key-time lookup",
    }
    lines = [line.split() for line in data("edpieces/uedit.txt").decode("ascii").splitlines() if line.strip()]
    assert len(lines) == 27
    checks["scene-list text"] = {
        "scene_names": [line[0] for line in lines],
        "extra_model_tokens": [token for line in lines for token in line[1:] if token.lower().endswith(".lwo")],
        "scope": "Text inspection only; does not execute either original parser or verify selectable IDs.",
    }
    report = {
        "checker_sha256": sha(Path(__file__).read_bytes()),
        "source_helper_sha256": sha(Path(__file__).with_name("check_sources.py").read_bytes()),
        "manifest_sha256": sha(args.manifest.read_bytes()),
        "sources": list(sources.values()), "checks": checks,
        "limits": "Bounded source comparisons only. No runtime execution, UV validation, scene rendering, universal non-use proof, or development chronology.",
    }
    with args.report.open("x", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"{len(checks)} bounded relationship checks passed.")
if __name__ == "__main__":
    main()
