#!/usr/bin/env python3
"""Caption layout and ASS generation shared by every subtitle path.

The layout is decided from real glyph advances (see `font_tables`), not from a
character budget. That matters in Russian: at the same point size Cyrillic runs
noticeably wider than Latin, so a preset tuned on English text overflows a
vertical frame without any warning. Here the engine measures each candidate line,
breaks where it must, and shrinks the type only if breaking is not enough.

Colours are authored as `#RRGGBB` with a separate opacity and converted to ASS
`&HAABBGGRR` once, because hand-writing byte-swapped BGR is how presets end up
with the wrong accent colour.
"""
from __future__ import annotations

import json
import math
import re
from dataclasses import dataclass, field
from pathlib import Path

from font_tables import find_family_file, open_font
from skill_config import CAPTION_STYLES, Canvas, FONT_PACK

# ---------------------------------------------------------------------- colours

_HEX = re.compile(r"^#?([0-9a-fA-F]{6})$")


def ass_colour(value: str | None, opacity: float = 1.0) -> str:
    """`#RRGGBB` + opacity -> `&HAABBGGRR`. Existing `&H...` values pass through."""
    if value is None:
        return "&H00FFFFFF"
    text = value.strip()
    if text.upper().startswith("&H"):
        return text
    match = _HEX.match(text)
    if not match:
        raise ValueError(f"Colour must be #RRGGBB or &HAABBGGRR, got {value!r}")
    red, green, blue = (int(match.group(1)[index:index + 2], 16) for index in (0, 2, 4))
    alpha = round((1.0 - max(0.0, min(1.0, opacity))) * 255)
    return f"&H{alpha:02X}{blue:02X}{green:02X}{red:02X}"


def ass_alpha(opacity: float) -> str:
    return f"&H{round((1.0 - max(0.0, min(1.0, opacity))) * 255):02X}&"


def rounded_rect(width: float, height: float, radius: float) -> str:
    """ASS `\\p1` drawing for a rounded rectangle spanning 0,0..width,height.

    The path starts at 0,0 rather than at -w/2,-h/2 on purpose: libass offsets a
    drawing by half its own bounding box, so a shape authored symmetrically about
    the origin lands shifted left and up by half its size while a shape spanning
    0..w is centred by `\\an5` exactly like text. Corners are cubic beziers with
    the standard circle constant, because a square corner is the single clearest
    tell of a caption plate that was drawn by a script and not designed.
    """
    radius = max(0.0, min(radius, width / 2, height / 2))
    if radius < 0.5:
        return (f"m 0 0 l {width:.0f} 0 l {width:.0f} {height:.0f} l 0 {height:.0f}")
    # 1 - 0.5523 : distance from the corner to the bezier control point.
    c = radius * 0.44772
    w, h, r = width, height, radius
    return (
        f"m {r:.1f} 0 "
        f"l {w - r:.1f} 0 "
        f"b {w - c:.1f} 0 {w:.1f} {c:.1f} {w:.1f} {r:.1f} "
        f"l {w:.1f} {h - r:.1f} "
        f"b {w:.1f} {h - c:.1f} {w - c:.1f} {h:.1f} {w - r:.1f} {h:.1f} "
        f"l {r:.1f} {h:.1f} "
        f"b {c:.1f} {h:.1f} 0 {h - c:.1f} 0 {h - r:.1f} "
        f"l 0 {r:.1f} "
        f"b 0 {c:.1f} {c:.1f} 0 {r:.1f} 0"
    )


def ass_time(value: float) -> str:
    value = max(0.0, value)
    hours = int(value // 3600)
    value -= hours * 3600
    minutes = int(value // 60)
    seconds = value - minutes * 60
    return f"{hours}:{minutes:02d}:{seconds:05.2f}"


def escape(text: str) -> str:
    """Neutralise ASS markup in transcript text.

    A literal `{` would open an override block and swallow the rest of the line,
    and a leading space is dropped by the parser unless it is protected.
    """
    out = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
    out = out.replace("\r", "").replace("\n", " ")
    if out.startswith(" "):
        out = "\\h" + out[1:]
    return out


# ------------------------------------------------------------------- tokenising

SUFFIX_TOKEN = re.compile(r"^[,.;:!?%…)\]»}’\"]|^-{1,2}$")
SENTENCE_END = re.compile(r"[.!?…]+[»\")\]]*$")
OPENING_TOKEN = re.compile(r"^[(\[«“]$")


def is_suffix(word: str) -> bool:
    return bool(SUFFIX_TOKEN.match(word))


def ends_sentence(word: str) -> bool:
    return bool(SENTENCE_END.search(word))


def display_of(word: dict) -> str:
    """The exact string that will be rendered.

    Case folding has to happen before measurement, not at render time: uppercase
    Cyrillic is materially wider than lowercase, so measuring "мы" and drawing
    "МЫ" makes per-word positions collide.
    """
    return word.get("display") or word["word"]


def apply_case(words: list[dict], uppercase: bool) -> list[dict]:
    return [
        {**word, "display": word["word"].upper() if uppercase else word["word"]}
        for word in words
    ]


def join_words(words: list[dict]) -> str:
    out = ""
    for item in words:
        token = display_of(item)
        if not out or is_suffix(token) or OPENING_TOKEN.match(out[-1:]):
            out += token
        else:
            out += " " + token
    return out


# --------------------------------------------------------------------- grouping

@dataclass
class GroupRules:
    max_words: int = 4
    max_chars: int = 34
    pause: float = 0.42
    min_duration: float = 0.42
    max_duration: float = 3.2
    break_on_sentence: bool = True
    min_display: float = 0.9


# Words that bind forward, to what comes after them. A caption block that ends on
# one of these reads as broken mid-thought — "…изображения, но" / "…движок по" —
# because the reader is left holding a hinge with nothing on the other side.
# These are the closed word classes of Russian, so the list is complete rather
# than a sample: prepositions, conjunctions, particles and the pronominal
# determiners that always precede their noun.
PROCLITIC = frozenset("""
в во на за под подо над надо при про для из изо от ото до к ко с со о об обо у
без через сквозь между меж перед передо после около возле вокруг вдоль вместо
кроме сверх ради среди против навстречу благодаря вопреки согласно
и а но да или либо ни то ежели если чтобы чтоб что как когда пока хотя хоть
потому поэтому зато также тоже причём притом впрочем однако значит будто словно
точно ведь мол дескать
не ни же ли бы б уж вот вон аж лишь только даже разве неужели пусть пускай
мой моя моё мои твой твоя твоё твои наш наша наше наши ваш ваша ваше ваши
его её их этот эта это эти тот та те такой такая такое такие
весь вся всё все каждый каждая каждое каждые любой любая любое любые
один одна одно одни два две три четыре пять шесть семь восемь девять десять
двух трёх трех четырёх четырех пяти шести семи восьми девяти десяти обоих обеих
этого этой этих этим этими том той тех того таких такого всех всего всей
самый самая самое самые более менее очень
""".split())


def binds_forward(token: str) -> bool:
    """True if the block must not end on this word."""
    return token.strip(" ,.!?;:«»\"()-—").casefold() in PROCLITIC


def token_of(word: dict) -> str:
    return display_of(word) if word.get("display") else word.get("word", "")


def split_into_phrases(words: list[dict], rules: GroupRules) -> list[list[dict]]:
    """Cut the stream only where speech itself stops: full stops and real pauses.

    These are the boundaries nobody can argue with. Everything finer is decided
    afterwards, with the whole phrase visible — which is the point: a greedy
    left-to-right fill cannot know that breaking one word earlier would have left
    a clean pair instead of a four-plus-one.
    """
    phrases: list[list[dict]] = []
    current: list[dict] = []
    for word in words:
        token = token_of(word)
        if not token.strip():
            continue
        if current:
            gap = float(word["start"]) - float(current[-1]["end"])
            if gap >= rules.pause:
                phrases.append(current)
                current = []
        current.append(word)
        if rules.break_on_sentence and ends_sentence(token):
            phrases.append(current)
            current = []
    if current:
        phrases.append(current)
    return [phrase for phrase in phrases if phrase]


def block_cost(words: list[dict], rules: GroupRules, is_last: bool) -> float:
    """How bad this block is as a unit of reading. Lower is better.

    The weights encode what a viewer notices, in order of how much it hurts:
    a line that does not fit is unreadable, a line ending on a hinge word is
    confusing, a line that flashes past cannot be read at all, and only then
    does length balance matter.
    """
    tokens = [token_of(word) for word in words]
    text = join_words(words)
    cost = 0.0

    if len(text) > rules.max_chars:
        cost += 60.0 + (len(text) - rules.max_chars) * 9.0
    if len(words) > rules.max_words:
        cost += 45.0 * (len(words) - rules.max_words)

    # The defect this rewrite exists for.
    if not is_last and binds_forward(tokens[-1]):
        cost += 55.0
    # A block that is only a hinge word plus one more is barely better.
    if not is_last and len(words) >= 2 and binds_forward(tokens[-2]) and len(words) == 2:
        cost += 12.0

    span = float(words[-1]["end"]) - float(words[0]["start"])
    if span > rules.max_duration:
        cost += 40.0 * (span - rules.max_duration)
    if span < rules.min_display:
        # Under a second on screen is not a caption, it is a flash.
        cost += 26.0 * (rules.min_display - span)

    # Prefer blocks near the target size, and prefer ending at punctuation.
    target = max(2.0, rules.max_words - 0.5)
    cost += abs(len(words) - target) * 3.0
    if not is_last:
        last = tokens[-1].rstrip("»\")]")
        if last.endswith((",", ";", ":", "—", "–")):
            cost -= 14.0
        elif ends_sentence(tokens[-1]):
            cost -= 20.0
    if len(words) == 1 and not is_last:
        cost += 18.0
    return cost


def segment_phrase(phrase: list[dict], rules: GroupRules) -> list[list[dict]]:
    """Optimal split of one phrase into blocks, by dynamic programming.

    Exhaustive over break positions rather than greedy, so the segmentation is
    the best one available and — the part that matters for a pipeline anybody
    re-runs — the *same* one every time. A greedy filler produces "четыре слова"
    plus a one-word orphan whenever a phrase is five words long; this looks at
    both halves before deciding.

    Enclitics (a bare comma, a closing quote) are never separated from the word
    they follow, so a break is only ever considered before a real token.
    """
    count = len(phrase)
    if count == 0:
        return []
    # Positions where a break is even legal: not before a suffix token.
    breakable = [index for index in range(1, count)
                 if not is_suffix(token_of(phrase[index]))]
    if not breakable:
        return [phrase]

    best: list[float] = [0.0] + [float("inf")] * count
    parent: list[int] = [0] * (count + 1)
    for end in range(1, count + 1):
        # Only consider starts that leave a block of a plausible size; the cost
        # function rejects the rest anyway, and this keeps it near-linear.
        lowest = max(0, end - rules.max_words - 3)
        for start in range(lowest, end):
            if start != 0 and start not in breakable:
                continue
            previous = best[start]
            if previous == float("inf"):
                continue
            candidate = previous + block_cost(phrase[start:end], rules, end == count)
            if candidate < best[end]:
                best[end] = candidate
                parent[end] = start

    cuts: list[int] = []
    position = count
    while position > 0:
        cuts.append(position)
        position = parent[position]
    cuts.reverse()

    blocks: list[list[dict]] = []
    start = 0
    for cut in cuts:
        blocks.append(phrase[start:cut])
        start = cut
    return [block for block in blocks if block]


def group_words(words: list[dict], rules: GroupRules) -> list[list[dict]]:
    """Split the word stream into caption blocks at the places speech breaks.

    Two stages. First the stream is cut only where speech actually stops — full
    stops and measured pauses. Then each phrase is segmented optimally, with the
    whole phrase in view.

    The single-pass greedy version this replaces broke on a word counter, so a
    block ended wherever the fourth word happened to fall. On real Russian that
    lands on a preposition or a conjunction roughly one block in five, and the
    caption reads as though it were cut off: "распознавать изображения, но",
    "строю собственный движок по". The reader is handed a hinge with nothing
    attached.
    """
    groups: list[list[dict]] = []
    for phrase in split_into_phrases(words, rules):
        groups.extend(segment_phrase(phrase, rules))

    # A one-word tail after a full block reads as a glitch; fold it back in.
    if len(groups) >= 2 and len(groups[-1]) == 1 and not ends_sentence(groups[-2][-1]["word"]):
        tail_span = float(groups[-1][0]["end"]) - float(groups[-1][0]["start"])
        if tail_span < rules.min_duration and len(groups[-2]) < rules.max_words + 1:
            groups[-2].extend(groups.pop())
    return [group for group in groups if group]


# ----------------------------------------------------------------------- layout

@dataclass
class LayoutLine:
    words: list[dict]
    text: str
    width: float
    x_positions: list[float] = field(default_factory=list)
    word_widths: list[float] = field(default_factory=list)


@dataclass
class BlockLayout:
    lines: list[LayoutLine]
    size: float
    line_height: float
    width: float
    shrunk: bool
    overflow: bool


class Typesetter:
    """Measures with the real font and decides breaks, size and word positions."""

    def __init__(self, font_family: str, fonts_dir: Path | str = FONT_PACK,
                 weight: int = 400, italic: bool = False) -> None:
        self.family = font_family
        self.weight = weight
        self.fonts_dir = str(fonts_dir)
        path = find_family_file(self.fonts_dir, font_family, weight, italic)
        self.font_path = path
        self.font = open_font(path) if path else None

    @property
    def resolved(self) -> bool:
        return self.font is not None

    def width(self, text: str, size: float, tracking: float = 0.0) -> float:
        """Width the way libass will render it: `size` is an ASS Fontsize."""
        if self.font is None:
            # Conservative fallback once libass's own size scaling is folded in:
            # Cyrillic in a bold sans averages ~0.62 em, and the scale is ~0.8.
            return len(text) * size * 0.5 + tracking * max(0, len(text) - 1)
        return self.font.measure(text, size, tracking)

    def line_height(self, size: float, spacing: float) -> float:
        if self.font is None:
            return size * 0.95 * spacing
        return self.font.line_height(size, spacing)

    def wrap(self, words: list[dict], size: float, tracking: float,
             max_width: float, max_lines: int, gap_extra: float = 0.0,
             per_word: bool = False) -> list[LayoutLine] | None:
        lines: list[LayoutLine] = []
        current: list[dict] = []
        for word in words:
            candidate = current + [word]
            probe = self._finish_line(candidate, size, tracking, gap_extra, per_word)
            if current and probe.width > max_width:
                lines.append(self._finish_line(current, size, tracking, gap_extra, per_word))
                current = [word]
                if len(lines) >= max_lines:
                    return None
            else:
                current = candidate
        if current:
            lines.append(self._finish_line(current, size, tracking, gap_extra, per_word))
        if len(lines) > max_lines:
            return None
        if any(line.width > max_width for line in lines):
            return None
        return lines

    def _finish_line(self, words: list[dict], size: float, tracking: float,
                     gap_extra: float = 0.0, per_word: bool = False) -> LayoutLine:
        text = join_words(words)
        # Word positions relative to the line's left edge, so a highlight box and
        # its word are always derived from the same measurement. `gap_extra`
        # widens the word space to clear the outline and any highlight padding:
        # positioning by advance alone makes thick outlines of adjacent words
        # collide and the line reads as one long token.
        cursor = 0.0
        positions: list[float] = []
        widths: list[float] = []
        space = self.width(" ", size, tracking)
        for index, word in enumerate(words):
            token = display_of(word)
            if index and not is_suffix(token):
                cursor += space + gap_extra
            token_width = self.width(token, size, tracking)
            positions.append(cursor)
            widths.append(token_width)
            cursor += token_width
        # libass lays out the joined string itself unless every word is placed
        # explicitly, so the width that matters differs per mode.
        total = cursor if per_word else self.width(text, size, tracking)
        return LayoutLine(words=words, text=text, width=total,
                          x_positions=positions, word_widths=widths)

    def layout(self, words: list[dict], size: float, tracking: float, max_width: float,
               max_lines: int, spacing: float, min_scale: float = 0.76,
               gap_extra_ratio: float = 0.0, per_word: bool = False) -> BlockLayout:
        """Break to fit; only shrink the type when breaking cannot fit the block."""
        attempt = size
        shrunk = False
        while attempt >= size * min_scale:
            lines = self.wrap(words, attempt, tracking * attempt, max_width, max_lines,
                              gap_extra_ratio * attempt, per_word)
            if lines is not None:
                return BlockLayout(
                    lines=lines, size=attempt, line_height=self.line_height(attempt, spacing),
                    width=max((line.width for line in lines), default=0.0),
                    shrunk=shrunk, overflow=False,
                )
            attempt *= 0.96
            shrunk = True
        # Nothing fits: place it anyway at the floor size and report the overflow
        # rather than pretending the frame is wider than it is.
        floor = size * min_scale
        gap = gap_extra_ratio * floor
        lines = self.wrap(words, floor, tracking * floor, max_width * 1.5, max_lines + 2,
                          gap, per_word) or [
            self._finish_line(words, floor, tracking * floor, gap, per_word)
        ]
        return BlockLayout(lines=lines, size=floor, line_height=self.line_height(floor, spacing),
                           width=max((line.width for line in lines), default=0.0),
                           shrunk=True, overflow=True)


# ----------------------------------------------------------------------- presets

DEFAULT_PRESET: dict = {
    "id": "unnamed",
    "font": "Montserrat",
    "bold": True,
    "italic": False,
    "uppercase": False,
    "size_ratio": 0.036,
    "tracking_ratio": 0.0,
    "line_spacing": 1.04,
    "max_words": 4,
    "max_lines": 2,
    "max_chars": 34,
    "max_width_ratio": 0.86,
    "pause": 0.42,
    "position": "lower",
    "margin_ratio": 0.24,
    "outline_ratio": 0.06,
    "shadow_ratio": 0.022,
    "colors": {
        "spoken": "#FFFFFF",
        "unspoken": "#FFFFFF",
        "accent": "#FFD64A",
        "outline": "#000000",
        "outline_opacity": 0.82,
        "shadow": "#000000",
        "shadow_opacity": 0.35,
    },
    "word_mode": "karaoke",
    "entry": {"kind": "pop", "duration": 0.13, "scale_from": 0.88, "rise_ratio": 0.012, "blur_from": 1.6},
    "exit": {"kind": "fade", "duration": 0.07},
    "box": None,
    "plate": None,
    "extrude": None,
    "glow": None,
    "chromatic_fringe": None,
    "line_stagger": 0.035,
    "use": "",
}


def deep_merge(base: dict, patch: dict) -> dict:
    result = dict(base)
    for key, value in patch.items():
        if isinstance(value, dict) and isinstance(result.get(key), dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result


def load_preset(value: str) -> dict:
    path = Path(value)
    if not path.exists():
        path = CAPTION_STYLES / f"{value}.json"
    if not path.exists():
        available = ", ".join(sorted(p.stem for p in CAPTION_STYLES.glob("*.json")))
        raise SystemExit(f"Caption preset not found: {value}\nAvailable: {available}")
    raw = json.loads(path.read_text(encoding="utf-8"))
    preset = deep_merge(DEFAULT_PRESET, raw)
    preset.setdefault("id", path.stem)
    return preset


def list_presets() -> list[dict]:
    presets = []
    for path in sorted(CAPTION_STYLES.glob("*.json")):
        raw = json.loads(path.read_text(encoding="utf-8"))
        presets.append({
            "id": raw.get("id", path.stem),
            "font": raw.get("font"),
            "word_mode": raw.get("word_mode", DEFAULT_PRESET["word_mode"]),
            "position": raw.get("position", DEFAULT_PRESET["position"]),
            "uppercase": raw.get("uppercase", False),
            "use": raw.get("use", ""),
        })
    return presets


# ------------------------------------------------------------------ ASS assembly

@dataclass
class Geometry:
    canvas: Canvas
    size: float
    max_width: float
    centre_x: float
    baseline_y: float
    margin_v: int


def resolve_geometry(preset: dict, canvas: Canvas) -> Geometry:
    size = preset["size_ratio"] * canvas.height
    max_width = preset["max_width_ratio"] * canvas.width
    safe = canvas.safe_box
    position = preset["position"]
    if position == "center":
        baseline = canvas.height / 2
    elif position == "upper":
        baseline = safe["top"] + preset["margin_ratio"] * canvas.height * 0.4
    else:
        baseline = canvas.height - preset["margin_ratio"] * canvas.height
    baseline = max(safe["top"] + size, min(baseline, canvas.height - size * 0.6))
    return Geometry(
        canvas=canvas, size=size, max_width=max_width,
        centre_x=canvas.width / 2, baseline_y=baseline,
        margin_v=round(canvas.height - baseline),
    )


def entry_tags(preset: dict, geometry: Geometry) -> str:
    entry = preset.get("entry") or {}
    kind = entry.get("kind", "none")
    if kind in (None, "none"):
        return ""
    duration = int(round(float(entry.get("duration", 0.12)) * 1000))
    if duration <= 0:
        return ""
    tags = ""
    if kind in ("pop", "pop_blur"):
        scale = round(float(entry.get("scale_from", 0.88)) * 100)
        overshoot = min(108, round(100 + (100 - scale) * 0.28))
        first = max(1, int(duration * 0.68))
        tags += (
            f"\\fscx{scale}\\fscy{scale}"
            f"\\t(0,{first},\\fscx{overshoot}\\fscy{overshoot})"
            f"\\t({first},{duration},\\fscx100\\fscy100)"
        )
    elif kind == "scale":
        scale = round(float(entry.get("scale_from", 0.9)) * 100)
        tags += f"\\fscx{scale}\\fscy{scale}\\t(0,{duration},\\fscx100\\fscy100)"
    if kind in ("fade", "pop", "pop_blur", "scale", "rise"):
        tags += f"\\alpha&HFF&\\t(0,{max(1, int(duration * 0.7))},\\alpha&H00&)"
    if entry.get("blur_from"):
        blur = float(entry["blur_from"])
        tags += f"\\blur{blur:g}\\t(0,{duration},\\blur0)"
    return tags


def exit_tags(preset: dict, block_duration: float) -> str:
    exit_spec = preset.get("exit") or {}
    if exit_spec.get("kind") not in ("fade", "fade_scale"):
        return ""
    duration = float(exit_spec.get("duration", 0.07))
    if duration <= 0 or block_duration <= duration * 1.5:
        return ""
    start = int(round((block_duration - duration) * 1000))
    end = int(round(block_duration * 1000))
    tags = f"\\t({start},{end},\\alpha&HFF&)"
    if exit_spec["kind"] == "fade_scale":
        tags += f"\\t({start},{end},\\fscx104\\fscy104)"
    return tags


PER_WORD_MODES = {"box", "one_word", "word_pop"}


def karaoke_run(words: list[dict], preset: dict, accent: str, base: str,
                emphasis: set | None = None, emphasis_colour: str | None = None) -> str:
    """Progressive `\\kf` fill.

    In ASS karaoke the *unsung* part uses SecondaryColour and the sung part uses
    PrimaryColour, so the base colour goes to `\\2c` and the fill colour to `\\1c`.
    This is a cumulative wipe: to highlight only the word currently being spoken
    use one of the per-word modes instead.

    Emphasised words get their own fill colour inline and restore the block's colour
    afterwards. Without this the emphasis set was silently ignored whenever the
    preset used karaoke, so `--emphasis-numbers` had no visible effect at all.
    """
    emphasis = emphasis or set()
    highlight = emphasis_colour or accent
    parts: list[str] = []
    for index, word in enumerate(words):
        token = display_of(word)
        following = words[index + 1]["start"] if index + 1 < len(words) else word["end"]
        centis = max(1, round((max(0.04, float(following) - float(word["start"]))) * 100))
        text = escape(token)
        separator = "" if index == 0 or is_suffix(token) else " "
        if word.get("id") in emphasis and highlight != accent:
            # Colour override must sit inside the same override block as `\kf` so the
            # karaoke timing is not broken into a separate syllable.
            parts.append(f"{separator}{{\\kf{centis}\\1c{highlight}}}{text}{{\\1c{accent}}}")
        else:
            parts.append(f"{separator}{{\\kf{centis}}}{text}")
    return f"{{\\1c{accent}\\2c{base}}}" + "".join(parts)


def build_events(groups: list[list[dict]], preset: dict, canvas: Canvas,
                 typesetter: Typesetter, emphasis: set[int] | None = None) -> tuple[list[str], list[dict]]:
    """Render every block into ASS dialogue lines plus a machine-readable map."""
    geometry = resolve_geometry(preset, canvas)
    colors = preset["colors"]
    spoken = ass_colour(colors.get("spoken"), 1.0)
    unspoken = ass_colour(colors.get("unspoken", colors.get("spoken")), 1.0)
    accent = ass_colour(colors.get("accent"), 1.0)
    mode = preset["word_mode"]
    emphasis = emphasis or set()
    events: list[str] = []
    blocks: list[dict] = []

    for block_id, raw_group in enumerate(groups):
        group = apply_case(raw_group, bool(preset["uppercase"]))
        start = float(group[0]["start"])
        raw_end = float(group[-1]["end"]) + float(preset.get("hold", 0.07))
        next_start = float(groups[block_id + 1][0]["start"]) if block_id + 1 < len(groups) else raw_end
        # Never let two blocks overlap: a stacked pair is unreadable and the
        # word-coverage gate cannot attribute the overlap to either block.
        end = min(raw_end, max(start + 0.2, next_start - 0.01))
        duration = end - start
        # Clearance each word needs beyond its advance: the outline grows every
        # glyph outward by `bord`, and a highlight box adds its own padding.
        gap_extra_ratio = 0.0
        if mode in PER_WORD_MODES:
            gap_extra_ratio = 2 * float(preset["outline_ratio"])
            if mode == "box" and preset.get("box"):
                gap_extra_ratio += 2 * float(preset["box"].get("pad_ratio", 0.16))
        layout = typesetter.layout(
            group, geometry.size, preset["tracking_ratio"], geometry.max_width,
            preset["max_lines"], preset["line_spacing"],
            gap_extra_ratio=gap_extra_ratio, per_word=mode in PER_WORD_MODES,
        )
        total_height = layout.line_height * len(layout.lines)
        if preset["position"] == "center":
            first_baseline = geometry.baseline_y - total_height / 2 + layout.line_height / 2
        elif preset["position"] == "upper":
            first_baseline = geometry.baseline_y
        else:
            first_baseline = geometry.baseline_y - total_height + layout.line_height / 2
        tracking_px = preset["tracking_ratio"] * layout.size
        scale = layout.size / geometry.size if geometry.size else 1.0

        # A backdrop plate behind the whole block. Drawn once per block from the
        # measured line widths, so it hugs the type instead of being a guessed
        # fixed-width band. Layer 2 keeps it under every text draw including the
        # per-word highlight box on layer 6.
        plate = preset.get("plate")
        if plate:
            pad_x = float(plate.get("pad_x_ratio", 0.42)) * layout.size
            pad_y = float(plate.get("pad_y_ratio", 0.26)) * layout.size
            plate_w = layout.width + 2 * pad_x
            plate_h = layout.line_height * len(layout.lines) + 2 * pad_y
            plate_cy = first_baseline + (len(layout.lines) - 1) * layout.line_height / 2
            radius_ratio = plate.get("radius_ratio", 0.34)
            radius = (plate_h / 2 if radius_ratio == "pill"
                      else float(radius_ratio) * layout.size)
            plate_entry = ""
            if plate.get("entry", True):
                grow = int(round(float((preset.get("entry") or {}).get("duration", 0.12)) * 1000))
                if grow > 0:
                    plate_entry = (
                        f"\\fscx88\\fscy88\\t(0,{grow},\\fscx100\\fscy100)"
                        f"\\alpha&HFF&\\t(0,{max(1, int(grow * 0.8))},\\alpha&H00&)"
                    )
            # `\1c` carries colour only: libass keeps the style's alpha and drops
            # the AA byte of an eight-digit value. Transparency has to be set with
            # `\1a`, or every plate renders fully opaque no matter what the preset
            # asked for.
            plate_opacity = float(plate.get("opacity", 0.72))
            plate_colour = ass_colour(plate.get("color", "#0B0B0F"), 1.0)
            plate_border = ""
            if plate.get("border_ratio"):
                plate_border = (
                    f"\\bord{float(plate['border_ratio']) * layout.size:.2f}"
                    f"\\3c{ass_colour(plate.get('border_color', '#FFFFFF'), 1.0)}"
                    f"\\3a{ass_alpha(float(plate.get('border_opacity', 0.9)))}"
                )
            if plate_opacity > 0.004 or plate_border:
                events.append(
                    f"Dialogue: 2,{ass_time(start)},{ass_time(end)},Plate,,0,0,0,,"
                    f"{{\\an5\\pos({geometry.centre_x:.1f},{plate_cy:.1f})\\p1"
                    f"\\1c{plate_colour}\\1a{ass_alpha(plate_opacity)}"
                    f"\\bord0\\shad0\\4a&HFF&{plate_border}"
                    f"\\blur{float(plate.get('blur', 0)):g}{plate_entry}}}"
                    f"{rounded_rect(plate_w, plate_h, radius)}{{\\p0}}"
                )
            accent_bar = plate.get("accent_bar")
            if accent_bar:
                bar_w = float(accent_bar.get("width_ratio", 0.9)) * plate_w
                bar_h = max(2.0, float(accent_bar.get("height_ratio", 0.055)) * layout.size)
                bar_y = plate_cy + (plate_h / 2 if accent_bar.get("side", "bottom") == "bottom"
                                    else -plate_h / 2) + float(accent_bar.get("offset_ratio", 0.12)) * layout.size * (
                    1 if accent_bar.get("side", "bottom") == "bottom" else -1)
                events.append(
                    f"Dialogue: 2,{ass_time(start)},{ass_time(end)},Plate,,0,0,0,,"
                    f"{{\\an5\\pos({geometry.centre_x:.1f},{bar_y:.1f})\\p1"
                    f"\\1c{ass_colour(accent_bar.get('color', colors.get('accent')), 1.0)}"
                    f"\\1a{ass_alpha(float(accent_bar.get('opacity', 1.0)))}"
                    f"\\bord0\\shad0\\4a&HFF&{plate_entry}}}"
                    f"{rounded_rect(bar_w, bar_h, bar_h / 2)}{{\\p0}}"
                )

        common = (
            f"\\fs{layout.size:.1f}\\fsp{tracking_px:.2f}"
            f"\\bord{preset['outline_ratio'] * layout.size:.2f}"
            f"\\shad{preset['shadow_ratio'] * layout.size:.2f}"
        )
        block_words: list[dict] = []

        for line_index, line in enumerate(layout.lines):
            y = first_baseline + line_index * layout.line_height
            stagger = float(preset.get("line_stagger") or 0.0) * line_index
            line_start = min(start + stagger, max(start, end - 0.08))
            animation = entry_tags(preset, geometry) + exit_tags(preset, end - line_start)

            if mode in PER_WORD_MODES:
                left = geometry.centre_x - line.width / 2
                box = preset.get("box") if mode == "box" else None
                active_scale = float(preset.get("active_scale", 1.0))
                for word_index, word in enumerate(line.words):
                    token = display_of(word)
                    text = escape(token)
                    word_x = left + line.x_positions[word_index] + line.word_widths[word_index] / 2
                    word_start = max(line_start, float(word["start"]))
                    word_end = min(end, max(word_start + 0.05, float(word["end"])))
                    base_colour = accent if word.get("id") in emphasis else unspoken

                    # The resting word is drawn around the active window, never
                    # under it: two overlapping draws of the same glyphs read as
                    # a heavier weight and a dirty outline.
                    for segment_start, segment_end, with_animation in (
                        (line_start, word_start, True),
                        (word_end, end, False),
                    ):
                        if segment_end - segment_start < 0.02:
                            continue
                        tags = animation if with_animation else ""
                        events.append(
                            f"Dialogue: 8,{ass_time(segment_start)},{ass_time(segment_end)},Main,,0,0,0,,"
                            f"{{\\an5\\pos({word_x:.1f},{y:.1f}){common}\\1c{base_colour}{tags}}}{text}"
                        )

                    if box:
                        pad = float(box.get("pad_ratio", 0.16)) * layout.size
                        width = line.word_widths[word_index] + 2 * pad
                        height = layout.size * float(box.get("height_ratio", 1.2))
                        # Drawing coordinates start at 0, not at -w/2. libass
                        # offsets a drawing by half its own bounding box, so a
                        # shape symmetric about the origin lands shifted left and
                        # up by half its size, while a shape spanning 0..w is
                        # centred by an5 exactly like text. Measured on a frame,
                        # not assumed.
                        box_y = y + float(box.get("y_offset_ratio", -0.02)) * layout.size
                        radius_spec = box.get("radius_ratio", 0.0)
                        box_radius = (height / 2 if radius_spec == "pill"
                                      else float(radius_spec) * layout.size)
                        drawing = rounded_rect(width, height, box_radius)
                        events.append(
                            f"Dialogue: 6,{ass_time(word_start)},{ass_time(word_end)},Box,,0,0,0,,"
                            f"{{\\an5\\pos({word_x:.1f},{box_y:.1f})\\p1\\1c"
                            f"{ass_colour(box.get('color'), 1.0)}"
                            f"\\1a{ass_alpha(float(box.get('opacity', 1.0)))}"
                            f"\\bord0\\shad0\\4a&HFF&}}{drawing}{{\\p0}}"
                        )
                        highlight = ass_colour(box.get("text_color", "#101010"), 1.0)
                        active_tags = f"\\1c{highlight}\\bord0\\shad0"
                    else:
                        active_tags = f"\\1c{accent}"
                    if active_scale != 1.0:
                        percent = round(active_scale * 100)
                        active_tags += (
                            f"\\fscx{percent}\\fscy{percent}"
                            f"\\t(0,90,\\fscx{percent}\\fscy{percent})"
                        )
                    events.append(
                        f"Dialogue: 10,{ass_time(word_start)},{ass_time(word_end)},Main,,0,0,0,,"
                        f"{{\\an5\\pos({word_x:.1f},{y:.1f}){common}{active_tags}}}{text}"
                    )
                    block_words.append({
                        "id": word.get("id"), "word": word["word"], "display": token,
                        "start": round(word_start, 3),
                        "end": round(word_end, 3), "line": line_index,
                        "x": round(word_x, 1), "y": round(y, 1),
                        "width": round(line.word_widths[word_index], 1),
                    })
            else:
                plain = escape(line.text)
                text = (
                    karaoke_run(line.words, preset, accent, unspoken, emphasis,
                                ass_colour(colors.get("emphasis", colors.get("accent")), 1.0)
                                if colors.get("emphasis") else None)
                    if mode == "karaoke" else plain
                )
                colour_tag = "" if mode == "karaoke" else f"\\1c{spoken}"
                fringe = preset.get("chromatic_fringe")
                if fringe:
                    for layer, key, colour in (
                        (4, "red_x", fringe.get("red", "#FF3B2F")),
                        (5, "green_x", fringe.get("green", "#22FF6A")),
                    ):
                        offset = float(fringe.get(key, 0.0)) * layout.size / 40
                        events.append(
                            f"Dialogue: {layer},{ass_time(line_start)},{ass_time(end)},Fringe,,0,0,0,,"
                            f"{{\\an5\\pos({geometry.centre_x + offset:.1f},{y:.1f}){common}"
                            f"\\1c{ass_colour(colour, 1.0)}"
                            f"\\1a{ass_alpha(float(fringe.get('opacity', 0.34)))}"
                            f"\\bord0\\shad0{animation}}}{plain}"
                        )
                # Выдавка: несколько смещённых копий позади текста, от дальней к
                # ближней. Именно так делается объём в стиле видеоэссе — не
                # тенью, а телом буквы, уходящим вглубь кадра.
                #
                # Копии рисуются с `\bord0`: обводка на каждом слое сложилась бы
                # в грязный контур по краю выдавки, а на кириллице с её частыми
                # вертикалями это слипается в кашу.
                extrude = preset.get("extrude")
                if extrude:
                    depth = int(extrude.get("depth", 6))
                    angle = float(extrude.get("angle", 90.0))
                    step = float(extrude.get("step_ratio", 0.022)) * layout.size
                    dx = step * math.cos(math.radians(angle))
                    dy = step * math.sin(math.radians(angle))
                    far = ass_colour(extrude.get("color", "#0B0B0F"), 1.0)
                    alpha = ass_alpha(float(extrude.get("opacity", 1.0)))
                    for level in range(depth, 0, -1):
                        events.append(
                            f"Dialogue: {2 + max(0, 5 - level)},{ass_time(line_start)},"
                            f"{ass_time(end)},Extrude,,0,0,0,,"
                            f"{{\\an5\\pos({geometry.centre_x + dx * level:.1f},"
                            f"{y + dy * level:.1f}){common}\\1c{far}\\1a{alpha}"
                            f"\\bord0\\shad0\\4a&HFF&{animation}}}{plain}"
                        )

                glow = preset.get("glow")
                if glow:
                    glow_colour = ass_colour(glow.get("color", "#FFFFFF"), 1.0)
                    glow_alpha = ass_alpha(float(glow.get("opacity", 0.35)))
                    events.append(
                        f"Dialogue: 3,{ass_time(line_start)},{ass_time(end)},Glow,,0,0,0,,"
                        f"{{\\an5\\pos({geometry.centre_x:.1f},{y:.1f}){common}"
                        f"\\1c{glow_colour}\\3c{glow_colour}"
                        f"\\1a{glow_alpha}\\3a{glow_alpha}"
                        f"\\bord{float(glow.get('radius_ratio', 0.14)) * layout.size:.1f}"
                        f"\\blur{float(glow.get('blur', 6))}\\shad0{animation}}}{plain}"
                    )
                events.append(
                    f"Dialogue: 10,{ass_time(line_start)},{ass_time(end)},Main,,0,0,0,,"
                    f"{{\\an5\\pos({geometry.centre_x:.1f},{y:.1f}){common}{colour_tag}{animation}}}{text}"
                )
                for word in line.words:
                    block_words.append({
                        "id": word.get("id"), "word": word["word"],
                        "start": round(float(word["start"]), 3),
                        "end": round(float(word["end"]), 3), "line": line_index,
                        "x": None, "y": round(y, 1),
                    })

        blocks.append({
            "id": block_id,
            "start": round(start, 3),
            "end": round(end, 3),
            "duration": round(duration, 3),
            "text": join_words(group),
            "word_ids": [word.get("id") for word in group],
            "lines": [line.text for line in layout.lines],
            "font_size": round(layout.size, 1),
            "scale_of_nominal": round(scale, 3),
            "measured_width": round(layout.width, 1),
            "max_width": round(geometry.max_width, 1),
            "shrunk": layout.shrunk,
            "overflow": layout.overflow,
            "words": block_words,
        })
    return events, blocks


def build_header(preset: dict, canvas: Canvas, fontname: str) -> str:
    colors = preset["colors"]
    size = preset["size_ratio"] * canvas.height
    geometry = resolve_geometry(preset, canvas)
    bold = -1 if preset.get("bold") else 0
    italic = -1 if preset.get("italic") else 0
    outline = ass_colour(colors.get("outline", "#000000"), float(colors.get("outline_opacity", 0.82)))
    shadow = ass_colour(colors.get("shadow", "#000000"), float(colors.get("shadow_opacity", 0.35)))
    spoken = ass_colour(colors.get("spoken"), 1.0)
    accent = ass_colour(colors.get("accent"), 1.0)
    style_format = (
        "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
        "BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
        "BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
    )
    side = round(canvas.width * canvas.safe_side)
    common = (
        f"{size:.0f},{spoken},{accent},{outline},{shadow},{bold},{italic},0,0,100,100,"
        f"{preset['tracking_ratio'] * size:.2f},0,1,"
        f"{preset['outline_ratio'] * size:.2f},{preset['shadow_ratio'] * size:.2f},5,"
        f"{side},{side},{geometry.margin_v},1"
    )
    styles = [
        f"Style: Main,{fontname},{common}",
        f"Style: Box,{fontname},{common}",
        f"Style: Fringe,{fontname},{common}",
        f"Style: Glow,{fontname},{common}",
        f"Style: Plate,{fontname},{common}",
        f"Style: Extrude,{fontname},{common}",
    ]
    return (
        "[Script Info]\n"
        "; Generated by ai-pro-video-production. Geometry is derived from the canvas,\n"
        "; so this file is only valid at the PlayRes below.\n"
        "ScriptType: v4.00+\n"
        f"PlayResX: {canvas.width}\n"
        f"PlayResY: {canvas.height}\n"
        "ScaledBorderAndShadow: yes\n"
        "WrapStyle: 2\n"
        "YCbCr Matrix: TV.709\n"
        "Kerning: yes\n"
        "\n[V4+ Styles]\n"
        f"{style_format}\n"
        + "\n".join(styles)
        + "\n\n[Events]\n"
        "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
    )


def preset_weight(preset: dict) -> int:
    """The wght the preset asks for, defaulting Bold to 700 and the rest to 400."""
    return int(preset.get("weight") or (700 if preset.get("bold") else 400))


def make_typesetter(preset: dict, fontname: str, fonts_dir) -> Typesetter:
    """Build a Typesetter bound to the exact face libass will render."""
    return Typesetter(fontname, fonts_dir, preset_weight(preset),
                      bool(preset.get("italic")))


def resolve_fontname(preset: dict, manifest: dict | None) -> tuple[str, list[str]]:
    """Map preset font + weight onto a family name libass can actually match.

    Google variable fonts report `name ID 1 = "<Family> Thin"`, so asking for the
    bare family silently falls back to a system font. The install manifest records
    the real name of every static instance that was produced; this looks the
    requested weight up there and reports honestly when it has to guess.
    """
    family = preset["font"]
    weight = preset_weight(preset)
    notes: list[str] = []
    if not manifest:
        return family, ["Нет font_manifest.json — имя шрифта не проверено. Запусти scripts/install_fonts.py"]
    key = {400: "regular", 500: "medium", 600: "semibold", 700: "bold",
           800: "extrabold", 900: "black"}.get(weight, "regular")
    for entry in manifest.get("families", []):
        if entry["family"].casefold() != family.casefold() and entry["id"] != family.casefold():
            continue
        names = entry.get("ass_names", {})
        if key in names:
            return names[key], notes
        for fallback in ("black", "extrabold", "bold", "semibold", "medium", "regular"):
            if fallback in names:
                notes.append(
                    f"{family}: начертание {key} не установлено, взято {fallback}. "
                    f"Доступно: {', '.join(sorted(names))}"
                )
                return names[fallback], notes
    notes.append(f"{family} отсутствует в font_manifest.json — libass подставит системный шрифт")
    return family, notes
