#!/usr/bin/env python3
"""Проверяет, что титры сделаны из реальных слов и стоят на их границах.

Конец блока сравнивается не с «последнее слово + фиксированный хвост», а с тем же
правилом, по которому его вычисляет движок: хвост обрезается по началу следующего
блока, чтобы два ASS-события не наложились. Без этого плотная речь давала ложные
ошибки на каждом втором блоке.
"""
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path


def norm(value: str) -> str:
    return re.sub(r"[^a-zа-яё0-9]+", "", value.lower())


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("timeline")
    parser.add_argument("caption_map")
    parser.add_argument("--start-tolerance", type=float, default=0.08)
    parser.add_argument("--end-tolerance", type=float, default=0.14)
    parser.add_argument("--min-coverage", type=float, default=0.97)
    parser.add_argument("--hold", type=float, default=0.07,
                        help="Хвост блока после последнего слова, как в движке")
    parser.add_argument("--report", default="sync_report.json")
    args = parser.parse_args()

    timeline = json.loads(Path(args.timeline).read_text(encoding="utf-8"))
    caption_map = json.loads(Path(args.caption_map).read_text(encoding="utf-8"))
    words = {word["id"]: word for word in timeline["words"]}
    blocks = caption_map["blocks"]
    covered: set = set()
    errors: list[str] = []
    boundary: list[tuple[float, float]] = []

    for index, block in enumerate(blocks):
        ids = block["word_ids"]
        missing = [item for item in ids if item not in words]
        if missing:
            errors.append(f"Блок {block['id']}: неизвестные word_ids {missing}")
            continue
        group = [words[item] for item in ids]
        covered.update(ids)

        start_error = abs(float(block["start"]) - group[0]["start"])
        # The engine clamps the tail to the next block's start; mirror that.
        raw_end = group[-1]["end"] + args.hold
        next_start = float(blocks[index + 1]["start"]) if index + 1 < len(blocks) else raw_end
        expected_end = min(raw_end, max(float(block["start"]) + 0.2, next_start - 0.01))
        end_error = abs(float(block["end"]) - expected_end)
        boundary.append((start_error, end_error))
        if start_error > args.start_tolerance:
            errors.append(f"Блок {block['id']}: начало ошибается на {start_error:.3f} с")
        if end_error > args.end_tolerance:
            errors.append(f"Блок {block['id']}: конец ошибается на {end_error:.3f} с")

        # Сравнивается нормализованный поток символов, а не список токенов.
        # Whisper иногда отдаёт запятую приклеенной к следующему слову («15» и
        # «,3»), движок справедливо соединяет их без пробела, и посимвольное
        # сравнение остаётся верным при любом решении о пробелах — при этом
        # выдуманное или пропущенное слово всё равно ловится.
        expected_stream = "".join(norm(word["word"]) for word in group)
        actual_stream = norm(block["text"])
        if actual_stream != expected_stream:
            errors.append(
                f"Блок {block['id']}: текст не совпадает с реальными словами: "
                f"{actual_stream!r} != {expected_stream!r}"
            )

    coverage = len(covered) / max(1, len(words))
    first_ok = (
        not blocks
        or timeline.get("first_word_start") is None
        or abs(blocks[0]["start"] - timeline["first_word_start"]) <= args.start_tolerance
    )
    if coverage < args.min_coverage:
        errors.append(f"Покрытие слов {coverage:.2%} ниже {args.min_coverage:.2%}")
    if not first_ok:
        errors.append("Первый титр не совпадает с первым словом")

    overflow = [block["id"] for block in blocks if block.get("overflow")]
    if overflow:
        errors.append(
            f"Блоки не влезли в кадр даже на минимальном кегле: {overflow[:8]}"
        )

    report = {
        "status": "ok" if not errors else "failed",
        "coverage": round(coverage, 4),
        "words": len(words),
        "covered": len(covered),
        "blocks": len(blocks),
        "preset": caption_map.get("preset"),
        "font_requested": caption_map.get("font_requested"),
        "font_resolved": caption_map.get("font_resolved"),
        "canvas": caption_map.get("canvas", {}).get("id"),
        "first_word_start": timeline.get("first_word_start"),
        "first_caption_start": blocks[0]["start"] if blocks else None,
        "mean_start_error": round(sum(x for x, _ in boundary) / max(1, len(boundary)), 4),
        "mean_end_error": round(sum(y for _, y in boundary) / max(1, len(boundary)), 4),
        "max_start_error": round(max((x for x, _ in boundary), default=0.0), 4),
        "max_end_error": round(max((y for _, y in boundary), default=0.0), 4),
        "shrunk_blocks": [block["id"] for block in blocks if block.get("shrunk")],
        "overflow_blocks": overflow,
        "notes": caption_map.get("notes", []),
        "errors": errors,
    }
    Path(args.report).parent.mkdir(parents=True, exist_ok=True)
    Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n",
                                 encoding="utf-8")
    print(json.dumps(report, ensure_ascii=False, indent=2))
    if errors:
        raise SystemExit(2)


if __name__ == "__main__":
    main()
