#!/usr/bin/env python3
"""Build word-synced ASS captions from the real timeline and a tested preset.

Everything positional is derived from the chosen canvas, so the same preset works
at 720x1280 and 1080x1920 without retuning. Line breaks come from measured glyph
advances, and the report says explicitly when a block had to be shrunk or could
not be fitted at all — a silent overflow is what puts text under the TikTok UI.
"""
from __future__ import annotations

import argparse
import json
import re
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,
    join_words,
    list_presets,
    load_preset,
    resolve_fontname,
    resolve_geometry,
)
from skill_config import FONT_PACK, FONT_SOURCES, canvas as resolve_canvas, emit, utf8_stdout  # noqa: E402

MANIFEST = FONT_SOURCES / "font_manifest.json"


def load_manifest() -> dict | None:
    if not MANIFEST.exists():
        return None
    try:
        return json.loads(MANIFEST.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return None


def load_words(path: Path) -> tuple[list[dict], dict]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    if isinstance(payload, list):
        words = payload
        meta: dict = {}
    else:
        words = payload.get("words") or []
        meta = {key: value for key, value in payload.items() if key != "words"}
    cleaned = []
    for index, word in enumerate(words):
        token = str(word.get("word", "")).strip()
        if not token:
            continue
        try:
            start = float(word["start"])
            end = float(word["end"])
        except (KeyError, TypeError, ValueError):
            continue
        cleaned.append({
            **word,
            "word": token,
            "start": start,
            "end": max(end, start + 0.02),
            "id": word.get("id", index),
        })
    cleaned.sort(key=lambda item: (item["start"], item["end"]))
    return cleaned, meta


def emphasis_ids(words: list[dict], patterns: list[str], literals: list[str],
                 numbers: bool) -> set[int]:
    """Words to accent, matched case-insensitively on the token.

    Patterns are never split on a separator: a regex legitimately contains commas
    inside a character class, and splitting `^\\d+([.,]\\d+)?%?$` on commas produces
    two invalid fragments. Each `--emphasis` is one whole regex; plain words go
    through `--emphasis-words`, which is the common case.
    """
    marked: set[int] = set()
    compiled = []
    for pattern in patterns:
        if not pattern.strip():
            continue
        try:
            compiled.append(re.compile(pattern, re.IGNORECASE))
        except re.error as error:
            raise SystemExit(
                f"Некорректное регулярное выражение в --emphasis: {pattern!r} — {error}"
            )
    wanted = {value.strip().casefold() for value in literals if value.strip()}
    number = re.compile(r"^\d+([.,]\d+)?%?$")
    for word in words:
        token = word["word"]
        bare = token.strip(".,!?:;»«()\"'%").casefold()
        if any(rule.search(token) for rule in compiled):
            marked.add(word["id"])
        elif bare in wanted:
            marked.add(word["id"])
        elif numbers and number.match(token.strip(".,!?:;»«")):
            marked.add(word["id"])
        elif word.get("emphasis") or word.get("accent"):
            marked.add(word["id"])
    return marked


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("timeline", nargs="?", help="timeline.json or *.words.json")
    parser.add_argument("output", nargs="?", help="Output .ass path")
    parser.add_argument("--map", default="caption_map.json")
    parser.add_argument("--preset", default="signature_plate", help="Preset id or JSON path")
    parser.add_argument("--canvas", default="reels",
                        help="reels | reels_hq | square | portrait_45 | landscape | preview | WxH[@fps]")
    parser.add_argument("--width", type=int, help="Override canvas width")
    parser.add_argument("--height", type=int, help="Override canvas height")
    parser.add_argument("--font", help="Override the preset font family")
    parser.add_argument("--fonts-dir", default=str(FONT_PACK),
                        help="Flat font directory used for measurement and burn-in")
    parser.add_argument("--size-ratio", type=float, help="Override font size as a fraction of height")
    parser.add_argument("--max-words", type=int)
    parser.add_argument("--max-chars", type=int)
    parser.add_argument("--max-lines", type=int)
    parser.add_argument("--pause", type=float, help="Gap in seconds that forces a new block")
    parser.add_argument("--position", choices=["lower", "center", "upper"])
    parser.add_argument("--margin-ratio", type=float)
    parser.add_argument("--uppercase", action="store_true")
    parser.add_argument("--word-mode", choices=["karaoke", "plain", "word_pop", "box", "one_word"])
    parser.add_argument("--emphasis", action="append", default=[],
                        help="Одно регулярное выражение целиком. Можно повторять флаг. "
                             "По запятым НЕ режется: запятая легальна внутри [.,]")
    parser.add_argument("--emphasis-words", default="",
                        help="Простой список слов через запятую, без регулярных выражений")
    parser.add_argument("--emphasis-numbers", action="store_true",
                        help="Подсветить все числа и проценты")
    parser.add_argument("--list-presets", action="store_true")
    parser.add_argument("--report", help="Write the layout report here")
    args = parser.parse_args()

    if args.list_presets:
        emit({"presets": list_presets()})
        return
    if not args.timeline or not args.output:
        parser.error("timeline and output are required unless --list-presets is used")

    preset = load_preset(args.preset)
    for key, value in (
        ("font", args.font), ("size_ratio", args.size_ratio), ("max_words", args.max_words),
        ("max_chars", args.max_chars), ("max_lines", args.max_lines), ("pause", args.pause),
        ("position", args.position), ("margin_ratio", args.margin_ratio),
        ("word_mode", args.word_mode),
    ):
        if value is not None:
            preset[key] = value
    if args.uppercase:
        preset["uppercase"] = True

    board = resolve_canvas(args.canvas)
    if args.width or args.height:
        board = resolve_canvas(f"{args.width or board.width}x{args.height or board.height}@{board.fps}")

    words, meta = load_words(Path(args.timeline))
    if not words:
        raise SystemExit(f"No usable words in {args.timeline}")

    manifest = load_manifest()
    fontname, notes = resolve_fontname(preset, manifest)
    typesetter = make_typesetter(preset, fontname, args.fonts_dir)
    if not typesetter.resolved:
        notes.append(
            f"Шрифт {fontname} не найден в {args.fonts_dir}: перенос строк рассчитан "
            f"по средней ширине, а не по метрикам. Запусти scripts/install_fonts.py"
        )

    rules = GroupRules(
        max_words=int(preset["max_words"]),
        max_chars=int(preset["max_chars"]),
        pause=float(preset["pause"]),
    )
    # Case folding before grouping so the character budget counts what is drawn.
    groups = group_words(apply_case(words, bool(preset["uppercase"])), rules)
    marked = emphasis_ids(
        words,
        args.emphasis or [],
        [value for value in args.emphasis_words.split(",")] if args.emphasis_words else [],
        args.emphasis_numbers,
    )
    events, blocks = build_events(groups, preset, board, typesetter, marked)

    header = build_header(preset, board, fontname)
    output = Path(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(header + "\n".join(events) + "\n", encoding="utf-8-sig")

    geometry = resolve_geometry(preset, board)
    covered = {word["id"] for block in blocks for word in block["words"] if word["id"] is not None}
    overflow = [block["id"] for block in blocks if block["overflow"]]
    shrunk = [block["id"] for block in blocks if block["shrunk"] and not block["overflow"]]
    longest = max(blocks, key=lambda block: block["measured_width"], default=None)

    caption_map = {
        "version": "4.0",
        "timeline": str(args.timeline),
        "preset": preset["id"],
        "font_requested": preset["font"],
        "font_resolved": fontname,
        "fonts_dir": str(Path(args.fonts_dir).resolve()),
        "canvas": board.as_dict(),
        "word_mode": preset["word_mode"],
        "nominal_font_size": round(geometry.size, 1),
        "max_text_width": round(geometry.max_width, 1),
        "blocks": blocks,
        "notes": notes,
    }
    Path(args.map).parent.mkdir(parents=True, exist_ok=True)
    Path(args.map).write_text(
        json.dumps(caption_map, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )

    summary = {
        "status": "ok" if not overflow else "ok_with_overflow",
        "output": str(output),
        "map": args.map,
        "preset": preset["id"],
        "font_resolved": fontname,
        "canvas": f"{board.width}x{board.height}@{board.fps}",
        "blocks": len(blocks),
        "events": len(events),
        "words_in_timeline": len(words),
        "words_in_captions": len(covered),
        "word_coverage": round(len(covered) / len(words), 4) if words else 0.0,
        "shrunk_blocks": shrunk,
        "overflow_blocks": overflow,
        "longest_block": None if longest is None else {
            "id": longest["id"], "text": longest["text"],
            "width": longest["measured_width"], "limit": longest["max_width"],
            "lines": longest["lines"], "font_size": longest["font_size"],
        },
        "notes": notes,
        "next": (
            "Проверь визуально самый длинный блок, затем прожигай: "
            f"python scripts/burn_ass_captions.py IN.mp4 {output.name} OUT.mp4 "
            f"--fonts-dir {Path(args.fonts_dir).as_posix()}"
        ),
    }
    if args.report:
        Path(args.report).parent.mkdir(parents=True, exist_ok=True)
        Path(args.report).write_text(
            json.dumps({**summary, "map": caption_map}, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
    emit(summary)
    if overflow:
        print(
            f"warning: {len(overflow)} блок(ов) не влезли даже на минимальном размере — "
            f"сократи текст или уменьши size_ratio",
            file=sys.stderr,
        )


if __name__ == "__main__":
    main()
