#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pillow"]
# ///
"""Build a Gemma-branded HTML slide deck from a template.

Inlines every brand asset the way `document-branding`'s `apply_branding.py` does
for documents: the Kanit weights are base64-embedded, SVGs are inlined with their
ids and class names scoped, and cover art is downscaled and embedded as a data
URI. The result is one self-contained file that survives leaving this machine and
renders under a strict CSP, which is what Artifact publishing requires.

Emits two files from one template:
  <output>                 standalone HTML, opens in any browser
  <output>.artifact.html   body-only fragment for the Artifact tool, which
                           supplies its own doctype/html/head/body wrapper

Usage:
  uv run build_deck.py <template.html> <output.html> \
      [--cover <picture-name-or-path>] [--var KEY=VALUE ...]

`--var` substitutes `{{KEY}}` in the template. Use it for any figure that would
go stale if typed in by hand: compute it at build time, pass it here, and
rebuilding refreshes it. An unsubstituted `{{KEY}}` is a build error, never a
placeholder that ships to an audience.
"""

import argparse
import base64
import re
import sys
from io import BytesIO
from pathlib import Path

from PIL import Image

# Brand assets live in the sibling document-branding skill: one copy of the fonts,
# logos and pictures serves both documents and decks, rather than two that drift.
# That makes document-branding a hard dependency of this skill; the guard below
# turns its absence into one clear line instead of a FileNotFoundError on a font.
ASSETS = Path(__file__).resolve().parents[2] / "document-branding" / "assets"

if not ASSETS.is_dir():
    raise SystemExit(
        f"error: brand assets not found at {ASSETS}\n"
        "This skill reads fonts, logos and pictures from the sibling\n"
        "`document-branding` skill, so both must be installed together in the\n"
        "gemma-commercial plugin. Install or restore document-branding and retry."
    )

FONTS = {
    200: "Kanit-ExtraLight.ttf",
    400: "Kanit-Regular.ttf",
    500: "Kanit-Medium.ttf",
    # Display-size headlines only. The brand guide sets document titles at Medium,
    # but a 60px slide headline set at 500 reads limp next to the house deck, whose
    # section and closing slides are visibly heavier. Body copy stays at 200/400.
    600: "Kanit-SemiBold.ttf",
}

# Logos keep their own fills, so they take the scoping path rather than the
# currentColor path. The negative mark is multi-colour by design (lavender, super
# purple and white facets), which is exactly why its fills must survive.
LOGOS = {
    # The house deck's foot logo is the negative secondary lockup: GEMMA in white,
    # ANALYTICS in light purple. The all-white variant reads flatter next to it.
    "LOGO_SECONDARY_NEGATIVE": "logos/secondary_logo/gemma_secondary_logo_negative.svg",
    "LOGO_WHITE": "logos/secondary_logo/gemma_secondary_logo_white.svg",
    "LOGO_MARK_NEGATIVE": "logos/logo_mark/gemma_logo_mark_negative.svg",
    "LOGO_MARK_WHITE": "logos/logo_mark/gemma_logo_mark_white.svg",
}

# Decorative shapes are recoloured from CSS, so their baked-in fill is stripped.
ELEMENTS = {
    "ELEM_BLOB": "elements/blob/gemma_blob_purple.svg",
    "ELEM_GEM": "elements/gem/gemma_gem_purple.svg",
    "ELEM_ASTERISK": "elements/asterisk/gemma_asterisk_purple.svg",
    "ELEM_SEMICOLON": "elements/semicolon/gemma_semicolon_purple.svg",
}

# Two-tone shapes keep their baked fills, for the same reason the negative logo
# mark does: the white dot above the lavender comma IS the asset, not a default
# waiting to be recoloured. Stripping the fills would flatten it to one colour.
TWO_TONE = {
    "ELEM_SEMICOLON_NEGATIVE": "elements/semicolon/gemma_semicolon_negativ.svg",
}

# The blob doubles as a frame for a picture (section dividers). Its own fill is
# irrelevant there, only its outline.
BLOB_ASSET = "elements/blob/gemma_blob_purple.svg"

# Shade laid inside the blob's clip, so display-size white type reads over the
# picture's mid tones. The deck's own ground rather than Midnight Plum: plum at
# this opacity darkens the render but pushes saturation back up, which read as a
# more vivid purple than the house deck's.
BLOB_SHADE = "#160329"
BLOB_SHADE_DEFAULT = 0.36

COVER_WIDTH = 1760  # Displays at 1280px; this keeps a hi-dpi projector honest.

# Share of less-than-opaque pixels above which a picture's alpha is treated as
# shape rather than as noise, deciding PNG over JPEG. See `picture_data_uri`.
ALPHA_IS_MEANINGFUL = 0.02

WRAPPER = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{head}
</head>
<body>
{body}
</body>
</html>
"""


def font_faces() -> str:
    blocks = []
    for weight, filename in FONTS.items():
        b64 = base64.b64encode((ASSETS / "fonts" / filename).read_bytes()).decode("ascii")
        blocks.append(
            "    @font-face {\n"
            "      font-family: 'Kanit';\n"
            f"      src: url(data:font/ttf;base64,{b64}) format('truetype');\n"
            f"      font-weight: {weight};\n"
            # Not `block`. `block` renders text invisibly for as long as its face
            # is unavailable, and in Chrome's print pipeline a face can simply
            # never arrive: the text is then laid out, measured, and absent from
            # the PDF. `swap` shows a fallback instead of nothing, so a font
            # problem degrades to the wrong typeface rather than to silence.
            "      font-display: swap;\n"
            "    }"
        )
    return "\n".join(blocks)


def _scope(svg: str, prefix: str) -> str:
    """Scope an inlined SVG's ids AND class names to itself.

    Two separate collisions, both silent:

    - ids: repeated inlines produce duplicate ids, which is invalid HTML and the
      wrong target for any url(#...) reference in a gradient-bearing asset.
    - classes: every Illustrator export names its fills `.cls-1`, `.cls-2`, ...
      inside its own `<defs><style>`, and inlining hoists those rules to document
      scope. The secondary logo declares `.cls-1 { fill: #fff }` while the negative
      logo mark declares `.cls-1 { fill: #a976e5 }`, so without scoping whichever
      lands later in the DOM recolours BOTH and the wordmark silently turns
      lavender.
    """
    for raw_id in set(re.findall(r'id="([^"]+)"', svg)):
        svg = svg.replace(f'id="{raw_id}"', f'id="{prefix}-{raw_id}"')
        svg = svg.replace(f"url(#{raw_id})", f"url(#{prefix}-{raw_id})")
        svg = svg.replace(f'xlink:href="#{raw_id}"', f'xlink:href="#{prefix}-{raw_id}"')
    # One pass handles cls-1 and cls-10 alike, in selectors and class attributes.
    return re.sub(r"cls-(\d+)", rf"{prefix}-cls-\1", svg)


def inline_logo(rel_path: str, marker: str, instance: int) -> str:
    svg = (ASSETS / rel_path).read_text(encoding="utf-8")
    svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.DOTALL).strip()
    return _scope(svg, f"{marker.lower().replace('_', '-')}-{instance}")


def inline_element(rel_path: str, marker: str, instance: int) -> str:
    """Inline a decorative shape, stripped so CSS `color` drives its fill."""
    svg = (ASSETS / rel_path).read_text(encoding="utf-8")
    svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.DOTALL)
    svg = re.sub(r"<defs>.*?</defs>", "", svg, flags=re.DOTALL)
    svg = re.sub(r'\s*class="cls-\d+"', "", svg)
    svg = svg.replace("<svg ", '<svg fill="currentColor" aria-hidden="true" ', 1)
    return _scope(svg.strip(), f"{marker.lower().replace('_', '-')}-{instance}")


def blob_clipped_image(uri: str, instance: int, shade: float = BLOB_SHADE_DEFAULT) -> str:
    """A blob-shaped photo, as one self-contained SVG.

    CSS `mask-image` was the obvious first choice and it does not survive Chrome's
    print pipeline: the mask is dropped, `background-size` falls back to `contain`,
    and the picture tiles as a bare rectangle. Nothing warns you, and the slide
    looks correct on screen. Printing to PDF is a first-class output of this deck,
    so the shape has to be SVG-native rather than a CSS effect over a background.

    `slice` on the <image> is the SVG equivalent of `background-size: cover`.

    The shade is part of the shape, not decoration: display-size white type sits on
    top of a blob-framed picture, and the raw particle renders are bright enough in
    their mid tones to fight it. Shading inside the clip keeps the tint to the blob
    rather than laying a rectangle over the slide, and it rides along into print.
    """
    svg = (ASSETS / BLOB_ASSET).read_text(encoding="utf-8")
    view_match = re.search(r'viewBox="([^"]+)"', svg)
    if not view_match:
        raise SystemExit(f"error: no viewBox found in {BLOB_ASSET}")
    view = view_match.group(1)
    paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
    if not paths:
        raise SystemExit(f"error: no path found in {BLOB_ASSET}")
    _, _, vw, vh = view.split()
    cid = f"blobclip-{instance}"
    outline = "".join(f'<path d="{d}"/>' for d in paths)
    tint = (
        f'<rect x="0" y="0" width="{vw}" height="{vh}" fill="{BLOB_SHADE}" '
        f'opacity="{shade}" clip-path="url(#{cid})"/>'
        if shade > 0 else ""
    )
    return (
        f'<svg viewBox="{view}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">'
        f'<defs><clipPath id="{cid}">{outline}</clipPath></defs>'
        f'<image href="{uri}" x="0" y="0" width="{vw}" height="{vh}" '
        f'preserveAspectRatio="xMidYMid slice" clip-path="url(#{cid})"/>'
        f"{tint}</svg>"
    )


ROTATIONS = {90: Image.ROTATE_90, 180: Image.ROTATE_180, 270: Image.ROTATE_270}


def picture_data_uri(
    name_or_path: str, width: int = COVER_WIDTH, label: str = "cover", rotate: int = 0
) -> str:
    """Downscale a picture and return it as a bare JPEG data URI.

    Accepts a bare filename from `document-branding/assets/pictures/` or any path.
    Full-resolution art is far too heavy to inline: the purple particle renders are
    2000x1500 PNGs at 1.8-3.2 MB, base64-encoding to several megabytes, against
    roughly 110 KB once scaled and JPEG-encoded.
    """
    src = Path(name_or_path)
    if not src.exists():
        src = ASSETS / "pictures" / name_or_path
    if not src.exists():
        raise SystemExit(f"error: picture not found: {name_or_path}")

    im = Image.open(src)
    # Rotate here rather than with a CSS transform. A landscape render whose ribbon
    # should run down a tall column needs turning, and `transform: rotate()` is one
    # of the properties Chrome's print pipeline drops: the slide would be right on
    # screen and unrotated in the PDF. Rotating the pixels cannot desynchronise.
    if rotate:
        if rotate not in ROTATIONS:
            raise SystemExit(f"error: --pic rotation must be 90, 180 or 270, got {rotate}")
        im = im.transpose(ROTATIONS[rotate])
    # Transparency has to survive. A circle-masked portrait flattened to RGB gains a
    # black square behind it, and the corners of that square show as a dark ring
    # just outside the frame's border-radius. Alpha-bearing sources therefore take
    # the PNG path; only opaque artwork gets JPEG's compression.
    # Having an alpha channel is not the same as carrying shape in it. The purple
    # particle renders are RGBA PNGs whose alpha is incidental: it bottoms out at 154
    # and only 0.2% of pixels are less than opaque. Routing those down the lossless
    # path cost 1.2 MB each against 110 KB, quintupling the deck. A genuinely masked
    # source is nothing like that: the circle-cut portrait is 64% non-opaque. So the
    # question is not "is there alpha" but "is the alpha doing work", and the gap
    # between 0.2% and 64% is wide enough to answer it by measurement.
    keeps_alpha = False
    if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info):
        alpha = im.convert("RGBA").getchannel("A")
        non_opaque = sum(alpha.histogram()[:250]) / (im.width * im.height)
        keeps_alpha = non_opaque > ALPHA_IS_MEANINGFUL
    # Never upscale: a 423px portrait gains nothing from being stretched to 1200.
    target = min(width, im.width)
    buf = BytesIO()
    if keeps_alpha:
        im = im.convert("RGBA")
        im = im.resize((target, round(im.height * target / im.width)), Image.LANCZOS)
        im.save(buf, "PNG", optimize=True)
        mime = "image/png"
    else:
        im = im.convert("RGB")
        im = im.resize((target, round(im.height * target / im.width)), Image.LANCZOS)
        im.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
        mime = "image/jpeg"
    print(f"{label}: {src.name} -> {len(buf.getvalue()) / 1024:.0f} KB inlined ({mime})")
    return f"data:{mime};base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("template")
    parser.add_argument("output")
    parser.add_argument("--cover", help="Picture filename from the brand pictures folder, or a path")
    parser.add_argument("--pic", action="append", default=[], metavar="NAME=FILE[@ROT]",
                        help="Inline FILE as /*PIC_NAME*/ or <!--BLOB_PIC_NAME-->. "
                             "Append @90, @180 or @270 to turn it. Repeatable.")
    parser.add_argument("--var", action="append", default=[], metavar="KEY=VALUE",
                        help="Substitute {{KEY}} in the template. Repeatable.")
    args = parser.parse_args()

    html = Path(args.template).read_text(encoding="utf-8")
    out_path = Path(args.output)

    html = html.replace("<!--FONT_FACES-->", font_faces())

    for mapping, fn in ((LOGOS, inline_logo), (TWO_TONE, inline_logo), (ELEMENTS, inline_element)):
        for marker, rel in mapping.items():
            token = f"<!--{marker}-->"
            instance = 0
            while token in html:
                instance += 1
                html = html.replace(token, fn(rel, marker, instance), 1)

    if "/*COVER_IMAGE*/" in html:
        if not args.cover:
            print("error: template wants cover art; pass --cover", file=sys.stderr)
            return 1
        html = html.replace("/*COVER_IMAGE*/", f"url({picture_data_uri(args.cover)})")

    # Panel art displays at roughly half slide width, so it needs far less than the
    # cover's 1760px. Keeping it smaller is what stops a six-picture deck ballooning.
    # Each picture is usable two ways: /*PIC_NAME*/ as a CSS background, and
    # <!--BLOB_PIC_NAME--> as a blob-clipped SVG.
    for pair in args.pic:
        name, sep, filename = pair.partition("=")
        if not sep or not name:
            print(f"error: --pic wants NAME=FILE[@ROTATION], got {pair!r}", file=sys.stderr)
            return 1
        # An @90 suffix turns the picture. Only split on a trailing run of digits, so
        # a filename that legitimately contains "@" still resolves.
        rotate = 0
        head, at, tail = filename.rpartition("@")
        if at and tail.isdigit():
            filename, rotate = head, int(tail)
        css_token, blob_token = f"/*PIC_{name}*/", f"<!--BLOB_PIC_{name}-->"
        if css_token not in html and blob_token not in html:
            print(f"error: --pic {name} given but neither {css_token} nor "
                  f"{blob_token} is in the template", file=sys.stderr)
            return 1
        uri = picture_data_uri(filename, 1200, f"pic {name}", rotate)
        html = html.replace(css_token, f"url({uri})")
        instance = 0
        while blob_token in html:
            instance += 1
            html = html.replace(blob_token, blob_clipped_image(uri, instance), 1)

    for pair in args.var:
        key, sep, value = pair.partition("=")
        if not sep or not key:
            print(f"error: --var wants KEY=VALUE, got {pair!r}", file=sys.stderr)
            return 1
        html = html.replace(f"{{{{{key}}}}}", value)

    leftover = re.findall(r"<!--(FONT_FACES|LOGO_[A-Z_]+|ELEM_[A-Z_]+|BLOB_PIC_[A-Z_0-9]+)-->", html)
    leftover += re.findall(r"/\*(COVER_IMAGE|PIC_[A-Z_0-9]+)\*/", html)
    leftover += re.findall(r"\{\{([A-Z_0-9]+)\}\}", html)
    if leftover:
        print(f"error: unsubstituted placeholders: {sorted(set(leftover))}", file=sys.stderr)
        return 1

    # The template is a body fragment: <title> and <style> belong in <head>.
    match = re.search(r"(<title>.*?</title>\s*<style>.*?</style>)(.*)", html, re.DOTALL)
    if not match:
        print("error: template must open with <title> then <style>", file=sys.stderr)
        return 1

    out_path.write_text(
        WRAPPER.format(head=match.group(1), body=match.group(2).strip()), encoding="utf-8"
    )
    artifact = out_path.with_suffix(".artifact.html")
    artifact.write_text(html, encoding="utf-8")

    for p in (out_path, artifact):
        print(f"wrote {p} ({len(p.read_bytes()) / 1024:.0f} KB, self-contained)")
    print(f"\nNow verify it: uv run audit_deck.py {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
