#!/usr/bin/env python3
"""Decorate the cuts that already exist, in one pass over a finished base.

`render_transitions.py` *makes* joins: it takes separate clips and overlaps them
with `xfade`. That is the right tool while the timeline is still a list of clips.
It is the wrong tool once the cut is assembled and retimed, because feeding a
finished file back through `xfade` means cutting it apart again and losing the
speed ramps that were baked in.

This does the other half of the job. The cuts are already there — hard, on the
frame, from the speech recut. What is missing is any acknowledgement that a cut
happened, which is why a technically correct recut still watches like a jump.
Here each cut point gets a short effect *around* it, applied with `enable=` on a
single stream, so the whole thing is one decode and one encode with no clip
surgery at all.

Every effect is two to six frames. That is not timidity: an effect long enough to
look at is an effect the viewer watches instead of the content, and on a vertical
phone screen anything past ~8 frames reads as a render fault rather than as
style.

Effects are chosen against the *reason* the cut exists — a breath is not a change
of subject and must not be announced like one — and against an energy budget, so
a 100-second video does not accumulate forty flashes.
"""
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 window(at: float, frames: int, fps: int) -> tuple[float, float]:
    half = frames / (2.0 * fps)
    return max(0.0, at - half), at + half


# Only a handful of ffmpeg filters accept a *per-frame expression* in their
# options; most take a plain number parsed once at init. `colorbalance`,
# `rgbashift`, `chromashift` and `noise` are in the second group, and handing
# them an expression fails at filter-open with "Unable to parse option value" —
# not at parse time, so it survives every check short of a render.
#
# So the recipes are split by mechanism:
#   * ramped effects use `eq`, `hue`, `crop` and `rotate`, which do take
#     expressions — and need `eval=frame`, because their default is to evaluate
#     once and hold the first value for the whole file;
#   * constant effects use `enable=` on a filter that supports timeline editing.
#     For a four-frame glitch that is not a compromise: a hard on/off displacement
#     reads *better* than a ramped one, because a real signal fault does not fade in.
def ramp_expression(a: float, b: float) -> str:
    """Triangle over the window: rises to the cut, falls away from it.

    A flat boost across the window reads as a lighting error rather than a hit.

    Clamped to zero outside the window by the expression itself, not only by
    `enable=`. The raw triangle goes sharply *negative* away from the window, so
    any filter that evaluates it outside — because it lacks timeline support, or
    because the chain is reordered later — would apply a large inverted effect
    across the whole file. The clamp is written `(x+|x|)/2` rather than
    `max(0, x)` because a comma inside an unquoted filter option is read by the
    graph parser as the separator between two filters.
    """
    triangle = f"(1-abs(2*(t-{a:.4f})/{b - a:.6f}-1))"
    return f"(({triangle}+abs({triangle}))/2)"


def enable_on(a: float, b: float) -> str:
    return f":enable='between(t,{a:.4f},{b:.4f})'"


def flash_white(a: float, b: float, strength: float) -> list[str]:
    ramp = ramp_expression(a, b)
    return [f"eq=eval=frame:brightness={strength:.3f}*{ramp}"
            f":saturation=1-{0.35 * strength:.3f}*{ramp}"
            f":contrast=1+{0.12 * strength:.3f}*{ramp}" + enable_on(a, b)]


def rgb_split(a: float, b: float, strength: float) -> list[str]:
    shift = max(2, int(round(20 * strength)))
    return [f"rgbashift=rh=-{shift}:bh={shift}" + enable_on(a, b)]


def chroma_tear(a: float, b: float, strength: float) -> list[str]:
    shift = max(3, int(round(26 * strength)))
    return [f"chromashift=cbh=-{shift}:crh={shift}:cbv={max(1, shift // 3)}" + enable_on(a, b),
            f"noise=alls={max(6, int(round(14 * strength)))}:allf=t+u" + enable_on(a, b)]


def shake(a: float, b: float, strength: float, width: int, height: int) -> list[str]:
    ramp = ramp_expression(a, b)
    amount = 0.02 * strength
    # Crop an inset window and move it. Scaling the whole frame to fake a shake
    # resamples every pixel; moving the crop touches none of them.
    inset = 0.04
    cw, ch = int(width * (1 - inset)) & ~1, int(height * (1 - inset)) & ~1
    return [
        # No `eval=frame` here: `crop` has no such option, and passing one fails
        # the graph with "Option not found". Its `x`/`y` expressions are already
        # re-evaluated every frame by default — unlike `eq`, where the default is
        # to evaluate once and hold.
        f"crop=w={cw}:h={ch}:"
        f"x='(iw-ow)/2+{amount:.4f}*iw*{ramp}*sin(t*97)':"
        f"y='(ih-oh)/2+{amount:.4f}*ih*{ramp}*sin(t*61)'",
        # crop's size is fixed for the whole stream, so the frame is restored to
        # the canvas once, after the chain, rather than per event.
        f"scale={width}:{height}:flags=bicubic",
    ]


def light_leak(a: float, b: float, strength: float) -> list[str]:
    ramp = ramp_expression(a, b)
    # `hue` takes expressions natively and evaluates them per frame. A positive
    # rotation of a few degrees plus a lift is a warm wash without touching the
    # grade the rest of the file carries.
    return [f"hue=h={12 * strength:.2f}*{ramp}:s=1+{0.25 * strength:.3f}*{ramp}"
            + enable_on(a, b),
            f"eq=eval=frame:brightness={0.10 * strength:.3f}*{ramp}"
            f":gamma_r=1+{0.10 * strength:.3f}*{ramp}"
            f":gamma_b=1-{0.08 * strength:.3f}*{ramp}" + enable_on(a, b)]


def dip_dark(a: float, b: float, strength: float) -> list[str]:
    ramp = ramp_expression(a, b)
    return [f"eq=eval=frame:brightness=-{0.42 * strength:.3f}*{ramp}"
            f":saturation=1-{0.30 * strength:.3f}*{ramp}" + enable_on(a, b)]


def build_chain(events: list[dict], strength: float, width: int, height: int) -> list[str]:
    """One filter instance per *family*, not per event.

    A filter with `enable=` still runs for every frame of the file — `enable`
    decides whether the result is used, not whether the work happens. Emitting
    one `eq` per cut therefore makes ffmpeg walk the whole 1080x1920 stream once
    per effect: eight events cost eight full passes to change 1.6 seconds of
    picture, and the step took longer than every other stage combined.

    Merging is safe precisely because `ramp_expression` clamps itself to zero
    outside its window: the sum of all ramps is the value of whichever ramp is
    active, and zero everywhere else. So every `eq`-family event collapses into a
    single `eq` whose brightness is a sum, and the constant-value glitches
    collapse into one filter each with the windows OR'd together in `enable`.
    """
    chain: list[str] = []
    by_kind: dict[str, list[dict]] = {}
    for event in events:
        by_kind.setdefault(event["recipe"], []).append(event)

    def summed(kind: str, factor: float) -> str:
        return "+".join(f"{factor:.4f}*{ramp_expression(e['from'], e['to'])}"
                        for e in by_kind.get(kind, [])) or "0"

    def windows(kind: str) -> str:
        parts = [f"between(t,{e['from']:.4f},{e['to']:.4f})" for e in by_kind.get(kind, [])]
        return "+".join(parts) if parts else "0"

    # --- eq family: flash, dip and the lift half of light_leak in one instance.
    eq_terms: list[str] = []
    if by_kind.get("flash"):
        eq_terms.append(("brightness", summed("flash", 0.55 * strength)))
    bright = [summed("flash", 0.55 * strength)] if by_kind.get("flash") else []
    if by_kind.get("dip"):
        bright.append(f"-1*({summed('dip', 0.42 * strength)})")
    if by_kind.get("light_leak"):
        bright.append(summed("light_leak", 0.10 * strength))
    saturation = ["1"]
    if by_kind.get("flash"):
        saturation.append(f"-1*({summed('flash', 0.35 * strength)})")
    if by_kind.get("dip"):
        saturation.append(f"-1*({summed('dip', 0.30 * strength)})")
    if bright or len(saturation) > 1:
        options = ["eval=frame"]
        if bright:
            options.append("brightness=" + "+".join(bright))
        if len(saturation) > 1:
            options.append("saturation=" + "+".join(saturation))
        if by_kind.get("flash"):
            options.append(f"contrast=1+{summed('flash', 0.12 * strength)}")
        chain.append("eq=" + ":".join(options))
    _ = eq_terms

    # --- hue: the warm rotation of light_leak.
    if by_kind.get("light_leak"):
        chain.append(f"hue=h={summed('light_leak', 12 * strength)}"
                     f":s=1+{summed('light_leak', 0.25 * strength)}")

    # --- constant-value glitches: one instance each, windows OR'd in `enable`.
    if by_kind.get("rgb_split"):
        shift = max(2, int(round(20 * strength)))
        chain.append(f"rgbashift=rh=-{shift}:bh={shift}"
                     f":enable='gt({windows('rgb_split')},0)'")
    if by_kind.get("chroma_tear"):
        shift = max(3, int(round(26 * strength)))
        gate = windows("chroma_tear")
        chain.append(f"chromashift=cbh=-{shift}:crh={shift}:cbv={max(1, shift // 3)}"
                     f":enable='gt({gate},0)'")
        chain.append(f"noise=alls={max(6, int(round(14 * strength)))}:allf=t+u"
                     f":enable='gt({gate},0)'")

    # --- shake: one crop whose offset is the sum of every shake window.
    if by_kind.get("shake"):
        amount = 0.02 * strength
        inset = 0.04
        cw, ch = int(width * (1 - inset)) & ~1, int(height * (1 - inset)) & ~1
        dx = "+".join(f"{amount:.4f}*iw*{ramp_expression(e['from'], e['to'])}*sin(t*97)"
                      for e in by_kind["shake"])
        dy = "+".join(f"{amount:.4f}*ih*{ramp_expression(e['from'], e['to'])}*sin(t*61)"
                      for e in by_kind["shake"])
        chain.append(f"crop=w={cw}:h={ch}:x='(iw-ow)/2+{dx}':y='(ih-oh)/2+{dy}'")
        chain.append(f"scale={width}:{height}:flags=bicubic")

    chain.append("format=yuv420p")
    return chain


RECIPES: dict[str, dict] = {
    "flash":       {"frames": 4, "energy": 4, "sfx": "flash_pop",
                    "use": "Смена мысли, вывод. Самый сильный акцент."},
    "rgb_split":   {"frames": 5, "energy": 4, "sfx": "glitch_stutter",
                    "use": "Сбой, контраст «до/после», технический блок."},
    "chroma_tear": {"frames": 5, "energy": 5, "sfx": "data_burst",
                    "use": "Разрыв повествования. Не чаще двух раз."},
    "shake":       {"frames": 5, "energy": 4, "sfx": "impact_low",
                    "use": "Удар, отрицание, резкое «нет»."},
    "light_leak":  {"frames": 8, "energy": 2, "sfx": "air_tail",
                    "use": "Тёплая смена сцены. Мягче вспышки."},
    "dip":         {"frames": 6, "energy": 2, "sfx": "boom_cinematic",
                    "use": "Конец главы, пауза вниз."},
}

# What a cut of each kind is allowed to become. A breath is not a change of
# subject: announcing it with a flash is the single most common way an automated
# edit gives itself away.
BY_REASON: dict[str, list[str]] = {
    "sentence_pause": ["flash", "light_leak", "rgb_split", "dip"],
    "tail":           ["dip", "light_leak"],
    "breath":         ["light_leak"],
    "thought":        ["light_leak", "rgb_split"],
    "default":        ["light_leak"],
}


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("input")
    parser.add_argument("output")
    parser.add_argument("--timeline", required=True,
                        help="timeline.json — cut points come from clips[].global_start")
    parser.add_argument("--recut", help="recut.json, to read why each cut exists")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--intent", default="dynamic",
                        choices=["calm", "editorial", "dynamic", "tech"])
    parser.add_argument("--energy-per-minute", type=int, default=14)
    parser.add_argument("--strength", type=float, default=1.0)
    parser.add_argument("--sfx-plan", help="Write the matching SFX events here")
    parser.add_argument("--crf", type=int, default=17)
    parser.add_argument("--preset", default="slow")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--list", action="store_true")
    args = parser.parse_args()

    if args.list:
        emit({"recipes": {name: {**spec} for name, spec in RECIPES.items()},
              "by_reason": BY_REASON})
        return

    require_binary("ffmpeg")
    source = Path(args.input).resolve()
    if not source.is_file():
        raise SystemExit(f"Not found: {source}")
    board = resolve_canvas(args.canvas)
    info = probe(source)
    video = info.get("video") or {}
    width = int(video.get("display_width") or board.width)
    height = int(video.get("display_height") or board.height)
    fps = int(round(float(video.get("avg_frame_rate") or board.fps)))
    duration = float(video.get("duration") or info.get("duration") or 0.0)

    timeline = json.loads(Path(args.timeline).read_text(encoding="utf-8"))
    reasons: list[str] = []
    if args.recut:
        plan = json.loads(Path(args.recut).read_text(encoding="utf-8"))
        reasons = [segment.get("cut_reason", "default") for segment in plan.get("segments", [])]

    cuts: list[dict] = []
    for index, clip in enumerate(timeline.get("clips") or []):
        at = float(clip.get("global_start", 0.0))
        if at <= 0.25 or at >= duration - 0.25:
            continue
        # Segment N ends at the cut that starts segment N+1, so the reason that
        # produced this join is the previous segment's.
        reason = reasons[index - 1] if 0 < index <= len(reasons) else "default"
        cuts.append({"at": round(at, 3), "reason": reason})

    cold = timeline.get("cold_open") or {}
    if cold.get("length"):
        # The rejoin already carries its own transition from render_cold_open.
        cuts = [cut for cut in cuts if abs(cut["at"] - float(cold["length"])) > 0.2]

    # Budget. Energy is spent chronologically, and a cut that cannot afford an
    # effect simply stays a hard cut — which is the correct default anyway.
    budget = args.energy_per_minute * max(1.0, duration / 60.0)
    preference = {
        "calm": ["light_leak", "dip"],
        "editorial": ["light_leak", "flash", "dip"],
        "dynamic": ["flash", "rgb_split", "shake", "light_leak"],
        "tech": ["rgb_split", "chroma_tear", "flash", "light_leak"],
    }[args.intent]

    chosen: list[dict] = []
    spent = 0.0
    last_recipe = None
    used_counts: dict[str, int] = {}
    for cut in cuts:
        allowed = BY_REASON.get(cut["reason"], BY_REASON["default"])
        options = [name for name in preference if name in allowed]
        if not options:
            options = allowed
        # Never the same effect twice in a row: repetition is what makes an
        # automated edit legible as automated.
        options = [name for name in options if name != last_recipe] or options
        pick = None
        for name in options:
            spec = RECIPES[name]
            if spent + spec["energy"] > budget:
                continue
            if used_counts.get(name, 0) >= max(2, int(duration / 25)):
                continue
            pick = name
            break
        if not pick:
            continue
        spec = RECIPES[pick]
        a, b = window(cut["at"], spec["frames"], fps)
        chosen.append({**cut, "recipe": pick, "frames": spec["frames"],
                       "from": round(a, 4), "to": round(b, 4),
                       "energy": spec["energy"], "sfx": spec["sfx"],
                       "why": spec["use"]})
        spent += spec["energy"]
        used_counts[pick] = used_counts.get(pick, 0) + 1
        last_recipe = pick

    if not chosen:
        emit({"status": "nothing_to_do", "cuts_found": len(cuts),
              "note": "Ни один стык не получил эффекта: проверь --timeline и бюджет."})
        return

    strength = max(0.2, min(2.0, args.strength))
    chain = build_chain(chosen, strength, width, height)

    report = {
        "status": "planned",
        "input": str(source),
        "duration": round(duration, 3),
        "cuts_found": len(cuts),
        "effects_applied": len(chosen),
        "energy_spent": spent,
        "energy_budget": round(budget, 1),
        "intent": args.intent,
        "events": chosen,
        "note": ("Эффекты навешиваются на уже существующие стыки одним проходом, "
                 "клипы не разрезаются заново — поэтому скоростные рампы из "
                 "нарезки речи остаются нетронутыми."),
    }
    if args.sfx_plan:
        Path(args.sfx_plan).write_text(json.dumps(
            {"sfx": [{"at": event["at"], "id": event["sfx"], "gain_db": -17,
                      "why": event["why"]} for event in chosen]},
            ensure_ascii=False, indent=2), encoding="utf-8")
        report["sfx_plan"] = args.sfx_plan
    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", ",".join(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": ",".join(chain),
                       "tail": (done.stderr or "").strip()[-1800:]})
        emit(report)
        raise SystemExit(done.returncode)

    actual = probe(output)
    report["status"] = "ok"
    report["output"] = str(output)
    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()
