#!/usr/bin/env python3
"""
cutdowns.py — batch-render IG/social cuts from a master video.

Takes a master + a markdown/CSV cut sheet + an aspect ratio and emits a folder
of social-ready exports with loudness normalised to -14 LUFS (Instagram/TikTok
spec), proper crop, and h.264 1080p.

Replaces an hour of click-cut-export per cutdown batch with one command.

Usage:
  python3 cutdowns.py master.mov cuts.md --aspect 9:16
  python3 cutdowns.py master.mp4 cuts.csv --aspect 1:1 --out ./social
  python3 cutdowns.py master.mov cuts.md --aspect 16:9 --no-loudnorm

Cut sheet formats:
  Markdown (one beat per line):
    - 00:01:30 - 00:01:45 | Hero shot
    - 00:02:10 - 00:02:30 | Closing line
  Or CSV:
    start,end,label
    00:01:30,00:01:45,Hero shot
    00:02:10,00:02:30,Closing line

Output naming:  001-9x16-hero-shot.mp4

Local, free. Needs ffmpeg. Python 3.9+. Standard library only.
"""
from __future__ import annotations

import argparse
import csv
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path


ASPECTS = {
    "9:16":  (1080, 1920),   # IG Reel / TikTok / Stories
    "1:1":   (1080, 1080),   # IG feed square
    "4:5":   (1080, 1350),   # IG feed portrait
    "16:9":  (1920, 1080),   # YouTube / web
    "2.35:1":(1920, 818),    # cinema scope (rare for social)
}


@dataclass
class Cut:
    start: float
    end: float
    label: str

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


TC_RE = re.compile(r"(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?")


def parse_tc(s: str) -> float:
    s = s.strip()
    m = TC_RE.fullmatch(s)
    if not m:
        raise ValueError(f"unparseable timecode: {s!r}")
    hh, mm, ss, ms = m.groups()
    total = (int(hh or 0) * 3600) + (int(mm) * 60) + int(ss)
    if ms:
        total += int(ms.ljust(3, "0")) / 1000
    return total


def slugify(text: str, maxlen: int = 40) -> str:
    s = re.sub(r"[^a-zA-Z0-9]+", "-", text.strip().lower()).strip("-")
    return s[:maxlen] or "cut"


def parse_md_cuts(path: Path) -> list[Cut]:
    cuts: list[Cut] = []
    line_re = re.compile(
        r"^\s*[-*]?\s*"                        # optional bullet
        r"(?P<start>(?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.,]\d+)?)"
        r"\s*[-–—]+\s*"
        r"(?P<end>(?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.,]\d+)?)"
        r"\s*[|:]\s*"
        r"(?P<label>.*?)\s*$"
    )
    for line in path.read_text(encoding="utf-8").splitlines():
        m = line_re.match(line)
        if not m:
            continue
        try:
            cuts.append(Cut(parse_tc(m.group("start")),
                            parse_tc(m.group("end")),
                            m.group("label")))
        except ValueError:
            continue
    return cuts


def parse_csv_cuts(path: Path) -> list[Cut]:
    cuts: list[Cut] = []
    with path.open(encoding="utf-8") as f:
        for row in csv.DictReader(f):
            try:
                cuts.append(Cut(parse_tc(row["start"]),
                                parse_tc(row["end"]),
                                row.get("label", "cut")))
            except (KeyError, ValueError):
                continue
    return cuts


def render_cut(
    master: Path, cut: Cut, idx: int, aspect: str, out_dir: Path,
    loudnorm: bool, dry_run: bool,
) -> Path:
    w, h = ASPECTS[aspect]
    slug = slugify(cut.label)
    out_name = f"{idx:03d}-{aspect.replace(':', 'x')}-{slug}.mp4"
    out_path = out_dir / out_name

    # Build filter chain. Crop centred, scale, then pad if needed.
    vf = (
        f"scale=if(gt(a\\,{w}/{h})\\,-2\\,{w}):if(gt(a\\,{w}/{h})\\,{h}\\,-2),"
        f"crop={w}:{h}"
    )

    af = (
        "loudnorm=I=-14:TP=-1.5:LRA=11" if loudnorm
        else "anull"
    )

    cmd = [
        "ffmpeg", "-hide_banner", "-loglevel", "error", "-stats",
        "-ss", f"{cut.start:.3f}", "-to", f"{cut.end:.3f}",
        "-i", str(master),
        "-vf", vf,
        "-af", af,
        "-c:v", "libx264", "-preset", "medium", "-crf", "20",
        "-c:a", "aac", "-b:a", "192k",
        "-pix_fmt", "yuv420p",
        "-movflags", "+faststart",
        "-y", str(out_path),
    ]
    if dry_run:
        print("DRY RUN:", " ".join(cmd))
        return out_path
    subprocess.run(cmd, check=True)
    return out_path


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="cutdowns", description=__doc__.split("\n\n")[0])
    p.add_argument("master", type=Path, help="Master video file")
    p.add_argument("sheet", type=Path, help="Cut sheet (.md or .csv)")
    p.add_argument("--aspect", choices=list(ASPECTS), default="9:16",
                   help="Output aspect ratio. Default 9:16 (IG Reel).")
    p.add_argument("--out", type=Path, default=None,
                   help="Output folder. Default: <master parent>/cutdowns/")
    p.add_argument("--no-loudnorm", action="store_true",
                   help="Skip -14 LUFS audio normalisation")
    p.add_argument("--dry-run", action="store_true", help="Print commands, don't render")
    args = p.parse_args(argv)

    if not args.master.is_file():
        print(f"cutdowns: master not found: {args.master}", file=sys.stderr)
        return 2
    if not args.sheet.is_file():
        print(f"cutdowns: cut sheet not found: {args.sheet}", file=sys.stderr)
        return 2

    if args.sheet.suffix.lower() == ".csv":
        cuts = parse_csv_cuts(args.sheet)
    else:
        cuts = parse_md_cuts(args.sheet)

    if not cuts:
        print("cutdowns: no cuts parsed.", file=sys.stderr)
        print("  Markdown format:  - 00:01:30 - 00:01:45 | Hero shot", file=sys.stderr)
        print("  CSV format: start,end,label  with rows like  00:01:30,00:01:45,Hero shot", file=sys.stderr)
        return 1

    out_dir = args.out or args.master.parent / "cutdowns"
    out_dir.mkdir(parents=True, exist_ok=True)

    print(f"cutdowns: rendering {len(cuts)} cut(s) at {args.aspect} into {out_dir}/")
    rendered: list[Path] = []
    for idx, cut in enumerate(cuts, 1):
        if cut.duration <= 0.5:
            print(f"  [{idx}] SKIP {cut.label!r} — duration {cut.duration:.2f}s too short", file=sys.stderr)
            continue
        print(f"  [{idx}] {cut.start:.1f}s → {cut.end:.1f}s  {cut.label!r}")
        try:
            out = render_cut(args.master, cut, idx, args.aspect, out_dir,
                             loudnorm=not args.no_loudnorm, dry_run=args.dry_run)
            rendered.append(out)
        except subprocess.CalledProcessError as e:
            print(f"    FAILED: ffmpeg exit {e.returncode}", file=sys.stderr)

    print(f"\ncutdowns: rendered {len(rendered)} cut(s).")
    if rendered:
        print(f"  Output: {out_dir}/")
    return 0


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