#!/usr/bin/env python3
"""Install every font in a folder (recursively) into ~/Library/Fonts.

Font downloads are messy: foundries ship the same face as .otf, .ttf AND .woff2
side by side, often nested two folders deep. Copying all of it would flood Font
Book with duplicate families and with web formats macOS cannot even use. So:

  * only desktop formats are installed - .otf, .ttf, .ttc, .dfont
  * .woff / .woff2 are skipped; they are for CSS, Font Book rejects them
  * when one face ships as both .otf and .ttf, only the .otf is installed
  * faces already in ~/Library/Fonts or /Library/Fonts are left alone
  * files whose header is not a real font are reported, never copied

    install-fonts.py ~/Downloads/fonts             # preview only
    install-fonts.py ~/Downloads/fonts --install   # actually copy

Re-running is safe: anything already installed is skipped by content hash.
"""
import argparse
import collections
import hashlib
import pathlib
import shutil
import sys

DEST = pathlib.Path.home() / "Library" / "Fonts"
SYSTEM = pathlib.Path("/Library/Fonts")

# Preference order when the same face ships in several desktop formats. OTF
# first: it is what the foundry drew, TTF is usually a conversion of it.
PREFERENCE = [".otf", ".ttf", ".ttc", ".dfont"]
WEB_ONLY = {".woff", ".woff2", ".eot", ".svg"}

# First four bytes of a real sfnt file.
MAGIC = {b"OTTO", b"true", b"ttcf", b"\x00\x01\x00\x00", b"typ1"}


def is_font(path):
    try:
        with path.open("rb") as fh:
            return fh.read(4) in MAGIC
    except OSError:
        return False


def sha(path):
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def family_of(path, root):
    """Top-level folder under root, which is how foundries name a family."""
    rel = path.relative_to(root)
    return rel.parts[0] if len(rel.parts) > 1 else "(loose)"


def collect(root):
    """-> (chosen, web_skipped, bad). chosen is one path per face."""
    by_face, web, bad = collections.defaultdict(dict), 0, []
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.name.startswith("."):
            continue
        ext = path.suffix.lower()
        if ext in WEB_ONLY:
            web += 1
            continue
        if ext not in PREFERENCE:
            continue
        if not is_font(path):
            bad.append(path)
            continue
        # Key on family + face name so two families sharing a face name (say
        # "Regular.otf") do not collide.
        by_face[(family_of(path, root), path.stem)][ext] = path

    chosen = []
    for formats in by_face.values():
        for ext in PREFERENCE:
            if ext in formats:
                chosen.append(formats[ext])
                break
    return sorted(chosen), web, bad


def installed_index():
    """Every font already on this Mac, by filename and by content hash."""
    names, hashes = {}, set()
    for folder in (DEST, SYSTEM):
        if not folder.is_dir():
            continue
        for path in folder.iterdir():
            if path.is_file() and path.suffix.lower() in PREFERENCE:
                names[path.name.lower()] = path
                try:
                    hashes.add(sha(path))
                except OSError:
                    pass
    return names, hashes


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("folder", nargs="?", default=str(pathlib.Path.home() / "Downloads" / "fonts"))
    ap.add_argument("--install", action="store_true", help="copy the files (default: preview)")
    ap.add_argument("--force", action="store_true", help="overwrite a same-named font that differs")
    args = ap.parse_args()

    root = pathlib.Path(args.folder).expanduser().resolve()
    if not root.is_dir():
        print(f"Not a folder: {root}")
        return 1

    chosen, web, bad = collect(root)
    if not chosen:
        print(f"No installable fonts under {root}.")
        print("Only .otf/.ttf/.ttc/.dfont can be installed; .woff2 is web-only.")
        return 1

    names, hashes = installed_index()
    fresh, already, conflict = [], [], []
    for path in chosen:
        if sha(path) in hashes:
            already.append(path)
        elif path.name.lower() in names:
            (fresh if args.force else conflict).append(path)
        else:
            fresh.append(path)

    families = collections.Counter(family_of(p, root) for p in fresh)
    print(f"Source   {root}")
    print(f"Faces    {len(chosen)} installable, from {len(set(family_of(p, root) for p in chosen))} families")
    if web:
        print(f"Skipped  {web} web-only files (.woff2 and friends cannot be installed)")
    if bad:
        print(f"Skipped  {len(bad)} file(s) that are not valid fonts:")
        for p in bad[:5]:
            print(f"           {p.relative_to(root)}")
        if len(bad) > 5:
            print(f"           ... and {len(bad) - 5} more")
    if already:
        print(f"Skipped  {len(already)} already installed")
    if conflict:
        print(f"Skipped  {len(conflict)} same name but different content (use --force):")
        for p in conflict[:5]:
            print(f"           {p.name}")
        if len(conflict) > 5:
            print(f"           ... and {len(conflict) - 5} more")

    if not fresh:
        print("\nNothing new to install.")
        return 0

    print(f"\n{'Installing' if args.install else 'Would install'} {len(fresh)} face(s) into {DEST}:")
    for family, n in families.most_common():
        print(f"  {family:<22} {n}")

    if not args.install:
        print("\nPreview only. Re-run with --install to copy them.")
        return 0

    DEST.mkdir(parents=True, exist_ok=True)
    done, failed = 0, []
    for path in fresh:
        try:
            shutil.copy2(path, DEST / path.name)
            done += 1
        except OSError as exc:
            failed.append((path.name, exc))
    print(f"\nInstalled {done} font(s) into {DEST}.")
    if failed:
        print(f"{len(failed)} failed:")
        for name, exc in failed[:10]:
            print(f"  {name}: {exc}")
    print("Font Book picks these up automatically; restart an app to see them in its menu.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
