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

Renders two editor-facing reports:

* ``CUTTER_REPORT.md`` — text + base64 stills, self-contained.
* ``CUTTER_REPORT.html`` — full detail with Frame-Locked Compare video per
  beat (trailer left / source right, frame-synchronised), SMPTE timecodes,
  scene and segment info, score warnings, and rematch hints.

This report is the single source of truth for the video editor and is
the only report that should be opened for review.

Usage (from project root):
    python scripts/generate_cutter_report.py              # stills + compare clips
    python scripts/generate_cutter_report.py --no-stills  # text only
    python scripts/generate_cutter_report.py --no-clips   # stills only, no video
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path

# ---------------------------------------------------------------------------
# Frame-rate / timecode helpers
# ---------------------------------------------------------------------------


def probe_fps(video_path: Path) -> float | None:
    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 HH:MM:SS:FF."""
    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}"


def fmt_s(t: float | None) -> str:
    """Format seconds as a short decimal string."""
    return f"{t:.2f}s" if t is not None else "—"


# ---------------------------------------------------------------------------
# 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 ""


# ---------------------------------------------------------------------------
# Video / image extraction helpers
# ---------------------------------------------------------------------------

STILL_WIDTH = 480
STILL_QUALITY = 4
CLIP_WIDTH = 480
CLIP_MAX_DURATION_S = 30.0
SCENE_START_GUARD_S = 0.04
# Each half of the side-by-side compare strip
COMPARE_HALF_W = 480
COMPARE_H = 270   # 16:9


def _run(cmd: list[str], timeout: int = 120) -> bool:
    try:
        subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)
        return True
    except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
        return False


def _forced_beats() -> set[int]:
    raw = os.environ.get("CUTTER_REPORT_FORCE_BEATS", "")
    forced: set[int] = set()
    for part in re.split(r"[,;\s]+", raw):
        if not part:
            continue
        try:
            forced.add(int(part))
        except ValueError:
            continue
    return forced


def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
    """Always render fresh."""
    if not video_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    return _run([
        "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),
    ], timeout=30)


def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path,
                 width: int = CLIP_WIDTH, fps: int = 25) -> bool:
    """Extract a re-encoded clip with accurate double-seek."""
    if not video_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    preroll = 2.0 if start_s >= 2.0 else 0.0
    input_seek = max(0.0, start_s - preroll)
    accurate_seek = start_s - input_seek
    return _run([
        "ffmpeg", "-y", "-loglevel", "error",
        "-ss", f"{input_seek:.3f}", "-i", str(video_path),
        "-ss", f"{accurate_seek:.3f}", "-t", f"{max(0.04, duration_s):.3f}",
        "-map", "0:v:0",
        "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
        "-vf", f"fps={fps},scale={width}:-2,setsar=1,setpts=PTS-STARTPTS",
        "-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
    ], timeout=60)


def _black_clip(duration_s: float, out: Path, width: int, height: int, fps: int = 25) -> bool:
    return _run([
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "lavfi",
        "-i", f"color=c=black:s={width}x{height}:r={fps}:d={duration_s:.3f}",
        "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
        "-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
    ], timeout=30)


def _concat_clips(parts: list[Path], out: Path) -> bool:
    if not parts:
        return False
    if len(parts) == 1:
        try:
            if parts[0].resolve() != out.resolve():
                if out.exists():
                    out.unlink()
                parts[0].rename(out)
            return out.exists()
        except OSError:
            return False
    list_file = out.with_name(f"{out.stem}_concat.txt")
    list_file.write_text(
        "\n".join(f"file '{p.absolute().as_posix()}'" for p in parts) + "\n",
        encoding="utf-8",
    )
    ok = _run([
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "concat", "-safe", "0", "-i", str(list_file),
        "-c", "copy", "-movflags", "+faststart", str(out),
    ], timeout=120)
    try:
        list_file.unlink()
    except OSError:
        pass
    return ok


def extract_source_beat_clip(
    source_path: Path,
    segments: list[dict],
    total_duration_s: float,
    out: Path,
    width: int = CLIP_WIDTH,
) -> bool:
    """Build a beat-length source clip with black filler between segments.

    Each segment is placed at its trailer_offset_s position; gaps before,
    between, and after segments are filled with black so the clip is
    frame-synchronised with the trailer.
    """
    if not source_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    height = width // 16 * 9
    fps = 25

    if not segments:
        return _black_clip(max(0.04, total_duration_s), out, width, height, fps)

    sorted_segs = sorted(segments, key=lambda s: float(s.get("trailer_offset_s", 0)))
    parts: list[Path] = []
    cursor = 0.0

    def add_black(dur: float, idx: int) -> None:
        if dur < 0.02:
            return
        tmp = out.with_name(f"{out.stem}_p{idx:02d}blk.mp4")
        if _black_clip(dur, tmp, width, height, fps):
            parts.append(tmp)

    def add_src(start_s: float, dur: float, idx: int) -> None:
        if dur < 0.02:
            return
        tmp = out.with_name(f"{out.stem}_p{idx:02d}src.mp4")
        if extract_clip(source_path, start_s, dur, tmp, width, fps):
            parts.append(tmp)

    for i, seg in enumerate(sorted_segs):
        offset = max(0.0, float(seg.get("trailer_offset_s", 0)))
        dur = max(0.0, float(seg.get("duration_s", 0)))
        add_black(offset - cursor, len(parts))
        add_src(float(seg["in_point_s"]), dur, len(parts))
        cursor = max(cursor, offset + dur)
    add_black(total_duration_s - cursor, len(parts))

    if not parts:
        return False

    ok = _concat_clips(parts, out)
    for p in parts:
        if p != out:
            try:
                p.unlink(missing_ok=True)
            except OSError:
                pass
    return ok


def extract_concat_clip(
    video_path: Path,
    segments: list[tuple[float, float]],
    out: Path,
) -> bool:
    """Simple back-to-back concat (no black fills) — used for cutter-side clips."""
    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)
    ok = _concat_clips(parts, out)
    for p in parts:
        if p != out:
            try:
                p.unlink(missing_ok=True)
            except OSError:
                pass
    return ok


def build_compare_clip(
    trailer_path: Path,
    trailer_start: float,
    trailer_dur: float,
    source_path: Path,
    segments: list[dict],
    out: Path,
) -> bool:
    """Frame-locked side-by-side: trailer left, source right."""
    if not trailer_path.exists():
        return False
    out.parent.mkdir(parents=True, exist_ok=True)

    ref_tmp = out.with_name(f"{out.stem}_ref.mp4")
    src_tmp = out.with_name(f"{out.stem}_src.mp4")

    try:
        if not extract_clip(trailer_path, trailer_start, trailer_dur, ref_tmp,
                            COMPARE_HALF_W, fps=25):
            return False
        if not extract_source_beat_clip(source_path, segments, trailer_dur, src_tmp,
                                        COMPARE_HALF_W):
            return False

        norm = (f"fps=25,scale={COMPARE_HALF_W}:{COMPARE_H}:"
                "force_original_aspect_ratio=decrease,"
                f"pad={COMPARE_HALF_W}:{COMPARE_H}:(ow-iw)/2:(oh-ih)/2,"
                "setsar=1,setpts=PTS-STARTPTS")
        fc = f"[0:v]{norm}[ref];[1:v]{norm}[src];[ref][src]hstack=inputs=2[v]"
        return _run([
            "ffmpeg", "-y", "-loglevel", "error",
            "-i", str(ref_tmp), "-i", str(src_tmp),
            "-filter_complex", fc, "-map", "[v]",
            "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
            "-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
        ], timeout=180)
    finally:
        for p in (ref_tmp, src_tmp):
            try:
                p.unlink(missing_ok=True)
            except OSError:
                pass


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 | None, mime: str) -> str | None:
    if not path or 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 model
# ---------------------------------------------------------------------------


@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
    num_segments: int
    segments: list[dict] = field(default_factory=list)
    phase: str = ""
    composition: str = ""
    setting: str = ""
    trailer_still: Path | None = None
    source_still: Path | None = None
    trailer_clip: Path | None = None
    source_clip: Path | None = None
    compare_clip: Path | None = None

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

    @property
    def status_de(self) -> str:
        if not self.matched:
            return "Kein Treffer"
        return "Bestätigt" if self.confirmed else "Vorläufig"


# ---------------------------------------------------------------------------
# Row collection
# ---------------------------------------------------------------------------


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,
    scenes_by_id: dict[int, dict] | None = None,
) -> 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)
    force_beats = _forced_beats()

    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 ""

        segs: list[dict] = []
        num_segs = 0
        if rec is not None:
            segs = rec.get("segments") or []
            num_segs = len(segs)
            if scenes_by_id:
                rec_scene = scenes_by_id.get(int(rec.get("scene_id", -1)))
                if rec_scene and float(rec["in_point_s"]) < float(rec_scene["start_s"]) + SCENE_START_GUARD_S:
                    guarded_start = min(
                        float(rec_scene["end_s"]) - 0.04,
                        float(rec_scene["start_s"]) + SCENE_START_GUARD_S,
                    )
                    shift = guarded_start - float(rec["in_point_s"])
                    rec = dict(rec)
                    rec["in_point_s"] = guarded_start
                    rec["out_point_s"] = max(float(rec["in_point_s"]) + 0.04, float(rec["out_point_s"]) + shift)
                fixed_segs = []
                for seg in segs:
                    fixed = dict(seg)
                    seg_scene = scenes_by_id.get(int(fixed.get("scene_id", -1)))
                    if seg_scene and float(fixed["in_point_s"]) < float(seg_scene["start_s"]) + SCENE_START_GUARD_S:
                        guarded_start = min(
                            float(seg_scene["end_s"]) - 0.04,
                            float(seg_scene["start_s"]) + SCENE_START_GUARD_S,
                        )
                        shift = guarded_start - float(fixed["in_point_s"])
                        fixed["in_point_s"] = guarded_start
                        fixed["out_point_s"] = max(float(fixed["in_point_s"]) + 0.04, float(fixed["out_point_s"]) + shift)
                    fixed_segs.append(fixed)
                segs = fixed_segs

        trailer_still = source_still = None
        trailer_clip = source_clip = compare_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 tjpg.exists() and bid not in force_beats:
                trailer_still = tjpg
            elif 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 sjpg.exists() and bid not in force_beats:
                    source_still = sjpg
                elif extract_still(source_path, s_still, sjpg):
                    source_still = sjpg

        if with_clips:
            beat_dur = max(0.5, min(CLIP_MAX_DURATION_S, beat["end_s"] - beat["start_s"]))

            # Trailer clip (cutter-side, simple)
            tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
            if tmp4.exists() and bid not in force_beats:
                trailer_clip = tmp4
            elif extract_clip(trailer_path, beat["start_s"], beat_dur, tmp4):
                trailer_clip = tmp4

            if rec is not None:
                smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
                if smp4.exists() and bid not in force_beats:
                    source_clip = smp4
                elif num_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

                # Frame-locked compare video
                cmp4 = clips_dir / f"beat_{bid:02d}_compare.mp4"
                compare_segs = segs
                if not compare_segs:
                    seg_dur = max(0.04, min(beat_dur, rec["out_point_s"] - rec["in_point_s"]))
                    compare_segs = [{
                        "trailer_offset_s": 0.0,
                        "duration_s": seg_dur,
                        "scene_id": rec.get("scene_id"),
                        "in_point_s": rec["in_point_s"],
                        "out_point_s": rec["in_point_s"] + seg_dur,
                        "match_score": rec.get("match_score", 0.0),
                        "is_confirmed": rec.get("is_confirmed", False),
                    }]
                if cmp4.exists() and bid not in force_beats:
                    compare_clip = cmp4
                elif build_compare_clip(
                    trailer_path, beat["start_s"], beat_dur,
                    source_path, compare_segs,
                    cmp4,
                ):
                    compare_clip = cmp4

        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,
            num_segments=num_segs,
            segments=segs,
            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,
            compare_clip=compare_clip,
        ))
    return rows


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


def render_markdown(
    rows: list[BeatRow], trailer_fps: float, source_fps: float,
    trailer_path: Path, source_path: Path,
    generated_at: datetime,
) -> 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"Generiert: **{generated_at.strftime('%Y-%m-%d %H:%M:%S')}**")
    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.")
    out.append("")

    out.append("## Status-Legende")
    out.append("")
    out.append("| Status | Bedeutung |")
    out.append("|--------|-----------|")
    out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |")
    out.append("| `?` | Vorläufig — korrekte Szene, Phase im NLE prüfen |")
    out.append("| `MAN.` | Kein automatischer Treffer — manuell setzen |")
    out.append("")
    out.append(
        f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)"
        f" · **{len(rows) - matched}** manuell."
    )
    out.append("")

    out.append("## Beat-Tabelle")
    out.append("")
    out.append("| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status |")
    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 "—"
        scene = str(r.scene_id) if r.matched else "—"
        if r.num_segments > 1:
            scene += f" (+{r.num_segments - 1})"
        out.append(
            f"| {r.bid:>4} | {ti} – {to} | {si} – {so}"
            f" | {scene} | {sc} | {r.status} |"
        )
    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)
        dur = r.trailer_out_s - r.trailer_in_s
        out.append(f"### Beat {r.bid:02d} — {r.status} / {r.status_de}")
        out.append("")
        out.append(f"- **Trailer**: {ti} – {to} ({dur:.2f} s)")
        if r.matched:
            si = smpte(r.source_in_s, source_fps)
            so = smpte(r.source_out_s, source_fps)
            scene_info = f"scene {r.scene_id}"
            if r.num_segments > 1:
                scene_ids = list(dict.fromkeys(
                    str(s.get("scene_id", "?")) for s in r.segments
                ))
                scene_info = f"scenes {', '.join(scene_ids)} ({r.num_segments} Segmente)"
            out.append(f"- **Source** : {si} – {so}  ({scene_info}, score {r.score:.3f})")
            if r.num_segments > 1:
                for i, seg in enumerate(r.segments):
                    seg_tc = smpte(seg.get("in_point_s"), source_fps)
                    seg_dur = seg.get("duration_s", 0)
                    seg_offset = seg.get("trailer_offset_s", 0)
                    out.append(
                        f"  - Seg {i + 1}: TC {seg_tc}  dur {seg_dur:.2f}s"
                        f"  @ Trailer-Offset {seg_offset:.2f}s"
                        f"  (scene {seg.get('scene_id', '?')})"
                    )
        else:
            out.append("- **Source** : — (manuell setzen)")
        if r.score > 0 and r.score < 0.65:
            out.append(f"- ⚠ Score {r.score:.3f} unter 0.65 — visuell prüfen")
        out.append(f"- **Rematch**: `python cli.py rematch --beat {r.bid}`")
        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("")

        if r.compare_clip:
            rel_clip = f"output/cutter_clips/beat_{r.bid:02d}_compare.mp4"
            out.append(f"**[▶️ Frame-Locked Compare Video ansehen]({rel_clip})**")
            out.append("")

        t_uri = data_uri(r.trailer_still, "image/jpeg")
        s_uri = data_uri(r.source_still, "image/jpeg")
        if t_uri or s_uri:
            out.append("| Trailer | Source |")
            out.append("|:---:|:---:|")
            t_cell = f"![Trailer {r.bid}]({t_uri})" if t_uri else "_(kein Still)_"
            s_cell = f"![Source {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">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cutter-Report & Match-Report</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
  --fg: #e4e4e7;
  --bg-gradient: linear-gradient(135deg, #09090b 0%, #18181b 100%);
  --mut: #a1a1aa;
  --card-bg: rgba(24, 24, 27, 0.6);
  --card-border: rgba(255, 255, 255, 0.08);
  --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
  --accent: #6366f1;
  --accent-hover: #818cf8;
  
  --ok: #4ade80;
  --q: #fbbf24;
  --man: #f87171;
  --ok-bg: rgba(74, 222, 128, 0.15);
  --q-bg: rgba(251, 191, 36, 0.15);
  --man-bg: rgba(248, 113, 113, 0.15);
  
  --code-bg: rgba(0, 0, 0, 0.3);
  --code-fg: #86efac;
  --warn: #fef08a;
  --warn-bg: rgba(161, 98, 7, 0.3);
}

*, *::before, *::after { box-sizing: border-box; }
html, body { 
  margin: 0; padding: 0; 
  font-family: 'Inter', sans-serif; 
  color: var(--fg); 
  background: var(--bg-gradient); 
  background-attachment: fixed;
  font-size: 15px; line-height: 1.6; 
  -webkit-font-smoothing: antialiased;
}

.wrap { max-width: 1400px; margin: 0 auto; padding: 40px 24px; }

h1, h2, h3 { font-family: 'Outfit', sans-serif; font-weight: 600; letter-spacing: -0.02em; }
h1 { 
  margin: 0 0 8px; font-size: 36px; 
  background: linear-gradient(to right, #a855f7, #6366f1, #3b82f6);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  display: inline-block;
}
h2 { margin: 48px 0 20px; font-size: 24px; border-bottom: 1px solid var(--card-border); padding-bottom: 12px; }

.header-glass {
  background: rgba(15, 15, 16, 0.4);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--card-border);
  border-radius: 16px;
  padding: 24px;
  margin-bottom: 40px;
  box-shadow: var(--glass-shadow);
}

.meta { color: var(--mut); font-size: 14px; margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 16px; }
.meta b { color: var(--fg); font-weight: 500; }
.meta-chip { background: rgba(255, 255, 255, 0.05); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--card-border); }

.summary { font-size: 16px; font-weight: 500; }
.recent-changes { margin-top: 16px; padding-top: 16px; border-top: 1px dashed var(--card-border); color: #c084fc; font-size: 14px; }

/* Legend table */
table.leg { border-collapse: separate; border-spacing: 0; margin: 12px 0 32px; font-size: 14px; width: 100%; max-width: 600px; }
table.leg td { padding: 12px 16px; border-bottom: 1px solid var(--card-border); background: var(--card-bg); }
table.leg tr:first-child td:first-child { border-top-left-radius: 12px; }
table.leg tr:first-child td:last-child { border-top-right-radius: 12px; }
table.leg tr:last-child td:first-child { border-bottom-left-radius: 12px; border-bottom: none; }
table.leg tr:last-child td:last-child { border-bottom-right-radius: 12px; border-bottom: none; }

/* Overview table */
.table-container { overflow-x: auto; border-radius: 12px; border: 1px solid var(--card-border); margin-bottom: 48px; box-shadow: var(--glass-shadow); }
table.ov { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--card-bg); }
table.ov th, table.ov td { padding: 14px 16px; border-bottom: 1px solid var(--card-border); text-align: left; white-space: nowrap; }
table.ov th { background: rgba(255, 255, 255, 0.03); font-family: 'Outfit', sans-serif; font-weight: 500; font-size: 15px; color: var(--mut); text-transform: uppercase; letter-spacing: 0.05em; }
table.ov td.num { text-align: right; font-variant-numeric: tabular-nums; font-family: 'Inter', monospace; }
table.ov tr { transition: background-color 0.2s ease; }
table.ov tr:hover { background: rgba(255, 255, 255, 0.05); }

/* Badges */
.badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
.badge.ok  { background: var(--ok-bg);  color: var(--ok); border: 1px solid rgba(74, 222, 128, 0.2); }
.badge.q   { background: var(--q-bg);   color: var(--q); border: 1px solid rgba(251, 191, 36, 0.2); }
.badge.man { background: var(--man-bg); color: var(--man); border: 1px solid rgba(248, 113, 113, 0.2); }

/* Beat cards */
.beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; }
.beat { 
  background: var(--card-bg); 
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid var(--card-border); 
  border-radius: 16px;
  padding: 24px; 
  box-shadow: var(--glass-shadow);
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.beat:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45); border-color: rgba(255,255,255,0.15); }
.beat-hdr { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.beat-hdr h3 { margin: 0; font-size: 20px; color: #fff; }
.beat-hdr .status-text { font-size: 14px; color: var(--mut); margin-left: auto; }

/* Compare video */
.compare-wrap { margin-bottom: 20px; border-radius: 12px; overflow: hidden; position: relative; background: #000; box-shadow: inset 0 0 0 1px var(--card-border); }
.compare-label { position: absolute; top: 12px; left: 12px; z-index: 10; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); padding: 4px 10px; border-radius: 4px; color: #fff; border: 1px solid rgba(255,255,255,0.1); }
.compare-wrap video, .compare-wrap img { width: 100%; height: auto; display: block; transition: opacity 0.3s; }
.compare-wrap:hover video { opacity: 0.95; }
.stills-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; background: var(--card-border); }
.stills-pair img { width: 100%; height: auto; background: #000; }
.empty-media { display: flex; align-items: center; justify-content: center; aspect-ratio: 32/9; background: rgba(0,0,0,0.5); color: var(--mut); font-size: 14px; }

/* Metadata footer inside card */
.beat-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; font-size: 14px; border-top: 1px solid var(--card-border); padding-top: 20px; }
.kv { margin: 8px 0; display: flex; flex-direction: column; gap: 2px; }
.kv b { color: var(--mut); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.kv-val { color: var(--fg); font-weight: 400; }
.tc { font-family: 'Inter', monospace; font-size: 13px; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05); }
.seg-list { margin: 8px 0 0 0; padding-left: 0; font-size: 13px; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.seg-list li { background: rgba(255,255,255,0.03); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--card-border); font-family: 'Inter', monospace; }
.warn-box { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 13px; margin: 12px 0; border: 1px solid rgba(254, 240, 138, 0.2); display: flex; align-items: center; gap: 8px; }
.hint { display: inline-block; background: var(--code-bg); color: var(--code-fg); font-family: 'Inter', monospace; font-size: 12px; padding: 6px 12px; border-radius: 6px; margin-top: 12px; border: 1px solid rgba(134, 239, 172, 0.2); }
</style>
</head>
<body><div class="wrap">
"""

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


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


def _get_recent_changes(project_root: Path) -> str:
    try:
        import subprocess
        proc = subprocess.run(
            ["git", "log", "--invert-grep", "--grep=Auto-update", "-1", "--pretty=%B"],
            capture_output=True, text=True, cwd=str(project_root), timeout=5
        )
        if proc.stdout:
            lines = proc.stdout.strip().split('\n')
            cleaned = [line for line in lines if line and not line.startswith('Co-Authored-By')]
            return "<br>".join(cleaned[:3]) or "No recent changes found."
    except Exception:
        pass
    return "No recent changes available."


def render_html(
    rows: list[BeatRow],
    trailer_fps: float,
    source_fps: float,
    trailer_path: Path,
    source_path: Path,
    with_clips: bool,
    generated_at: datetime,
    project_root: Path = Path("."),
) -> 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]

    # Header
    recent = _get_recent_changes(project_root)
    
    parts.append('<div class="header-glass">')
    parts.append(f'<h1>Cutter & Match Report</h1>')
    parts.append('<div class="meta">')
    parts.append(
        f'<span class="meta-chip">Generiert: <b>{generated_at.strftime("%Y-%m-%d %H:%M:%S")}</b></span>'
        f'<span class="meta-chip">Trailer: <b>{_he(trailer_path.name)}</b> @ {trailer_fps:.3f} fps</span>'
        f'<span class="meta-chip">Source: <b>{_he(source_path.name)}</b> @ {source_fps:.3f} fps</span>'
    )
    parts.append('</div>')
    parts.append(
        f'<div class="summary"><b>{len(rows)}</b> Beats &mdash; '
        f'<span style="color:var(--ok)"><b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt)</span> &mdash; '
        f'<span style="color:var(--man)"><b>{len(rows) - matched}</b> manuell</span>.</div>'
    )
    parts.append(f'<div class="recent-changes"><b>Recent Changes:</b><br>{recent}</div>')
    parts.append('</div>')

    # Legend
    parts.append('<h2>Legende</h2>')
    parts.append('<table class="leg"><tbody>')
    parts.append(
        '<tr><td><span class="badge ok">OK</span></td>'
        '<td>Bestätigt — direkt in Schnitt-Timeline übernehmen</td></tr>'
    )
    parts.append(
        '<tr><td><span class="badge q">?</span></td>'
        '<td>Vorläufig — Phase und Aktion im NLE visuell prüfen</td></tr>'
    )
    parts.append(
        '<tr><td><span class="badge man">MAN.</span></td>'
        '<td>Kein Treffer — manuell suchen oder Schwarzbild einfügen</td></tr>'
    )
    parts.append('</tbody></table>')

    # Overview table
    parts.append('<h2>Übersicht</h2>')
    parts.append('<div class="table-container">')
    parts.append(
        '<table class="ov"><thead><tr>'
        '<th>Beat</th><th>Trailer TC In&ndash;Out</th><th>Dauer</th>'
        '<th>Source TC In</th><th>Scene</th><th>Score</th><th>Status</th>'
        '</tr></thead><tbody>'
    )
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        dur = r.trailer_out_s - r.trailer_in_s
        si = smpte(r.source_in_s, source_fps) if r.matched else "—"
        sc = f"{r.score:.3f}" if r.matched else "—"
        scene = str(r.scene_id) if r.matched else "—"
        if r.num_segments > 1:
            all_scenes = list(dict.fromkeys(
                str(s.get("scene_id", "?")) for s in r.segments
            ))
            scene = "+".join(all_scenes)
        bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
        parts.append(
            f'<tr>'
            f'<td class="num"><a href="#beat-{r.bid:02d}">{r.bid:02d}</a></td>'
            f'<td class="tc">{ti}&ndash;{to}</td>'
            f'<td class="num">{dur:.2f}s</td>'
            f'<td class="tc">{si}</td>'
            f'<td class="num">{_he(scene)}</td>'
            f'<td class="num">{sc}</td>'
            f'<td><span class="badge {bcls}">{r.status}</span></td>'
            f'</tr>'
        )
    parts.append('</tbody></table></div>')

    # Per-beat cards
    parts.append('<h2>Beat-Details</h2>')
    parts.append('<div class="beats-grid">')
    for r in rows:
        ti = smpte(r.trailer_in_s, trailer_fps)
        to = smpte(r.trailer_out_s, trailer_fps)
        dur = r.trailer_out_s - r.trailer_in_s
        bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]

        parts.append(f'<div class="beat" id="beat-{r.bid:02d}">')

        # Header row
        parts.append('<div class="beat-hdr">')
        parts.append(f'<h3>Beat {r.bid:02d}</h3>')
        parts.append(f'<span class="badge {bcls}">{r.status}</span>')
        parts.append(f'<span class="status-text">{_he(r.status_de)}</span>')
        parts.append('</div>')

        # Media: compare clip or still pair
        parts.append('<div class="compare-wrap">')
        cmp_uri = data_uri(r.compare_clip, "video/mp4") if (with_clips and r.compare_clip) else None
        if cmp_uri:
            parts.append(
                '<div class="compare-label">'
                '&#9654; Trailer &nbsp;/&nbsp; &#9654; Source (Frame-Locked Compare)'
                '</div>'
            )
            parts.append(f'<video controls preload="metadata" muted loop src="{cmp_uri}"></video>')
        else:
            t_uri = data_uri(r.trailer_still, "image/jpeg")
            s_uri = data_uri(r.source_still, "image/jpeg")
            if t_uri or s_uri:
                parts.append('<div class="stills-pair">')
                parts.append(
                    f'<img src="{t_uri}" alt="Trailer {r.bid}">'
                    if t_uri else '<div class="empty-media">— kein Still —</div>'
                )
                parts.append(
                    f'<img src="{s_uri}" alt="Source {r.bid}">'
                    if s_uri else '<div class="empty-media">— manuell setzen —</div>'
                )
                parts.append('</div>')
            else:
                parts.append('<div class="empty-media">— kein Vorschau verfügbar —</div>')
        parts.append('</div>')  # .compare-wrap

        # Metadata
        parts.append('<div class="beat-meta">')

        # Left col: trailer info
        parts.append('<div>')
        parts.append(f'<div class="kv"><b>Trailer</b> <div class="kv-val"><span class="tc">{ti}&ndash;{to}</span>'
                     f' &nbsp;<span style="color:var(--mut)">({dur:.2f}s)</span></div></div>')
        if r.phase:
            parts.append(f'<div class="kv"><b>Phase</b> <span class="kv-val">{_he(r.phase)}</span></div>')
        if r.composition:
            extra = f", {r.setting}" if r.setting else ""
            parts.append(f'<div class="kv"><b>Bild</b> <span class="kv-val">{_he(r.composition + extra)}</span></div>')
        parts.append('</div>')

        # Right col: source info
        parts.append('<div>')
        if r.matched:
            si = smpte(r.source_in_s, source_fps)
            so = smpte(r.source_out_s, source_fps)
            if r.num_segments > 1:
                all_scenes = list(dict.fromkeys(
                    str(s.get("scene_id", "?")) for s in r.segments
                ))
                scene_str = f"Scenes {', '.join(all_scenes)} &middot; {r.num_segments}&nbsp;Segmente"
                parts.append(
                    f'<div class="kv"><b>Source</b> <div class="kv-val"><span class="tc">{si}</span>'
                    f' &nbsp;<span style="color:var(--mut)">(multi-shot)</span></div></div>'
                )
                parts.append(f'<div class="kv"><b>Scene</b> <span class="kv-val">{scene_str}</span></div>')
                # Segment list
                parts.append('<ul class="seg-list">')
                for i, seg in enumerate(r.segments):
                    seg_tc = smpte(seg.get("in_point_s"), source_fps)
                    seg_dur = float(seg.get("duration_s", 0))
                    seg_off = float(seg.get("trailer_offset_s", 0))
                    seg_sc = int(seg.get("scene_id", 0))
                    seg_score = float(seg.get("match_score", 0))
                    parts.append(
                        f'<li>Seg&nbsp;{i + 1}: <span class="tc">{seg_tc}</span>'
                        f' &nbsp;dur&nbsp;{seg_dur:.2f}s'
                        f' &nbsp;@&nbsp;off&nbsp;{seg_off:.2f}s'
                        f' &nbsp;sc&nbsp;{seg_sc}'
                        f' &nbsp;score&nbsp;{seg_score:.3f}</li>'
                    )
                parts.append('</ul>')
            else:
                parts.append(
                    f'<div class="kv"><b>Source</b>'
                    f' <div class="kv-val"><span class="tc">{si}&ndash;{so}</span></div></div>'
                )
                parts.append(
                    f'<div class="kv"><b>Scene</b> <span class="kv-val">{r.scene_id}'
                    f' &middot; Score&nbsp;{r.score:.3f}</span></div>'
                )
            if r.score > 0 and r.score < 0.65:
                parts.append(
                    f'<div class="warn-box">&#9888; Score {r.score:.3f} unter 0.65'
                    f' &mdash; visuell prüfen</div>'
                )
        else:
            parts.append('<div class="kv">&mdash; kein automatischer Treffer &mdash;</div>')

        parts.append(
            f'<div><code class="hint">python cli.py rematch --beat {r.bid}</code></div>'
        )
        parts.append('</div>')  # right col

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

    parts.append('</div>') # .beats-grid
    parts.append(HTML_FOOT)
    return "".join(parts)


# ---------------------------------------------------------------------------
# Top-level entry point
# ---------------------------------------------------------------------------


def render_report(
    project_root: Path,
    with_stills: bool = True,
    with_clips: bool = True,
) -> tuple[str, str]:
    """Return (markdown_text, html_text). Caller writes both files."""
    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 = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
    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())
    scene_path = cache / "scene_index.json"
    scenes = json.loads(scene_path.read_text()) if scene_path.exists() else []
    scenes_by_id = {int(s["scene_id"]): s for s in scenes}
    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, scenes_by_id,
    )

    now = datetime.now()
    md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path, now)
    html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips, now)
    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 (text-only markdown)")
    parser.add_argument("--no-clips", action="store_true",
                        help="skip video clip rendering (stills only)")
    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=not args.no_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())
