import argparse
import csv
import hashlib
import json
import math
import os
from pathlib import Path
import re
import subprocess
import sys
from fractions import Fraction
from xml.etree import ElementTree
from zipfile import ZipFile


HERE = Path(__file__).resolve().parent
RECIPE = "agent-task-card-explanation"
LIBRARY = Path("videos/editing/benchmark/library")
NUMERIC = {
    "x": 0, "y": 0, "opacity": 1, "scale": 1, "scaleX": 1, "scaleY": 1,
    "rotation": 0, "rotationX": 0, "rotationY": 0, "radius": 0,
}


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


def file_hash(path):
    with path.open("rb") as stream:
        return hashlib.file_digest(stream, "sha256").hexdigest()


def require(condition, message):
    if not condition:
        raise ValueError(message)


def unpack(value, tracks):
    if isinstance(value, dict):
        if set(value) == {"track"}:
            return tracks[value["track"]]
        return {key: unpack(item, tracks) for key, item in value.items()}
    if isinstance(value, list):
        return [unpack(item, tracks) for item in value]
    return value


def flatten(layers, parent=None):
    for layer in layers:
        yield layer, parent
        yield from flatten(layer.get("children", []), layer["id"])


def sample(track, frame, fallback=0):
    if track is None:
        return fallback
    if isinstance(track, (int, float)):
        return track
    require(bool(track), "Empty animation track")
    if frame <= track[0]["frame"]:
        return track[0]["value"]
    for left, right in zip(track, track[1:]):
        if frame > right["frame"]:
            continue
        if frame == right["frame"]:
            return right["value"]
        if left.get("easing") == "hold":
            return left["value"]
        progress = (frame - left["frame"]) / (right["frame"] - left["frame"])
        eased = progress * progress * (3 - 2 * progress) if left.get("easing") == "smooth" else progress
        return left["value"] + (right["value"] - left["value"]) * eased
    return track[-1]["value"]


def text_at(layer, content, frame):
    value = content[layer["slot"]]
    reveal = layer.get("reveal")
    if reveal:
        require(reveal["unit"] == "characters" and reveal.get("from", "start") == "start", "Unsupported reveal")
        count = next((step["count"] for step in reversed(reveal["steps"]) if step["frame"] <= frame), 0)
        value = value[:count]
    return layer.get("prefix", "") + value + layer.get("suffix", "")


def identity():
    return [[float(row == column) for column in range(4)] for row in range(4)]


def multiply(left, right):
    return [[sum(left[row][inner] * right[inner][column] for inner in range(4)) for column in range(4)] for row in range(4)]


def translate(horizontal, vertical):
    matrix = identity()
    matrix[0][3], matrix[1][3] = horizontal, vertical
    return matrix


def matrix_for(values, layer):
    origin_x, origin_y = layer.get("origin", [0, 0])
    perspective = identity()
    if layer.get("perspective"):
        perspective[3][2] = -1 / layer["perspective"]
    rotation_x, rotation_y, rotation_z = (math.radians(values[key]) for key in ("rotationX", "rotationY", "rotation"))
    rotate_x, rotate_y, rotate_z, scale = (identity() for _ in range(4))
    rotate_x[1][1], rotate_x[1][2], rotate_x[2][1], rotate_x[2][2] = math.cos(rotation_x), -math.sin(rotation_x), math.sin(rotation_x), math.cos(rotation_x)
    rotate_y[0][0], rotate_y[0][2], rotate_y[2][0], rotate_y[2][2] = math.cos(rotation_y), math.sin(rotation_y), -math.sin(rotation_y), math.cos(rotation_y)
    rotate_z[0][0], rotate_z[0][1], rotate_z[1][0], rotate_z[1][1] = math.cos(rotation_z), -math.sin(rotation_z), math.sin(rotation_z), math.cos(rotation_z)
    scale[0][0], scale[1][1] = values["scale"] * values["scaleX"], values["scale"] * values["scaleY"]
    matrix = identity()
    for operation in (translate(values["x"], values["y"]), translate(origin_x, origin_y), perspective, rotate_x, rotate_y, rotate_z, scale, translate(-origin_x, -origin_y)):
        matrix = multiply(matrix, operation)
    return matrix


def project(matrix, horizontal, vertical):
    position = [horizontal, vertical, 0, 1]
    result = [sum(row[index] * position[index] for index in range(4)) for row in matrix]
    require(abs(result[3]) > 1e-12, "Projection at infinity")
    return [result[0] / result[3], result[1] / result[3]]


def pose_rows(recipe, content, frame):
    records = {}
    for layer, parent in flatten(recipe["layers"]):
        ancestor = records.get(parent)
        values = {key: sample(layer.get(key), frame, fallback) for key, fallback in NUMERIC.items()}
        for key in ("width", "height"):
            values[key] = sample(layer.get(key), frame, ancestor["values"][key] if ancestor else recipe["canvas"][key])
        local_matrix = matrix_for(values, layer)
        world_matrix = multiply(ancestor["matrix"], local_matrix) if ancestor else local_matrix
        active = layer["start"] <= frame < layer["end"] and (ancestor["active"] if ancestor else True)
        width, height = values["width"], values["height"]
        corners = [project(world_matrix, horizontal, vertical) for horizontal, vertical in ((0, 0), (width, 0), (width, height), (0, height))]
        row = {
            "id": layer["id"], "parent": parent, "kind": layer["kind"], "frame": frame,
            "sourceFrame": recipe["source"]["startFrame"] + frame,
            "localSeconds": str(Fraction(str(frame)) / 60),
            "sourceSeconds": str((Fraction(str(frame)) + recipe["source"]["startFrame"]) / 60),
            "active": active, "values": values, "matrix": world_matrix,
            "effectiveOpacity": values["opacity"] * (ancestor["effectiveOpacity"] if ancestor else 1) if active else 0,
            "quadPixelsTLTRBRBL": corners,
            "quadFusionTLTRBRBL": [[horizontal / 1920, 1 - vertical / 1080] for horizontal, vertical in corners],
            "anchorPixels": project(world_matrix, 0, 0),
            "localFusionCenter": [(values["x"] + width / 2) / 1920, 1 - (values["y"] + height / 2) / 1080],
        }
        if layer["kind"] == "text":
            row["text"] = text_at(layer, content, frame) if active else ""
        records[layer["id"]] = row
    return list(records.values())


def validate_svg(data):
    require(b"<!DOCTYPE" not in data and b"<!ENTITY" not in data, "SVG external entity")
    document = ElementTree.fromstring(data)
    allowed = {"svg", "metadata", "defs", "filter", "feGaussianBlur", "linearGradient", "radialGradient", "stop", "rect", "g", "path", "ellipse"}
    for element in document.iter():
        require(element.tag.split("}")[-1] in allowed, "Unexpected SVG element")
        for key, value in element.attrib.items():
            require(not key.split("}")[-1].lower().startswith("on"), "SVG event handler")
            require(key.split("}")[-1] not in {"href", "src"}, "SVG external or raster reference")
            require("data:" not in value.lower() and "@import" not in value.lower(), "Embedded data")
            for target in re.findall(r"url\(([^)]+)\)", value):
                require(target.startswith("#"), "Non-local SVG paint")
    return {"width": int(document.attrib["width"]), "height": int(document.attrib["height"])}


def resolve_stage(stage=None):
    configured = stage if stage is not None else os.environ.get("FIELDNOTES_STAGE")
    if configured is None or not os.fspath(configured).strip():
        raise ValueError("Source data is not configured. Pass --stage or set FIELDNOTES_STAGE; no verification was performed.")
    return Path(configured)


def load(stage=None):
    stage = resolve_stage(stage)
    locks = json.loads((HERE / "bindings.json").read_text())
    library = Path(stage) / LIBRARY
    members = {}
    for archive_record in locks["archives"]:
        path = library / "downloads" / archive_record["path"]
        require(path.stat().st_size == archive_record["bytes"] and file_hash(path) == archive_record["sha256"], f"Archive changed: {path.name}")
        with ZipFile(path) as archive:
            for record in archive_record["selectedMembers"]:
                with archive.open(record["path"]) as stream:
                    data = stream.read()
                require(len(data) == record["bytes"] and sha256(data) == record["sha256"], f"Member changed: {record['path']}")
                members[record["path"]] = data
    recipe = json.loads(members[f"project/recipes/{RECIPE}.json"])
    require(recipe["source"]["fps"] == {"numerator": 60, "denominator": 1}, "Do not retime")
    require((recipe["source"]["startFrame"], recipe["source"]["endFrameExclusive"]) == (1156, 1450), "Source range changed")
    compact = json.loads((HERE / "controls.json").read_text())
    require(unpack(compact["layers"], compact["tracks"]) == recipe["layers"], "Control model differs from actual recipe")
    require(compact["recipeSha256"] == sha256(members[f"project/recipes/{RECIPE}.json"]), "Wrong recipe binding")
    for variant, key in (("default", "defaults"), ("adaptation", "adaptation")):
        require(json.loads(members[f"project/examples/{RECIPE}/{variant}.json"]) == recipe[key], "Example content drift")
        require(compact["contents"][variant] == recipe[key], "Control content drift")
        for field in recipe[key].values():
            if isinstance(field, dict) and field.get("kind") == "image":
                path = "project/public/" + field["path"]
                require(path.endswith(".svg"), "Only original SVG assets are permitted")
                dimensions = validate_svg(members[path])
                require(dimensions == {key: field[key] for key in ("width", "height")}, "SVG dimensions changed")
    for record in locks["retainedFiles"]:
        path = library / record["path"]
        require(path.stat().st_size == record["bytes"] and file_hash(path) == record["sha256"], f"Retained evidence changed: {record['path']}")
    return recipe, members, locks


def binding_report(recipe, members, locks, stage):
    toolkit = json.loads(members[f"project/toolkit-manifests/{RECIPE}.json"])
    result = {}
    for variant, render_binding in toolkit["renderBindings"].items():
        for name, expected in render_binding["fileHashes"].items():
            require(sha256(members["project/" + name]) == expected, f"Frozen render member mismatch: {name}")
        review = toolkit["reviewedRenders"][variant]
        data = (Path(stage) / LIBRARY / review["manifest"]["path"]).read_bytes()
        manifest = json.loads(data)
        require(manifest["binding"] == render_binding["binding"], "Retained binding drift")
        require(manifest["inputProps"] == render_binding["inputProps"], "Retained input drift")
        require(manifest["inputProps"]["recipe"] == recipe, "Rendered recipe differs")
        require(manifest["inputProps"]["content"] == recipe["defaults" if variant == "default" else "adaptation"], "Rendered content differs")
        result[variant] = {
            "selectedFileHashesMatch": True, "inputPropsAndBindingEqual": True,
            "retainedManifestSha256": sha256(data), "indexedManifestSha256": review["manifest"]["sha256"],
            "wrapperHashMatchesIndex": sha256(data) == review["manifest"]["sha256"],
            "movieSha256": review["movie"]["sha256"],
        }
    return result


def renderer_check(recipe, members):
    frames = sorted(set(range(295)) | {key + offset for key in (0, 13, 24, 43, 74, 80, 99, 112, 113, 159, 270, 274, 280, 281, 293, 294) for offset in (-0.25, 0.25)})
    frames = [frame for frame in frames if 0 <= frame <= 294]
    cases = []
    for variant, key in (("default", "defaults"), ("adaptation", "adaptation")):
        for frame in frames:
            cases.append({"variant": variant, "frame": frame, "rows": pose_rows(recipe, recipe[key], frame)})
    payload = {
        "timing": members["project/src/timing.ts"].decode(),
        "treatment": members["project/src/Treatment.tsx"].decode(),
        "root": members["project/src/Root.tsx"].decode(),
        "recipe": recipe, "cases": cases,
    }
    environment = dict(os.environ)
    environment.pop("NODE_COMPILE_CACHE", None)
    environment.pop("NODE_OPTIONS", None)
    completed = subprocess.run(["node", "--disable-warning=ExperimentalWarning", str(HERE / "renderer-check.mjs")], input=json.dumps(payload), text=True, capture_output=True, env=environment, check=True)
    return json.loads(completed.stdout)


def probe(stage, locks):
    result = {}
    for record in locks["retainedFiles"]:
        if not record["path"].endswith(".mp4"):
            continue
        result[record["path"]] = json.loads(subprocess.check_output([
            "ffprobe", "-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate,avg_frame_rate,nb_frames,duration:format=duration",
            "-of", "json", str(Path(stage) / LIBRARY / record["path"]),
        ], text=True))
    return result


def fusion_expression(track, start=0):
    if isinstance(track, (int, float)):
        return str(track)
    clock = f"(time-({start}))"
    expression = str(track[-1]["value"])
    for left, right in reversed(list(zip(track, track[1:]))):
        progress = f"(({clock}-{left['frame']})/{right['frame']-left['frame']})"
        eased = f"({progress}*{progress}*(3-2*{progress}))" if left.get("easing") == "smooth" else progress
        value = str(left["value"]) if left.get("easing") == "hold" else f"({left['value']}+({right['value']-left['value']})*{eased})"
        expression = f"iif({clock}<{right['frame']},{value},{expression})"
    return f"iif({clock}<={track[0]['frame']},{track[0]['value']},{expression})"


def curve(track):
    if isinstance(track, (int, float)):
        return {"constant": track}
    segments = []
    for left, right in zip(track, track[1:]):
        distance = right["frame"] - left["frame"]
        segment = {"left": left, "right": right, "mode": left.get("easing", "linear")}
        if segment["mode"] == "smooth":
            segment["bezierHandlesFrameValue"] = [[left["frame"] + distance / 3, left["value"]], [right["frame"] - distance / 3, right["value"]]]
        segments.append(segment)
    return {"keys": track, "segments": segments, "outside": "Clamp to first/last value; hold steps change AT the right key."}


def parse_frames(value):
    if value == "all":
        return list(range(294))
    frames = [float(item) for item in value.split(",")]
    require(all(math.isfinite(frame) and 0 <= frame <= 294 for frame in frames), "Frames must be within 0..294; 294 is the exclusive-boundary probe")
    return [int(frame) if frame.is_integer() else frame for frame in frames]


def main():
    parser = argparse.ArgumentParser(description="Read-only, archive-bound task-card controls; no render, extraction, installation or native Resolve automation.")
    parser.add_argument("command", choices=("verify", "table", "curve", "expression"))
    parser.add_argument("--stage", help="Subset root containing videos/editing/benchmark/library; overrides FIELDNOTES_STAGE")
    parser.add_argument("--variant", choices=("default", "adaptation"), default="default")
    parser.add_argument("--frames", default="74,112,113,159,270,278,280,281,293")
    parser.add_argument("--layer")
    parser.add_argument("--property")
    parser.add_argument("--format", choices=("json", "csv"), default="json")
    parser.add_argument("--fusion-start", type=int, default=0)
    parser.add_argument("--probe", action="store_true")
    args = parser.parse_args()
    try:
        args.stage = resolve_stage(args.stage)
    except ValueError as error:
        parser.error(str(error))
    recipe, members, locks = load(args.stage)
    layers = {layer["id"]: layer for layer, _ in flatten(recipe["layers"])}
    if args.layer:
        require(args.layer in layers, "Unknown layer")
    if args.command == "verify":
        result = {
            "scope": "numeric/source-code and retained-byte checks only",
            "archivesAndSelectedMembersMatch": True, "compactControlsEqualRecipe": True,
            "originalSvgCount": sum(name.endswith(".svg") for name in members),
            "renderBindings": binding_report(recipe, members, locks, args.stage),
            "rendererOracle": renderer_check(recipe, members),
            "newRenders": 0, "nativeResolveVerified": False, "movingSourceReviewed": False,
            "completionCredit": 0, "criticVerdict": None,
            "blockers": locks["blockers"],
        }
        if args.probe:
            result["retainedMediaProbe"] = probe(args.stage, locks)
    elif args.command in ("curve", "expression"):
        require(args.layer and args.property, "Choose --layer and --property")
        require(args.property in NUMERIC or args.property in ("width", "height"), "Choose a numeric control")
        track = layers[args.layer].get(args.property, NUMERIC.get(args.property))
        require(track is not None, "This dimension inherits its parent")
        if args.command == "expression":
            print(fusion_expression(track, args.fusion_start))
            return
        result = curve(track)
    else:
        content = recipe["defaults" if args.variant == "default" else "adaptation"]
        result = [row for frame in parse_frames(args.frames) for row in pose_rows(recipe, content, frame) if not args.layer or row["id"] == args.layer]
        if args.format == "csv":
            output = csv.writer(sys.stdout)
            output.writerow(["frame", "sourceFrame", "layer", "parent", "active", "x", "y", "width", "height", "opacity", "worldAnchorX", "worldAnchorY", "text", "quadFusionTLTRBRBL"])
            for row in result:
                output.writerow([row["frame"], row["sourceFrame"], row["id"], row["parent"], row["active"], *[row["values"][key] for key in ("x", "y", "width", "height")], row["effectiveOpacity"], *row["anchorPixels"], row.get("text", ""), json.dumps(row["quadFusionTLTRBRBL"])])
            return
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()
