#!/usr/bin/env python3
"""Audit the installed font pack the way the renderer will actually use it.

Three independent checks, because each one catches a different silent failure:

  glyphs   — Cyrillic, Latin, digits and typographic punctuation are present, so a
             Russian caption never renders as boxes;
  license  — an OFL/LICENSE file sits next to every downloaded source;
  libass   — the decisive one. It renders a probe frame through the same
             `ass` filter the burn-in step uses and reads libass's own
             `fontselect:` decision back out of the log. If libass silently
             substituted a system font, this fails here instead of in the export.

The libass check is what proves `--fonts-dir` is wired correctly: libass does not
recurse into sub-directories, so auditing `assets/fonts` (sources) reports zero
resolvable families while `assets/fonts/all` (the flat pack) reports all of them.
"""
from __future__ import annotations

import argparse
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from font_tables import (  # noqa: E402
    CYRILLIC_BASE,
    DIGITS,
    LATIN_BASE,
    PUNCTUATION_OPTIONAL,
    PUNCTUATION_REQUIRED,
    FontError,
    FontFile,
    find_family_file,
    open_font,
)

ROOT = Path(__file__).resolve().parents[1]
DEFAULT_FLAT = ROOT / "assets" / "fonts" / "all"
PROBE_TEXT = "Съешь ещё этих мягких булок 42%"

ASS_HEADER = """[Script Info]
ScriptType: v4.00+
PlayResX: 720
PlayResY: 320
ScaledBorderAndShadow: yes

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
"""

ASS_EVENTS = """
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""


def ffmpeg_path(path: Path) -> str:
    return path.resolve().as_posix().replace(":", r"\:").replace("'", r"\'")


def libass_probe(fonts_dir: Path, requests: list[tuple[str, int]]) -> dict[tuple[str, int], str]:
    """Ask libass to resolve every (family, bold) pair in a single render.

    All requests go into one script as separate styles. libass logs one
    `fontselect:` line per distinct (family, weight, italic) triple, so a single
    frame resolves the whole pack — a probe per family would mean hundreds of
    ffmpeg launches.
    """
    if shutil.which("ffmpeg") is None or not requests:
        return {}
    styles = []
    events = []
    for index, (family, bold) in enumerate(requests):
        name = f"P{index}"
        styles.append(
            f"Style: {name},{family},64,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,"
            f"{bold},0,0,0,100,100,0,0,1,2,0,5,10,10,10,1"
        )
        events.append(
            f"Dialogue: {index % 100},0:00:00.00,0:00:02.00,{name},,0,0,0,,"
            f"{{\\pos(360,160)}}{PROBE_TEXT}"
        )
    script_text = ASS_HEADER + "\n".join(styles) + ASS_EVENTS + "\n".join(events) + "\n"
    with tempfile.TemporaryDirectory(prefix="font_audit_") as workdir:
        script = Path(workdir) / "probe.ass"
        script.write_text(script_text, encoding="utf-8")
        completed = subprocess.run(
            [
                "ffmpeg", "-y", "-v", "verbose",
                "-f", "lavfi", "-i", "color=c=black:s=720x320:d=0.04",
                "-vf", f"ass=filename='{ffmpeg_path(script)}':fontsdir='{ffmpeg_path(fonts_dir)}'",
                "-frames:v", "1", "-f", "null", "-",
            ],
            capture_output=True, text=True, errors="replace",
        )
    decisions: dict[tuple[str, int], str] = {}
    for asked, chosen in re.findall(r"fontselect: \(([^)]*)\) -> ([^,]+)", completed.stderr):
        parts = [part.strip() for part in asked.split(",")]
        if len(parts) < 2:
            continue
        family = ",".join(parts[:-2]).strip() if len(parts) > 3 else parts[0]
        try:
            weight = int(parts[-2])
        except ValueError:
            continue
        decisions[(family, -1 if weight >= 700 else 0)] = chosen.strip()
    return {key: decisions.get(key, "<no decision>") for key in requests}


MEASURE_TEXT = "Съешь подняли выручку 240"


def measure_check(fonts_dir: Path, faces: list[dict], sample: int = 12) -> list[dict]:
    """Compare the layout engine's predicted width against rendered ink width.

    Line breaking is only as good as this model. One render per family at a large
    size makes the systematic error visible; anything past a few percent means the
    engine will either overflow the frame or waste it.
    """
    if shutil.which("ffmpeg") is None:
        return []
    step = max(1, len(faces) // sample)
    chosen = [face for face in faces[::step] if face.get("readable")][:sample]
    results = []
    size = 200
    # PlayRes must equal the probe frame, otherwise libass rescales the whole
    # script and the measurement describes the wrong size.
    probe_width, probe_height = 3200, 700
    header = (
        "[Script Info]\nScriptType: v4.00+\n"
        f"PlayResX: {probe_width}\nPlayResY: {probe_height}\n"
        "ScaledBorderAndShadow: yes\nWrapStyle: 2\n\n[V4+ Styles]\n"
        "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
        "BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
        "BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
    )
    with tempfile.TemporaryDirectory(prefix="font_measure_") as workdir:
        script = Path(workdir) / "measure.ass"
        frame = Path(workdir) / "measure.png"
        for face in chosen:
            family = face.get("ass_fontname")
            if not family:
                continue
            # Probe the very face being measured. Regular and Bold share one
            # name ID 1, so the Bold flag has to match the weight the prediction
            # came from or the comparison is between two different faces.
            weight = face.get("weight_class") or 400
            bold = -1 if weight >= 600 else 0
            path = find_family_file(str(fonts_dir), family, weight)
            if not path:
                continue
            script.write_text(
                header
                + f"Style: M,{family},{size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,"
                  f"{bold},0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n"
                  "\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, "
                  "MarginV, Effect, Text\n"
                + f"Dialogue: 0,0:00:00.00,0:00:04.00,M,,0,0,0,,"
                  f"{{\\an5\\pos({probe_width // 2},{probe_height // 2})}}{MEASURE_TEXT}\n",
                encoding="utf-8",
            )
            completed = subprocess.run(
                ["ffmpeg", "-y", "-v", "error", "-f", "lavfi",
                 "-i", f"color=c=black:s={probe_width}x{probe_height}:d=0.04",
                 "-vf", f"ass=filename='{ffmpeg_path(script)}':fontsdir='{ffmpeg_path(fonts_dir)}'",
                 "-frames:v", "1", str(frame)],
                capture_output=True, text=True, errors="replace",
            )
            if completed.returncode or not frame.exists():
                continue
            ink = ink_extent(frame, probe_width, probe_height)
            if ink is None:
                continue
            predicted = open_font(path).measure(MEASURE_TEXT, size)
            results.append({
                "family": family,
                "predicted_px": round(predicted, 1),
                "rendered_ink_px": ink,
                # Ink is narrower than the advance by the first and last side
                # bearings, so a small positive error is expected and correct.
                "error_percent": round(100 * (predicted - ink) / ink, 1),
                "libass_scale": round(open_font(path).libass_scale, 4),
            })
    return results


def ink_extent(png: Path, width: int, height: int) -> int | None:
    """Horizontal extent of lit pixels, measured on the decoded frame itself.

    `cropdetect` would be the obvious tool but it only reports at info log level
    and needs a run of frames, so a single probe frame silently yields nothing.
    Decoding to raw grey and scanning columns is exact and has no such condition.
    """
    try:
        import numpy
    except ImportError:
        return None
    completed = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", str(png), "-f", "rawvideo",
         "-pix_fmt", "gray", "-frames:v", "1", "-"],
        capture_output=True,
    )
    if completed.returncode or len(completed.stdout) < width * height:
        return None
    frame = numpy.frombuffer(completed.stdout[: width * height], dtype=numpy.uint8)
    frame = frame.reshape(height, width)
    columns = numpy.where(frame.max(axis=0) > 50)[0]
    if columns.size == 0:
        return None
    return int(columns[-1] - columns[0] + 1)


def audit_file(path: Path) -> dict:
    try:
        font = FontFile(path)
    except (FontError, OSError) as exc:
        return {"path": path.name, "readable": False, "error": str(exc)}
    missing = {
        "cyrillic": font.covers(CYRILLIC_BASE),
        "latin": font.covers(LATIN_BASE),
        "digits": font.covers(DIGITS),
        "punctuation": font.covers(PUNCTUATION_REQUIRED),
    }
    optional_gap = font.covers(PUNCTUATION_OPTIONAL)
    metrics = font.vertical_metrics
    return {
        "path": path.name,
        "readable": True,
        "ass_fontname": font.names.get(1) or font.family,
        "postscript_name": font.names.get(6),
        "typographic_family": font.names.get(16),
        "style": font.subfamily,
        "weight_class": metrics.get("weight_class"),
        "variable": font.variable,
        "glyphs": len(font.cmap),
        "missing": {key: value for key, value in missing.items() if value},
        "missing_optional_punctuation": optional_gap,
        "glyphs_ok": not any(missing.values()),
        "burnin_safe": not font.variable and (metrics.get("weight_class") or 400) >= 400,
        "probe_width_px_at_64": round(font.measure(PROBE_TEXT, 64), 1),
    }


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("font_dir", nargs="?", default=str(DEFAULT_FLAT),
                        help="Flat directory handed to ffmpeg (default: assets/fonts/all)")
    parser.add_argument("--report", help="Write the full JSON report here")
    parser.add_argument("--no-libass", action="store_true",
                        help="Skip the render probe (glyph and license checks only)")
    parser.add_argument("--measure-check", action="store_true",
                        help="Compare predicted text width against rendered ink width")
    parser.add_argument("--measure-samples", type=int, default=12)
    parser.add_argument("--quiet", action="store_true", help="Print the summary only")
    args = parser.parse_args()

    root = Path(args.font_dir)
    if not root.exists():
        raise SystemExit(f"Font directory does not exist: {root}\nRun: python scripts/install_fonts.py")

    faces = sorted(path for path in root.glob("*.ttf"))
    faces += sorted(path for path in root.glob("*.otf"))
    nested = [path for path in root.rglob("*.ttf") if path.parent != root]
    files = [audit_file(path) for path in faces]

    manifest_path = ROOT / "assets" / "fonts" / "font_manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {}
    licenses: list[dict] = []
    for family in manifest.get("families", []):
        license_path = ROOT / family.get("license_file", "")
        licenses.append({
            "family": family["family"],
            "license": family.get("license"),
            "file": family.get("license_file"),
            "present": license_path.is_file(),
        })

    probes: list[dict] = []
    if not args.no_libass:
        wanted: list[tuple[str, int]] = []
        for family in manifest.get("families", []):
            names = family.get("ass_names", {})
            if names.get("regular"):
                wanted.append((names["regular"], 0))
            if names.get("bold"):
                wanted.append((names["bold"], -1))
            for key in ("semibold", "extrabold", "black"):
                if names.get(key):
                    wanted.append((names[key], 0))
        wanted = list(dict.fromkeys(wanted))
        if not wanted:
            wanted = [(item["ass_fontname"], 0) for item in files if item.get("ass_fontname")]
            wanted = list(dict.fromkeys(wanted))
        # libass reports the PostScript name of whatever it picked, so compare
        # against the PostScript names present in the pack rather than guessing.
        available = {
            (item.get("postscript_name") or item.get("ass_fontname") or "")
            .casefold().replace(" ", "").replace("-", "")
            for item in files
        }
        available.discard("")
        resolution = libass_probe(root, wanted)
        for (family, bold), chosen in resolution.items():
            normalized = chosen.casefold().replace(" ", "").replace("-", "")
            probes.append({
                "request": family,
                "bold": bold,
                "resolved": chosen,
                "from_pack": normalized in available,
            })

    measurements = measure_check(root, files, args.measure_samples) if args.measure_check else []
    measurement_failures = [
        item for item in measurements if not (-2.0 <= item["error_percent"] <= 4.0)
    ]

    glyph_failures = [item for item in files if not item.get("glyphs_ok", False)]
    unreadable = [item for item in files if not item.get("readable")]
    license_failures = [item for item in licenses if not item["present"]]
    substitutions = [item for item in probes if not item["from_pack"]]
    variable_faces = [item for item in files if item.get("variable")]

    report = {
        "font_dir": str(root),
        "faces": len(files),
        "families_in_manifest": len(manifest.get("families", [])),
        "checks": {
            "glyphs": {"ok": not glyph_failures and not unreadable,
                       "failures": [item["path"] for item in glyph_failures + unreadable]},
            "licenses": {"ok": not license_failures,
                         "failures": [item["family"] for item in license_failures]},
            "libass": {
                "ran": bool(probes),
                "ok": bool(probes) and not substitutions,
                "requests": len(probes),
                "substituted": [
                    f"{item['request']} (bold={item['bold']}) -> {item['resolved']}"
                    for item in substitutions
                ],
            },
            "variable_in_flat_pack": {
                "ok": not variable_faces,
                "faces": [item["path"] for item in variable_faces],
                "why": "Variable faces in the burn-in pack resolve to their default "
                       "instance, which is Thin for most Google families.",
            },
            "measurement": {
                "ran": bool(measurements),
                "ok": bool(measurements) and not measurement_failures,
                "tolerance": "predicted advance may exceed rendered ink by 0-4% (side bearings)",
                "samples": measurements,
                "failures": [
                    f"{item['family']}: predicted {item['predicted_px']}px vs ink "
                    f"{item['rendered_ink_px']}px ({item['error_percent']:+}%)"
                    for item in measurement_failures
                ],
            },
        },
        "nested_files_ignored_by_libass": [str(path.relative_to(root)) for path in nested[:20]],
        "files": files,
        "licenses": licenses,
        "libass_probes": probes,
    }
    ok = (
        report["checks"]["glyphs"]["ok"]
        and report["checks"]["licenses"]["ok"]
        and (args.no_libass or report["checks"]["libass"]["ok"])
        and report["checks"]["variable_in_flat_pack"]["ok"]
        and (not measurements or report["checks"]["measurement"]["ok"])
    )
    report["ok"] = ok

    if args.report:
        destination = Path(args.report)
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    summary = {
        "ok": ok,
        "font_dir": str(root),
        "faces": len(files),
        "glyphs_ok": report["checks"]["glyphs"]["ok"],
        "licenses_ok": report["checks"]["licenses"]["ok"],
        "libass_ok": report["checks"]["libass"]["ok"] if probes else None,
        "libass_requests": len(probes),
        "substituted": report["checks"]["libass"]["substituted"][:10],
        "glyph_failures": report["checks"]["glyphs"]["failures"][:10],
        "measurement_ok": report["checks"]["measurement"]["ok"] if measurements else None,
        "measurement_errors": [
            f"{item['family']}: {item['error_percent']:+}%" for item in measurements
        ],
    }
    print(json.dumps(summary if args.quiet else report, ensure_ascii=False, indent=2))
    raise SystemExit(0 if ok else 2)


if __name__ == "__main__":
    main()
