#!/usr/bin/env python3
"""Build one Open Graph share card per route, as a font-independent SVG.

Reads `assets/og/cards.json` (written by scripts/build-og-images.mjs from the
*built* HTML, so a card can never drift from the page's real <title> and
description) and writes `assets/og/<slug>.svg`. Rasterizing to PNG is the node
script's job.

The lockup is inlined from lockup.svg rather than redrawn or recomposed here —
the card tracks the logo automatically, and there is only one copy of the
geometry.

    ./.venv/bin/python assets/brand/build_og.py
"""
import json
import os
import re
from typeset import load, layout, advance, wrap, clusters, require_glyphs, svg

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
OG = os.path.join(ROOT, "assets", "og")

# 1200x630 is the size every crawler agrees on, and 1.91:1 is what Facebook,
# LinkedIn, Slack and X all crop to. Anything else gets cropped by someone.
W, H = 1200, 630
PAD = 76
BAR = 12                       # clay rule along the top edge — the brand signature
COL = W - 2 * PAD              # usable text column

# --- per site: the palette, in semantic roles, from the @theme block ---
C = {
    "paper": "#FFFFFF",       # page surface
    "line": "#E5E5E5",        # the footer rule
    "ink": "#111111",         # title
    "ink_soft": "#666666",    # description and footer text
    "anchor": "#333333",      # the top rule — the anchor colour, never the spark
}

# --- per site: display face for the title, text face for everything else ---
# Pass an axes dict for a variable font, nothing for a static one.
display = load(f"{HERE}/src/Display.ttf", {"wght": 700})
body_f = load(f"{HERE}/src/Text.ttf", {"wght": 400})
meta_f = load(f"{HERE}/src/Text.ttf", {"wght": 500})

# Every card carries the attribution. The footer contract reaches the share
# card too: a demo that gets pasted into a chat is exactly where the credit
# has to survive, because nobody clicks through to the footer.
ATTRIB = "A demo by AlphaBlue · Creative direction by Luke"

# The bottom-left slot is the pitch, not this site's hostname. A card is met as
# an unfurled link in someone else's chat window, so the one line of spare room
# on it is worth more pointing at where you get one of these than repeating the
# address the reader is already looking at. Same on every site in the family —
# deliberately not derived from `site`.
PITCH = "https://start.alphablueth.com"

# Largest-first: the first size whose wrap fits the line budget wins, so a
# short title is not punished for a long sibling. Tune to the display face.
#
# The ceiling matters as much as the floor. A three-word title fits every size
# in the list, so the largest one is what it gets — set the top too low and
# short titles sit marooned in a half-empty 1200x630 canvas looking like a bug.
TITLE_SIZES = [96, 86, 78, 70, 62]
TITLE_LH = 1.14
DESC_EM = 27
DESC_LH = 1.45
# Three, not two. A meta description is written to fill roughly 150-200
# characters, which is three lines at this size — cap it at two and every card
# ellipsizes mid-sentence.
DESC_MAX_LINES = 3
TITLE_TRACK = -3               # per site: tighten a loose display face at size
GAP = 38                       # title block to description


def read_svg(name):
    """Inner markup + viewBox of a generated brand SVG."""
    with open(f"{HERE}/{name}.svg") as fh:
        src = fh.read()
    vb = [float(n) for n in re.search(r'viewBox="([^"]+)"', src).group(1).split()]
    inner = re.sub(r"^.*?<svg[^>]*>", "", src, flags=re.S)
    inner = re.sub(r"</svg>\s*$", "", inner, flags=re.S)
    return inner.strip(), vb


def place(name, x, y, height):
    """Inline a brand SVG scaled to `height`, with its viewBox origin at (x, y)."""
    inner, (vx, vy, vw, vh) = read_svg(name)
    k = height / vh
    return (f'  <g transform="translate({x - vx * k:.2f},{y - vy * k:.2f}) scale({k:.5f})">\n'
            f"{inner}\n  </g>\n"), vw * k


def text(font, s, em, x, baseline, fill, tracking=0.0):
    items, _ = layout(font, s, em, tracking, x, baseline)
    return f'  <path fill="{fill}" d="{" ".join(items)}"/>\n'


def clamp(font, s, em, max_w, max_lines):
    """Wrap to at most max_lines, ellipsizing the last one if it overflows."""
    lines = wrap(font, s, em, max_w)
    if len(lines) <= max_lines:
        return lines
    kept = lines[:max_lines]
    tail = kept[-1]
    while tail and advance(font, tail + "…", em) > max_w:
        if " " in tail:
            tail = tail.rsplit(" ", 1)[0]
        else:
            # Drop a whole cluster, not a character: `tail[:-1]` on Thai cuts the
            # consonant out from under its vowel or tone mark and leaves the mark
            # floating on the one before it.
            tail = "".join(clusters(tail)[:-1])
    kept[-1] = tail + "…"
    return kept


def card(title, description):
    # Up front, before anything measures a width — the width functions look the
    # glyph up too, and would otherwise fail first with a bare KeyError on a
    # codepoint number instead of naming the font and the character.
    require_glyphs(display, title)
    require_glyphs(body_f, description)
    require_glyphs(meta_f, ATTRIB + PITCH)

    body = f'  <path fill="{C["paper"]}" d="M0,0 H{W} V{H} H0 Z"/>\n'
    body += f'  <path fill="{C["anchor"]}" d="M0,0 H{W} V{BAR} H0 Z"/>\n'

    # ---- header: the composed lockup, placed whole.
    # Not mark + wordmark side by side. build_logo.py has already solved how those
    # two sit together — including the cases this would get wrong, such as a
    # wordmark that stacks the name over a sub-line — and recomposing them here
    # re-derives that relationship badly and lets the card drift away from the
    # site header the moment the lockup is retuned. One source, placed once.
    lock_h = 64
    lock_y = PAD
    g, _ = place("lockup", PAD, lock_y, lock_h)
    body += g
    header_bottom = lock_y + lock_h

    # ---- footer rule and attribution
    rule_y = H - PAD - 46
    body += f'  <path fill="{C["line"]}" d="M{PAD},{rule_y} H{W - PAD} V{rule_y + 2} H{PAD} Z"/>\n'
    foot_base = rule_y + 40
    body += text(meta_f, PITCH, 22, PAD, foot_base, C["ink_soft"])
    attrib_w = advance(meta_f, ATTRIB, 22)
    body += text(meta_f, ATTRIB, 22, W - PAD - attrib_w, foot_base, C["ink_soft"])

    # ---- title + description, vertically centred in what is left
    top, bottom = header_bottom + 34, rule_y - 34
    free = bottom - top
    desc_lines = clamp(body_f, description, DESC_EM, COL, DESC_MAX_LINES)
    desc_h = len(desc_lines) * DESC_EM * DESC_LH

    for em in TITLE_SIZES:
        title_lines = clamp(display, title, em, COL, 3)
        block = len(title_lines) * em * TITLE_LH + GAP + desc_h
        if block <= free:
            break

    y = top + (free - block) / 2 + em * 0.82          # first baseline
    for line in title_lines:
        body += text(display, line, em, PAD, y, C["ink"], TITLE_TRACK)
        y += em * TITLE_LH

    y += GAP - em * TITLE_LH + DESC_EM * 0.9
    for line in desc_lines:
        body += text(body_f, line, DESC_EM, PAD, y, C["ink_soft"])
        y += DESC_EM * DESC_LH

    return svg((0, 0, W, H), body, f"{title} — {ATTRIB}")


with open(f"{OG}/cards.json") as fh:
    manifest = json.load(fh)

os.makedirs(OG, exist_ok=True)
for c in manifest["cards"]:
    with open(f"{OG}/{c['slug']}.svg", "w") as fh:
        fh.write(card(c["title"], c["description"]))
    print(f"  {c['slug']}.svg  <- {c['route']}")

print(f"done -> {OG}/")
