"""
scripts/generate_cutter_report.py — generate CUTTER_REPORT.{md,html} from cache

Renders two reports for the video editor:

* ``CUTTER_REPORT.md`` — text + base64-embedded preview stills. Self-
  contained (no broken image links on git server), opens in any markdown
  viewer.
* ``CUTTER_REPORT.html`` — same data with HTML layout, side-by-side
  preview stills, and optionally side-by-side 3-second MP4 video clips
  per beat for sight-checking phase agreement.

Frame rates:

* Trailer fps is taken from ``config.toml`` if ``[paths] trailer_frame_rate``
  is set, otherwise from ffprobe on the trailer file.
* Source fps is taken from ``[export] edl_frame_rate`` in config.toml. This
  is the value the EDL/FCPXML uses, so it matches what the cutter sees in
  the NLE timeline. ffprobe is used only as a last-resort fallback.

Usage (from project root):

    python scripts/generate_cutter_report.py                # full report
    python scripts/generate_cutter_report.py --no-stills    # text-only md
    python scripts/generate_cutter_report.py --with-clips   # also render
                                                            # video previews
"""

from __future__ import annotations

import argparse
import base64
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path

# ----------------------------------------------------------------------------
# Frame-rate handling
# ----------------------------------------------------------------------------


def probe_fps(video_path: Path) -> float | None:
    """Return the file's frame rate via ffprobe. Tries r_frame_rate first."""
    if not video_path.exists():
        return None
    for key in ("r_frame_rate", "avg_frame_rate"):
        try:
            proc = subprocess.run(
                [
                    "ffprobe", "-v", "error",
                    "-select_streams", "v:0",
                    "-show_entries", f"stream={key}",
                    "-of", "default=noprint_wrappers=1:nokey=1",
                    str(video_path),
                ],
                capture_output=True, text=True, timeout=10,
            )
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return None
        raw = proc.stdout.strip()
        if not raw or raw == "0/0":
            continue
        if "/" in raw:
            num, _, den = raw.partition("/")
            try:
                n, d = float(num), float(den)
                if d:
                    return n / d
            except ValueError:
                continue
        try:
            return float(raw)
        except ValueError:
            continue
    return None


def smpte(t: float | None, fps: float) -> str:
    """Format seconds as h:mm:ss:ff using nearest-int frame counter."""
    if t is None:
        return "--:--:--:--"
    fps_int = max(1, int(round(fps)))
    total = int(round(t * fps_int))
    h = total // (3600 * fps_int)
    m = (total // (60 * fps_int)) % 60
    s = (total // fps_int) % 60
    f = total % fps_int
    return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"


# ----------------------------------------------------------------------------
# Vision-description helpers
# ----------------------------------------------------------------------------


def best_beat_description(items: dict, beat_id: int, start_s: float, end_s: float) -> str | None:
    best, best_diff = None, 1e9
    for key, value in items.items():
        if not key.startswith(f"beat:{beat_id}:") or not isinstance(value, dict):
            continue
        try:
            parts = key.split(":")
            ks, ke = float(parts[2]), float(parts[3])
        except (IndexError, ValueError):
            continue
        diff = abs(ks - start_s) + abs(ke - end_s)
        if diff < best_diff:
            best_diff = diff
            best = value
    return best.get("description", "") if best else None


def parse_field(desc: str | None, key: str) -> str:
    if not desc:
        return ""
    match = re.search(rf'"{key}"\s*:\s*"([^"]+)"', desc)
    return match.group(1) if match else ""


# ----------------------------------------------------------------------------
# Stills / clips
# ----------------------------------------------------------------------------


STILL_WIDTH = 480
STILL_QUALITY = 4
CLIP_WIDTH = 480
# Clips run for the full beat / match duration, with this cap as a safety net
# so a runaway match doesn't pull a 60 s preview. Most beats are below 10 s
# anyway. The cap should never silently truncate a normal beat — set it well
# above any realistic beat length.
CLIP_MAX_DURATION_S = 30.0


def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
    """Always render fresh. The match position can change without the source
    video changing, so a mtime-based cache would silently serve stale frames
    from the previous match. The cutter expects bit-current previews."""
    if not video_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-ss", f"{max(0.0, t_s):.3f}",
        "-i", str(video_path),
        "-frames:v", "1",
        "-vf", f"scale={STILL_WIDTH}:-2",
        "-q:v", str(STILL_QUALITY),
        str(out),
    ]
    try:
        subprocess.run(cmd, check=True, capture_output=True, timeout=30)
    except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
        return False
    return out.exists() and out.stat().st_size > 0


def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path) -> bool:
    """Always render fresh — see extract_still for rationale."""
    if not video_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-ss", f"{max(0.0, start_s):.3f}",
        "-i", str(video_path),
        "-t", f"{max(0.04, duration_s):.3f}",
        "-vf", f"scale={CLIP_WIDTH}:-2",
        "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
        "-pix_fmt", "yuv420p",
        "-an",
        "-movflags", "+faststart",
        str(out),
    ]
    try:
        subprocess.run(cmd, check=True, capture_output=True, timeout=60)
    except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
        return False
    return out.exists() and out.stat().st_size > 0


def extract_concat_clip(
    video_path: Path,
    segments: list[tuple[float, float]],
    out: Path,
) -> bool:
    """Render each (start_s, duration_s) segment then concat into one MP4.

    Used for multi-shot source matches so the cutter sees the assembled
    source side-by-side with the trailer beat instead of just one segment.
    """
    if not video_path.exists() or not segments:
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    parts: list[Path] = []
    for idx, (seg_start, seg_dur) in enumerate(segments):
        part = out.with_name(f"{out.stem}_seg{idx:02d}.mp4")
        if not extract_clip(video_path, seg_start, seg_dur, part):
            return False
        parts.append(part)
    if not parts:
        return False
    # Single segment: just rename / copy.
    if len(parts) == 1:
        if parts[0].resolve() != out.resolve():
            try:
                if out.exists():
                    out.unlink()
                parts[0].rename(out)
            except OSError:
                return False
        return out.exists() and out.stat().st_size > 0
    # Multi-segment: concat via ffmpeg concat demuxer (codec params match
    # because every segment is rendered through extract_clip with identical
    # encoder settings).
    list_file = out.with_name(f"{out.stem}_concat.txt")
    list_file.write_text(
        "\n".join(f"file '{part.absolute().as_posix()}'" for part in parts) + "\n",
        encoding="utf-8",
    )
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "concat", "-safe", "0", "-i", str(list_file),
        "-c", "copy",
        "-movflags", "+faststart",
        str(out),
    ]
    try:
        subprocess.run(cmd, check=True, capture_output=True, timeout=60)
    finally:
        try:
            list_file.unlink()
        except OSError:
            pass
    return out.exists() and out.stat().st_size > 0


def beat_still_time(start_s: float, end_s: float) -> float:
    duration = max(0.04, end_s - start_s)
    return start_s + min(0.4, duration * 0.3)


def data_uri(path: Path, mime: str) -> str | None:
    if not path.exists() or path.stat().st_size == 0:
        return None
    payload = base64.b64encode(path.read_bytes()).decode("ascii")
    return f"data:{mime};base64,{payload}"


# ----------------------------------------------------------------------------
# Per-beat data
# ----------------------------------------------------------------------------


@dataclass
class BeatRow:
    bid: int
    trailer_in_s: float
    trailer_out_s: float
    source_in_s: float | None
    source_out_s: float | None
    scene_id: int | None
    score: float
    confirmed: bool
    matched: bool
    phase: str
    composition: str
    setting: str
    trailer_still: Path | None
    source_still: Path | None
    trailer_clip: Path | None
    source_clip: Path | None

    @property
    def status(self) -> str:
        if not self.matched:
            return "MAN."
        return "OK" if self.confirmed else "?"


def collect_rows(
    project_root: Path,
    beats: list[dict],
    results: dict[int, dict],
    vis_items: dict,
    trailer_path: Path,
    source_path: Path,
    with_stills: bool,
    with_clips: bool,
) -> list[BeatRow]:
    stills_dir = project_root / "output" / "cutter_stills"
    clips_dir = project_root / "output" / "cutter_clips"
    if with_stills:
        stills_dir.mkdir(parents=True, exist_ok=True)
    if with_clips:
        clips_dir.mkdir(parents=True, exist_ok=True)

    rows: list[BeatRow] = []
    for beat in beats:
        bid = beat["beat_id"]
        rec = results.get(bid)
        desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""

        trailer_still = source_still = trailer_clip = source_clip = None
        if with_stills:
            t_still = beat_still_time(beat["start_s"], beat["end_s"])
            tjpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
            if extract_still(trailer_path, t_still, tjpg):
                trailer_still = tjpg
            if rec is not None:
                src_dur = max(0.04, rec["out_point_s"] - rec["in_point_s"])
                s_still = rec["in_point_s"] + min(0.4, src_dur * 0.3)
                sjpg = stills_dir / f"beat_{bid:02d}_source.jpg"
                if extract_still(source_path, s_still, sjpg):
                    source_still = sjpg
        if with_clips:
            # Trailer clip: full beat duration so the cutter sees the entire
            # reference beat, not an arbitrary-length excerpt.
            tdur = max(0.5, min(CLIP_MAX_DURATION_S, beat["end_s"] - beat["start_s"]))
            tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
            if extract_clip(trailer_path, beat["start_s"], tdur, tmp4):
                trailer_clip = tmp4
            # Source clip:
            #   - segmented match (multi-shot beat): concatenate each
            #     segment back-to-back so the cutter sees the assembled
            #     source (e.g. man-shot + reaction shot) at the same total
            #     length as the trailer beat.
            #   - single match: extract the full matched span.
            # May be shorter than the beat when a match drops before the
            # beat ends (fade / shot change in source) — intentional.
            if rec is not None:
                segs = rec.get("segments") or []
                smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
                if len(segs) >= 2:
                    seg_specs = [
                        (
                            float(s["in_point_s"]),
                            max(0.04, float(s["out_point_s"]) - float(s["in_point_s"])),
                        )
                        for s in segs
                        if float(s["out_point_s"]) > float(s["in_point_s"])
                    ]
                    if seg_specs and extract_concat_clip(source_path, seg_specs, smp4):
                        source_clip = smp4
                else:
                    sdur = max(0.5, min(CLIP_MAX_DURATION_S, rec["out_point_s"] - rec["in_point_s"]))
                    if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
                        source_clip = smp4

        rows.append(BeatRow(
            bid=bid,
            trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"],
            source_in_s=rec["in_point_s"] if rec else None,
            source_out_s=rec["out_point_s"] if rec else None,
            scene_id=rec["scene_id"] if rec else None,
            score=rec["match_score"] if rec else 0.0,
            confirmed=bool(rec and rec.get("is_confirmed")),
            matched=rec is not None,
            phase=parse_field(desc, "action_phase") or parse_field(desc, "subject"),
            composition=parse_field(desc, "composition"),
            setting=parse_field(desc, "setting"),
            trailer_still=trailer_still,
            source_still=source_still,
            trailer_clip=trailer_clip,
            source_clip=source_clip,
        ))
    return rows


# ----------------------------------------------------------------------------
# Markdown renderer
# ----------------------------------------------------------------------------


def render_markdown(
    rows: list[BeatRow], trailer_fps: float, source_fps: float,
    trailer_path: Path, source_path: Path,
) -> str:
    matched = sum(1 for r in rows if r.matched)
    confirmed = sum(1 for r in rows if r.confirmed)

    out: list[str] = []
    out.append("# Cutter-Report — manuelles Nachschneiden")
    out.append("")
    out.append(f"Stand: {date.today().isoformat()}")
    out.append("")
    out.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
    out.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
    out.append("")
    out.append(
        "Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate — "
        "passt 1:1 zum Schnittplatz."
    )
    out.append("")
    out.append(
        "Bilder sind base64-eingebettet (kein toter Link). Für Video-Vorschau "
        "siehe `CUTTER_REPORT.html` (gleiche Daten, mit Clips)."
    )
    out.append("")

    out.append("## Status-Legende")
    out.append("")
    out.append("| Status | Bedeutung | Was tun? |")
    out.append("|--------|-----------|----------|")
    out.append("| `OK`   | bestätigt durch CV + Vision-Phasenprüfung | übernehmen |")
    out.append("| `?`    | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen |")
    out.append("| `MAN.` | kein automatischer Treffer | manuell setzen oder Schwarzfade |")
    out.append("")

    out.append(f"**Beats:** {len(rows)} gesamt · **{matched}** automatisch (**{confirmed}** bestätigt) · **{len(rows)-matched}** manuell.")
    out.append("")

    out.append("## Beat-Tabelle")
    out.append("")
    out.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Bild laut Vision |")
    out.append("|-----:|------------------|------------------|------:|:------:|-------------------|")
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        si = smpte(r.source_in_s, source_fps) if r.matched else "—"
        so = smpte(r.source_out_s, source_fps) if r.matched else "—"
        sc = f"{r.score:.3f}" if r.matched else "—"
        out.append(f"| {r.bid:>4} | {ti}-{to} | {si}-{so} | {sc} | {r.status} | {r.phase[:80]} |")
    out.append("")

    out.append("## Beat-Details")
    out.append("")
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        out.append(f"### Beat {r.bid:02d} — Status `{r.status}`")
        out.append("")
        out.append(f"- **Trailer**: {ti} – {to}")
        if r.matched:
            si = smpte(r.source_in_s, source_fps)
            so = smpte(r.source_out_s, source_fps)
            out.append(f"- **Source** : {si} – {so}  (scene {r.scene_id}, score {r.score:.3f})")
        else:
            out.append("- **Source** : — (manuell setzen)")
        if r.phase:
            out.append(f"- **Phase**  : {r.phase}")
        if r.composition:
            extra = f", {r.setting}" if r.setting else ""
            out.append(f"- **Bild**   : {r.composition}{extra}")
        out.append("")

        # Inline base64 stills
        t_uri = data_uri(r.trailer_still, "image/jpeg") if r.trailer_still else None
        s_uri = data_uri(r.source_still, "image/jpeg") if r.source_still else None
        if t_uri or s_uri:
            out.append("| Trailer | Source |")
            out.append("|:---:|:---:|")
            t_cell = f"![Trailer beat {r.bid}]({t_uri})" if t_uri else "_(kein Still)_"
            s_cell = f"![Source beat {r.bid}]({s_uri})" if s_uri else "_(MAN.)_"
            out.append(f"| {t_cell} | {s_cell} |")
            out.append("")

    return "\n".join(out)


# ----------------------------------------------------------------------------
# HTML renderer
# ----------------------------------------------------------------------------


HTML_HEAD = """<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Cutter-Report</title>
<style>
:root { color-scheme: light dark; --fg:#222; --bg:#fafafa; --mut:#666;
  --ok:#1e8e3e; --q:#d18907; --man:#c33; --card:#fff; --bd:#ddd; }
@media (prefers-color-scheme: dark) {
  :root { --fg:#eee; --bg:#181818; --mut:#aaa; --card:#222; --bd:#333; }
}
html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
  Roboto,sans-serif;color:var(--fg);background:var(--bg);font-size:14px;}
.wrap{max-width:1200px;margin:0 auto;padding:24px;}
h1{margin:0 0 4px;font-size:24px;}
h2{margin:32px 0 12px;font-size:18px;border-bottom:1px solid var(--bd);padding-bottom:4px;}
h3{margin:24px 0 6px;font-size:16px;}
.meta{color:var(--mut);margin-bottom:16px;}
.summary{margin:8px 0 24px;font-size:15px;}
table.tab{width:100%;border-collapse:collapse;margin:12px 0 24px;font-size:13px;}
table.tab th,table.tab td{padding:6px 8px;border-bottom:1px solid var(--bd);text-align:left;
  vertical-align:top;}
table.tab th{background:var(--card);}
table.tab td.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;}
table.tab td.num{text-align:right;font-variant-numeric:tabular-nums;}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-weight:600;font-size:12px;}
.badge.ok{background:rgba(30,142,62,.15);color:var(--ok);}
.badge.q{background:rgba(209,137,7,.15);color:var(--q);}
.badge.man{background:rgba(204,51,51,.15);color:var(--man);}
.beat{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:14px 0 28px;
  background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:14px;}
.beat .full{grid-column:1/-1;}
.col h4{margin:0 0 6px;font-size:13px;color:var(--mut);font-weight:600;
  text-transform:uppercase;letter-spacing:.05em;}
.col img,.col video{width:100%;height:auto;border-radius:4px;background:#000;}
.kv{margin:4px 0;font-size:13px;}
.kv b{display:inline-block;min-width:64px;color:var(--mut);font-weight:500;}
.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}
.empty{display:flex;align-items:center;justify-content:center;width:100%;
  aspect-ratio:16/9;background:#000;color:#888;border-radius:4px;font-size:13px;}
</style>
</head>
<body><div class="wrap">
"""


HTML_FOOT = """</div></body></html>
"""


def html_escape(s: str) -> str:
    return (s.replace("&", "&amp;").replace("<", "&lt;")
             .replace(">", "&gt;").replace('"', "&quot;"))


def render_html(
    rows: list[BeatRow], trailer_fps: float, source_fps: float,
    trailer_path: Path, source_path: Path, with_clips: bool,
) -> str:
    matched = sum(1 for r in rows if r.matched)
    confirmed = sum(1 for r in rows if r.confirmed)

    parts: list[str] = [HTML_HEAD]
    parts.append(f'<h1>Cutter-Report — {date.today().isoformat()}</h1>')
    parts.append('<div class="meta">')
    parts.append(f"<div><b>Trailer</b> <code>{html_escape(trailer_path.name)}</code> @ {trailer_fps:.3f} fps</div>")
    parts.append(f"<div><b>Source</b> <code>{html_escape(source_path.name)}</code> @ {source_fps:.3f} fps</div>")
    parts.append("<div>Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate.</div>")
    parts.append("</div>")
    parts.append(f'<div class="summary">{len(rows)} Beats — <b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt) — <b>{len(rows)-matched}</b> manuell.</div>')

    parts.append("<h2>Status-Legende</h2>")
    parts.append('<table class="tab"><thead><tr><th>Status</th><th>Bedeutung</th><th>Was tun?</th></tr></thead><tbody>')
    parts.append('<tr><td><span class="badge ok">OK</span></td><td>bestätigt durch CV + Vision-Phasenprüfung</td><td>übernehmen, optional sichten</td></tr>')
    parts.append('<tr><td><span class="badge q">?</span></td><td>korrekte Szene, Phase ggf. um wenige Frames verschoben</td><td>im NLE prüfen, Source-In nachjustieren</td></tr>')
    parts.append('<tr><td><span class="badge man">MAN.</span></td><td>kein automatischer Treffer</td><td>manuell suchen oder Schwarzfade</td></tr>')
    parts.append('</tbody></table>')

    # Compact table
    parts.append("<h2>Beat-Tabelle</h2>")
    parts.append('<table class="tab"><thead><tr><th>Beat</th><th>Trailer In / Out</th><th>Source In / Out</th><th>Score</th><th>Status</th><th>Phase</th></tr></thead><tbody>')
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        si = smpte(r.source_in_s, source_fps) if r.matched else "—"
        so = smpte(r.source_out_s, source_fps) if r.matched else "—"
        sc = f"{r.score:.3f}" if r.matched else "—"
        bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
        parts.append(
            f'<tr><td class="num">{r.bid}</td>'
            f'<td class="tc">{ti}–{to}</td>'
            f'<td class="tc">{si}–{so}</td>'
            f'<td class="num">{sc}</td>'
            f'<td><span class="badge {bcls}">{r.status}</span></td>'
            f'<td>{html_escape(r.phase[:120])}</td></tr>'
        )
    parts.append("</tbody></table>")

    # Per-beat detail cards
    parts.append("<h2>Beat-Details</h2>")
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
        parts.append('<div class="beat">')
        parts.append(f'<div class="full"><h3>Beat {r.bid:02d} <span class="badge {bcls}">{r.status}</span></h3></div>')

        # Trailer column
        parts.append('<div class="col">')
        parts.append('<h4>Trailer</h4>')
        clip_uri = data_uri(r.trailer_clip, "video/mp4") if (with_clips and r.trailer_clip) else None
        if clip_uri:
            parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
        elif r.trailer_still:
            uri = data_uri(r.trailer_still, "image/jpeg") or ""
            parts.append(f'<img src="{uri}" alt="Trailer beat {r.bid}">')
        else:
            parts.append('<div class="empty">— kein Vorschaubild —</div>')
        parts.append(f'<div class="kv tc"><b>TC</b> {ti} – {to}</div>')
        if r.phase:
            parts.append(f'<div class="kv"><b>Phase</b> {html_escape(r.phase)}</div>')
        if r.composition:
            extra = f", {r.setting}" if r.setting else ""
            parts.append(f'<div class="kv"><b>Bild</b> {html_escape(r.composition + extra)}</div>')
        parts.append('</div>')

        # Source column
        parts.append('<div class="col">')
        parts.append('<h4>Source</h4>')
        clip_uri = data_uri(r.source_clip, "video/mp4") if (with_clips and r.source_clip) else None
        if clip_uri:
            parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
        elif r.source_still:
            uri = data_uri(r.source_still, "image/jpeg") or ""
            parts.append(f'<img src="{uri}" alt="Source beat {r.bid}">')
        else:
            parts.append('<div class="empty">— manuell setzen —</div>')
        if r.matched:
            si = smpte(r.source_in_s, source_fps)
            so = smpte(r.source_out_s, source_fps)
            parts.append(f'<div class="kv tc"><b>TC</b> {si} – {so}</div>')
            parts.append(f'<div class="kv"><b>Scene</b> {r.scene_id} · <b>Score</b> {r.score:.3f}</div>')
        else:
            parts.append('<div class="kv">— kein automatischer Treffer —</div>')
        parts.append('</div>')

        parts.append('</div>')  # .beat

    parts.append(HTML_FOOT)
    return "".join(parts)


# ----------------------------------------------------------------------------
# Top-level
# ----------------------------------------------------------------------------


def render_report(
    project_root: Path,
    with_stills: bool = True,
    with_clips: bool = False,
) -> tuple[str, str]:
    """Return (markdown, html). Both written by main()."""
    sys.path.insert(0, str(project_root))
    from src.core.config import load_config

    cfg = load_config(project_root / "config.toml")

    trailer_path = Path(cfg.paths.reference_trailer)
    source_path = Path(cfg.paths.source_movie)

    # Source fps: trust config.toml's edl_frame_rate (it's what the EDL/FCPXML
    # uses, hence what the cutter sees in the NLE). Fall back to ffprobe only
    # if no value is configured.
    source_fps = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
    # Trailer fps: optional config override, else ffprobe, else fallback to
    # source fps so the two sides at least share a number.
    trailer_fps_cfg = getattr(cfg.paths, "trailer_frame_rate", None)
    trailer_fps = float(trailer_fps_cfg) if trailer_fps_cfg else (probe_fps(trailer_path) or source_fps)

    cache = project_root / ".cache"
    results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
    beats = json.loads((cache / "trailer_beats.json").read_text())
    vis_path = cache / "vision_descriptions.json"
    vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}

    rows = collect_rows(
        project_root, beats, results, vis_items,
        trailer_path, source_path, with_stills, with_clips,
    )
    md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path)
    html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips)
    return md, html


def main() -> int:
    parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.{md,html} from current cache")
    parser.add_argument("--no-stills", action="store_true", help="skip frame extraction (markdown stays text-only)")
    parser.add_argument("--with-clips", action="store_true", help="also render 3 s MP4 previews per beat (slow)")
    args = parser.parse_args()

    here = Path(__file__).resolve().parent
    project_root = here.parent
    md, html = render_report(
        project_root,
        with_stills=not args.no_stills,
        with_clips=args.with_clips,
    )
    (project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
    (project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
    print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
    print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
