#!/usr/bin/env python3
"""Execute the reframe schedule in one pass, as hard cuts rather than zooms.

The punch-in is the only way a single static camera produces a cut. Done right
it is *instant*: one frame wide, the next frame tight, no ramp. That reads as an
edit. A half-second ease reads as a zoom, and a zoom is a camera move the viewer
notices — which is the opposite of the intent.

Two implementation details decide whether it looks professional or cheap:

  * **crop before scale.** The window is taken from the source at its native
    resolution and scaled to the canvas afterwards, so a 1.14 punch on a 1080p
    portrait source is still sampling real pixels. Scaling to the canvas first
    and zooming the result resamples an already-resampled image, and the tight
    shots come out visibly softer than the wide ones — which the viewer reads as
    a compression artefact, not as an edit.
  * **frame the subject, not the centre.** The crop window is placed on the
    measured subject centre from the plan. Punching into the geometric centre of
    a portrait frame reliably cuts off the top of the head, because a person
    filmed on a phone sits above centre.

Alternating the scale and drifting the centre a few percent between events stops
every punch looking like the same move — the tell of an automated edit.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import probe  # noqa: E402
from skill_config import canvas as resolve_canvas, emit, require_binary, utf8_stdout  # noqa: E402


def piecewise(events: list[dict], key: str, default: str, clock: str) -> str:
    """Nested if(between(clock, a, b), value, ...) over the schedule.

    Built as an expression rather than as N trimmed segments concatenated: one
    filter is a single decode and a single encode, where the segment approach
    re-encodes every piece and puts a GOP boundary at every punch — visible as a
    quality pulse on exactly the frames the viewer is looking at.

    `clock` is the caller's time expression. It is not `t`: `zoompan` does not
    define `t`, and using it fails at filter-configuration time with "undefined
    constant" rather than at parse time, so the mistake survives every check
    short of an actual render. The frame counter `on` divided by the output rate
    is the reliable clock here, and it is exact because `d=1` makes one input
    frame produce one output frame.
    """
    expression = default
    for event in reversed(events):
        expression = (f"if(between({clock},{event['at']:.4f},{event['until']:.4f}),"
                      f"{event[key]},{expression})")
    return expression


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("plan", help="retention_plan.json")
    parser.add_argument("input")
    parser.add_argument("output")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--crf", type=int, default=17)
    parser.add_argument("--preset", default="slow")
    parser.add_argument("--max-zoom", type=float, default=1.30,
                        help="Refuse anything tighter — it will be soft")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    require_binary("ffmpeg")
    plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
    source = Path(args.input).resolve()
    if not source.is_file():
        raise SystemExit(f"Not found: {source}")
    board = resolve_canvas(plan.get("canvas", args.canvas))
    width, height, fps = board.width, board.height, board.fps

    info = probe(source)
    video = info.get("video") or {}
    # `display_*`, not `stored_*`: a phone clip is commonly stored 1920x1080 with
    # a −90° matrix, so the stored size is the frame lying on its side. Reading
    # the wrong key here does not raise — it silently falls back to the canvas
    # size and every crop window is computed against a frame that does not exist.
    in_w = int(video.get("display_width") or width)
    in_h = int(video.get("display_height") or height)
    duration = float(video.get("duration") or info.get("duration") or 0.0)

    reframes = [event for event in plan.get("events", [])
                if event.get("kind") == "reframe" and event.get("zoom")]
    too_tight = [event for event in reframes if float(event["zoom"]) > args.max_zoom]
    if too_tight:
        raise SystemExit(
            f"{len(too_tight)} событий с зумом больше {args.max_zoom}: "
            f"{[e['zoom'] for e in too_tight[:5]]}. На этом исходнике они будут мыльными.")
    if not reframes:
        emit({"status": "nothing_to_do",
              "note": "В плане нет событий kind=reframe с зумом."})
        return

    # The source is cover-cropped to the canvas aspect first, so the punch window
    # is expressed against the frame the viewer actually sees rather than against
    # the parts of the source that get cropped away anyway.
    target_ratio = width / height
    source_ratio = in_w / in_h
    if source_ratio > target_ratio:
        base_h, base_w = in_h, int(round(in_h * target_ratio))
    else:
        base_w, base_h = in_w, int(round(in_w / target_ratio))
    base_w -= base_w % 2
    base_h -= base_h % 2

    prepared: list[dict] = []
    for event in reframes:
        zoom = float(event["zoom"])
        centre = event.get("center") or {}
        cx = float(centre.get("cx", 0.5))
        cy = float(centre.get("cy", 0.45))
        crop_w = base_w / zoom
        crop_h = base_h / zoom
        # Clamp so the window never leaves the frame: an unclamped centre on a
        # subject standing near the edge slides the crop off and freezes on a
        # black band.
        x = min(max(cx * base_w - crop_w / 2, 0.0), base_w - crop_w)
        y = min(max(cy * base_h - crop_h / 2, 0.0), base_h - crop_h)
        prepared.append({"at": float(event["at"]), "until": float(event["until"]),
                         "zoom": zoom, "x": round(x, 2), "y": round(y, 2),
                         "w": round(crop_w, 2), "h": round(crop_h, 2),
                         "trigger": event.get("trigger_word")})

    # crop evaluates w/h once at init, so a single crop cannot change size over
    # time. The window is therefore fixed at the tightest scale in the plan and
    # the *wide* state is produced by scaling the source up to meet it — the
    # arithmetic is inverted, the picture is not.
    tightest = max(item["zoom"] for item in prepared)
    win_w = int(round(base_w / tightest)) & ~1
    win_h = int(round(base_h / tightest)) & ~1

    # ...which is why the graph below uses `zoompan` rather than `crop`: its z, x
    # and y are all per-frame expressions, so one filter covers the whole
    # schedule. The window size above is kept only to report how much real
    # resolution the tightest event actually has to work with.
    clock = f"on/{fps}"
    zoom_expression = piecewise(
        [{"at": item["at"], "until": item["until"], "z": f"{item['zoom']:.4f}"}
         for item in prepared], "z", "1", clock)
    x_expression = piecewise(
        [{"at": item["at"], "until": item["until"],
          "x": f"{item['x'] / base_w:.5f}*iw"} for item in prepared], "x",
        "iw/2-(iw/zoom/2)", clock)
    y_expression = piecewise(
        [{"at": item["at"], "until": item["until"],
          "y": f"{item['y'] / base_h:.5f}*ih"} for item in prepared], "y",
        "ih/2-(ih/zoom/2)", clock)

    chain = (
        f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos,"
        f"crop={width}:{height},setsar=1,fps={fps},"
        f"zoompan=z='{zoom_expression}':x='{x_expression}':y='{y_expression}'"
        f":d=1:s={width}x{height}:fps={fps},"
        f"format=yuv420p"
    )

    report = {
        "status": "planned",
        "input": str(source),
        "canvas": f"{width}x{height}@{fps}",
        "source_frame": f"{in_w}x{in_h}",
        "cover_crop": f"{base_w}x{base_h}",
        "reframes": len(prepared),
        "tightest_zoom": tightest,
        "window_at_tightest": f"{win_w}x{win_h}",
        "resampling_note": (
            f"Самый тесный кадр берёт {win_w}x{win_h} исходных пикселей и растягивает "
            f"их до {width}x{height} — это {width / max(1, win_w):.2f}x. "
            "Выше 1.3 картинка начинает мылить, поэтому план это и проверяет."),
        "events": prepared,
    }
    if args.dry_run:
        emit(report)
        return

    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    command = [
        "ffmpeg", "-y", "-v", "warning", "-nostdin", "-i", str(source),
        "-vf", chain,
        "-map", "0:v:0", "-map", "0:a?",
        "-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf),
        "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", str(fps),
        "-c:a", "copy", "-movflags", "+faststart", str(output),
    ]
    done = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if done.returncode:
        report.update({"status": "failed", "chain": chain,
                       "tail": (done.stderr or "").strip()[-1800:]})
        emit(report)
        raise SystemExit(done.returncode)

    actual = probe(output)
    report["status"] = "ok"
    report["output"] = str(output)
    report["expected_duration"] = round(duration, 3)
    report["actual_duration"] = actual.get("duration")
    drift = (round(actual["duration"] - duration, 4) if actual.get("duration") else None)
    report["duration_drift"] = drift
    report["drift_ok"] = drift is not None and abs(drift) <= max(0.08, 2.0 / fps)
    emit(report)
    if not report["drift_ok"]:
        raise SystemExit(2)


if __name__ == "__main__":
    main()
