#!/usr/bin/env python3
"""Install the curated Cyrillic font pack and build a libass-ready flat directory.

Two problems this script exists to solve, both of which silently ruin burned-in
captions:

1. `libass` does not recurse into sub-directories of `fontsdir`. A pack stored as
   `assets/fonts/<family>/<file>.ttf` is never seen, and every render falls back
   to a system font without an error. So the pack is also materialised flat into
   `assets/fonts/all/`.

2. Most Google variable fonts default to their thinnest instance. `Montserrat
   [wght].ttf` reports `name ID 1 = "Montserrat Thin"`, and libass matches on
   name ID 1 — so an ASS style asking for `Montserrat` matches nothing and falls
   back to Arial. Static instances are therefore generated with correct RIBBI
   names (`Montserrat`/`Regular`, `Montserrat`/`Bold`, `Montserrat Black`), which
   is also what makes real weights available instead of faux-bold.

Instancing needs `fonttools`. Without it the script still installs and flattens
the originals, records the real family names it found, and reports every family
whose default instance is too light to burn in.
"""
from __future__ import annotations

import argparse
import concurrent.futures
import hashlib
import io
import json
import shutil
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote

import requests

sys.path.insert(0, str(Path(__file__).resolve().parent))
from font_tables import CYRILLIC_BASE, DIGITS, FontError, FontFile  # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
FONTS = ROOT / "assets" / "fonts"
CATALOG = FONTS / "font_catalog.json"
FLAT_DIRNAME = "all"

# Latin + Latin-ext + combining marks + full Cyrillic + typographic punctuation
# + currency (₽, €) + № + arrows. Vietnamese, Greek and Devanagari are dropped:
# they roughly double file size and no Russian-language reel needs them.
SUBSET_UNICODES = (
    "U+0020-007E,U+00A0-00FF,U+0100-017F,U+0180-024F,U+0259,U+02BB-02BC,"
    "U+0300-036F,U+0400-052F,U+1E00-1E9F,U+2000-206F,U+2070-2071,U+2074-208E,"
    "U+20A0-20BF,U+2100-2138,U+2190-21BB,U+2202,U+2205-2206,U+220F,U+2211-2212,"
    "U+221A,U+221E,U+2229,U+222B,U+2248,U+2260-2265,U+25A0-25CF,U+2605-2606,"
    "U+2713-2717,U+FB00-FB06,U+FEFF,U+FFFD"
)

# Axis values to pin when instancing, per family id. Anything unlisted stays at
# its own default. Inter is pinned to a display optical size because captions are
# rendered far larger than the 14pt default the axis assumes.
AXIS_PINS: dict[str, dict[str, float]] = {
    # Inter's opsz axis is deliberately left at its default: pinning it makes
    # `updateFontNames` bake the optical size into the family ("Inter 28pt"),
    # which no preset or hand-written ASS style would ever ask for.
    "tektur": {"wdth": 100},
    "adventpro": {"wdth": 100},
    "geologica": {"CRSV": 0, "SHRP": 0, "slnt": 0},
    "handjet": {"ELGR": 1, "ELSH": 2},
}

DEFAULT_WEIGHTS = {
    "core": (400, 600, 700, 900),
    "pro": (400, 700, 900),
    "fx": (400, 700),
}

STYLE_FOR_WEIGHT = {
    100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
    600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black",
}


def sha256_bytes(payload: bytes) -> str:
    return hashlib.sha256(payload).hexdigest()


def fetch(url: str, attempts: int = 4, timeout: int = 120) -> bytes:
    last: Exception | None = None
    for attempt in range(attempts):
        try:
            response = requests.get(url, timeout=timeout, headers={"User-Agent": "ai-pro-video-skill/4"})
            response.raise_for_status()
            return response.content
        except Exception as exc:  # network flakiness is expected, not exceptional
            last = exc
            time.sleep(1.5 * (attempt + 1))
    raise RuntimeError(f"Could not download {url}: {last}")


def encode_path(name: str) -> str:
    return quote(name, safe="")


# ------------------------------------------------------------------ instancing

def load_fonttools():
    try:
        import logging

        from fontTools import subset as ft_subset
        from fontTools.ttLib import TTFont
        from fontTools.varLib import instancer
    except ImportError:
        return None
    # The subsetter narrates every table it cannot handle (FFTM, meta, ...).
    # Those are irrelevant to rendering and drown out the warnings that matter.
    logging.getLogger("fontTools.subset").setLevel(logging.ERROR)
    logging.getLogger("fontTools.varLib").setLevel(logging.ERROR)
    logging.getLogger("fontTools.ttLib").setLevel(logging.ERROR)
    return {"TTFont": TTFont, "instancer": instancer, "subset": ft_subset}


def subset_font(tools: dict, font, keep_unicodes: str):
    options = tools["subset"].Options()
    options.layout_features = ["*"]
    options.name_IDs = ["*"]
    options.name_legacy = True
    options.name_languages = ["*"]
    options.notdef_outline = True
    options.recalc_bounds = True
    options.glyph_names = False
    subsetter = tools["subset"].Subsetter(options=options)
    subsetter.populate(unicodes=tools["subset"].parse_unicodes(keep_unicodes))
    subsetter.subset(font)
    return font


def plan_weights(axes: dict[str, dict], requested: tuple[int, ...],
                 available: list[int] | None = None) -> list[int]:
    """Pick wght values to instantiate.

    Requested weights are snapped to the font's own named instances. Instancing
    with `updateFontNames=True` reads the STAT table, which only describes the
    weights the designer actually named, so asking JetBrains Mono for SemiBold
    (it ships Medium and Bold, nothing between) fails outright instead of
    interpolating. Snapping keeps the naming correct for every family.
    """
    wght = axes.get("wght")
    if wght is None:
        return [0]  # static face: emit as-is
    low, high = int(wght["min"]), int(wght["max"])
    pool = sorted({w for w in (available or []) if low <= w <= high})
    chosen: list[int] = []
    for weight in requested:
        value = max(low, min(high, weight))
        if pool:
            value = min(pool, key=lambda candidate: (abs(candidate - value), candidate))
        if value not in chosen:
            chosen.append(value)
    # Always keep at least one weight heavy enough to burn in over footage.
    if chosen and max(chosen) < 600:
        heavier = [w for w in (pool or [high]) if w >= 600]
        if heavier:
            chosen.append(min(heavier))
    return sorted(dict.fromkeys(chosen))


def rename_static(font, weight: int, italic: bool) -> None:
    """Write RIBBI-compatible names onto an instance that STAT could not describe.

    libass matches on name ID 1, so the family/style split decides whether an ASS
    `Fontname` resolves at all. Regular and Bold stay inside the base family (they
    are reachable via the style's Bold flag); every other weight becomes its own
    family, exactly how Google ships static releases.
    """
    style = STYLE_FOR_WEIGHT.get(weight, str(weight))
    name = font["name"]
    base_family = name.getDebugName(16) or name.getDebugName(1) or "Font"
    base_family = base_family.strip()
    for suffix in STYLE_FOR_WEIGHT.values():
        if suffix != "Regular" and base_family.endswith(" " + suffix):
            base_family = base_family[: -len(suffix) - 1]
            break
    if style in ("Regular", "Bold"):
        family, subfamily = base_family, style
    else:
        family, subfamily = f"{base_family} {style}", "Regular"
    if italic:
        subfamily = "Italic" if subfamily == "Regular" else f"{subfamily} Italic"
    full = f"{family} {subfamily}".replace(" Regular", "").strip() or family
    postscript = f"{family.replace(' ', '')}-{subfamily.replace(' ', '')}"
    for name_id, value in ((1, family), (2, subfamily), (4, full), (6, postscript),
                           (16, base_family), (17, f"{style} Italic" if italic else style)):
        name.setName(value, name_id, 3, 1, 0x409)
    if "OS/2" in font:
        font["OS/2"].usWeightClass = weight


def instantiate(tools: dict, source: Path, family_id: str, weights: list[int],
                do_subset: bool) -> list[tuple[str, bytes]]:
    """Return [(filename, ttf bytes)] of static faces built from one source file."""
    base = tools["TTFont"](str(source))
    variable = "fvar" in base
    italic = "italic" in source.name.casefold()
    if not variable:
        payload = source.read_bytes()
        if do_subset:
            font = tools["TTFont"](io.BytesIO(payload))
            subset_font(tools, font, SUBSET_UNICODES)
            buffer = io.BytesIO()
            font.save(buffer)
            payload = buffer.getvalue()
        return [(source.name, payload)]

    axes = {axis.axisTag: {"min": axis.minValue, "default": axis.defaultValue, "max": axis.maxValue}
            for axis in base["fvar"].axes}
    named = sorted({
        int(round(instance.coordinates["wght"]))
        for instance in base["fvar"].instances
        if "wght" in instance.coordinates
    })
    pins = dict(AXIS_PINS.get(family_id, {}))
    results: list[tuple[str, bytes]] = []
    for weight in plan_weights(axes, tuple(weights), named):
        location: dict[str, float] = {}
        for tag, spec in axes.items():
            if tag == "wght":
                location[tag] = float(weight)
            elif tag in pins:
                location[tag] = max(spec["min"], min(spec["max"], float(pins[tag])))
            else:
                location[tag] = float(spec["default"])
        font = tools["TTFont"](str(source))
        try:
            static = tools["instancer"].instantiateVariableFont(
                font, location, inplace=False, updateFontNames=True, optimize=True
            )
        except Exception:
            # No STAT entry for this location: instantiate anyway and write RIBBI
            # names by hand so libass can still match the face.
            static = tools["instancer"].instantiateVariableFont(
                tools["TTFont"](str(source)), location, inplace=False,
                updateFontNames=False, optimize=True,
            )
            rename_static(static, weight, italic)
        if "wght" not in axes:
            # A variable font with no weight axis (Climate Crisis has YEAR, Handjet
            # has element size) gets named after its pinned coordinate — "Climate
            # Crisis 0" — which no ASS style will ever ask for. Force the plain
            # typographic family back onto name ID 1.
            rename_static(static, 400, italic)
        if do_subset:
            subset_font(tools, static, SUBSET_UNICODES)
        buffer = io.BytesIO()
        static.save(buffer)
        family = static["name"].getDebugName(1) or source.stem
        style = static["name"].getDebugName(2) or STYLE_FOR_WEIGHT.get(weight, str(weight))
        slug = family.replace(" ", "")
        suffix = "Italic" if italic and "italic" not in style.casefold() else ""
        filename = f"{slug}-{style.replace(' ', '')}{suffix}.ttf"
        results.append((filename, buffer.getvalue()))
    return results


# ------------------------------------------------------------------- pipeline

def download_family(entry: dict, force: bool) -> dict:
    family_dir = FONTS / entry["id"]
    family_dir.mkdir(parents=True, exist_ok=True)
    sources = []
    names = [*entry["files"], entry.get("license_file", "OFL.txt")]
    for name in names:
        destination = family_dir / name
        url = f"{entry['base_url']}/{encode_path(name)}"
        if destination.exists() and not force:
            payload = destination.read_bytes()
        else:
            payload = fetch(url)
            destination.write_bytes(payload)
        sources.append({
            "file": destination.relative_to(ROOT).as_posix(),
            "url": url,
            "sha256": sha256_bytes(payload),
            "bytes": len(payload),
        })
    return {"id": entry["id"], "dir": family_dir, "sources": sources}


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--tier", choices=["core", "pro", "all"], default="pro",
                        help="core = 13 essential families, pro = core+pro, all = everything")
    parser.add_argument("--only", help="Comma-separated family ids or groups")
    parser.add_argument("--weights", help="Comma-separated wght values, e.g. 400,700,900")
    parser.add_argument("--no-subset", action="store_true",
                        help="Keep every script in the font instead of Latin+Cyrillic")
    parser.add_argument("--skip-download", action="store_true",
                        help="Rebuild the flat pack from already-downloaded sources")
    parser.add_argument("--force", action="store_true", help="Re-download existing sources")
    parser.add_argument("--jobs", type=int, default=6)
    args = parser.parse_args()

    catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
    tiers = {"core": {"core"}, "pro": {"core", "pro"}, "all": {"core", "pro", "fx"}}[args.tier]
    wanted = catalog["families"]
    if args.only:
        keys = {value.strip().casefold() for value in args.only.split(",") if value.strip()}
        wanted = [f for f in wanted if f["id"] in keys or f["group"] in keys
                  or f["family"].casefold() in keys]
    else:
        wanted = [f for f in wanted if f["tier"] in tiers]
    if not wanted:
        raise SystemExit("Nothing selected. Check --tier / --only.")

    tools = load_fonttools()
    warnings: list[str] = []
    if tools is None:
        warnings.append(
            "fonttools is not installed, so variable fonts cannot be turned into static "
            "weights. Captions will be limited to each font's default instance and any "
            "font whose default is Thin/Light must not be used for burn-in. "
            "Fix with: pip install fonttools"
        )

    downloaded: list[dict] = []
    if args.skip_download:
        for entry in wanted:
            family_dir = FONTS / entry["id"]
            sources = []
            for name in [*entry["files"], entry.get("license_file", "OFL.txt")]:
                path = family_dir / name
                if path.exists():
                    payload = path.read_bytes()
                    sources.append({
                        "file": path.relative_to(ROOT).as_posix(),
                        "url": f"{entry['base_url']}/{encode_path(name)}",
                        "sha256": sha256_bytes(payload),
                        "bytes": len(payload),
                    })
            if not sources:
                warnings.append(f"{entry['family']}: no local sources, run without --skip-download")
                continue
            downloaded.append({"id": entry["id"], "dir": family_dir, "sources": sources})
    else:
        with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
            futures = {pool.submit(download_family, entry, args.force): entry for entry in wanted}
            for future in concurrent.futures.as_completed(futures):
                entry = futures[future]
                try:
                    downloaded.append(future.result())
                except Exception as exc:
                    warnings.append(f"{entry['family']}: download failed — {exc}")

    by_id = {item["id"]: item for item in downloaded}
    flat = FONTS / FLAT_DIRNAME
    flat.mkdir(parents=True, exist_ok=True)
    manifest_path = FONTS / "font_manifest.json"
    previous: dict = {}
    if manifest_path.exists():
        try:
            previous = json.loads(manifest_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            previous = {}
    kept: list[dict] = []
    selected_ids = {entry["id"] for entry in wanted}
    if args.only:
        # A partial install must not evict families it was not asked about.
        # Only the selected families' faces are cleared and rebuilt; everything
        # else keeps both its files and its manifest entry.
        keep_files = set()
        for family in previous.get("families", []):
            if family["id"] in selected_ids:
                continue
            kept.append(family)
            keep_files.update(Path(face["file"]).name for face in family.get("faces", []))
        for stale in flat.glob("*.ttf"):
            if stale.name not in keep_files:
                stale.unlink()
    else:
        for stale in flat.glob("*.ttf"):
            stale.unlink()

    families: list[dict] = list(kept)
    for entry in wanted:
        record = by_id.get(entry["id"])
        if record is None:
            continue
        requested = (
            tuple(int(v) for v in args.weights.split(",") if v.strip())
            if args.weights else DEFAULT_WEIGHTS.get(entry["tier"], DEFAULT_WEIGHTS["pro"])
        )
        faces: list[dict] = []
        for source_name in entry["files"]:
            source_path = record["dir"] / source_name
            if not source_path.exists():
                warnings.append(f"{entry['family']}: missing {source_name}")
                continue
            if tools is not None:
                try:
                    built = instantiate(tools, source_path, entry["id"], list(requested),
                                        not args.no_subset)
                except Exception as exc:
                    warnings.append(f"{entry['family']}: instancing failed ({exc}); copied as-is")
                    built = [(source_name, source_path.read_bytes())]
            else:
                built = [(source_name, source_path.read_bytes())]
            for filename, payload in built:
                target = flat / filename
                target.write_bytes(payload)
                try:
                    font = FontFile(target)
                    report = {
                        "family": font.family,
                        "name_id_1": font.names.get(1),
                        "style": font.subfamily,
                        "weight": font.vertical_metrics.get("weight_class"),
                        "variable": font.variable,
                        "cyrillic_ok": not font.covers(CYRILLIC_BASE),
                        "digits_ok": not font.covers(DIGITS),
                    }
                except (FontError, OSError) as exc:
                    warnings.append(f"{filename}: unreadable after build ({exc})")
                    continue
                if not report["cyrillic_ok"]:
                    warnings.append(f"{filename}: incomplete Cyrillic coverage")
                if report["variable"] and (report["weight"] or 400) < 400:
                    warnings.append(
                        f"{filename}: variable face whose default instance is weight "
                        f"{report['weight']} — do not use it for burn-in captions"
                    )
                faces.append({
                    "file": target.relative_to(ROOT).as_posix(),
                    "ass_fontname": report["name_id_1"] or report["family"],
                    "style": report["style"],
                    "weight": report["weight"],
                    "bytes": len(payload),
                    "sha256": sha256_bytes(payload),
                    "cyrillic_ok": report["cyrillic_ok"],
                    "digits_ok": report["digits_ok"],
                })
        ass_names: dict[str, str] = {}
        for face in faces:
            weight = face["weight"] or 400
            if face["style"].casefold() in {"italic", "bold italic"}:
                continue
            key = STYLE_FOR_WEIGHT.get(weight, str(weight)).casefold()
            ass_names[key] = face["ass_fontname"]
        families.append({
            "id": entry["id"],
            "family": entry["family"],
            "group": entry["group"],
            "tier": entry["tier"],
            "use": entry.get("use"),
            "license": entry["license"],
            "license_file": (record["dir"] / entry.get("license_file", "OFL.txt"))
                .relative_to(ROOT).as_posix(),
            "sources": record["sources"],
            "faces": faces,
            "ass_names": ass_names,
        })

    manifest = {
        "version": "4.0",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "catalog_version": catalog.get("version"),
        "tier": args.tier if not args.only else f"{previous.get('tier', args.tier)}+only:{args.only}",
        "weights": args.weights or "per-tier defaults",
        "subset": not args.no_subset,
        "subset_unicodes": None if args.no_subset else SUBSET_UNICODES,
        "fonttools": None if tools is None else "available",
        "flat_dir": flat.relative_to(ROOT).as_posix(),
        "burn_in_note": (
            "Pass this flat directory to ffmpeg: --fonts-dir assets/fonts/all. "
            "libass does not recurse, so assets/fonts alone resolves nothing."
        ),
        "families": sorted(families, key=lambda item: (item["tier"], item["group"], item["id"])),
        "warnings": warnings,
    }
    manifest_path.write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    total_bytes = sum(face["bytes"] for family in families for face in family["faces"])
    print(json.dumps({
        "families": len(families),
        "faces": sum(len(family["faces"]) for family in families),
        "flat_dir": manifest["flat_dir"],
        "flat_megabytes": round(total_bytes / 1024 / 1024, 2),
        "warnings": len(warnings),
        "status": "ok" if not warnings else "ok_with_warnings",
    }, ensure_ascii=False, indent=2))
    for warning in warnings[:20]:
        print(f"warning: {warning}", file=sys.stderr)


if __name__ == "__main__":
    main()
