#!/usr/bin/env python3
"""Generate conf/custom.css from the Australis Skyfall source in this directory.

Homepage serves exactly two files out of its config dir — custom.css and
custom.js — with no static route for anything alongside them. A @font-face
pointing at a vendored .woff2 would therefore 404, and an @import of a sibling
token file would 404 too. So this build does three things that cannot be done
in plain CSS:

  1. inlines the font faces as data: URIs;
  2. concatenates the vendored token files into one sheet;
  3. bridges Skyfall's `[data-theme="light"]` selector to the `light` CLASS
     Homepage actually sets on <html>.

Usage:  python3 stacks/homepage/theme/build.py
Then:   scripts/deploy-stack.sh esh-docker-vm homepage --conf
        (a settings.yaml change needs a container recreate; a custom.css
         change needs only a browser reload — custom.css is served per request)
"""

import base64
import hashlib
import pathlib
import re
import sys

HERE = pathlib.Path(__file__).resolve().parent
OUT = HERE.parent / "conf" / "custom.css"

# ---------------------------------------------------------------------------
# VENDORED, VERBATIM, FROM THE SKYFALL BUNDLE. Concatenated in this order:
# families before semantics, tokens before bindings.
#
# The hashes are the anti-fork guard. The theme this one replaces built a
# parallel palette "derived from the Australis philosophy" instead of using the
# system's own values, and it had to be torn out twice. A vendored file is
# either byte-identical to the bundle or it is a fork wearing the bundle's
# name; there is no third state, so a mismatch FAILS the build rather than
# warning. To legitimately update one, drop in the new file and record its new
# hash in the same commit.
# ---------------------------------------------------------------------------
VENDORED = {
    "colors.css": "75f78b674dd7fd87ac414cf04165f0e99b016e54d3b546f0e96378c08fdd7b88",
    "layout.css": "a3826169b7a6b604d9c456c073ba3788297ae88bb703862a95171e3858f94c56",
    "typography.css": "2ec1a667ced0653292eed0f724b8bc294765d740531ee10e48ebda5f5ca6f2f8",
}

# Our own bindings — the only file in this directory that is ours to edit.
BINDINGS = "skyfall.css.in"

# ---------------------------------------------------------------------------
# Skyfall keys light mode off `[data-theme="light"]` on <html>. Homepage never
# touches data attributes, and — this is the constraint that shapes everything
# below — it will not give us its own theme toggle either: rendering that
# toggle requires settings.yaml to leave `theme:` unpinned, and an unpinned
# `theme:` makes the page's data loader throw (measured 2026-08-24; see
# conf/custom.js for the evidence). So `theme: dark` stays pinned, Homepage's
# class is always `dark`, and light mode is driven by `data-theme` written by
# our own toggle in custom.js, or by the OS preference when nothing is stored.
#
# Editing the vendored file to add those selectors would fork it; hand-copying
# its light block into the bindings would drift the moment the bundle updates.
# So re-emit the block here, mechanically, in three forms:
#
#   [data-theme="light"], html.light   explicit choice (our toggle; also the
#                                      spelling a real Skyfall app would use,
#                                      kept so the sheet stays portable)
#   @media (prefers-color-scheme: light)
#     html:not([data-theme=...])       the OS preference, and ONLY while no
#                                      explicit choice is present — the :not()
#                                      pair is what makes a stored "dark"
#                                      survive a light-mode OS.
# ---------------------------------------------------------------------------
LIGHT_BLOCK = re.compile(r'^\[data-theme="light"\]\s*\{\n(.*?)^\}', re.M | re.S)


def expand_light(match: "re.Match[str]") -> str:
    body = match.group(1).rstrip("\n")
    return (
        '[data-theme="light"],\nhtml.light {\n' + body + "\n}\n\n"
        "@media (prefers-color-scheme: light) {\n"
        '  html:not([data-theme="dark"]):not([data-theme="light"]) {\n'
        + body
        + "\n  }\n}"
    )

# One VARIABLE face per stand-in family — the whole 100-900 axis in a single
# file — plus Supreme's three static cuts, which is how Supreme ships. Latin
# subset only. See the type note in skyfall.css.in for why two of the three
# families are substitutions.
FONTS_VARIABLE = [
    ("Space Grotesk", "SpaceGrotesk-Variable.woff2"),  # stands in for Bespoke Sans
    ("JetBrains Mono", "JetBrainsMono-Variable.woff2"),  # stands in for Victor Mono
]
FONTS_STATIC = [
    ("Supreme", "Supreme-400.woff2", 400),
    ("Supreme", "Supreme-500.woff2", 500),
    # Supreme ships no 600 cut; 600 and 700 both resolve to this file.
    ("Supreme", "Supreme-700.woff2", "600 700"),
]

# Tokens the generated sheet cannot work without. A vendoring mistake that
# drops a block produces a stylesheet that parses fine and renders nothing, so
# assert on the values rather than trusting the copy. One from each vendored
# file, plus proof the light theme survived.
REQUIRED = [
    "--sea-15",  # colors.css, family layer
    "--surface-card",  # colors.css, semantic layer
    "--shadow-sm",  # layout.css
    "--tracking-caps",  # typography.css
    "html.light",  # the explicit-choice selector was emitted
    "prefers-color-scheme: light",  # the OS-preference copy was emitted
    "#skyfall-theme-toggle",  # the toggle has styling, not just behaviour
]


def sha256(path: pathlib.Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def font_faces() -> str:
    out = []
    for family, filename in FONTS_VARIABLE:
        path = HERE / "fonts" / filename
        if not path.exists():
            sys.exit(f"missing font: {path}")
        b64 = base64.b64encode(path.read_bytes()).decode("ascii")
        out.append(
            f"@font-face {{\n"
            f'  font-family: "{family}";\n'
            f"  src: url(data:font/woff2;base64,{b64}) format('woff2');\n"
            f"  font-weight: 100 900;\n"  # variable axis
            f"  font-style: normal;\n"
            f"  font-display: swap;\n"
            f"}}"
        )
    for family, filename, weight in FONTS_STATIC:
        path = HERE / "fonts" / filename
        if not path.exists():
            sys.exit(f"missing font: {path}")
        b64 = base64.b64encode(path.read_bytes()).decode("ascii")
        out.append(
            f"@font-face {{\n"
            f'  font-family: "{family}";\n'
            f"  src: url(data:font/woff2;base64,{b64}) format('woff2');\n"
            f"  font-weight: {weight};\n"
            f"  font-style: normal;\n"
            f"  font-display: swap;\n"
            f"}}"
        )
    return "\n".join(out)


def vendored_css() -> str:
    """Concatenate the vendored token files, refusing to build if one moved."""
    parts, drift = [], []
    for name, expected in VENDORED.items():
        path = HERE / name
        if not path.exists():
            sys.exit(
                f"missing vendored token file: {path}\n"
                f"Recover it with: git show 45c1995:stacks/homepage/theme/{name}"
            )
        actual = sha256(path)
        if actual != expected:
            drift.append((name, actual, expected))
        parts.append(f"/* ==== vendored: {name} ({actual[:12]}) ==== */\n{path.read_text()}")

    if drift:
        for name, actual, expected in drift:
            print(f"VENDOR DRIFT: {name}", file=sys.stderr)
            print(f"    on disk : {actual}", file=sys.stderr)
            print(f"    expected: {expected}", file=sys.stderr)
        sys.exit(
            "A vendored Skyfall token file has been modified. These are copied "
            "verbatim from the bundle and are not ours to edit — put the "
            "override in skyfall.css.in instead. If the bundle itself was "
            "legitimately updated, record the new hash in VENDORED in the same "
            "commit as the file."
        )

    print(f"vendored token files verified: {', '.join(VENDORED)}")
    return "\n\n".join(parts)


def main() -> None:
    src_path = HERE / BINDINGS
    if not src_path.exists():
        sys.exit(f"missing source: {src_path}")
    src = src_path.read_text()
    if "/* @@FONTS@@ */" not in src:
        sys.exit(f"{BINDINGS} lost its @@FONTS@@ placeholder")

    tokens = vendored_css()

    bridged, n = LIGHT_BLOCK.subn(expand_light, tokens)
    if not n:
        sys.exit(
            'no [data-theme="light"] rule found in the vendored tokens — the '
            "light theme would silently never apply"
        )
    print(f'expanded {n} [data-theme="light"] block(s): explicit + OS-preference')

    css = bridged + "\n\n" + src.replace("/* @@FONTS@@ */", font_faces())

    for token in REQUIRED:
        if token not in css:
            sys.exit(f"generated css has no {token} — a token block did not survive")

    OUT.write_text(css)
    print(f"wrote {OUT} ({len(css) / 1024:.0f} KB)")


if __name__ == "__main__":
    main()
