#!/usr/bin/env python3
"""Compose camera + screen into one frame, switching layout on a timed plan.

Everything is rendered in a single ffmpeg pass. Each layout span produces its own
scaled copy of the two sources and is composited with `enable='between(t,a,b)'`;
because the spans do not overlap in time, they can all coexist in one graph. The
alternative — rendering each span separately and concatenating — re-encodes the
whole video once per span and is what makes this slow on a long tutorial.

Layouts are defined against the canvas, not in absolute pixels, so the same plan
works for 9:16, 1:1 and 16:9. The vertical layouts assume the platform's UI eats
the bottom of the frame and keep the screen content above it.

Audio: one source is the voice and the other is optional, quiet, and ducked. Screen
capture usually holds interface sounds worth keeping at around -18 dB, but its
microphone is worse than the camera's and must not become the voice.
"""
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 render_core import PROFILES, capabilities, output_arguments  # noqa: E402
from skill_config import canvas as resolve_canvas, emit, require_binary, utf8_stdout  # noqa: E402

MAX_SPANS = 40


def ff(value: float) -> str:
    return f"{value:.6f}".rstrip("0").rstrip(".") or "0"


# Each layout returns the placement of the two sources as fractions of the canvas.
# `None` means the source is not shown in that layout.
def layouts(width: int, height: int) -> dict[str, dict]:
    portrait = height > width
    return {
        "screen_focus": {
            "use": "Экран во весь кадр. Основной режим, когда важно, что происходит.",
            "screen": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "fit": "contain"},
            "camera": None,
        },
        "camera_focus": {
            "use": "Только ты. Вступление, вывод, эмоция.",
            "screen": None,
            "camera": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "fit": "cover"},
        },
        "pip_camera": {
            "use": "Экран во весь кадр, ты в углу. Рабочий режим объяснения.",
            "screen": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "fit": "contain"},
            "camera": (
                {"x": 0.60, "y": 0.62, "w": 0.36, "h": 0.24, "fit": "cover"} if portrait
                else {"x": 0.72, "y": 0.68, "w": 0.26, "h": 0.30, "fit": "cover"}
            ),
        },
        "pip_screen": {
            "use": "Ты во весь кадр, экран вставкой. Когда важнее реакция, чем детали.",
            "screen": (
                {"x": 0.06, "y": 0.10, "w": 0.88, "h": 0.30, "fit": "contain"} if portrait
                else {"x": 0.68, "y": 0.06, "w": 0.30, "h": 0.26, "fit": "contain"}
            ),
            "camera": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "fit": "cover"},
        },
        "stacked": {
            "use": "Ты сверху, экран под тобой. Классика вертикального туториала.",
            "camera": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.34, "fit": "cover"},
            "screen": {"x": 0.0, "y": 0.34, "w": 1.0, "h": 0.46, "fit": "contain"},
        },
        "stacked_screen_top": {
            "use": "Экран сверху, ты снизу. Когда экран — главный герой.",
            "screen": {"x": 0.0, "y": 0.06, "w": 1.0, "h": 0.46, "fit": "contain"},
            "camera": {"x": 0.0, "y": 0.52, "w": 1.0, "h": 0.28, "fit": "cover"},
        },
        "split_even": {
            "use": "Ровно половина на половину.",
            "camera": ({"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5, "fit": "cover"} if portrait
                       else {"x": 0.0, "y": 0.0, "w": 0.5, "h": 1.0, "fit": "cover"}),
            "screen": ({"x": 0.0, "y": 0.5, "w": 1.0, "h": 0.5, "fit": "contain"} if portrait
                       else {"x": 0.5, "y": 0.0, "w": 0.5, "h": 1.0, "fit": "contain"}),
        },
    }


def box(spec: dict, width: int, height: int) -> tuple[int, int, int, int]:
    x = int(round(spec["x"] * width))
    y = int(round(spec["y"] * height))
    w = max(2, int(round(spec["w"] * width)) // 2 * 2)
    h = max(2, int(round(spec["h"] * height)) // 2 * 2)
    return x, y, w, h


def fit_chain(w: int, h: int, mode: str, zoom: dict | None = None) -> str:
    """Scale a source into a w x h box.

    `contain` never crops — a screen recording must not lose its edges, that is
    where the toolbars and the terminal prompt live. `cover` crops to fill, which
    is right for a face.
    """
    chain = ""
    if zoom:
        chain += (
            f"crop=iw*{zoom.get('w', 1.0)}:ih*{zoom.get('h', 1.0)}:"
            f"iw*{zoom.get('x', 0.0)}:ih*{zoom.get('y', 0.0)},"
        )
    if mode == "cover":
        chain += (
            f"scale={w}:{h}:force_original_aspect_ratio=increase:flags=lanczos,"
            f"crop={w}:{h}"
        )
    else:
        chain += (
            f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
            f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black"
        )
    return chain + ",setsar=1"


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("output", nargs="?")
    parser.add_argument("--sync", help="dual_sync.json from sync_dual_sources.py")
    parser.add_argument("--camera", help="Override the camera path")
    parser.add_argument("--screen", help="Override the screen path")
    parser.add_argument("--camera-start", type=float, help="Override the camera trim")
    parser.add_argument("--screen-start", type=float, help="Override the screen trim")
    parser.add_argument("--layout-plan", help="JSON list of {at, layout, zoom?}")
    parser.add_argument("--layout", default="pip_camera",
                        help="Single layout for the whole video when no plan is given")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--duration", type=float, help="Limit the output length")
    parser.add_argument("--voice", default="camera", choices=["camera", "screen"])
    parser.add_argument("--other-audio-db", type=float, default=-18.0,
                        help="Level of the non-voice source; use -200 to mute it")
    parser.add_argument("--background", default="0x0B0F14",
                        help="Colour behind the sources in split layouts")
    parser.add_argument("--pip-border", type=int, default=4,
                        help="Border thickness around inset sources, 0 to disable")
    parser.add_argument("--pip-border-color", default="0xF2F4F7@0.85")
    parser.add_argument("--profile", default="delivery", choices=list(PROFILES))
    parser.add_argument("--codec", default="h264", choices=["h264", "hevc"])
    parser.add_argument("--cpu", action="store_true")
    parser.add_argument("--filter-report")
    parser.add_argument("--list-layouts", action="store_true")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    board = resolve_canvas(args.canvas)
    table = layouts(board.width, board.height)
    if args.list_layouts:
        emit({"canvas": f"{board.width}x{board.height}",
              "layouts": {name: {"use": spec["use"],
                                 "camera": spec["camera"], "screen": spec["screen"]}
                          for name, spec in table.items()}})
        return
    if not args.output:
        parser.error("output is required unless --list-layouts is used")

    require_binary("ffmpeg")
    camera_path = args.camera
    screen_path = args.screen
    camera_start = args.camera_start
    screen_start = args.screen_start
    voice = args.voice
    if args.sync:
        sync = json.loads(Path(args.sync).read_text(encoding="utf-8"))
        camera_path = camera_path or sync["camera"]["path"]
        screen_path = screen_path or sync["screen"]["path"]
        trim = sync.get("trim_to_align", {})
        camera_start = camera_start if camera_start is not None else trim.get("camera_start", 0.0)
        screen_start = screen_start if screen_start is not None else trim.get("screen_start", 0.0)
        if args.voice == "camera" and sync.get("audio_recommendation", {}).get("use") == "screen":
            voice = "screen"
    if not camera_path or not screen_path:
        raise SystemExit("Need both sources: pass --sync, or --camera and --screen")
    camera_start = float(camera_start or 0.0)
    screen_start = float(screen_start or 0.0)

    camera = Path(camera_path).resolve()
    screen = Path(screen_path).resolve()
    for path in (camera, screen):
        if not path.is_file():
            raise SystemExit(f"Not found: {path}")
    camera_info = probe(camera)
    screen_info = probe(screen)
    overlap = min(
        float(camera_info.get("duration") or 0) - camera_start,
        float(screen_info.get("duration") or 0) - screen_start,
    )
    duration = min(overlap, args.duration) if args.duration else overlap
    if duration <= 0.2:
        raise SystemExit(
            f"The aligned sources overlap by only {overlap:.2f}s — check the sync offset"
        )

    if args.layout_plan:
        plan = json.loads(Path(args.layout_plan).read_text(encoding="utf-8"))
        entries = plan.get("spans") if isinstance(plan, dict) else plan
    else:
        entries = [{"at": 0.0, "layout": args.layout}]
    entries = sorted(entries, key=lambda item: float(item.get("at", 0.0)))
    if not entries:
        raise SystemExit("Layout plan is empty")
    if entries[0].get("at", 0.0) > 0.01:
        entries.insert(0, {"at": 0.0, "layout": args.layout})

    spans = []
    for index, entry in enumerate(entries):
        start = max(0.0, float(entry.get("at", 0.0)))
        end = float(entries[index + 1]["at"]) if index + 1 < len(entries) else duration
        end = min(end, duration)
        if end - start < 0.05:
            continue
        name = entry.get("layout", args.layout)
        if name not in table:
            raise SystemExit(f"Unknown layout '{name}'. Known: {', '.join(table)}")
        spans.append({"start": start, "end": end, "layout": name,
                      "zoom": entry.get("zoom"), "note": entry.get("note")})
    if not spans:
        raise SystemExit("No usable layout spans")
    if len(spans) > MAX_SPANS:
        raise SystemExit(
            f"{len(spans)} layout spans exceeds the {MAX_SPANS} the single-pass graph "
            f"handles. Split the timeline and render in parts."
        )

    report = {
        "camera": str(camera), "screen": str(screen),
        "camera_start": camera_start, "screen_start": screen_start,
        "canvas": f"{board.width}x{board.height}@{board.fps}",
        "duration": round(duration, 3),
        "voice": voice,
        "other_audio_db": args.other_audio_db,
        "spans": [
            {**span, "use": table[span["layout"]]["use"]} for span in spans
        ],
    }
    if args.dry_run:
        emit(report)
        return

    command = [
        "ffmpeg", "-y", "-v", "warning", "-nostdin",
        "-ss", ff(camera_start), "-i", str(camera),
        "-ss", ff(screen_start), "-i", str(screen),
    ]

    graph: list[str] = []
    graph.append(
        f"color=c={args.background}:s={board.width}x{board.height}:"
        f"r={board.fps}:d={ff(duration)}[base]"
    )
    # One scaled copy of each source per span that shows it.
    camera_uses = [i for i, span in enumerate(spans) if table[span["layout"]]["camera"]]
    screen_uses = [i for i, span in enumerate(spans) if table[span["layout"]]["screen"]]
    if camera_uses:
        outputs = "".join(f"[cam{i}]" for i in camera_uses)
        graph.append(
            f"[0:v]fps={board.fps},setpts=PTS-STARTPTS"
            + (f",split={len(camera_uses)}{outputs}" if len(camera_uses) > 1
               else f"{outputs}")
        )
    if screen_uses:
        outputs = "".join(f"[scr{i}]" for i in screen_uses)
        graph.append(
            f"[1:v]fps={board.fps},setpts=PTS-STARTPTS"
            + (f",split={len(screen_uses)}{outputs}" if len(screen_uses) > 1
               else f"{outputs}")
        )

    previous = "base"
    for index, span in enumerate(spans):
        spec = table[span["layout"]]
        # Largest first, so whichever source is full-frame becomes the backdrop and
        # the smaller one stays visible on top. Ordering by role instead would let
        # a full-frame camera bury the screen inset in `pip_screen`.
        order = sorted(
            (role for role in ("screen", "camera") if spec[role]),
            key=lambda role: -(spec[role]["w"] * spec[role]["h"]),
        )
        for role in order:
            label = f"scr{index}" if role == "screen" else f"cam{index}"
            placement = spec[role]
            x, y, w, h = box(placement, board.width, board.height)
            zoom = span["zoom"] if role == "screen" else None
            chain = fit_chain(w, h, placement["fit"], zoom)
            inset = not (w >= board.width and h >= board.height)
            if inset and args.pip_border > 0:
                chain += (
                    f",drawbox=x=0:y=0:w={w}:h={h}:"
                    f"color={args.pip_border_color}:t={args.pip_border}"
                )
            graph.append(f"[{label}]{chain}[p{index}{role[0]}]")
            out = f"ov{index}{role[0]}"
            graph.append(
                f"[{previous}][p{index}{role[0]}]overlay={x}:{y}:"
                f"enable='between(t,{ff(span['start'])},{ff(span['end'])})':"
                f"eof_action=pass[{out}]"
            )
            previous = out

    graph.append(f"[{previous}]format=yuv420p[vout]")

    voice_index = 0 if voice == "camera" else 1
    other_index = 1 - voice_index
    voice_has_audio = (camera_info if voice_index == 0 else screen_info).get("has_audio")
    other_has_audio = (camera_info if other_index == 0 else screen_info).get("has_audio")
    if not voice_has_audio:
        raise SystemExit(f"The chosen voice source ({voice}) has no audio track")
    mix_other = bool(other_has_audio) and args.other_audio_db > -100
    # Only split the voice when something will key off it: an unconsumed filter
    # output is a hard error in ffmpeg, not a warning.
    voice_tail = "asplit=2[voice][voice_key]" if mix_other else "anull[voice]"
    graph.append(
        f"[{voice_index}:a]asetpts=PTS-STARTPTS,"
        f"aformat=sample_rates=48000:channel_layouts=stereo,"
        f"atrim=duration={ff(duration)},{voice_tail}"
    )
    if mix_other:
        graph.append(
            f"[{other_index}:a]asetpts=PTS-STARTPTS,"
            f"aformat=sample_rates=48000:channel_layouts=stereo,"
            f"atrim=duration={ff(duration)},volume={args.other_audio_db:.2f}dB[other_raw]"
        )
        # Interface sounds are welcome; the second microphone's copy of the voice is
        # not. Ducking the other source against the voice keeps the clicks and
        # suppresses the comb-filtered double of the speech.
        graph.append(
            "[other_raw][voice_key]sidechaincompress=threshold=0.02:ratio=6:"
            "attack=5:release=250:makeup=1[other]"
        )
        graph.append("[voice][other]amix=inputs=2:normalize=0:dropout_transition=0[aout]")
    else:
        graph.append("[voice]anull[aout]")

    filter_graph = ";\n".join(graph)

    report_path = (
        Path(args.filter_report) if args.filter_report
        else Path(args.output).with_suffix(".layout_filter.txt")
    )
    report_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.write_text(filter_graph + "\n", encoding="utf-8")

    caps = capabilities()
    encoder_arguments, selection = output_arguments(
        args.codec, args.profile, prefer_gpu=not args.cpu, caps=caps
    )
    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    command += [
        "-filter_complex_script", str(report_path),
        "-map", "[vout]", "-map", "[aout]",
        *encoder_arguments,
        "-t", ff(duration),
        str(output),
    ]
    completed = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if completed.returncode:
        report.update({"status": "render_failed", "filter_report": str(report_path),
                       "tail": (completed.stderr or "").strip()[-1800:]})
        emit(report)
        raise SystemExit(completed.returncode)

    actual = probe(output)
    report.update({
        "status": "ok",
        "output": str(output),
        "actual_duration": actual.get("duration"),
        "encoder": selection,
        "filter_report": str(report_path),
    })
    emit(report)


if __name__ == "__main__":
    main()
