#!/usr/bin/env python3
from __future__ import annotations

import argparse
import csv
import json
import math
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable

import cv2
import numpy as np


MAX_FEATURE_DIM = 1600
RATIO_TEST = 0.72
RANSAC_REPROJ_THRESHOLD = 5.0
MIN_GOOD_MATCHES = 20
MIN_INLIERS = 12
MIN_INLIER_RATIO = 0.25
EDGE_GUARD_SECONDS = 1.0 / 30.0
MOTION_MIN_SCORE = 25.0
MOTION_MIN_INLIERS = 20
MOTION_MIN_INLIER_RATIO = 0.45
TRANSFORM_ERROR_DIAGONAL_RATIO = 0.003
MIN_TRANSFORM_ERROR_PIXELS = 3.0
MAX_TRANSFORM_ERROR_PIXELS = 12.0
MIN_REFINEMENT_SEGMENT_SECONDS = 2.0 / 30.0
MAX_REFINEMENT_DEPTH = 6
MAX_KEYFRAMES_PER_CLIP = 24
KEYFRAME_SIMPLIFICATION_TOLERANCE_MULTIPLIER = 1.5
MARKER_COMMENT_TAG = "automation-agent:reference-framing-match"


@dataclass
class ClipRow:
    n: int
    ref: str
    ref_clip_ref: str
    start: float
    end: float
    in_point: float
    out_point: float
    src: str
    ref_start: float
    ref_end: float
    ref_in: float
    ref_src: str

    @property
    def duration(self) -> float:
        return max(0.0, self.end - self.start)

    @property
    def analysis_start(self) -> float:
        return max(self.start, self.ref_start)

    @property
    def analysis_end(self) -> float:
        return min(self.end, self.ref_end)

    @property
    def analysis_duration(self) -> float:
        return max(0.0, self.analysis_end - self.analysis_start)


def clean_field(value: object) -> str:
    text = "" if value is None else str(value)
    return text.replace("\t", " ").replace("\r", " ").replace("\n", " | ")


def run_quiet(cmd: list[str]) -> None:
    result = subprocess.run(
        cmd,
        check=False,
        stdin=subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    if result.returncode != 0:
        print(f"ERROR|External command failed with exit code {result.returncode}: {cmd}", file=sys.stderr)
        if result.stdout:
            print("STDOUT:", file=sys.stderr)
            print(result.stdout.rstrip(), file=sys.stderr)
        if result.stderr:
            print("STDERR:", file=sys.stderr)
            print(result.stderr.rstrip(), file=sys.stderr)
        result.check_returncode()


def export_frame(ffmpeg: str, video: str, time_seconds: float, output: Path) -> bool:
    output.parent.mkdir(parents=True, exist_ok=True)
    output.unlink(missing_ok=True)
    run_quiet([
        ffmpeg,
        "-y",
        "-ss",
        f"{max(0.0, time_seconds):.6f}",
        "-i",
        video,
        "-frames:v",
        "1",
        "-update",
        "1",
        str(output),
    ])
    return output.is_file() and output.stat().st_size > 0


def frame_retry_timeline_times(requested: float, start: float, end: float) -> list[float]:
    candidates = [requested]
    for frames in (1, 2, 4, 8):
        delta = EDGE_GUARD_SECONDS * frames
        candidates.extend([requested - delta, requested + delta])
    result: list[float] = []
    for candidate in candidates:
        bounded = min(end, max(start, candidate))
        if not any(abs(existing - bounded) < 1e-6 for existing in result):
            result.append(bounded)
    return result


def read_image(path: Path) -> np.ndarray:
    image = cv2.imread(str(path), cv2.IMREAD_COLOR)
    if image is None:
        raise RuntimeError(f"Could not read image: {path}")
    return image


def resize_for_features(image: np.ndarray) -> tuple[np.ndarray, float]:
    h, w = image.shape[:2]
    scale = min(1.0, MAX_FEATURE_DIM / max(w, h))
    if scale == 1.0:
        return image, 1.0
    return cv2.resize(image, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA), scale


def create_detector():
    if hasattr(cv2, "SIFT_create"):
        return "SIFT", cv2.SIFT_create(nfeatures=8000)
    return "AKAZE", cv2.AKAZE_create()


def match_images(source_path: Path, ref_path: Path) -> dict:
    source = read_image(source_path)
    ref = read_image(ref_path)
    source_work, source_scale = resize_for_features(source)
    ref_work, ref_scale = resize_for_features(ref)

    detector_name, detector = create_detector()
    kp_source, des_source = detector.detectAndCompute(cv2.cvtColor(source_work, cv2.COLOR_BGR2GRAY), None)
    kp_ref, des_ref = detector.detectAndCompute(cv2.cvtColor(ref_work, cv2.COLOR_BGR2GRAY), None)
    if des_source is None or des_ref is None or len(kp_source) < 8 or len(kp_ref) < 8:
        return {"ok": False, "accepted": False, "reason": "not_enough_features", "score": 0.0}

    if detector_name == "SIFT":
        matcher = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=80))
    else:
        matcher = cv2.BFMatcher(cv2.NORM_HAMMING)

    raw_matches = matcher.knnMatch(des_source, des_ref, k=2)
    good = [pair[0] for pair in raw_matches if len(pair) == 2 and pair[0].distance < RATIO_TEST * pair[1].distance]
    if len(good) < 8:
        return {
            "ok": False,
            "accepted": False,
            "reason": "not_enough_good_matches",
            "score": 0.0,
            "goodMatches": len(good),
        }

    pts_source = np.float32([kp_source[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
    pts_ref = np.float32([kp_ref[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
    matrix_work, inliers = cv2.estimateAffinePartial2D(
        pts_source,
        pts_ref,
        method=cv2.RANSAC,
        ransacReprojThreshold=RANSAC_REPROJ_THRESHOLD,
        maxIters=10000,
        confidence=0.995,
        refineIters=20,
    )
    if matrix_work is None or inliers is None:
        return {"ok": False, "accepted": False, "reason": "ransac_failed", "score": 0.0, "goodMatches": len(good)}

    matrix_orig = matrix_work.astype(np.float64).copy()
    matrix_orig[:, :2] *= source_scale / ref_scale
    matrix_orig[:, 2] /= ref_scale

    inlier_count = int(inliers.sum())
    inlier_ratio = inlier_count / max(1, len(good))
    a, b = matrix_orig[0, 0], matrix_orig[0, 1]
    c, d = matrix_orig[1, 0], matrix_orig[1, 1]
    scale_x = math.sqrt(a * a + c * c)
    scale_y = math.sqrt(b * b + d * d)
    uniform_scale = (scale_x + scale_y) / 2.0
    rotation_degrees = math.degrees(math.atan2(c, a))
    tx, ty = float(matrix_orig[0, 2]), float(matrix_orig[1, 2])

    src_h, src_w = source.shape[:2]
    ref_h, ref_w = ref.shape[:2]
    pos_x = tx + uniform_scale * src_w / 2.0
    pos_y = ty + uniform_scale * src_h / 2.0
    score = inlier_count * inlier_ratio
    accepted = len(good) >= MIN_GOOD_MATCHES and inlier_count >= MIN_INLIERS and inlier_ratio >= MIN_INLIER_RATIO

    return {
        "ok": True,
        "accepted": accepted,
        "detector": detector_name,
        "goodMatches": len(good),
        "inliers": inlier_count,
        "inlierRatio": inlier_ratio,
        "score": score,
        "scale": uniform_scale * 100.0,
        "position": [pos_x / ref_w, pos_y / ref_h],
        "positionPixels": [pos_x, pos_y],
        "rotationDegrees": rotation_degrees,
        "sourceWidth": src_w,
        "sourceHeight": src_h,
        "referenceWidth": ref_w,
        "referenceHeight": ref_h,
    }


def validate_reference_raster_dimensions(
    reference_width: int,
    reference_height: int,
    sequence_width: float,
    sequence_height: float,
) -> None:
    expected_width = int(round(sequence_width))
    expected_height = int(round(sequence_height))
    if reference_width == expected_width and reference_height == expected_height:
        return
    raise RuntimeError(
        f"Unsupported V2 reference raster: {reference_width}x{reference_height}. "
        f"The active sequence is {expected_width}x{expected_height}. "
        "Direct V2 reference media must match the active sequence raster because "
        "Premiere can apply frame-size conforming outside the readable Motion Scale. "
        f"Pre-render the reference at {expected_width}x{expected_height} and run the workflow again."
    )


def validate_reference_raster(
    reference_path: Path,
    sequence_width: float,
    sequence_height: float,
) -> None:
    reference = read_image(reference_path)
    reference_height, reference_width = reference.shape[:2]
    validate_reference_raster_dimensions(
        reference_width,
        reference_height,
        sequence_width,
        sequence_height,
    )


def parse_manifest(path: Path) -> list[ClipRow]:
    rows: list[ClipRow] = []
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, delimiter="\t")
        for raw in reader:
            rows.append(
                ClipRow(
                    n=int(raw["clipNo"]),
                    ref=raw["clipRef"],
                    ref_clip_ref=raw["refClipRef"],
                    start=float(raw["start"]),
                    end=float(raw["end"]),
                    in_point=float(raw["in"]),
                    out_point=float(raw["out"]),
                    src=raw["src"],
                    ref_start=float(raw["refStart"]),
                    ref_end=float(raw["refEnd"]),
                    ref_in=float(raw["refIn"]),
                    ref_src=raw["refSrc"],
                )
            )
    return rows


def initial_sample_points(clip: ClipRow, enable_keyframes: bool) -> list[tuple[str, float]]:
    start = clip.analysis_start
    duration = clip.analysis_duration
    if duration <= 0:
        return []
    guard = min(EDGE_GUARD_SECONDS, duration * 0.1)
    first = start + guard
    last = start + duration - guard
    if last - first < MIN_REFINEMENT_SEGMENT_SECONDS:
        return [("mid", start + duration * 0.5)]
    if not enable_keyframes:
        return [
            ("early", first),
            ("mid", (first + last) * 0.5),
            ("late", last),
        ]
    return [
        ("edge_in", first),
        ("quarter", first + (last - first) * 0.25),
        ("mid", first + (last - first) * 0.5),
        ("three_quarter", first + (last - first) * 0.75),
        ("edge_out", last),
    ]


def reference_media_time(timeline_time: float, ref_start: float, ref_in: float) -> float:
    return ref_in + (timeline_time - ref_start)


def source_media_time(clip: ClipRow, timeline_time: float) -> float:
    return clip.in_point + (timeline_time - clip.start)


def motion_sample_is_reliable(sample: dict) -> bool:
    return bool(
        sample.get("accepted")
        and float(sample.get("score", 0.0)) >= MOTION_MIN_SCORE
        and int(sample.get("inliers", 0)) >= MOTION_MIN_INLIERS
        and float(sample.get("inlierRatio", 0.0)) >= MOTION_MIN_INLIER_RATIO
    )


def transform_tolerance_pixels(sample: dict) -> float:
    width = max(1.0, float(sample.get("referenceWidth", 1920.0)))
    height = max(1.0, float(sample.get("referenceHeight", 1080.0)))
    return min(
        MAX_TRANSFORM_ERROR_PIXELS,
        max(MIN_TRANSFORM_ERROR_PIXELS, math.hypot(width, height) * TRANSFORM_ERROR_DIAGONAL_RATIO),
    )


def interpolate_transform(left: dict, right: dict, timeline_time: float) -> dict:
    left_time = float(left["timelineTime"])
    right_time = float(right["timelineTime"])
    span = right_time - left_time
    alpha = 0.0 if abs(span) < 1e-9 else (timeline_time - left_time) / span
    alpha = min(1.0, max(0.0, alpha))
    return {
        "timelineTime": timeline_time,
        "position": [
            float(left["position"][0]) + (float(right["position"][0]) - float(left["position"][0])) * alpha,
            float(left["position"][1]) + (float(right["position"][1]) - float(left["position"][1])) * alpha,
        ],
        "scale": float(left["scale"]) + (float(right["scale"]) - float(left["scale"])) * alpha,
        "sourceWidth": left.get("sourceWidth", right.get("sourceWidth", 1920)),
        "sourceHeight": left.get("sourceHeight", right.get("sourceHeight", 1080)),
        "referenceWidth": left.get("referenceWidth", right.get("referenceWidth", 1920)),
        "referenceHeight": left.get("referenceHeight", right.get("referenceHeight", 1080)),
    }


def transformed_source_corners(sample: dict) -> list[tuple[float, float]]:
    source_width = max(1.0, float(sample.get("sourceWidth", 1920.0)))
    source_height = max(1.0, float(sample.get("sourceHeight", 1080.0)))
    reference_width = max(1.0, float(sample.get("referenceWidth", 1920.0)))
    reference_height = max(1.0, float(sample.get("referenceHeight", 1080.0)))
    center_x = float(sample["position"][0]) * reference_width
    center_y = float(sample["position"][1]) * reference_height
    scale = float(sample["scale"]) / 100.0
    half_width = source_width * scale * 0.5
    half_height = source_height * scale * 0.5
    return [
        (center_x - half_width, center_y - half_height),
        (center_x + half_width, center_y - half_height),
        (center_x - half_width, center_y + half_height),
        (center_x + half_width, center_y + half_height),
    ]


def transform_error_pixels(actual: dict, expected: dict) -> float:
    actual_corners = transformed_source_corners(actual)
    expected_corners = transformed_source_corners(expected)
    return max(
        math.hypot(actual_x - expected_x, actual_y - expected_y)
        for (actual_x, actual_y), (expected_x, expected_y) in zip(actual_corners, expected_corners)
    )


def position_error_pixels(actual: dict, expected: dict) -> float:
    reference_width = max(1.0, float(actual.get("referenceWidth", expected.get("referenceWidth", 1920.0))))
    reference_height = max(1.0, float(actual.get("referenceHeight", expected.get("referenceHeight", 1080.0))))
    return math.hypot(
        (float(actual["position"][0]) - float(expected["position"][0])) * reference_width,
        (float(actual["position"][1]) - float(expected["position"][1])) * reference_height,
    )


def scale_error_pixels(actual: dict, expected: dict) -> float:
    source_width = max(1.0, float(actual.get("sourceWidth", expected.get("sourceWidth", 1920.0))))
    source_height = max(1.0, float(actual.get("sourceHeight", expected.get("sourceHeight", 1080.0))))
    scale_delta = abs(float(actual["scale"]) - float(expected["scale"])) / 100.0
    return math.hypot(source_width, source_height) * scale_delta * 0.5


def channel_error_pixels(actual: dict, expected: dict, channel: str) -> float:
    if channel == "position":
        return position_error_pixels(actual, expected)
    if channel == "scale":
        return scale_error_pixels(actual, expected)
    raise ValueError(f"Unsupported transform channel: {channel}")


def channel_simplification_tolerance(sample: dict) -> float:
    return transform_tolerance_pixels(sample) * KEYFRAME_SIMPLIFICATION_TOLERANCE_MULTIPLIER


def simplify_channel_samples(samples: list[dict], channel: str) -> tuple[list[dict], dict]:
    ordered_by_time: dict[float, dict] = {}
    for sample in sorted(samples, key=lambda item: float(item["timelineTime"])):
        ordered_by_time[float(sample["timelineTime"])] = sample
    ordered = list(ordered_by_time.values())
    if not ordered:
        raise ValueError("Cannot simplify an empty transform channel")

    medoid = min(
        ordered,
        key=lambda candidate: (
            sum(channel_error_pixels(sample, candidate, channel) for sample in ordered),
            -float(candidate.get("score", 0.0)),
        ),
    )
    if all(
        channel_error_pixels(sample, medoid, channel) <= channel_simplification_tolerance(sample)
        for sample in ordered
    ):
        return [], medoid
    if len(ordered) <= 2:
        return ordered, medoid

    selected_indices = {0, len(ordered) - 1}

    def simplify_segment(left_index: int, right_index: int) -> None:
        if right_index - left_index <= 1:
            return
        left = ordered[left_index]
        right = ordered[right_index]
        worst_index = -1
        worst_ratio = 0.0
        for index in range(left_index + 1, right_index):
            actual = ordered[index]
            expected = interpolate_transform(left, right, float(actual["timelineTime"]))
            tolerance = channel_simplification_tolerance(actual)
            ratio = channel_error_pixels(actual, expected, channel) / max(tolerance, 1e-9)
            if ratio > worst_ratio:
                worst_ratio = ratio
                worst_index = index
        if worst_index < 0 or worst_ratio <= 1.0:
            return
        selected_indices.add(worst_index)
        simplify_segment(left_index, worst_index)
        simplify_segment(worst_index, right_index)

    simplify_segment(0, len(ordered) - 1)
    selected = [ordered[index] for index in sorted(selected_indices)]

    while len(selected) > 2:
        penultimate = selected[-2]
        penultimate_time = float(penultimate["timelineTime"])
        tail = [sample for sample in ordered if float(sample["timelineTime"]) >= penultimate_time]
        if not all(
            channel_error_pixels(sample, penultimate, channel) <= channel_simplification_tolerance(sample)
            for sample in tail
        ):
            break
        selected.pop()

    while len(selected) > 2:
        second = selected[1]
        second_time = float(second["timelineTime"])
        head = [sample for sample in ordered if float(sample["timelineTime"]) <= second_time]
        if not all(
            channel_error_pixels(sample, second, channel) <= channel_simplification_tolerance(sample)
            for sample in head
        ):
            break
        selected.pop(0)

    return selected[:MAX_KEYFRAMES_PER_CLIP], medoid


def sample_for_timeline_time(samples: list[dict], timeline_time: float) -> dict:
    ordered = sorted(samples, key=lambda item: float(item["timelineTime"]))
    for sample in ordered:
        if abs(float(sample["timelineTime"]) - timeline_time) < 1e-6:
            return sample
    if timeline_time <= float(ordered[0]["timelineTime"]):
        return ordered[0]
    if timeline_time >= float(ordered[-1]["timelineTime"]):
        return ordered[-1]
    for left, right in zip(ordered, ordered[1:]):
        if float(left["timelineTime"]) <= timeline_time <= float(right["timelineTime"]):
            return interpolate_transform(left, right, timeline_time)
    return ordered[-1]


def consensus_static_sample(samples: list[dict]) -> tuple[dict | None, list[dict]]:
    reliable = [sample for sample in samples if motion_sample_is_reliable(sample)]
    if len(reliable) < 3:
        return None, []
    best_cluster: list[dict] = []
    best_center: dict | None = None
    for candidate in reliable:
        tolerance = transform_tolerance_pixels(candidate)
        cluster = [sample for sample in reliable if transform_error_pixels(sample, candidate) <= tolerance]
        if len(cluster) > len(best_cluster):
            best_cluster = cluster
            best_center = candidate
        elif len(cluster) == len(best_cluster) and cluster:
            old_error = sum(transform_error_pixels(sample, best_center) for sample in best_cluster) if best_center else math.inf
            new_error = sum(transform_error_pixels(sample, candidate) for sample in cluster)
            if new_error < old_error:
                best_cluster = cluster
                best_center = candidate
    required = len(reliable) if len(reliable) <= 3 else len(reliable) - 1
    if len(best_cluster) < required:
        return None, []
    medoid = min(
        best_cluster,
        key=lambda candidate: (
            sum(transform_error_pixels(sample, candidate) for sample in best_cluster),
            -float(candidate.get("score", 0.0)),
        ),
    )
    outliers = [sample for sample in reliable if sample not in best_cluster]
    return medoid, outliers


def outlier_has_temporal_support(
    outlier: dict,
    consensus_samples: list[dict],
    sample_at: Callable[[float], dict],
) -> bool:
    if not consensus_samples:
        return False
    neighbor = min(
        consensus_samples,
        key=lambda sample: abs(float(sample["timelineTime"]) - float(outlier["timelineTime"])),
    )
    outlier_time = float(outlier["timelineTime"])
    neighbor_time = float(neighbor["timelineTime"])
    if abs(outlier_time - neighbor_time) < MIN_REFINEMENT_SEGMENT_SECONDS:
        return False
    confirm_time = (outlier_time + neighbor_time) * 0.5
    confirmation = sample_at(confirm_time)
    if not motion_sample_is_reliable(confirmation):
        return False
    expected = interpolate_transform(outlier, neighbor, confirm_time)
    return transform_error_pixels(confirmation, expected) <= transform_tolerance_pixels(confirmation)


def adaptive_keyframe_samples(
    initial_samples: list[dict],
    sample_at: Callable[[float], dict],
) -> tuple[list[dict], dict | None]:
    reliable = sorted(
        (sample for sample in initial_samples if motion_sample_is_reliable(sample)),
        key=lambda sample: float(sample["timelineTime"]),
    )
    static_sample, outliers = consensus_static_sample(reliable)
    if static_sample is not None:
        consensus = [sample for sample in reliable if sample not in outliers]
        if not any(outlier_has_temporal_support(outlier, consensus, sample_at) for outlier in outliers):
            return [], static_sample
    if len(reliable) < 2:
        return [], static_sample

    keyframes: dict[float, dict] = {
        float(reliable[0]["timelineTime"]): reliable[0],
        float(reliable[-1]["timelineTime"]): reliable[-1],
    }

    def refine(left: dict, right: dict, depth: int) -> None:
        if depth >= MAX_REFINEMENT_DEPTH or len(keyframes) >= MAX_KEYFRAMES_PER_CLIP:
            return
        left_time = float(left["timelineTime"])
        right_time = float(right["timelineTime"])
        if right_time - left_time <= MIN_REFINEMENT_SEGMENT_SECONDS:
            return
        requested_mid_time = (left_time + right_time) * 0.5
        midpoint = sample_at(requested_mid_time)
        if not motion_sample_is_reliable(midpoint):
            return
        mid_time = float(midpoint["timelineTime"])
        direct_expected = interpolate_transform(left, right, mid_time)
        direct_error = transform_error_pixels(midpoint, direct_expected)
        tolerance = transform_tolerance_pixels(midpoint)
        if direct_error <= tolerance:
            return

        quarter_time = (left_time + mid_time) * 0.5
        three_quarter_time = (mid_time + right_time) * 0.5
        confirmations = [
            (sample_at(quarter_time), left, midpoint),
            (sample_at(three_quarter_time), midpoint, right),
        ]
        piecewise_votes = 0
        for confirmation, piecewise_left, piecewise_right in confirmations:
            if not motion_sample_is_reliable(confirmation):
                continue
            confirmation_time = float(confirmation["timelineTime"])
            piecewise_expected = interpolate_transform(piecewise_left, piecewise_right, confirmation_time)
            direct_confirmation = interpolate_transform(left, right, confirmation_time)
            direct_confirmation_error = transform_error_pixels(confirmation, direct_confirmation)
            piecewise_error = transform_error_pixels(confirmation, piecewise_expected)
            confirmation_tolerance = transform_tolerance_pixels(confirmation)
            if piecewise_error + confirmation_tolerance * 0.25 < direct_confirmation_error:
                piecewise_votes += 1
        if piecewise_votes == 0:
            return

        keyframes[float(midpoint["timelineTime"])] = midpoint
        refine(left, midpoint, depth + 1)
        refine(midpoint, right, depth + 1)

    refine(reliable[0], reliable[-1], 0)
    ordered = [keyframes[time] for time in sorted(keyframes)]
    stable_anchor = ordered[0] if ordered else None
    is_stable = bool(
        stable_anchor
        and all(
            transform_error_pixels(sample, stable_anchor) <= transform_tolerance_pixels(sample)
            for sample in ordered[1:]
        )
    )
    if len(ordered) < 2 or is_stable:
        return [], static_sample or max(reliable, key=lambda sample: float(sample.get("score", 0.0)))
    return ordered[:MAX_KEYFRAMES_PER_CLIP], None


def quality_name(clip: ClipRow, status: str, score: float) -> str:
    if status == "UNSET":
        return f"UNSET C{clip.n:02d} q{round(score)}"
    if score < 25:
        return f"LOWQ{round(score)} C{clip.n:02d} SET"
    return f"Q{round(score)} C{clip.n:02d} SET"


def marker_color(status: str, score: float, ratio: float) -> int:
    if status == "UNSET":
        return 1
    if score >= 80 and ratio >= 0.70:
        return 0
    if score >= 25 and ratio >= 0.45:
        return 2
    return 3


def emit_record(writer: csv.writer, fields: list[object]) -> None:
    writer.writerow([clean_field(v) for v in fields])


def main() -> int:
    parser = argparse.ArgumentParser(description="Match V1 clips to overlapping V2 reference clips and emit Automation Agent TSV records.")
    parser.add_argument("--manifest", required=True)
    parser.add_argument("--ffmpeg", required=True)
    parser.add_argument("--frames-dir", required=True)
    parser.add_argument("--records", required=True)
    parser.add_argument("--report", required=True)
    parser.add_argument("--progress", default="")
    parser.add_argument("--sequence-width", type=float, required=True)
    parser.add_argument("--sequence-height", type=float, required=True)
    parser.add_argument("--start-row", type=int, default=0)
    parser.add_argument("--max-clips", type=int, default=0)
    parser.add_argument("--append-records", choices=["0", "1"], default="0")
    parser.add_argument("--enable-keyframes", choices=["0", "1"], default="1")
    args = parser.parse_args()
    if args.sequence_width <= 0 or args.sequence_height <= 0:
        parser.error("--sequence-width and --sequence-height must be greater than zero")

    clips = parse_manifest(Path(args.manifest))
    frames_dir = Path(args.frames_dir)
    records_path = Path(args.records)
    report_path = Path(args.report)
    records_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.parent.mkdir(parents=True, exist_ok=True)
    frames_dir.mkdir(parents=True, exist_ok=True)

    report: list[dict] = []
    enable_keyframes = args.enable_keyframes == "1"

    progress_handle = None
    if args.progress:
        progress_path = Path(args.progress)
        progress_path.parent.mkdir(parents=True, exist_ok=True)
        progress_handle = progress_path.open("a" if args.append_records == "1" else "w", encoding="utf-8", newline="")
        if args.append_records != "1":
            progress_handle.write("clipNo\tstatus\tscore\tinliers\tratio\tnote\n")
        progress_handle.flush()

    start = max(0, args.start_row)
    end = start + args.max_clips if args.max_clips > 0 else len(clips)
    selected_clips = clips[start:end]

    try:
        append_records = args.append_records == "1"
        with records_path.open("a" if append_records else "w", encoding="utf-8", newline="") as handle:
            writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
            if not append_records:
                writer.writerow(["recordType", "clipNo", "clipRef", "refClipRef", "start", "duration", "time", "scale", "x", "y", "score", "inliers", "ratio", "good", "color", "name", "note", "positionAction", "scaleAction"])

            for clip in selected_clips:
                sample_results: list[dict] = []
                sample_cache: dict[float, dict] = {}
                reference_raster_validated = False

                def analyze_at(timeline_time: float, label: str | None = None) -> dict:
                    nonlocal reference_raster_validated
                    cache_key = round(timeline_time, 6)
                    if cache_key in sample_cache:
                        return sample_cache[cache_key]
                    sample_label = label or f"adaptive_{len(sample_results):03d}"
                    source_frame = frames_dir / f"source_{clip.n:03d}_{sample_label}.png"
                    ref_frame = frames_dir / f"ref_{clip.n:03d}_{sample_label}.png"
                    analyzed_time: float | None = None
                    for candidate_time in frame_retry_timeline_times(
                        timeline_time,
                        clip.analysis_start,
                        clip.analysis_end,
                    ):
                        source_ok = export_frame(
                            args.ffmpeg,
                            clip.src,
                            source_media_time(clip, candidate_time),
                            source_frame,
                        )
                        ref_ok = export_frame(
                            args.ffmpeg,
                            clip.ref_src,
                            reference_media_time(candidate_time, clip.ref_start, clip.ref_in),
                            ref_frame,
                        )
                        if source_ok and ref_ok:
                            if not reference_raster_validated:
                                validate_reference_raster(
                                    ref_frame,
                                    args.sequence_width,
                                    args.sequence_height,
                                )
                                reference_raster_validated = True
                            analyzed_time = candidate_time
                            break
                        source_frame.unlink(missing_ok=True)
                        ref_frame.unlink(missing_ok=True)
                    if analyzed_time is None:
                        raise RuntimeError(
                            f"Could not export synchronized source/reference frames near timeline time {timeline_time:.6f} "
                            f"for clip {clip.n}"
                        )
                    result = match_images(source_frame, ref_frame)
                    result.update({
                        "sample": sample_label,
                        "requestedTimelineTime": timeline_time,
                        "timelineTime": analyzed_time,
                    })
                    sample_results.append(result)
                    sample_cache[cache_key] = result
                    sample_cache[round(analyzed_time, 6)] = result
                    return result

                initial_samples = [
                    analyze_at(timeline_time, label)
                    for label, timeline_time in initial_sample_points(clip, enable_keyframes)
                ]

                accepted = [s for s in sample_results if s.get("accepted")]
                successful = [s for s in sample_results if s.get("ok")]
                best = max(accepted or successful, key=lambda item: item.get("score", -1.0), default=None)
                apply = bool(best and best.get("accepted"))
                status = "SET" if apply else "UNSET"
                note = "no accepted match"

                if apply:
                    keyframe_samples: list[dict] = []
                    static_sample: dict | None = None
                    if enable_keyframes:
                        keyframe_samples, static_sample = adaptive_keyframe_samples(
                            initial_samples,
                            lambda timeline_time: analyze_at(timeline_time),
                        )
                        accepted = [s for s in sample_results if s.get("accepted")]
                        successful = [s for s in sample_results if s.get("ok")]
                        best = max(accepted or successful, key=lambda item: item.get("score", -1.0), default=best)
                    if len(keyframe_samples) >= 2:
                        position_keyframes, position_static_sample = simplify_channel_samples(
                            keyframe_samples,
                            "position",
                        )
                        scale_keyframes, scale_static_sample = simplify_channel_samples(
                            keyframe_samples,
                            "scale",
                        )
                        position_description = str(len(position_keyframes)) if position_keyframes else "static"
                        scale_description = str(len(scale_keyframes)) if scale_keyframes else "static"
                        note = (
                            f"adaptive linear transform written; samples={len(sample_results)}; "
                            f"positionKeys={position_description}; scaleKeys={scale_description}"
                        )
                        if not position_keyframes and not scale_keyframes:
                            best = dict(best)
                            best["position"] = list(position_static_sample["position"])
                            best["scale"] = float(scale_static_sample["scale"])
                            emit_record(writer, [
                                "UPDATE_STATIC",
                                clip.n,
                                clip.ref,
                                clip.ref_clip_ref,
                                f"{clip.start:.6f}",
                                f"{clip.duration:.6f}",
                                f"{best['timelineTime']:.6f}",
                                f"{best['scale']:.9f}",
                                f"{best['position'][0]:.12f}",
                                f"{best['position'][1]:.12f}",
                                f"{best['score']:.6f}",
                                best["inliers"],
                                f"{best['inlierRatio']:.6f}",
                                best["goodMatches"],
                                0,
                                "NA",
                                note,
                                "STATIC",
                                "STATIC",
                            ])
                        else:
                            position_times = {float(sample["timelineTime"]) for sample in position_keyframes}
                            scale_times = {float(sample["timelineTime"]) for sample in scale_keyframes}
                            event_times = sorted(position_times | scale_times)
                            for event_index, timeline_time in enumerate(event_times):
                                sample = sample_for_timeline_time(keyframe_samples, timeline_time)
                                position_action = "KEYFRAME" if timeline_time in position_times else "SKIP"
                                scale_action = "KEYFRAME" if timeline_time in scale_times else "SKIP"
                                position = sample["position"]
                                scale = sample["scale"]
                                if not position_keyframes and event_index == 0:
                                    position_action = "STATIC"
                                    position = position_static_sample["position"]
                                if not scale_keyframes and event_index == 0:
                                    scale_action = "STATIC"
                                    scale = scale_static_sample["scale"]
                                emit_record(writer, [
                                    "UPDATE_KEYFRAME",
                                    clip.n,
                                    clip.ref,
                                    clip.ref_clip_ref,
                                    f"{clip.start:.6f}",
                                    f"{clip.duration:.6f}",
                                    f"{timeline_time:.6f}",
                                    f"{float(scale):.9f}",
                                    f"{float(position[0]):.12f}",
                                    f"{float(position[1]):.12f}",
                                    f"{float(sample.get('score', best['score'])):.6f}",
                                    sample.get("inliers", best["inliers"]),
                                    f"{float(sample.get('inlierRatio', best['inlierRatio'])):.6f}",
                                    sample.get("goodMatches", best["goodMatches"]),
                                    0,
                                    "NA",
                                    note,
                                    position_action,
                                    scale_action,
                                ])
                    else:
                        best = static_sample or best
                        note = f"static transform written; samples={len(sample_results)}"
                        emit_record(writer, [
                            "UPDATE_STATIC",
                            clip.n,
                            clip.ref,
                            clip.ref_clip_ref,
                            f"{clip.start:.6f}",
                            f"{clip.duration:.6f}",
                            f"{best['timelineTime']:.6f}",
                            f"{best['scale']:.9f}",
                            f"{best['position'][0]:.12f}",
                            f"{best['position'][1]:.12f}",
                            f"{best['score']:.6f}",
                            best["inliers"],
                            f"{best['inlierRatio']:.6f}",
                            best["goodMatches"],
                            0,
                            "NA",
                            note,
                            "STATIC",
                            "STATIC",
                        ])

                score = float(best.get("score", 0.0)) if best else 0.0
                ratio = float(best.get("inlierRatio", 0.0)) if best else 0.0
                inliers = int(best.get("inliers", 0)) if best else 0
                good = int(best.get("goodMatches", 0)) if best else 0
                color = marker_color(status, score, ratio)
                marker_name = quality_name(clip, status, score)
                marker_note = f"{status}; score={score:.2f}; inliers={inliers}; ratio={ratio:.3f}; good={good}; {note}; {MARKER_COMMENT_TAG}"
                emit_record(writer, [
                    "MARKER",
                    clip.n,
                    clip.ref,
                    clip.ref_clip_ref,
                    f"{clip.start:.6f}",
                    f"{max(0.05, clip.duration):.6f}",
                    0,
                    0,
                    0,
                    0,
                    f"{score:.6f}",
                    inliers,
                    f"{ratio:.6f}",
                    good,
                    color,
                    marker_name,
                    marker_note,
                    "NA",
                    "NA",
                ])
                handle.flush()

                report.append({"clipNo": clip.n, "apply": apply, "best": best, "samples": sample_results, "status": status})
                if progress_handle:
                    progress_handle.write(f"{clip.n}\t{status}\t{score:.6f}\t{inliers}\t{ratio:.6f}\t{clean_field(note)}\n")
                    progress_handle.flush()
                print(f"{status}|clip={clip.n}|score={score:.2f}|inliers={inliers}|ratio={ratio:.3f}|note={note}", flush=True)
    finally:
        if progress_handle:
            progress_handle.close()

    report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
    applied = sum(1 for item in report if item["apply"])
    print(f"SUMMARY|clips={len(report)}|applied={applied}|unset={len(report) - applied}|records={records_path}|report={report_path}", flush=True)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:
        print(f"ERROR|{exc}", file=sys.stderr)
        raise
