"""Read-only TVGEP source audit. Python 3.10+; standard library only.
Usage: python check_sources.py DECODED_CAG_DIRECTORY source-manifest.json report.json
Requires already decoded CAG members. Does not decode archives, run retail,
modify game files, reproduce x86 experiments, or establish exhaustive non-use.
Files are identified by SHA-256, independent of extraction filename conventions.
The only write is the requested report; an existing destination is refused.
"""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import re
import struct

def sha(data):
    return hashlib.sha256(data).hexdigest()

def lwob_summary(data):
    """Export hashes/counts, never mesh arrays."""
    end = struct.unpack_from(">I", data, 4)[0] + 8
    if end != len(data):
        raise ValueError("LWOB FORM length mismatch")
    chunks = {}
    pos = 12
    while pos < end:
        kind = data[pos:pos + 4].decode("ascii")
        length = struct.unpack_from(">I", data, pos + 4)[0]
        payload = data[pos + 8:pos + 8 + length]
        if len(payload) != length:
            raise ValueError("truncated chunk")
        chunks.setdefault(kind, []).append(payload)
        pos += 8 + length + length % 2
    if pos != end or len(chunks.get("PNTS", [])) != 1 or len(chunks.get("POLS", [])) != 1:
        raise ValueError("unexpected chunk structure")
    points, polygons = chunks["PNTS"][0], chunks["POLS"][0]
    if len(points) % 12:
        raise ValueError("invalid point payload")
    vertices = len(points) // 12
    counts = Counter()
    triangles = 0
    pos = 0
    while pos < len(polygons):
        n = struct.unpack_from(">H", polygons, pos)[0]
        pos += 2
        ids = struct.unpack_from(">" + str(n) + "H", polygons, pos)
        pos += n * 2
        surface = struct.unpack_from(">h", polygons, pos)[0]
        pos += 2
        if surface < 0:
            # Fail closed on LWOB detail polygons rather than count them incorrectly.
            return {"format": "LWOB", "vertices": vertices,
                    "polygon_count_status": "unsupported detail polygons",
                    "PNTS_sha256": sha(points), "POLS_sha256": sha(polygons)}
        if any(i >= vertices for i in ids):
            raise ValueError("polygon index out of bounds")
        counts[str(n)] += 1
        triangles += max(n - 2, 0)
    if pos != len(polygons):
        raise ValueError("polygon payload length mismatch")
    return {"format": "LWOB", "vertices": vertices,
            "polygon_records": sum(counts.values()),
            "polygon_vertex_counts": dict(sorted(counts.items())),
            "fan_triangles_excluding_lines_points": triangles,
            "PNTS_sha256": sha(points), "POLS_sha256": sha(polygons)}

def scene_summary(data):
    """Reduce historical object paths to basenames."""
    text = data.decode("cp1252")
    objects = []
    for block in re.split(r"(?m)^LoadObject[ \t]+", text)[1:]:
        name = block.splitlines()[0].strip().replace("\\", "/").split("/")[-1]
        show = re.search(r"(?m)^ShowObject[ \t]+([^\r\n]+)", block)
        objects.append({"model_basename": name,
                        "ShowObject": show[1].split() if show else None})
    return {"objects": objects}

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 not args.decoded_directory.is_dir():
        ap.error("Decoded directory does not exist.")
    if args.report.exists():
        ap.error("Report already exists; choose a new output filename.")
    expected = json.loads(args.manifest.read_text(encoding="utf-8"))["members"]
    wanted = {row["sha256"] for row in expected}
    sizes = {row["size"] for row in expected}
    located = {}
    for path in args.decoded_directory.rglob("*"):
        if not path.is_file() or path.is_symlink() or path.stat().st_size not in sizes:
            continue
        data = path.read_bytes()
        digest = sha(data)
        if digest in wanted:
            located.setdefault(digest, path)
    rows, missing = [], []
    for source in expected:
        identity = f'{source["archive"]}/{source["name"]}'
        path = located.get(source["sha256"])
        if path is None:
            missing.append(identity)
            continue
        data = path.read_bytes()
        if len(data) != source["size"] or sha(data) != source["sha256"]:
            raise ValueError("Source changed during verification")
        row = {**source, "identity": identity}
        if Path(source["name"]).suffix.lower() == ".lws":
            row["scene"] = scene_summary(data)
        elif data[:4] == b"FORM" and data[8:12] == b"LWOB":
            row["mesh"] = lwob_summary(data)
        rows.append(row)
    result = {
        "method": "SHA-256 and size verification; direct LWOB chunk/record inspection; exact LWS LoadObject basename extraction.",
        "checker_sha256": sha(Path(__file__).read_bytes()),
        "manifest_sha256": sha(args.manifest.read_bytes()),
        "expected_members": len(expected), "verified_members": len(rows),
        "missing_members": missing, "members": rows,
        "limits": [
            "Requires prior archive decoding; does not validate the decoding algorithm.",
            "Hash matches identify bytes, not archive provenance or runtime use.",
            "LoadObject basenames alone do not resolve archive paths or scene reachability.",
            "Counts describe stored polygon records and fan triangles, not unique coordinate sets or runtime rendering.",
            "No binary execution, live gameplay, image decoding, UV validation, geometry fitting or exhaustive reference audit.",
        ],
    }
    with args.report.open("x", encoding="utf-8") as stream:
        json.dump(result, stream, ensure_ascii=False, indent=2)
        stream.write("\n")
    print(f'Verified {len(rows)}/{len(expected)} member identities; missing {len(missing)}.')
    if missing:
        raise SystemExit(1)

if __name__ == "__main__":
    main()
