#!/usr/bin/env python3
"""Render every caption preset onto a real frame so the style is chosen by eye.

Typography decisions cannot be made from JSON. This burns each preset over the
same background at the delivery resolution, draws the platform safe box on top,
and writes one PNG per preset plus a contact sheet. The safe-box overlay is the
point: a preset that looks fine in isolation may still put its second line under
the TikTok caption strip.
"""
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from caption_engine import (  # noqa: E402
    GroupRules,
    apply_case,
    make_typesetter,
    build_events,
    build_header,
    group_words,
    list_presets,
    load_preset,
    resolve_fontname,
)
from skill_config import (  # noqa: E402
    CAPTION_STYLES,
    FONT_PACK,
    FONT_SOURCES,
    canvas as resolve_canvas,
    emit,
    ffmpeg_filter_path,
    require_binary,
    run,
    utf8_stdout,
)

SAMPLE = "Мы подняли выручку на 240% за три месяца без рекламы"


def sample_words(text: str, start: float = 0.25) -> list[dict]:
    words = []
    cursor = start
    for index, token in enumerate(text.split()):
        duration = 0.18 + len(token) * 0.045
        words.append({"id": index, "word": token,
                      "start": round(cursor, 3), "end": round(cursor + duration, 3)})
        cursor += duration + 0.03
    return words


def background_filter(width: int, height: int, source: Path | None) -> tuple[list[str], str]:
    if source is not None:
        return ["-i", str(source)], (
            f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos,"
            f"crop={width}:{height}"
        )
    # A neutral gradient with mid-grey and near-white bands: white captions with a
    # dark outline and dark captions with a light box both get stress-tested.
    return (
        ["-f", "lavfi", "-i",
         f"gradients=s={width}x{height}:c0=0x1B2733:c1=0xB9C4CE"
         f":x0=0:y0=0:x1={width}:y1={height}:d=120:r=30"],
        f"drawbox=x=0:y={int(height * 0.62)}:w={width}:h={int(height * 0.14)}:"
        f"color=0xE9EDF0@0.85:t=fill,"
        f"drawbox=x=0:y={int(height * 0.30)}:w={width}:h={int(height * 0.10)}:"
        f"color=0x08090B@0.9:t=fill"
    )


def safe_box_filter(board, show: bool) -> str:
    if not show:
        return ""
    box = board.safe_box
    return (
        f",drawbox=x={box['left']}:y={box['top']}:"
        f"w={box['right'] - box['left']}:h={box['bottom'] - box['top']}:"
        f"color=0x00FF88@0.55:t=3"
    )


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--out-dir", default="caption_previews")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--text", default=SAMPLE, help="Sample line; use real transcript text")
    parser.add_argument("--background", help="Frame or image to burn onto (a real frame is better)")
    parser.add_argument("--presets", help="Comma-separated subset")
    parser.add_argument("--at", type=float, default=1.0, help="Timestamp to capture, seconds")
    parser.add_argument("--fonts-dir", default=str(FONT_PACK))
    parser.add_argument("--no-safe-box", action="store_true")
    parser.add_argument("--sheet", default="contact_sheet.jpg", help="Contact sheet filename")
    parser.add_argument("--columns", type=int, default=5)
    args = parser.parse_args()

    require_binary("ffmpeg")
    board = resolve_canvas(args.canvas)
    out_dir = Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    manifest_path = FONT_SOURCES / "font_manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else None

    wanted = [item["id"] for item in list_presets()]
    if args.presets:
        keep = {value.strip() for value in args.presets.split(",") if value.strip()}
        wanted = [item for item in wanted if item in keep]
    if not wanted:
        raise SystemExit(f"No presets selected. Available in {CAPTION_STYLES}")

    words = sample_words(args.text)
    background = Path(args.background) if args.background else None
    if background is not None and not background.is_file():
        raise SystemExit(f"Background not found: {background}")

    rendered: list[dict] = []
    problems: list[str] = []
    for preset_id in wanted:
        preset = load_preset(preset_id)
        fontname, notes = resolve_fontname(preset, manifest)
        typesetter = make_typesetter(preset, fontname, args.fonts_dir)
        rules = GroupRules(max_words=int(preset["max_words"]),
                           max_chars=int(preset["max_chars"]),
                           pause=float(preset["pause"]))
        groups = group_words(apply_case(words, bool(preset["uppercase"])), rules)
        events, blocks = build_events(groups, preset, board, typesetter)
        # Show the block that is visible at --at, or the widest one if none is.
        visible = [b for b in blocks if b["start"] <= args.at <= b["end"]]
        target = (visible or sorted(blocks, key=lambda b: -b["measured_width"]))[0]
        moment = (target["start"] + target["end"]) / 2

        # The label rides along as an extra ASS event rather than a drawtext
        # filter: drawtext needs its own font path escaping and falls back to
        # fontconfig, which is absent on a stock Windows ffmpeg build.
        label = (
            f"Style: PreviewLabel,{fontname},{max(20, board.height // 52)},"
            f"&H0088FF00,&H0088FF00,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,2,0,7,28,28,28,1"
        )
        label_event = (
            f"Dialogue: 20,0:00:00.00,9:59:59.00,PreviewLabel,,0,0,0,,"
            f"{preset_id}  |  {fontname}  |  {preset['word_mode']}"
        )
        header = build_header(preset, board, fontname)
        header = header.replace("\n\n[Events]", f"\n{label}\n\n[Events]")
        ass_path = out_dir / f"{preset_id}.ass"
        ass_path.write_text(header + "\n".join([*events, label_event]) + "\n", encoding="utf-8-sig")

        png_path = out_dir / f"{preset_id}.png"
        inputs, prepare = background_filter(board.width, board.height, background)
        chain = (
            f"{prepare},ass=filename='{ffmpeg_filter_path(ass_path)}':"
            f"fontsdir='{ffmpeg_filter_path(Path(args.fonts_dir))}'"
            f"{safe_box_filter(board, not args.no_safe_box)}"
        )
        if background is None:
            # A lavfi source is seeked with a trim filter, not an input option —
            # and deliberately without setpts: the `ass` filter picks events by
            # timestamp, so resetting PTS to zero would hide the very block being
            # previewed and leave an empty frame.
            chain = f"trim=start={moment:.3f}:end={moment + 0.1:.3f},{chain}"
        else:
            # A still image has one frame at t=0, so shift it to the block's time.
            chain = f"setpts={moment:.3f}/TB,{chain}"
        command = ["ffmpeg", "-y", "-v", "error", *inputs,
                   "-vf", chain, "-frames:v", "1", str(png_path)]
        completed = subprocess.run(command, capture_output=True, text=True, errors="replace")
        if completed.returncode or not png_path.exists():
            problems.append(f"{preset_id}: {(completed.stderr or '').strip()[:300]}")
            continue
        rendered.append({
            "preset": preset_id,
            "font_requested": preset["font"],
            "font_resolved": fontname,
            "word_mode": preset["word_mode"],
            "png": str(png_path),
            "block": target["text"],
            "lines": target["lines"],
            "font_size": target["font_size"],
            "shrunk": target["shrunk"],
            "overflow": target["overflow"],
            "notes": notes,
        })

    sheet_path = None
    if rendered and shutil.which("ffmpeg"):
        sheet_path = out_dir / args.sheet
        columns = max(1, args.columns)
        rows = (len(rendered) + columns - 1) // columns
        tile_width = 360
        tile_height = round(tile_width / board.aspect)
        command = ["ffmpeg", "-y", "-v", "error"]
        for item in rendered:
            command += ["-i", item["png"]]
        scaled = "".join(
            f"[{index}:v]scale={tile_width}:{tile_height}[t{index}];"
            for index in range(len(rendered))
        )
        inputs = "".join(f"[t{index}]" for index in range(len(rendered)))
        graph = (
            scaled
            + f"{inputs}xstack=inputs={len(rendered)}:layout="
            + "|".join(
                f"{(index % columns) * tile_width}_{(index // columns) * tile_height}"
                for index in range(len(rendered))
            )
            + f":fill=black[out]"
        )
        if len(rendered) == 1:
            graph = f"[0:v]scale={tile_width}:{tile_height}[out]"
        command += ["-filter_complex", graph, "-map", "[out]", "-frames:v", "1",
                    "-q:v", "3", str(sheet_path)]
        result = subprocess.run(command, capture_output=True, text=True, errors="replace")
        if result.returncode:
            problems.append(f"contact sheet: {(result.stderr or '').strip()[:300]}")
            sheet_path = None
        else:
            _ = rows

    emit({
        "status": "ok" if not problems else "ok_with_problems",
        "canvas": f"{board.width}x{board.height}",
        "out_dir": str(out_dir.resolve()),
        "rendered": len(rendered),
        "contact_sheet": str(sheet_path) if sheet_path else None,
        "previews": rendered,
        "problems": problems,
        "next": "Открой contact sheet, выбери 2–3 кандидата, потом смотри их PNG в полном размере.",
    })


if __name__ == "__main__":
    main()
