#!/usr/bin/env python3
"""Face pixelation analyzer for the Automation Agent face-pixelation example.

Reads analysis frames exported from the active Premiere sequence, detects and
tracks faces, and writes per-face crop-keyframe records that the DSL applies
to cloned clips carrying a Mosaic + Crop effect pair.

Input (written by the DSL program):
  --manifest <tsv>   Lines: CLIP\t<clipIndex>\t<startSeconds>\t<endSeconds>\t<framePrefix>
  --frames-dir <dir> Folder containing exported frames named
                     <framePrefix>-<NNN>-<T>_<mmm>.<ext> (time embedded in name).

Output:
  --records <tsv>    One line per detected face track:
                     TRACK\t<clipIndex>\t<lane>\t<tStart>\t<tEnd>\t<mosaicH>\t<mosaicV>
                          \t<times,csv>\t<left,csv>\t<top,csv>\t<right,csv>\t<bottom,csv>
                     Times are sequence seconds; crop values are percentages.
  --progress <file>  Appended human-readable progress lines; final line "DONE".

Detectors:
  dummy  Synthetic moving box (no cv2 required). Used to exercise the pipeline
         before the OpenCV runtime pack ships face detection modules.
  yunet  OpenCV FaceDetectorYN (requires cv2 with objdetect+dnn and --model).
"""

from __future__ import annotations

import argparse
import glob
import math
import os
import re
import sys


FRAME_TIME_RE = re.compile(r"-(\d+)_(\d{3})\.[A-Za-z0-9]+$")


def log_progress(path, message):
    if not path:
        return
    with open(path, "a", encoding="utf-8") as handle:
        handle.write(message + "\n")


class Detection:
    __slots__ = ("t", "x", "y", "w", "h", "score", "eyes")

    def __init__(self, t, x, y, w, h, score=1.0, eyes=None):
        self.t = t
        self.x = x  # normalized 0..1 (left edge)
        self.y = y
        self.w = w
        self.h = h
        self.score = score
        self.eyes = eyes  # ((lx, ly), (rx, ry)) normalized, or None


class DummyDetector:
    """Deterministic synthetic face path so the pipeline can be tested
    without a face-capable OpenCV build."""

    def detect(self, frame_path, t):
        cx = 0.5 + 0.20 * math.sin(2 * math.pi * t / 8.0)
        cy = 0.45 + 0.10 * math.cos(2 * math.pi * t / 8.0)
        w = 0.18
        h = 0.30
        eyes = ((cx - w * 0.2, cy - h * 0.12), (cx + w * 0.2, cy - h * 0.12))
        return [Detection(t, cx - w / 2, cy - h / 2, w, h, 1.0, eyes)]


class YuNetDetector:
    def __init__(self, model_path, min_confidence):
        import cv2  # noqa: F401 - requires objdetect+dnn build

        self.cv2 = cv2
        self.min_confidence = min_confidence
        self.detector = cv2.FaceDetectorYN.create(
            model_path, "", (320, 320), score_threshold=min_confidence
        )

    def detect(self, frame_path, t):
        cv2 = self.cv2
        image = cv2.imread(frame_path)
        if image is None:
            return []
        height, width = image.shape[:2]
        self.detector.setInputSize((width, height))
        _, faces = self.detector.detect(image)
        detections = []
        if faces is None:
            return detections
        for face in faces:
            x, y, w, h = face[0], face[1], face[2], face[3]
            right_eye = (face[4] / width, face[5] / height)
            left_eye = (face[6] / width, face[7] / height)
            score = float(face[14])
            detections.append(
                Detection(
                    t,
                    x / width,
                    y / height,
                    w / width,
                    h / height,
                    score,
                    (left_eye, right_eye),
                )
            )
        return detections


def iou(a, b):
    ax2, ay2 = a.x + a.w, a.y + a.h
    bx2, by2 = b.x + b.w, b.y + b.h
    ix = max(0.0, min(ax2, bx2) - max(a.x, b.x))
    iy = max(0.0, min(ay2, by2) - max(a.y, b.y))
    inter = ix * iy
    union = a.w * a.h + b.w * b.h - inter
    return inter / union if union > 0 else 0.0


def build_tracks(frames_detections, max_gap_samples, min_track_samples, min_iou):
    """frames_detections: list of (t, [Detection]) sorted by t.
    Greedy IoU association into tracks; tolerates short gaps."""
    tracks = []  # each: {"dets": [Detection], "gap": int, "open": bool}
    for t, dets in frames_detections:
        unmatched = list(dets)
        for track in tracks:
            if not track["open"]:
                continue
            last = track["dets"][-1]
            best = None
            best_iou = min_iou
            for det in unmatched:
                value = iou(last, det)
                if value > best_iou:
                    best_iou = value
                    best = det
            if best is not None:
                track["dets"].append(best)
                track["gap"] = 0
                unmatched.remove(best)
            else:
                track["gap"] += 1
                if track["gap"] > max_gap_samples:
                    track["open"] = False
        for det in unmatched:
            tracks.append({"dets": [det], "gap": 0, "open": True})
    return [t["dets"] for t in tracks if len(t["dets"]) >= min_track_samples]


def smooth_series(values, window):
    if window <= 1 or len(values) <= 2:
        return values
    half = window // 2
    smoothed = []
    for i in range(len(values)):
        lo = max(0, i - half)
        hi = min(len(values), i + half + 1)
        smoothed.append(sum(values[lo:hi]) / (hi - lo))
    return smoothed


def eyes_bar_box(det):
    """Derive an eye-bar rectangle from eye landmarks (fallback: upper face)."""
    if det.eyes is not None:
        (lx, ly), (rx, ry) = det.eyes
        cx = (lx + rx) / 2.0
        cy = (ly + ry) / 2.0
        eye_span = abs(rx - lx)
        w = max(eye_span * 2.2, det.w * 0.9)
        h = max(det.h * 0.28, 0.02)
        return (cx - w / 2, cy - h / 2, w, h)
    return (det.x, det.y + det.h * 0.18, det.w, det.h * 0.3)


class MotionModel:
    """Maps sequence-normalized coordinates into clip-normalized coordinates,
    inverting the clip's Motion transform (Position/Scale/Anchor Point, all
    normalized in Premiere's API; Scale in percent). Rotation and mixed
    clip/sequence resolutions without frame-size conforming are not handled.

    Samples: list of (t, posX, posY, scaleX, scaleY, anchorX, anchorY) sorted
    by t; a single sample means a static transform."""

    def __init__(self, samples):
        self.samples = sorted(samples)

    IDENTITY_SAMPLE = (0.0, 0.5, 0.5, 100.0, 100.0, 0.5, 0.5)

    @classmethod
    def identity(cls):
        return cls([cls.IDENTITY_SAMPLE])

    def at(self, t):
        samples = self.samples
        if len(samples) == 1:
            return samples[0]
        best = samples[0]
        for sample in samples:
            if sample[0] <= t + 1e-6:
                best = sample
            else:
                break
        return best

    def seq_to_clip(self, t, x, y):
        _, pos_x, pos_y, scale_x, scale_y, anchor_x, anchor_y = self.at(t)
        sx = max(scale_x, 1.0) / 100.0
        sy = max(scale_y, 1.0) / 100.0
        return (anchor_x + (x - pos_x) / sx, anchor_y + (y - pos_y) / sy)

    def map_box(self, t, x, y, w, h):
        x1, y1 = self.seq_to_clip(t, x, y)
        x2, y2 = self.seq_to_clip(t, x + w, y + h)
        return (min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1))


def parse_motion_lines(path):
    """Collect MOTION manifest lines into per-clip MotionModel instances.
    Line: MOTION\t<clipIndex>\t<t|STATIC>\t<posX>\t<posY>\t<scale>\t<scaleW>
          \t<uniform>\t<anchorX>\t<anchorY>"""
    per_clip = {}
    with open(path, "r", encoding="utf-8") as handle:
        for line in handle:
            parts = line.rstrip("\n").split("\t")
            if len(parts) < 10 or parts[0] != "MOTION":
                continue
            clip_index = int(parts[1])
            t = 0.0 if parts[2] == "STATIC" else float(parts[2])
            pos_x, pos_y = float(parts[3]), float(parts[4])
            scale = float(parts[5])
            scale_w = float(parts[6])
            uniform = parts[7].strip().lower() in ("true", "1")
            anchor_x, anchor_y = float(parts[8]), float(parts[9])
            scale_x = scale if uniform else scale_w
            per_clip.setdefault(clip_index, []).append(
                (t, pos_x, pos_y, scale_x, scale, anchor_x, anchor_y))
    return {idx: MotionModel(samples) for idx, samples in per_clip.items()}


def locate_bundled_model():
    """Find the YuNet model shipped in the Python runtime pack, relative to
    the interpreter: <root>/bin/python3 (mac) or <root>/python.exe (win)."""
    exe_dir = os.path.dirname(os.path.abspath(sys.executable))
    for root in (os.path.dirname(exe_dir), exe_dir):
        candidate_dir = os.path.join(root, "share", "opencv-models")
        if os.path.isdir(candidate_dir):
            models = sorted(
                f for f in os.listdir(candidate_dir)
                if f.startswith("face_detection_yunet") and f.endswith(".onnx")
            )
            if models:
                return os.path.join(candidate_dir, models[0])
    return None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--manifest", required=True)
    parser.add_argument("--frames-dir", required=True)
    parser.add_argument("--records", required=True)
    parser.add_argument("--progress", default="")
    parser.add_argument("--detector", choices=["dummy", "yunet"], default="dummy")
    parser.add_argument("--model", default="")
    parser.add_argument("--mode", choices=["face", "eyes"], default="face")
    parser.add_argument("--min-confidence", type=float, default=0.6)
    parser.add_argument("--padding", type=float, default=0.25,
                        help="Box padding as fraction of box size.")
    parser.add_argument("--max-lanes", type=int, default=2)
    parser.add_argument("--blocks-across-face", type=int, default=8)
    parser.add_argument("--smooth-window", type=int, default=5)
    parser.add_argument("--max-gap-samples", type=int, default=3)
    parser.add_argument("--min-track-samples", type=int, default=4)
    parser.add_argument("--min-iou", type=float, default=0.2)
    parser.add_argument("--track-pad-seconds", type=float, default=0.25,
                        help="Extend each face track by this lead-in/lead-out "
                             "(crop values hold the first/last keyframe).")
    parser.add_argument("--edge-snap-seconds", type=float, default=0.5,
                        help="Snap a track start/end to the clip boundary when "
                             "it lies within this distance, so a face visible "
                             "from the first frame is covered from the first "
                             "frame.")
    args = parser.parse_args()

    if args.detector == "yunet":
        model_path = args.model or locate_bundled_model()
        if not model_path or not os.path.isfile(model_path):
            print(
                "yunet detector requires the bundled YuNet ONNX model "
                "(share/opencv-models in the Python runtime pack) or --model",
                file=sys.stderr,
            )
            return 2
        detector = YuNetDetector(model_path, args.min_confidence)
    else:
        detector = DummyDetector()

    clips = []
    with open(args.manifest, "r", encoding="utf-8") as handle:
        for line in handle:
            parts = line.rstrip("\n").split("\t")
            if len(parts) >= 5 and parts[0] == "CLIP":
                clips.append({
                    "index": int(parts[1]),
                    "start": float(parts[2]),
                    "end": float(parts[3]),
                    "prefix": parts[4],
                })

    if not clips:
        print("manifest contained no CLIP lines", file=sys.stderr)
        return 2

    motion_models = parse_motion_lines(args.manifest)

    entries = []
    total_skipped = 0
    for clip in clips:
        pattern = os.path.join(args.frames_dir, clip["prefix"] + "-*.*")
        frame_paths = sorted(glob.glob(pattern))
        frames = []
        for path in frame_paths:
            match = FRAME_TIME_RE.search(os.path.basename(path))
            if not match:
                continue
            t = int(match.group(1)) + int(match.group(2)) / 1000.0
            frames.append((t, path))
        frames.sort()
        log_progress(args.progress,
                     f"clip {clip['index']}: analyzing {len(frames)} frames")
        frames_detections = []
        for t, path in frames:
            frames_detections.append((t, detector.detect(path, t)))
        tracks = build_tracks(frames_detections, args.max_gap_samples,
                              args.min_track_samples, args.min_iou)
        log_progress(args.progress,
                     f"clip {clip['index']}: {len(tracks)} face track(s)")

        # Final track ranges: pad with a lead-in/lead-out, then snap to the
        # clip boundary when the remaining gap is small, so faces visible from
        # the very first analyzed frame are covered from the first clip frame.
        def final_range(dets):
            t_start = dets[0].t - args.track_pad_seconds
            t_end = dets[-1].t + args.track_pad_seconds
            if t_start - clip["start"] <= args.edge_snap_seconds:
                t_start = clip["start"]
            if clip["end"] - t_end <= args.edge_snap_seconds:
                t_end = clip["end"]
            t_start = max(clip["start"], t_start)
            t_end = min(clip["end"], t_end)
            # Round start up / end down to the emitted 4-decimal precision so
            # the clamped values never fall outside the clip after formatting.
            t_start = math.ceil(t_start * 10000.0) / 10000.0
            t_end = math.floor(t_end * 10000.0) / 10000.0
            return t_start, t_end

        motion = motion_models.get(clip["index"], MotionModel.identity())
        for dets in tracks:
            boxes = []
            for det in dets:
                if args.mode == "eyes":
                    x, y, w, h = eyes_bar_box(det)
                else:
                    x, y, w, h = det.x, det.y, det.w, det.h
                # Detections are in sequence space; Crop operates in clip
                # space before Motion, so invert the clip's Motion transform.
                x, y, w, h = motion.map_box(det.t, x, y, w, h)
                pad_w = w * args.padding
                pad_h = h * args.padding
                x -= pad_w / 2
                y -= pad_h / 2
                w += pad_w
                h += pad_h
                boxes.append((det.t, x, y, w, h))

            times = [b[0] for b in boxes]
            xs = smooth_series([b[1] for b in boxes], args.smooth_window)
            ys = smooth_series([b[2] for b in boxes], args.smooth_window)
            ws = smooth_series([b[3] for b in boxes], args.smooth_window)
            hs = smooth_series([b[4] for b in boxes], args.smooth_window)

            left = [max(0.0, min(100.0, x * 100.0)) for x in xs]
            top = [max(0.0, min(100.0, y * 100.0)) for y in ys]
            right = [max(0.0, min(100.0, (1.0 - (x + w)) * 100.0)) for x, w in zip(xs, ws)]
            bottom = [max(0.0, min(100.0, (1.0 - (y + h)) * 100.0)) for y, h in zip(ys, hs)]

            avg_w = sum(ws) / len(ws)
            avg_h = sum(hs) / len(hs)
            mosaic_h = max(10, min(400, round(args.blocks_across_face / max(avg_w, 0.01))))
            mosaic_v = max(10, min(400, round(args.blocks_across_face / max(avg_h, 0.01))))

            range_start, range_end = final_range(dets)
            entries.append({
                "clip_index": clip["index"],
                "range": (range_start, range_end),
                "times": times,
                "left": left,
                "top": top,
                "right": right,
                "bottom": bottom,
                "mosaic": (mosaic_h, mosaic_v),
            })

    # Global lane assignment: clips may come from different source tracks and
    # overlap in time, and all pixelation clones are stacked on the shared
    # lane tracks above the topmost occupied track, so lanes are scheduled
    # across all clips at once.
    entries.sort(key=lambda e: e["range"])
    lane_end = {}  # lane -> last end time
    for entry in entries:
        t_start, t_end = entry["range"]
        assigned = None
        for lane in range(1, args.max_lanes + 1):
            if lane_end.get(lane, -1e9) <= t_start:
                assigned = lane
                break
        if assigned is None:
            total_skipped += 1
            continue
        lane_end[assigned] = t_end
        entry["lane"] = assigned

    def csv(values):
        return ",".join(f"{v:.4f}" for v in values)

    records = []
    for entry in entries:
        if "lane" not in entry:
            continue
        records.append("\t".join([
            "TRACK",
            str(entry["clip_index"]),
            str(entry["lane"]),
            f"{entry['range'][0]:.4f}",
            f"{entry['range'][1]:.4f}",
            str(entry["mosaic"][0]),
            str(entry["mosaic"][1]),
            csv(entry["times"]),
            csv(entry["left"]),
            csv(entry["top"]),
            csv(entry["right"]),
            csv(entry["bottom"]),
        ]))
    if total_skipped:
        log_progress(args.progress,
                     f"skipped {total_skipped} overlapping track(s) beyond "
                     f"{args.max_lanes} lanes")

    records.append(f"SUMMARY\t{len(records)}\t{total_skipped}")
    with open(args.records, "w", encoding="utf-8") as handle:
        handle.write("\n".join(records) + "\n")
    log_progress(args.progress, f"DONE {len(records)} track(s)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
