#!/usr/bin/env python3
"""Build the ingredient-glyph font from OpenMoji-black.

Subsets OpenMoji-black-glyf.ttf down to the drawings `IngredientGlyph` names,
remaps them onto private-use codepoints, renames the family, and writes both
the .ttf and the Swift scalar map that reads it.

Why the private-use remap: rendering an emoji character in a `Font.custom` is
not reliable on iOS. Core Text treats the Emoji_Presentation property as a
font *fallback* signal, so U+1F336 U+FE0F or a ZWJ sequence can be re-run
through Apple Color Emoji even when the custom font maps it. A private-use
scalar carries no emoji property at all, so there is nothing for the fallback
to fire on. Plane 15 is used rather than the BMP private-use area because
Apple Color Emoji itself claims U+E000-U+E4FF for legacy SoftBank emoji.

Deterministic: same inputs produce a byte-identical .ttf. `--check` rebuilds
into a temp dir and fails if the committed artefacts differ, so the font can
never drift from the table below.

Usage:
    python3 scripts/build_ingredient_glyphs.py
    python3 scripts/build_ingredient_glyphs.py --check
"""

import argparse
import hashlib
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from dataclasses import dataclass

# fontTools computes the glyph closure through sets, so the order glyphs land
# in the output follows Python's per-process hash seed and the .ttf differs
# byte for byte between runs. Pin the seed before anything imports, by
# relaunching once — it cannot be set from inside a running interpreter.
#
# The relaunch argv is built from a fixed whitelist, not `sys.argv` verbatim:
# security scanners reasonably flag spawning a process with argv traced back
# to raw CLI input, since that pattern is how injection bugs usually look.
# This script defines exactly one flag, so there is nothing to inject even
# in principle, but passing only the flags this script itself recognizes —
# rather than forwarding whatever arrived — is the actual fix, not a waiver.
if os.environ.get("PYTHONHASHSEED") != "0":
    relaunch_argv = [sys.executable, __file__]
    if "--check" in sys.argv:
        relaunch_argv.append("--check")
    result = subprocess.run(
        relaunch_argv, env={**os.environ, "PYTHONHASHSEED": "0"}, check=False
    )
    sys.exit(result.returncode)

from fontTools.subset import Options, Subsetter
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._c_m_a_p import CmapSubtable

ROOT = pathlib.Path(__file__).resolve().parent.parent
KIT = ROOT / "ios" / "Packages" / "MealPlannerKit" / "Sources" / "MealPlannerKit"
FONT_OUT = KIT / "Resources" / "Fonts" / "MealPlannerGlyphs.ttf"
LICENSE_OUT = KIT / "Resources" / "Fonts" / "LICENSE-OpenMoji.txt"
SWIFT_OUT = KIT / "Shopping" / "Generated" / "IngredientGlyphScalars.swift"

OPENMOJI_TAG = "15.1.0"
FONT_URL = (
    "https://raw.githubusercontent.com/hfg-gmuend/openmoji/"
    f"{OPENMOJI_TAG}/font/OpenMoji-black-glyf/OpenMoji-black-glyf.ttf"
)
# Pinned so a silently-rebuilt upstream release can never change what ships.
FONT_SHA256 = "1c64be3236fd7a400fb701af4c47210b736b69057ab6fb71182dd6892bde13f7"
CACHE = ROOT / "scripts" / ".cache" / f"OpenMoji-black-glyf-{OPENMOJI_TAG}.ttf"

FAMILY_NAME = "MealPlannerGlyphs"
POSTSCRIPT_NAME = "MealPlannerGlyphs-Regular"

# An arbitrary fixed date. Any constant will do; what matters is that it is
# not "now", which would make every rebuild produce a different file.
FIXED_EPOCH = 3_800_000_000


@dataclass(frozen=True)
class Glyph:
    """One drawing: a Swift case, where it comes from, where it lands."""

    case: str  # Swift enum case, lowerCamelCase
    source: int  # single Unicode scalar in OpenMoji-black
    pua: int  # plane-15 private-use scalar it is remapped to
    note: str  # what the drawing actually shows, for reviewers

    @property
    def group(self) -> str:
        return self.note.split(" - ")[0] if " - " in self.note else ""


# Glyphs are named for WHAT THEY SHOW, never for what they mean. `tin` is a
# picture of a can, not the concept "canned goods" -- which is what lets one
# drawing serve fifty ingredients without being wrong about any of them.
#
# `pua` values are append-only. Adding a glyph takes the next free number;
# never renumber, so a diff of the generated Swift file shows only new lines.
GLYPHS = [
    # -- produce ------------------------------------------------------------
    Glyph("carrot", 0x1F955, 0xF0001, "carrot"),
    Glyph("onion", 0x1F9C5, 0xF0002, "onion"),
    Glyph("garlicBulb", 0x1F9C4, 0xF0003, "garlic bulb"),
    Glyph("tomato", 0x1F345, 0xF0004, "tomato"),
    Glyph("potato", 0x1F954, 0xF0005, "potato"),
    Glyph("sweetPotato", 0x1F360, 0xF0006, "roasted sweet potato"),
    Glyph("bellPepper", 0x1FAD1, 0xF0007, "bell pepper"),
    Glyph(
        "chilli",
        0x1F336,
        0xF0008,
        "chilli pepper - also chilli flakes, cayenne, paprika",
    ),
    Glyph("eggplant", 0x1F346, 0xF0009, "eggplant"),
    Glyph("cucumber", 0x1F952, 0xF000A, "cucumber - also courgette, gherkin"),
    Glyph("broccoli", 0x1F966, 0xF000B, "broccoli - also cauliflower, sprouts"),
    Glyph(
        "leafyGreen", 0x1F96C, 0xF000C, "leafy green - lettuce, spinach, cabbage, kale"
    ),
    Glyph("mushroom", 0x1F344, 0xF000D, "mushroom"),
    Glyph("corn", 0x1F33D, 0xF000E, "ear of corn"),
    Glyph("pea", 0x1FADB, 0xF000F, "pea pod - also green beans, edamame"),
    Glyph("avocado", 0x1F951, 0xF0010, "avocado"),
    Glyph("olive", 0x1FAD2, 0xF0011, "olives"),
    Glyph("pumpkin", 0x1F383, 0xF0012, "pumpkin - also squash"),
    Glyph("gingerRoot", 0x1FADA, 0xF0013, "ginger root - also turmeric, galangal"),
    # -- fruit --------------------------------------------------------------
    Glyph("lemon", 0x1F34B, 0xF0014, "lemon - also lime, citrus zest and juice"),
    Glyph("apple", 0x1F34E, 0xF0015, "apple"),
    Glyph("banana", 0x1F34C, 0xF0016, "banana"),
    Glyph("berry", 0x1F353, 0xF0017, "strawberry - stands for berries generally"),
    Glyph("grape", 0x1F347, 0xF0018, "grapes - also raisins, sultanas"),
    Glyph("melon", 0x1F349, 0xF0019, "melon"),
    Glyph("stoneFruit", 0x1F351, 0xF001A, "peach - also nectarine, apricot, plum"),
    Glyph("tropicalFruit", 0x1F34D, 0xF001B, "pineapple - also mango, papaya"),
    Glyph("coconut", 0x1F965, 0xF001C, "coconut"),
    # -- meat ---------------------------------------------------------------
    Glyph("poultry", 0x1F357, 0xF001D, "poultry leg - chicken, turkey, duck"),
    Glyph("redMeat", 0x1F969, 0xF001E, "cut of raw meat - beef, lamb, pork, mince"),
    Glyph(
        "boneMeat", 0x1F356, 0xF001F, "meat on the bone - ribs, chops, roasting joints"
    ),
    Glyph("bacon", 0x1F953, 0xF0020, "bacon rashers - also pancetta, prosciutto"),
    Glyph("sausage", 0x1F32D, 0xF0021, "sausage - also hot dogs, chorizo"),
    # -- seafood ------------------------------------------------------------
    Glyph("fish", 0x1F41F, 0xF0022, "fish - salmon, cod, tuna, fillets"),
    Glyph("shrimp", 0x1F990, 0xF0023, "shrimp - also prawns"),
    Glyph("shellfish", 0x1F9AA, 0xF0024, "oyster - also mussels, clams, scallops"),
    Glyph("crab", 0x1F980, 0xF0025, "crab - also lobster"),
    # -- dairy & eggs -------------------------------------------------------
    Glyph("egg", 0x1F95A, 0xF0026, "egg"),
    Glyph(
        "milkGlass",
        0x1F95B,
        0xF0027,
        "glass of milk - milk, cream, yoghurt, buttermilk",
    ),
    Glyph("butter", 0x1F9C8, 0xF0028, "butter - also ghee, margarine"),
    Glyph("cheese", 0x1F9C0, 0xF0029, "cheese wedge"),
    # -- bakery & grains ----------------------------------------------------
    Glyph("bread", 0x1F35E, 0xF002A, "loaf of bread"),
    Glyph("baguette", 0x1F956, 0xF002B, "baguette - also ciabatta, rolls, buns"),
    Glyph("flatbread", 0x1FAD3, 0xF002C, "flatbread - tortilla, pita, naan"),
    Glyph("pastry", 0x1F950, 0xF002D, "croissant - pastry, puff pastry, filo"),
    Glyph("riceBowl", 0x1F35A, 0xF002E, "bowl of rice"),
    Glyph("pasta", 0x1F35D, 0xF002F, "spaghetti - all dried pasta"),
    Glyph("noodle", 0x1F35C, 0xF0030, "bowl of noodles - ramen, udon, rice noodles"),
    Glyph(
        "grainSack",
        0x1F33E,
        0xF0031,
        "sheaf of grain - flour, breadcrumbs, couscous, polenta",
    ),
    Glyph("oats", 0x1F963, 0xF0032, "bowl - oats, granola, cereal"),
    # -- pantry containers: the family-rule workhorses -----------------------
    Glyph(
        "bottle", 0x1F9F4, 0xF0033, "squeeze bottle - sauces, oils, vinegars, syrups"
    ),
    Glyph("tin", 0x1F96B, 0xF0034, "can of food - anything canned or tinned"),
    Glyph("jar", 0x1FAD9, 0xF0035, "jar - preserves, pastes, pickles, jarred goods"),
    Glyph(
        "spiceJar",
        0x1F9C2,
        0xF0036,
        "shaker - salt, ground spices, anything '* powder'",
    ),
    Glyph("pot", 0x1F372, 0xF0037, "pot of food - stocks, broths, bouillon"),
    Glyph("soupBowl", 0x1F35B, 0xF0038, "bowl of soup - curry pastes, prepared sauces"),
    Glyph("herbSprig", 0x1F33F, 0xF0039, "herb sprig - fresh and dried herbs"),
    # -- baking & sweet -----------------------------------------------------
    Glyph("sugarCube", 0x1F36C, 0xF003A, "sweet - sugars, sweeteners"),
    Glyph("honeyPot", 0x1F36F, 0xF003B, "honey pot - honey, treacle, golden syrup"),
    Glyph("chocolate", 0x1F36B, 0xF003C, "chocolate bar - also cocoa"),
    # -- legumes, nuts & seeds ----------------------------------------------
    Glyph("bean", 0x1FAD8, 0xF003D, "beans - also lentils, pulses"),
    Glyph("peanut", 0x1F95C, 0xF003E, "nuts - all nuts and nut butters"),
    Glyph("seed", 0x1F330, 0xF003F, "seeds"),
    # -- drinks -------------------------------------------------------------
    Glyph("wineGlass", 0x1F377, 0xF0040, "wine glass - wine, sherry, cooking wine"),
    Glyph("beerMug", 0x1F37A, 0xF0041, "beer - also cider, ale"),
    Glyph("coffeeCup", 0x2615, 0xF0042, "hot cup - coffee, espresso"),
    Glyph("teaCup", 0x1F375, 0xF0043, "teacup - tea, matcha"),
    Glyph("juiceGlass", 0x1F9C3, 0xF0044, "juice box - juices, cordials"),
    Glyph("waterDrop", 0x1F4A7, 0xF0045, "droplet - water, extracts, essences"),
    # -- aisle fallbacks ----------------------------------------------------
    Glyph("snowflake", 0x2744, 0xF0046, "snowflake - the frozen aisle"),
    Glyph("basket", 0x1F9FA, 0xF0047, "basket - the last-resort glyph"),
]

LICENSE_TEXT = f"""\
Ingredient glyph artwork
========================

MealPlannerGlyphs.ttf contains artwork from the OpenMoji project.

    OpenMoji - the open-source emoji and icon project
    https://openmoji.org
    Licence: CC BY-SA 4.0
    https://creativecommons.org/licenses/by-sa/4.0/

Modifications made for Servi, by scripts/build_ingredient_glyphs.py:

  * Subset from OpenMoji-black-glyf.ttf ({OPENMOJI_TAG}) to the {len(GLYPHS)}
    drawings the app uses.
  * Codepoints remapped to a supplementary private-use range (U+F0001 and up)
    so iOS cannot substitute its own colour emoji font for these glyphs.
  * Layout tables (GSUB/GPOS) dropped and advance widths equalised.
  * Font family renamed to "{FAMILY_NAME}" to distinguish this derivative
    from upstream OpenMoji.

The derived font file is itself licensed CC BY-SA 4.0. Share-alike applies to
that file, not to the application that displays it.
"""


def die(message: str) -> None:
    sys.exit(f"error: {message}")


def fetch_source() -> pathlib.Path:
    """Return the cached upstream font, downloading it if needed."""
    if CACHE.exists() and sha256(CACHE.read_bytes()) == FONT_SHA256:
        return CACHE

    CACHE.parent.mkdir(parents=True, exist_ok=True)
    print(f"downloading {FONT_URL}")
    with urllib.request.urlopen(FONT_URL) as response:  # noqa: S310 - pinned https URL
        payload = response.read()

    digest = sha256(payload)
    if digest != FONT_SHA256:
        die(
            "upstream font does not match the pinned hash.\n"
            f"  expected {FONT_SHA256}\n"
            f"  got      {digest}\n"
            "If upstream legitimately changed, review the new file and update "
            "FONT_SHA256."
        )
    CACHE.write_bytes(payload)
    return CACHE


def sha256(payload: bytes) -> str:
    return hashlib.sha256(payload).hexdigest()


def validate_table(cmap: dict) -> None:
    """Fail loudly on a table that could produce a silently broken font."""
    cases = [g.case for g in GLYPHS]
    if len(set(cases)) != len(cases):
        dupes = sorted({c for c in cases if cases.count(c) > 1})
        die(f"duplicate case names: {', '.join(dupes)}")

    puas = [g.pua for g in GLYPHS]
    if len(set(puas)) != len(puas):
        dupes = sorted({f"U+{p:05X}" for p in puas if puas.count(p) > 1})
        die(f"duplicate private-use codepoints: {', '.join(dupes)}")

    # Plane 15 only. U+E000-U+E4FF in particular is claimed by Apple Color
    # Emoji, and a collision there is exactly the substitution this remap
    # exists to prevent.
    out_of_range = [g for g in GLYPHS if not 0xF0000 <= g.pua <= 0xFFFFD]
    if out_of_range:
        die(
            "private-use codepoints outside plane 15: "
            + ", ".join(g.case for g in out_of_range)
        )

    # A source with no cmap entry would subset away to nothing and render as
    # a blank column on device -- caught here instead.
    missing = [g for g in GLYPHS if g.source not in cmap]
    if missing:
        die(
            "OpenMoji-black has no glyph for:\n"
            + "\n".join(f"  U+{g.source:04X} ({g.case})" for g in missing)
        )


def build_font(source: pathlib.Path) -> bytes:
    # recalcTimestamp=False or `save()` stamps head.modified with the wall
    # clock, and every rebuild differs from the last.
    font = TTFont(source, recalcTimestamp=False)
    cmap = dict(font.getBestCmap())
    validate_table(cmap)

    options = Options()
    # Drop GSUB/GPOS entirely. Nothing here needs shaping, and with no
    # substitution table there is no way for an emoji sequence to be
    # reassembled out of these glyphs.
    options.layout_features = []
    options.name_IDs = ["*"]  # keep the name table; the licence lives in it
    options.name_legacy = True
    options.name_languages = ["*"]
    options.notdef_outline = False  # a hollow box is a bug report, not a fallback
    options.recalc_bounds = True
    options.recalc_timestamp = False  # determinism
    options.hinting = False
    options.glyph_names = False
    options.drop_tables += ["DSIG", "GSUB", "GPOS"]

    subsetter = Subsetter(options=options)
    subsetter.populate(unicodes=[g.source for g in GLYPHS] + [0x20])
    subsetter.subset(font)

    remap_to_private_use(font, cmap)
    equalise_advances(font, cmap)
    rename(font)

    # Pin every timestamp so two runs of this script agree byte for byte.
    font["head"].created = FIXED_EPOCH
    font["head"].modified = FIXED_EPOCH
    font["head"].fontRevision = 1.0

    buffer = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False)
    font.save(buffer.name)
    payload = pathlib.Path(buffer.name).read_bytes()
    pathlib.Path(buffer.name).unlink()
    return payload


def remap_to_private_use(font: TTFont, source_cmap: dict) -> None:
    """Replace the cmap so the glyphs answer only to private-use scalars."""
    mapping = {g.pua: source_cmap[g.source] for g in GLYPHS}

    # Format 12 for the plane-15 scalars; format 4 carrying only the space,
    # because some tooling still expects a BMP subtable to exist.
    fmt12 = CmapSubtable.newSubtable(12)
    fmt12.platformID, fmt12.platEncID, fmt12.format = 3, 10, 12
    fmt12.reserved, fmt12.length, fmt12.language, fmt12.nGroups = 0, 0, 0, 0
    fmt12.cmap = mapping

    fmt4 = CmapSubtable.newSubtable(4)
    fmt4.platformID, fmt4.platEncID, fmt4.format = 3, 1, 4
    fmt4.reserved, fmt4.length, fmt4.language = 0, 0, 0
    fmt4.cmap = {0x20: source_cmap[0x20]}

    font["cmap"].tables = [fmt4, fmt12]
    font["cmap"].tableVersion = 0


def equalise_advances(font: TTFont, source_cmap: dict) -> None:
    """Give every glyph the same advance, so the icon column never wobbles."""
    names = [source_cmap[g.source] for g in GLYPHS]
    hmtx = font["hmtx"]
    advance = max(hmtx[name][0] for name in names)
    for name in names:
        _, lsb = hmtx[name]
        hmtx[name] = (advance, lsb)
    font["hhea"].advanceWidthMax = advance


def rename(font: TTFont) -> None:
    """Rewrite the name table: our identity, their licence."""
    attribution = (
        "Emoji artwork from the OpenMoji project (https://openmoji.org), "
        "licensed CC BY-SA 4.0. Modified for Servi: subset to a small "
        "glyph set, codepoints remapped to a private-use range, layout tables "
        "dropped, family renamed."
    )
    records = {
        0: attribution,
        1: FAMILY_NAME,
        2: "Regular",
        3: f"{FAMILY_NAME}; OpenMoji {OPENMOJI_TAG}; {len(GLYPHS)} glyphs",
        4: f"{FAMILY_NAME} Regular",
        5: f"Version 1.000; OpenMoji {OPENMOJI_TAG}",
        6: POSTSCRIPT_NAME,
        10: "Derivative of OpenMoji-black: glyph subset, private-use remap, rename.",
        13: "Creative Commons Attribution-ShareAlike 4.0 International",
        14: "https://creativecommons.org/licenses/by-sa/4.0/",
    }
    name = font["name"]
    name.names = []
    for name_id, value in records.items():
        name.setName(value, name_id, 3, 1, 0x409)  # Windows / Unicode BMP / en-US
        name.setName(value, name_id, 1, 0, 0)  # Macintosh / Roman / English


def build_swift() -> str:
    lines = [
        "// Generated by scripts/build_ingredient_glyphs.py - do not edit by hand.",
        "// Rebuild after changing GLYPHS in that script; `--check` enforces it.",
        "//",
        "// Glyph artwork: OpenMoji (https://openmoji.org), CC BY-SA 4.0. See",
        "// Resources/Fonts/LICENSE-OpenMoji.txt.",
        "",
        "extension IngredientGlyph {",
        "",
        "    /// The private-use scalar this drawing occupies in `MealPlannerGlyphs.ttf`.",
        "    ///",
        "    /// A `switch` rather than a dictionary on purpose: it makes the enum and",
        "    /// the font co-exhaustive at compile time. A case with no drawing fails to",
        "    /// build, and so does a drawing with no case. Neither can reach a device.",
        "    public var scalar: Unicode.Scalar {",
        "        switch self {",
    ]
    lines += [f'        case .{g.case}: return "\\u{{{g.pua:X}}}"' for g in GLYPHS]
    lines += [
        "        }",
        "    }",
        "}",
        "",
    ]
    return "\n".join(lines)


def swift_format(path: pathlib.Path) -> None:
    """Match what the pre-commit hook would produce, so they never disagree."""
    if not shutil.which("xcrun"):
        print("warning: xcrun not found; generated Swift left unformatted")
        return
    # The config is passed explicitly rather than left to swift-format's
    # search-upwards-from-the-file discovery: `--check` formats a scratch copy
    # in a temp dir, which would otherwise get tool defaults and never compare
    # equal to the committed file.
    result = subprocess.run(  # noqa: S603 - fixed argv, no shell
        [
            "xcrun",
            "swift-format",
            "format",
            "--configuration",
            str(ROOT / ".swift-format"),
            "--in-place",
            str(path),
        ],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        print(f"warning: swift-format failed: {result.stderr.strip()}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--check",
        action="store_true",
        help="verify the committed artefacts match this table; write nothing",
    )
    args = parser.parse_args()

    font_bytes = build_font(fetch_source())
    swift_source = build_swift()

    if args.check:
        return check(font_bytes, swift_source)

    for path in (FONT_OUT, LICENSE_OUT, SWIFT_OUT):
        path.parent.mkdir(parents=True, exist_ok=True)
    FONT_OUT.write_bytes(font_bytes)
    LICENSE_OUT.write_text(LICENSE_TEXT)
    SWIFT_OUT.write_text(swift_source)
    swift_format(SWIFT_OUT)

    print(
        f"{FONT_OUT.relative_to(ROOT)}  {len(font_bytes) / 1024:.1f} KB, {len(GLYPHS)} glyphs"
    )
    print(f"  sha256 {sha256(font_bytes)}")
    print(f"{SWIFT_OUT.relative_to(ROOT)}")
    print(f"{LICENSE_OUT.relative_to(ROOT)}")
    return 0


def check(font_bytes: bytes, swift_source: str) -> int:
    stale = []
    if not FONT_OUT.exists() or FONT_OUT.read_bytes() != font_bytes:
        committed = sha256(FONT_OUT.read_bytes()) if FONT_OUT.exists() else "(missing)"
        stale.append(
            f"{FONT_OUT.relative_to(ROOT)}\n    committed {committed}\n    rebuilt   {sha256(font_bytes)}"
        )
    if not LICENSE_OUT.exists() or LICENSE_OUT.read_text() != LICENSE_TEXT:
        stale.append(str(LICENSE_OUT.relative_to(ROOT)))
    # The committed Swift has been through swift-format; compare on the same
    # footing by formatting a scratch copy the same way.
    with tempfile.TemporaryDirectory() as tmp:
        scratch = pathlib.Path(tmp) / SWIFT_OUT.name
        scratch.write_text(swift_source)
        swift_format(scratch)
        if not SWIFT_OUT.exists() or SWIFT_OUT.read_text() != scratch.read_text():
            stale.append(str(SWIFT_OUT.relative_to(ROOT)))

    if stale:
        print("out of date with scripts/build_ingredient_glyphs.py:", file=sys.stderr)
        for path in stale:
            print(f"  {path}", file=sys.stderr)
        print("run: python3 scripts/build_ingredient_glyphs.py", file=sys.stderr)
        return 1
    print(f"up to date ({len(GLYPHS)} glyphs)")
    return 0


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