#!/usr/bin/env python3
"""Compare a translated ASS/SSA file with its source and report structural damage.

Usage:
    python3 check_ass_translation.py source.ass translated.ass
    python3 check_ass_translation.py source.ass translated.ass --font "Trebuchet MS=/path/to/font.ttf"

The script uses only the Python standard library. The optional --font check needs
fontTools (pip install fonttools) and reports characters that a style's font cannot draw.

It checks structure, not translation quality. A clean report means the file kept the
source's timing, styles, override tags, line breaks, comments, and drawings; it does
not mean the dialogue is accurate or reads well on screen.

Written by BookTranslator (https://www.booktranslator.app). Free to reuse.
"""

import argparse
import re
import sys
import unicodedata

EVENT_FIELDS = 10  # Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
BLOCK = re.compile(r"\{[^}]*\}")


def read_text(path):
    raw = open(path, "rb").read()
    for enc in ("utf-8-sig", "utf-16"):
        try:
            return raw.decode(enc)
        except UnicodeDecodeError:
            continue
    sys.exit(f"{path}: not UTF-8 or UTF-16; convert the encoding first")


def parse(path):
    sections, current, events, styles = {}, None, [], {}
    for lineno, line in enumerate(read_text(path).splitlines(), 1):
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            current = stripped.lower()
            sections[current] = []
            continue
        if current is None or not stripped:
            continue
        sections[current].append(line.rstrip())
        if current == "[events]" and ":" in line:
            kind, rest = line.split(":", 1)
            kind = kind.strip()
            if kind in ("Dialogue", "Comment"):
                fields = rest.lstrip().split(",", EVENT_FIELDS - 1)
                if len(fields) != EVENT_FIELDS:
                    events.append({"kind": kind, "line": lineno, "fields": None, "raw": line})
                else:
                    events.append({"kind": kind, "line": lineno, "fields": fields, "raw": line})
        if current in ("[v4+ styles]", "[v4 styles]") and line.startswith("Style:"):
            parts = line.split(":", 1)[1].split(",")
            styles[parts[0].strip()] = parts[1].strip()
    return sections, events, styles


def tag_blocks(text):
    """Override blocks that contain a tag. Brace text without a backslash is a comment."""
    return [b for b in BLOCK.findall(text) if "\\" in b]


def comment_blocks(text):
    return [b for b in BLOCK.findall(text) if "\\" not in b]


def drawing_runs(text):
    """Text drawn as vector shapes: everything after \\p1..\\p9 until \\p0 or the end."""
    runs, drawing, pos = [], False, 0
    for m in BLOCK.finditer(text):
        if drawing:
            runs.append(text[pos:m.start()])
        tags = re.findall(r"\\p(\d+)", m.group())
        if tags:
            drawing = tags[-1] != "0"
        pos = m.end()
    if drawing:
        runs.append(text[pos:])
    return runs


def visible_text(text):
    text = BLOCK.sub("", text) if not drawing_runs(text) else ""
    return text.replace("\\N", " ").replace("\\n", " ").replace("\\h", " ").strip()


def karaoke(text):
    durations = [int(d) for d in re.findall(r"\\[kK][fo]?(\d+)", text)]
    return len(durations), sum(durations)


def empty_syllables(text):
    """Karaoke tags followed directly by another block or the end of the line."""
    return len(re.findall(r"\\[kK][fo]?\d+[^}]*\}(?=\{|\s*$)", text))


def fonts_for(event, styles):
    names = [styles.get(event["fields"][3], "?")]
    names += [m.strip() for m in re.findall(r"\\fn([^\\}]+)", event["fields"][9])]
    return names


def check(src_path, out_path, font_files):
    errors, warnings = [], []
    s_sections, s_events, s_styles = parse(src_path)
    t_sections, t_events, t_styles = parse(out_path)

    for name in s_sections:
        if name not in t_sections:
            errors.append(f"section {name} is missing")
    for name in ("[script info]",):
        if s_sections.get(name) != t_sections.get(name):
            warnings.append(f"{name} changed; check PlayResX/PlayResY and WrapStyle")
    for style, font in s_styles.items():
        if style not in t_styles:
            errors.append(f"style {style!r} is missing")
        elif t_styles[style] != font:
            warnings.append(f"style {style!r} font changed: {font!r} -> {t_styles[style]!r}")
    def style_lines(sections):
        lines = sections.get("[v4+ styles]", []) + sections.get("[v4 styles]", [])
        return [l for l in lines if l.startswith("Style:")]
    s_style_lines, t_style_lines = style_lines(s_sections), style_lines(t_sections)
    for a, b in zip(s_style_lines, t_style_lines):
        if a.split(",")[2:] != b.split(",")[2:]:
            errors.append(f"style values changed beyond the font name: {b.split(',')[0]}")

    bad = [e for e in t_events if e["fields"] is None]
    for e in bad:
        errors.append(f"line {e['line']}: event has fewer than 10 fields")
    if len(s_events) != len(t_events):
        errors.append(f"event count changed: {len(s_events)} -> {len(t_events)}")

    for i, (s, t) in enumerate(zip(s_events, t_events), 1):
        where = f"event {i} (source line {s['line']})"
        if s["fields"] is None or t["fields"] is None:
            continue
        if s["kind"] != t["kind"]:
            errors.append(f"{where}: {s['kind']} became {t['kind']}")
        for idx, label in enumerate(("Layer", "Start", "End", "Style", "Name",
                                     "MarginL", "MarginR", "MarginV", "Effect")):
            if s["fields"][idx].strip() != t["fields"][idx].strip():
                errors.append(f"{where}: {label} changed {s['fields'][idx]!r} -> {t['fields'][idx]!r}")
        st, tt = s["fields"][9], t["fields"][9]
        if s["kind"] == "Comment":
            if st != tt:
                warnings.append(f"{where}: Comment text changed (comments never display)")
            continue
        if tag_blocks(st) != tag_blocks(tt):
            errors.append(f"{where}: override tags differ\n    source: {tag_blocks(st)}\n    output: {tag_blocks(tt)}")
        if drawing_runs(st) != drawing_runs(tt):
            errors.append(f"{where}: drawing commands changed (\\p shapes are not text)")
        hard_s, hard_t = st.count("\\N"), tt.count("\\N")
        if hard_s != hard_t:
            errors.append(f"{where}: \\N line breaks {hard_s} -> {hard_t}; confirm the new break is deliberate")
        for token in ("\\h", "\\n"):
            ts, tt_ = st.count(token), tt.count(token)
            if ts != tt_:
                warnings.append(f"{where}: {token} count {ts} -> {tt_} (expected if the target language has no spaces)")
            if tt_ and re.search(r"[\u3000-\u9fff\uac00-\ud7af]", visible_text(tt)):
                warnings.append(f"{where}: {token} kept in CJK text; it renders as a space")
        if comment_blocks(st) != comment_blocks(tt):
            warnings.append(f"{where}: inline brace comment changed or removed")
        sk, tk = karaoke(st), karaoke(tt)
        empty = empty_syllables(tt) - empty_syllables(st)
        if sk[0] and empty > 0:
            warnings.append(f"{where}: {empty} karaoke syllable(s) now highlight no text")
        if sk != tk:
            errors.append(f"{where}: karaoke syllables/centiseconds {sk} -> {tk}")
        elif sk[0] and visible_text(st) != visible_text(tt):
            warnings.append(f"{where}: karaoke line translated in place; {sk[0]} timed syllables now carry new words")
        if visible_text(st) and not visible_text(tt) and not drawing_runs(st):
            errors.append(f"{where}: dialogue text is empty")
        elif visible_text(st) and visible_text(st) == visible_text(tt):
            warnings.append(f"{where}: text unchanged (untranslated?): {visible_text(tt)!r}")

    if font_files:
        try:
            from fontTools.ttLib import TTFont, TTCollection
        except ImportError:
            sys.exit("--font needs fontTools: pip install fonttools")
        cmaps = {}
        for name, path in font_files.items():
            fonts = TTCollection(path).fonts if path.lower().endswith(".ttc") else [TTFont(path)]
            cmaps[name] = set().union(*(f.getBestCmap().keys() for f in fonts))
        used = {f for t in t_events if t["fields"] for f in fonts_for(t, t_styles)}
        for name in cmaps:
            if name not in used:
                warnings.append(f"--font {name!r} matches no style or \\fn font; it was not checked")
        for i, t in enumerate(t_events, 1):
            if t["fields"] is None or t["kind"] != "Dialogue" or drawing_runs(t["fields"][9]):
                continue
            for font in fonts_for(t, t_styles):
                if font not in cmaps:
                    continue
                missing = sorted({c for c in visible_text(t["fields"][9])
                                  if not c.isspace() and ord(c) not in cmaps[font]
                                  and unicodedata.category(c)[0] != "C"})
                if missing:
                    errors.append(f"event {i}: font {font!r} has no glyph for {len(missing)} "
                                  f"characters, e.g. {''.join(missing[:12])}")

    return errors, warnings


def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("source")
    ap.add_argument("translated")
    ap.add_argument("--font", action="append", default=[], metavar="NAME=PATH",
                    help="map a style font name to a .ttf/.otf/.ttc file for a glyph check")
    args = ap.parse_args()
    font_files = dict(f.split("=", 1) for f in args.font)
    errors, warnings = check(args.source, args.translated, font_files)
    for e in errors:
        print("ERROR  ", e)
    for w in warnings:
        print("WARN   ", w)
    print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()
