#!/usr/bin/env python3
"""Dependency-free TrueType/OpenType table reader.

Why this exists: caption layout must be decided from real glyph advances, not from
a character count. Cyrillic is wider than Latin at the same point size, so a
"max 32 characters" rule silently overflows the frame in Russian. Everything the
caption engine needs — the family name libass will match, the named instances a
variable font exposes, vertical metrics and per-glyph advances — is read here.

Limitations that are stated instead of hidden:
  * variable-font `HVAR` advance deltas are ignored, so measurements describe the
    default instance; for wght-only faces the error is a fraction of a percent
    at caption sizes, and the layout engine keeps a safety margin on top;
  * kerning (`kern`/`GPOS`) is not applied, which biases widths slightly wide —
    the safe direction for a "does it fit" decision.
"""
from __future__ import annotations

import struct
from functools import lru_cache
from pathlib import Path

CYRILLIC_BASE = (
    "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"
    "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
)
LATIN_BASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
DIGITS = "0123456789"
# Split by consequence: a caption cannot ship without the first set, while the
# second only costs visual consistency (libass falls back per glyph) and can be
# avoided by writing "..." instead of "…" or "N" instead of "№".
PUNCTUATION_REQUIRED = ".,!?:;-()%\"'/«»"
PUNCTUATION_OPTIONAL = "–—…№₽€°"
PUNCTUATION = PUNCTUATION_REQUIRED + PUNCTUATION_OPTIONAL


class FontError(RuntimeError):
    pass


def _u16(data: bytes, offset: int) -> int:
    return struct.unpack_from(">H", data, offset)[0]


def _s16(data: bytes, offset: int) -> int:
    return struct.unpack_from(">h", data, offset)[0]


def _u32(data: bytes, offset: int) -> int:
    return struct.unpack_from(">I", data, offset)[0]


def _fixed(data: bytes, offset: int) -> float:
    return struct.unpack_from(">i", data, offset)[0] / 65536.0


class FontFile:
    """Lazy reader over one TTF/OTF face."""

    def __init__(self, path: Path | str) -> None:
        self.path = Path(path)
        self.data = self.path.read_bytes()
        if len(self.data) < 12:
            raise FontError(f"{self.path} is too short to be a font")
        tag = self.data[:4]
        if tag == b"ttcf":
            raise FontError(f"{self.path} is a font collection; extract a single face first")
        if tag not in (b"\x00\x01\x00\x00", b"OTTO", b"true", b"typ1"):
            raise FontError(f"{self.path} has an unknown sfnt signature {tag!r}")
        self.tables: dict[bytes, tuple[int, int]] = {}
        for index in range(_u16(self.data, 4)):
            record = 12 + index * 16
            if record + 16 > len(self.data):
                break
            name, _checksum, offset, length = struct.unpack_from(">4sIII", self.data, record)
            self.tables[name] = (offset, length)
        self._cmap: dict[int, int] | None = None

    # ------------------------------------------------------------------ tables

    def has(self, table: str) -> bool:
        return table.encode("ascii").ljust(4)[:4] in self.tables

    def _offset(self, table: str) -> int | None:
        return (self.tables.get(table.encode("ascii").ljust(4)[:4]) or (None, None))[0]

    # -------------------------------------------------------------------- cmap

    @property
    def cmap(self) -> dict[int, int]:
        """Unicode codepoint -> glyph id, best available subtable."""
        if self._cmap is None:
            self._cmap = self._read_cmap()
        return self._cmap

    def _read_cmap(self) -> dict[int, int]:
        base = self._offset("cmap")
        if base is None:
            return {}
        data = self.data
        count = _u16(data, base + 2)
        best: tuple[int, int] | None = None
        for index in range(count):
            record = base + 4 + index * 8
            if record + 8 > len(data):
                break
            platform, encoding, relative = struct.unpack_from(">HHI", data, record)
            # Rank: Windows UCS-4 > Windows BMP > Unicode platform > anything else.
            rank = {
                (3, 10): 4,
                (0, 6): 4,
                (0, 4): 3,
                (3, 1): 3,
                (0, 3): 2,
                (0, 2): 1,
                (0, 1): 1,
            }.get((platform, encoding), 0)
            if rank == 0:
                continue
            if best is None or rank > best[0]:
                best = (rank, base + relative)
        if best is None:
            return {}
        return self._read_cmap_subtable(best[1])

    def _read_cmap_subtable(self, table: int) -> dict[int, int]:
        data = self.data
        fmt = _u16(data, table)
        mapping: dict[int, int] = {}
        if fmt == 4:
            seg_count = _u16(data, table + 6) // 2
            end_at = table + 14
            start_at = end_at + seg_count * 2 + 2
            delta_at = start_at + seg_count * 2
            range_at = delta_at + seg_count * 2
            for segment in range(seg_count):
                end = _u16(data, end_at + segment * 2)
                start = _u16(data, start_at + segment * 2)
                delta = _s16(data, delta_at + segment * 2)
                range_offset = _u16(data, range_at + segment * 2)
                if start > end:
                    continue
                for code in range(start, min(end, 0xFFFE) + 1):
                    if range_offset == 0:
                        glyph = (code + delta) & 0xFFFF
                    else:
                        address = range_at + segment * 2 + range_offset + (code - start) * 2
                        if address + 2 > len(data):
                            continue
                        glyph = _u16(data, address)
                        if glyph:
                            glyph = (glyph + delta) & 0xFFFF
                    if glyph:
                        mapping[code] = glyph
        elif fmt == 12:
            groups = _u32(data, table + 12)
            for group in range(groups):
                record = table + 16 + group * 12
                if record + 12 > len(data):
                    break
                start, end, glyph = struct.unpack_from(">III", data, record)
                if end < start or end - start > 0x20000:
                    continue
                for step, code in enumerate(range(start, end + 1)):
                    mapping[code] = glyph + step
        elif fmt == 6:
            first = _u16(data, table + 6)
            entries = _u16(data, table + 8)
            for step in range(entries):
                glyph = _u16(data, table + 10 + step * 2)
                if glyph:
                    mapping[first + step] = glyph
        elif fmt == 0:
            for code in range(256):
                glyph = data[table + 6 + code]
                if glyph:
                    mapping[code] = glyph
        return mapping

    def covers(self, text: str) -> str:
        """Return the characters of `text` the font cannot render."""
        cmap = self.cmap
        return "".join(dict.fromkeys(char for char in text if ord(char) not in cmap and char != " "))

    # -------------------------------------------------------------------- names

    @property
    def names(self) -> dict[int, str]:
        base = self._offset("name")
        if base is None:
            return {}
        data = self.data
        count = _u16(data, base + 2)
        storage = base + _u16(data, base + 4)
        result: dict[int, tuple[int, str]] = {}
        for index in range(count):
            record = base + 6 + index * 12
            if record + 12 > len(data):
                break
            platform, encoding, language, name_id, length, offset = struct.unpack_from(
                ">HHHHHH", data, record
            )
            start = storage + offset
            raw = data[start:start + length]
            if not raw:
                continue
            try:
                if platform == 3 or (platform == 0 and encoding in (3, 4, 6, 10)):
                    value = raw.decode("utf-16-be", errors="strict")
                elif platform == 0:
                    value = raw.decode("utf-16-be", errors="strict")
                else:
                    value = raw.decode("mac-roman", errors="strict")
            except (UnicodeDecodeError, LookupError):
                continue
            value = value.strip("\x00").strip()
            if not value:
                continue
            # Prefer Windows/English, then Windows/any, then the rest.
            rank = 3 if (platform == 3 and language == 0x409) else 2 if platform == 3 else 1
            previous = result.get(name_id)
            if previous is None or rank > previous[0]:
                result[name_id] = (rank, value)
        return {key: value for key, (_rank, value) in result.items()}

    @property
    def family(self) -> str:
        """The family name libass/fontconfig will match in an ASS `Fontname`."""
        names = self.names
        return names.get(16) or names.get(1) or self.path.stem

    @property
    def subfamily(self) -> str:
        names = self.names
        return names.get(17) or names.get(2) or "Regular"

    # --------------------------------------------------------------- variations

    @property
    def variable(self) -> bool:
        return self.has("fvar")

    @property
    def axes(self) -> list[dict]:
        base = self._offset("fvar")
        if base is None:
            return []
        data = self.data
        axes_offset = _u16(data, base + 4)
        axis_count = _u16(data, base + 8)
        axis_size = _u16(data, base + 10)
        names = self.names
        result = []
        for index in range(axis_count):
            record = base + axes_offset + index * axis_size
            tag = data[record:record + 4].decode("ascii", errors="replace")
            result.append({
                "tag": tag,
                "min": round(_fixed(data, record + 4), 3),
                "default": round(_fixed(data, record + 8), 3),
                "max": round(_fixed(data, record + 12), 3),
                "name": names.get(_u16(data, record + 18), tag),
            })
        return result

    @property
    def instances(self) -> list[dict]:
        """Named instances — what fontconfig exposes as selectable styles."""
        base = self._offset("fvar")
        if base is None:
            return []
        data = self.data
        axes_offset = _u16(data, base + 4)
        axis_count = _u16(data, base + 8)
        axis_size = _u16(data, base + 10)
        instance_count = _u16(data, base + 12)
        instance_size = _u16(data, base + 14)
        tags = [
            data[base + axes_offset + i * axis_size:base + axes_offset + i * axis_size + 4]
            .decode("ascii", errors="replace")
            for i in range(axis_count)
        ]
        names = self.names
        first = base + axes_offset + axis_count * axis_size
        result = []
        for index in range(instance_count):
            record = first + index * instance_size
            if record + instance_size > len(data):
                break
            subfamily_id = _u16(data, record)
            coordinates = {
                tags[axis]: round(_fixed(data, record + 4 + axis * 4), 3)
                for axis in range(axis_count)
            }
            result.append({
                "style": names.get(subfamily_id, f"instance{index}"),
                "coordinates": coordinates,
            })
        return result

    # ------------------------------------------------------------------ metrics

    @property
    def units_per_em(self) -> int:
        base = self._offset("head")
        if base is None:
            return 1000
        return _u16(self.data, base + 18) or 1000

    @property
    def vertical_metrics(self) -> dict:
        head = self._offset("hhea")
        os2 = self._offset("OS/2")
        upem = self.units_per_em
        result = {"units_per_em": upem}
        if head is not None:
            result["ascender"] = _s16(self.data, head + 4)
            result["descender"] = _s16(self.data, head + 6)
            result["line_gap"] = _s16(self.data, head + 8)
        if os2 is not None:
            version = _u16(self.data, os2)
            result["weight_class"] = _u16(self.data, os2 + 4)
            result["width_class"] = _u16(self.data, os2 + 6)
            result["typo_ascender"] = _s16(self.data, os2 + 68)
            result["typo_descender"] = _s16(self.data, os2 + 70)
            result["win_ascent"] = _u16(self.data, os2 + 74)
            result["win_descent"] = _u16(self.data, os2 + 76)
            if version >= 2:
                result["cap_height"] = _s16(self.data, os2 + 88)
                result["x_height"] = _s16(self.data, os2 + 86)
        return result

    @property
    def advances(self) -> list[int]:
        hhea = self._offset("hhea")
        hmtx = self._offset("hmtx")
        if hhea is None or hmtx is None:
            return []
        count = _u16(self.data, hhea + 34)
        result = []
        for index in range(count):
            record = hmtx + index * 4
            if record + 2 > len(self.data):
                break
            result.append(_u16(self.data, record))
        return result

    def advance(self, codepoint: int) -> int:
        advances = self._advance_cache
        glyph = self.cmap.get(codepoint)
        if glyph is None or not advances:
            return advances[0] if advances else self.units_per_em // 2
        if glyph < len(advances):
            return advances[glyph]
        return advances[-1]

    @property
    def _advance_cache(self) -> list[int]:
        if not hasattr(self, "__advances"):
            setattr(self, "__advances", self.advances)
        return getattr(self, "__advances")

    @property
    def libass_scale(self) -> float:
        """em size, per unit of ASS `Fontsize`.

        An ASS `Fontsize` is not an em size. libass calls `FT_Request_Size` with
        `FT_SIZE_REQUEST_TYPE_REAL_DIM` and a height of
        `size * (hhea.Ascender - hhea.Descender) / (usWinAscent + usWinDescent)`.
        REAL_DIM makes FreeType map the hhea ascender-to-descender span onto that
        height, so the two hhea terms cancel and the em works out to

            em = Fontsize * unitsPerEm / (usWinAscent + usWinDescent)

        For Montserrat that is 0.64 — a naive `em = Fontsize` overestimates every
        line by more than 50%, which is why character-count limits behave so
        erratically across families. Validated against rendered ink width for the
        whole bundled pack (see `scripts/font_audit.py --measure-check`).
        """
        metrics = self.vertical_metrics
        upem = metrics.get("units_per_em") or 1000
        win_ascent = metrics.get("win_ascent")
        win_descent = metrics.get("win_descent")
        if win_ascent and win_descent is not None and (win_ascent + win_descent) > 0:
            return upem / (win_ascent + win_descent)
        ascender = metrics.get("ascender")
        descender = metrics.get("descender")
        if ascender is not None and descender is not None and (ascender - descender) > 0:
            return upem / (ascender - descender)
        return 1.0

    @property
    def libass_line_scale(self) -> float:
        """Baseline-to-baseline advance per unit of `Fontsize`.

        With REAL_DIM the scaled ascender/descender that libass uses for line
        spacing come back out as `Fontsize * hheaSpan / winSpan`.
        """
        metrics = self.vertical_metrics
        ascender = metrics.get("ascender")
        descender = metrics.get("descender")
        win_ascent = metrics.get("win_ascent")
        win_descent = metrics.get("win_descent")
        if None in (ascender, descender, win_ascent, win_descent):
            return 1.0
        hori = ascender - descender
        win = win_ascent + win_descent
        if hori <= 0 or win <= 0:
            return 1.0
        return hori / win

    def measure(self, text: str, size_px: float, tracking: float = 0.0,
                as_ass_fontsize: bool = True) -> float:
        """Advance width of `text` in pixels, ignoring kerning.

        `size_px` is an ASS `Fontsize` by default, so `libass_scale` is applied.
        Pass `as_ass_fontsize=False` to measure at a literal em size.
        `tracking` is extra spacing per character in pixels, matching the ASS
        `Spacing` field so a measurement can mirror a caption preset.
        """
        em = size_px * (self.libass_scale if as_ass_fontsize else 1.0)
        upem = self.units_per_em
        total = 0
        for char in text:
            total += self.advance(ord(char))
        return total * em / upem + tracking * max(0, len(text) - 1)

    def line_height(self, size_px: float, spacing: float = 1.0) -> float:
        """Baseline-to-baseline distance libass will use at this `Fontsize`."""
        return size_px * self.libass_line_scale * spacing

    def report(self) -> dict:
        missing_cyrillic = self.covers(CYRILLIC_BASE)
        missing_latin = self.covers(LATIN_BASE)
        missing_digits = self.covers(DIGITS)
        missing_punctuation = self.covers(PUNCTUATION_REQUIRED)
        return {
            "path": str(self.path),
            "family": self.family,
            "subfamily": self.subfamily,
            "variable": self.variable,
            "axes": [axis["tag"] for axis in self.axes],
            "instances": [instance["style"] for instance in self.instances],
            "glyphs": len(self.cmap),
            "missing_cyrillic": missing_cyrillic,
            "missing_latin": missing_latin,
            "missing_digits": missing_digits,
            "missing_punctuation": missing_punctuation,
            "cyrillic_ok": not missing_cyrillic,
            "latin_ok": not missing_latin,
            "digits_ok": not missing_digits,
            "metrics": self.vertical_metrics,
        }


@lru_cache(maxsize=256)
def open_font(path: str) -> FontFile:
    return FontFile(path)


@lru_cache(maxsize=512)
def find_family_file(fonts_dir: str, family: str, weight: int = 400,
                     italic: bool = False) -> str | None:
    """Locate the face libass would pick for `family` at `weight`.

    The weight matters for measurement, not just for looks: RIBBI naming puts
    Regular and Bold under the *same* name ID 1, so "Montserrat" alone is
    ambiguous and measuring the wrong one biases every line by about 6%. libass
    resolves the ambiguity with the style's Bold flag; this mirrors that by
    picking the closest `usWeightClass`.
    """
    root = Path(fonts_dir)
    if not root.exists():
        return None
    target = family.strip().casefold()
    primary: list[tuple[int, int, str]] = []
    secondary: list[tuple[int, int, str]] = []
    for path in sorted(root.rglob("*.tt[fc]")) + sorted(root.rglob("*.otf")):
        if path.suffix.lower() == "ttc":
            continue
        try:
            font = open_font(str(path))
        except (FontError, OSError, struct.error):
            continue
        is_italic = "italic" in path.name.casefold() or "italic" in font.subfamily.casefold()
        face_weight = font.vertical_metrics.get("weight_class") or 400
        # Rank: exact italic match, then weight distance.
        score = (0 if is_italic == italic else 1, abs(face_weight - weight))
        # Name ID 1 is the key libass matches an ASS `Fontname` against. Name ID
        # 16 is only a fallback: a static instance of a variable font reports the
        # *base* family there — "Inter" for a file whose name ID 1 is
        # "Inter 28pt SemiBold".
        if (font.names.get(1) or "").strip().casefold() == target:
            primary.append((*score, str(path)))
        elif (font.names.get(16) or "").strip().casefold() == target:
            secondary.append((*score, str(path)))
    pool = primary or secondary
    if not pool:
        return None
    return min(pool)[2]


def measure_with_family(fonts_dir: str, family: str, text: str, size_px: float,
                        tracking: float = 0.0, weight: int = 400) -> float | None:
    path = find_family_file(fonts_dir, family, weight)
    if path is None:
        return None
    return open_font(path).measure(text, size_px, tracking)


if __name__ == "__main__":  # pragma: no cover - manual inspection helper
    import argparse
    import json

    parser = argparse.ArgumentParser(description="Inspect one font file")
    parser.add_argument("font")
    parser.add_argument("--measure")
    parser.add_argument("--size", type=float, default=64.0)
    args = parser.parse_args()
    font = FontFile(args.font)
    payload = font.report()
    payload["axis_detail"] = font.axes
    payload["instance_detail"] = font.instances
    if args.measure:
        payload["measured_px"] = round(font.measure(args.measure, args.size), 2)
    print(json.dumps(payload, ensure_ascii=False, indent=2))
