#!/usr/bin/env python3
"""fonts.py — HIGH LVL Clipper custom-font registry.

The owner keeps a real collection of downloaded display / hand / mono / serif faces
in ~/Library/Fonts (and a few loose ones in ~/Downloads). This tool scans those
locations, pulls the REAL family name out of each font file (the name libass /
CoreText resolve by — NOT the filename), classifies it, flags demo/trial licences,
and writes `fonts.json`. The dashboard + render tools read that registry so the
owner's own type shows up as presets — never "the regular fonts everybody uses".

Two jobs:
  * CLI  (`python3 tools/fonts.py`):  copy the loose Downloads fonts into
    ~/Library/Fonts (so fontconfig/CoreText can resolve them by family), scan every
    location, and (re)write fonts.json.  Uses fontTools if importable, else a tiny
    stdlib 'name'-table parser, else `fc-scan`.
  * import (`import fonts`):  stdlib-only helpers the server uses at runtime —
    load(), families(), resolve(name).  NO fontTools needed to import.

fonts.json entry: {family, file, style_hint: display|grotesk|mono|hand|serif,
                   commercial: ok|verify, subfamily}
"""
import json
import os
import shutil
import subprocess
from pathlib import Path

HOME = Path.home()
LIBRARY = HOME / "Library" / "Fonts"
DOWNLOADS = HOME / "Downloads"
STUDIO = Path(__file__).resolve().parent.parent
LOCAL_FONTS = STUDIO / "fonts"                 # optional in-repo drop dir (scanned if present)
REGISTRY = STUDIO / "fonts.json"
FONT_EXTS = (".otf", ".ttf", ".ttc", ".TTF", ".OTF", ".TTC")

# The owner's generic families — "the regular fonts everybody uses". Kept off the
# registry on purpose (his standing rule). Everything else in his collection stays.
DENY_FAMILIES = {"roboto", "arial", "helvetica", "helvetica neue", "times new roman"}

# Loose fonts that live in ~/Downloads (not scanned by fontconfig) and must be copied
# into ~/Library/Fonts before libass/CoreText can resolve them by family name.
DOWNLOADS_FONTS = [
    "Alycidon-Condensed.otf", "OptimusPrincepsSemiBold.ttf", "The Last Of Us Rough.ttf",
    "VCR_OSD_MONO_1.001.ttf", "italic-hand.medium.otf",
]


# ────────────────────────────── classification ──────────────────────────────
def classify(family, filename, fixed_pitch=False):
    """display | grotesk | mono | hand | serif — from the family/file name + fixed-pitch flag."""
    s = f"{family} {filename}".lower()
    if fixed_pitch or any(k in s for k in ("mono", "typewriter", "printer", "vcr",
                                           "dos vga", "courier", "pixel", " code")):
        return "mono"
    if any(k in s for k in ("hand", "cursive", "script", "writing", "apple", "erratic",
                            "homemade", "engineer", "delafield", "marker", "brush",
                            "signature", "scrawl")):
        return "hand"
    if any(k in s for k in ("princeps", "optimus", "serif", "didot", "garamond",
                            "baskerville", "playfair", "trajan", "roman", "georgia")):
        return "serif"
    if any(k in s for k in ("grotesk", "haas", "wave", " sans", "gothic", "neue", "grotesque")):
        return "grotesk"
    return "display"


def commercial_flag(family, filename):
    """'verify' for anything that reads as a demo/trial cut, else 'ok'."""
    s = f"{family} {filename}".lower()
    return "verify" if any(k in s for k in ("demo", "trial", "preview", "eval")) else "ok"


def _rep_score(subfamily, filename):
    """Higher = better representative file for a family (prefer upright Regular .otf)."""
    sub = (subfamily or "").lower()
    fn = filename.lower()
    score = 0.0
    if "italic" in sub or "italic" in fn or "oblique" in sub:
        score -= 60
    if sub in ("regular", "book", ""):
        score += 45
    elif "regular" in sub:
        score += 40
    elif "medium" in sub:
        score += 28
    elif "semibold" in sub or "semi bold" in sub:
        score += 16
    elif "book" in sub:
        score += 30
    # de-prioritise the extreme weights / widths as the family's face
    for k in ("thin", "black", "heavy", "condensed", "expanded", "extended", "light", "super"):
        if k in sub:
            score -= 8
    if fn.endswith(".otf"):
        score += 5
    score -= len(fn) * 0.02     # prefer the short/base filename
    return score


# ─────────────────────────────── name extraction ────────────────────────────
def _names_fonttools(path):
    """(family, subfamily, fixed_pitch) via fontTools, or None if unavailable/failed."""
    try:
        from fontTools.ttLib import TTFont
    except Exception:
        return None
    try:
        f = TTFont(str(path), fontNumber=0, lazy=True)
        nm = f["name"]

        def g(*ids):
            for i in ids:
                r = nm.getDebugName(i)
                if r:
                    return r.strip()
            return None
        fam = g(16, 1)
        sub = g(17, 2)
        fixed = bool(f["post"].isFixedPitch) if "post" in f else False
        f.close()
        return (fam, sub, fixed) if fam else None
    except Exception:
        return None


def _names_stdlib(path):
    """Minimal 'name'-table parser (stdlib only) for TTF/OTF. Returns (family, subfamily, False)."""
    import struct
    try:
        data = Path(path).read_bytes()
    except Exception:
        return None
    try:
        if data[:4] == b"ttcf":          # collection — jump to first font's offset table
            off = struct.unpack(">I", data[12:16])[0]
        else:
            off = 0
        num_tables = struct.unpack(">H", data[off + 4:off + 6])[0]
        rec = off + 12
        name_off = None
        for _ in range(num_tables):
            tag = data[rec:rec + 4]
            if tag == b"name":
                name_off = struct.unpack(">I", data[rec + 8:rec + 12])[0]
                break
            rec += 16
        if name_off is None:
            return None
        count = struct.unpack(">H", data[name_off + 2:name_off + 4])[0]
        storage = name_off + struct.unpack(">H", data[name_off + 4:name_off + 6])[0]
        found = {}
        for i in range(count):
            r = name_off + 6 + i * 12
            pid, eid, lid, nid, length, soff = struct.unpack(">HHHHHH", data[r:r + 12])
            raw = data[storage + soff:storage + soff + length]
            try:
                txt = raw.decode("utf-16-be") if (pid == 3 or pid == 0) else raw.decode("latin-1")
            except Exception:
                continue
            txt = txt.strip()
            if nid in (16, 1, 17, 2) and txt:
                found.setdefault(nid, txt)
        fam = found.get(16) or found.get(1)
        sub = found.get(17) or found.get(2)
        return (fam, sub, False) if fam else None
    except Exception:
        return None


def _names_fcscan(path):
    """Last-resort family via `fc-scan`."""
    try:
        out = subprocess.run(["fc-scan", "--format", "%{family}\n%{style}\n", str(path)],
                             capture_output=True, text=True, timeout=15).stdout.splitlines()
        fam = out[0].split(",")[0].strip() if out else None
        sub = out[1].split(",")[0].strip() if len(out) > 1 else None
        return (fam, sub, False) if fam else None
    except Exception:
        return None


def font_names(path):
    """Best-effort (family, subfamily, fixed_pitch). fontTools → stdlib → fc-scan."""
    for fn in (_names_fonttools, _names_stdlib, _names_fcscan):
        r = fn(path)
        if r and r[0]:
            return r
    return None


# ─────────────────────────────────── scan ───────────────────────────────────
def _iter_font_files():
    for d in (LIBRARY, DOWNLOADS, LOCAL_FONTS):
        if not d.exists():
            continue
        # ~/Downloads: only the loose top-level font files (don't crawl the whole folder)
        it = d.glob("*") if d == DOWNLOADS else d.rglob("*")
        for p in sorted(it):
            if p.is_file() and p.suffix in FONT_EXTS:
                yield p


def copy_downloads():
    """Copy the loose Downloads fonts into ~/Library/Fonts (copy, never move). Returns list copied."""
    LIBRARY.mkdir(parents=True, exist_ok=True)
    copied = []
    for name in DOWNLOADS_FONTS:
        src = DOWNLOADS / name
        if not src.exists():
            continue
        dst = LIBRARY / name
        try:
            if dst.exists() and dst.stat().st_size == src.stat().st_size:
                copied.append(name)           # already present (idempotent)
                continue
            shutil.copy2(src, dst)
            copied.append(name)
        except Exception:
            pass
    return copied


def scan():
    """Scan all locations → deduped list of family entries (one representative file each)."""
    fams = {}   # family -> {best_score, entry, files:[...]}
    for p in _iter_font_files():
        nm = font_names(p)
        if not nm:
            continue
        family, sub, fixed = nm
        if not family or family.lower() in DENY_FAMILIES:
            continue
        score = _rep_score(sub, p.name)
        cur = fams.get(family)
        entry = {
            "family": family,
            "file": str(p),
            "style_hint": classify(family, p.name, fixed),
            "commercial": commercial_flag(family, p.name),
            "subfamily": sub or "Regular",
        }
        if cur is None or score > cur["_score"]:
            # keep the winning representative but preserve a demo/verify flag if ANY cut is a demo
            if cur and cur["entry"]["commercial"] == "verify":
                entry["commercial"] = "verify"
            fams[family] = {"_score": score, "entry": entry}
        elif entry["commercial"] == "verify":
            cur["entry"]["commercial"] = "verify"
    out = [v["entry"] for v in fams.values()]
    out.sort(key=lambda e: (e["style_hint"], e["family"].lower()))
    return out


def build(write=True):
    copied = copy_downloads()
    fonts = scan()
    reg = {
        "generated_from": [str(LIBRARY), str(DOWNLOADS) + " (loose)", str(LOCAL_FONTS)],
        "copied_from_downloads": copied,
        "note": "Real family names (libass/CoreText resolve by family, not filename). "
                "commercial:'verify' = demo/trial cut, confirm a licence before commercial use.",
        "count": len(fonts),
        "fonts": fonts,
    }
    if write:
        REGISTRY.write_text(json.dumps(reg, indent=2, ensure_ascii=False))
    return reg


# ───────────────────────── runtime helpers (stdlib only) ────────────────────
_CACHE = None


def load():
    """Read fonts.json (cached). Returns the registry dict, or a stub if absent."""
    global _CACHE
    if _CACHE is None:
        try:
            _CACHE = json.loads(REGISTRY.read_text())
        except Exception:
            _CACHE = {"fonts": [], "count": 0}
    return _CACHE


def families():
    """List of {family, file, style_hint, commercial} from the registry."""
    return load().get("fonts", [])


def resolve(name):
    """Registry entry for a family name (exact, else case-insensitive), or None."""
    fonts = families()
    for e in fonts:
        if e["family"] == name:
            return e
    low = (name or "").lower()
    for e in fonts:
        if e["family"].lower() == low:
            return e
    return None


def file_for(name):
    """Absolute font-file path for a family (registry file, else fc-match), or None."""
    e = resolve(name)
    if e and Path(e["file"]).exists():
        return e["file"]
    try:
        r = subprocess.run(["fc-match", "-f", "%{file}", name],
                           capture_output=True, text=True, timeout=10)
        p = (r.stdout or "").strip()
        return p or None
    except Exception:
        return None


if __name__ == "__main__":
    reg = build()
    print(f"fonts.json → {REGISTRY}")
    print(f"copied from Downloads: {reg['copied_from_downloads']}")
    print(f"registered {reg['count']} families:")
    for e in reg["fonts"]:
        flag = "  ⚠ verify-licence" if e["commercial"] == "verify" else ""
        print(f"  [{e['style_hint']:8s}] {e['family']}{flag}")
