#!/usr/bin/env python3
"""WCAG 2.1 contrast audit for makeover theme files.

Reads any makeover .toml (surface/content/action/status/line/
category sections), converts hex to WCAG 2.1 relative luminance,
and reports pass/fail against AA-text (>= 4.5) and AA-UI (>= 3.0)
for every affordance-carrying token pair.

Also computes Alloy's derived tokens (border-subtle, border-strong)
via mix formulas and audits those too, so a makeover file that
was authored without Alloy's discipline still gets a full report.

Usage:
    python3 tools/wcag_audit.py <path/to/theme.toml>

Example:
    python3 tools/wcag_audit.py https://git.sr.ht/~maxmj/makeover/tree/main/item/themes/akari-dawn.toml
"""
import sys
import os

# ---------------------------------------------------------------- toml load

def _load_toml(path):
    try:
        import tomllib
    except ImportError:
        try:
            import tomli as tomllib  # noqa
        except ImportError:
            sys.exit("need tomllib (Python 3.11+) or `pip install --user tomli`")
    with open(path, "rb") as f:
        return tomllib.load(f)

# ---------------------------------------------------------------- color math

def hex_to_srgb(h):
    """#rrggbb -> (r, g, b) in [0, 1] sRGB (gamma-encoded)."""
    h = h.strip().lstrip("#")
    if len(h) != 6:
        raise ValueError(f"expected 6-hex color, got {h!r}")
    r = int(h[0:2], 16) / 255.0
    g = int(h[2:4], 16) / 255.0
    b = int(h[4:6], 16) / 255.0
    return r, g, b

def _linearize(c):
    """sRGB gamma -> linear sRGB per WCAG 2.1."""
    return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4

def relative_luminance_hex(h):
    r, g, b = (_linearize(c) for c in hex_to_srgb(h))
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(a_hex, b_hex):
    Ya = relative_luminance_hex(a_hex)
    Yb = relative_luminance_hex(b_hex)
    lo, hi = sorted((Ya, Yb))
    return (hi + 0.05) / (lo + 0.05)

def mix_hex(a_hex, b_hex, t):
    """Linear-sRGB mix. t=0 => a, t=1 => b. Returns #rrggbb."""
    ar, ag, ab = (_linearize(c) for c in hex_to_srgb(a_hex))
    br, bg, bb = (_linearize(c) for c in hex_to_srgb(b_hex))
    mr = ar + (br - ar) * t
    mg = ag + (bg - ag) * t
    mb = ab + (bb - ab) * t
    def _delinearize(c):
        return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
    r = round(_delinearize(mr) * 255)
    g = round(_delinearize(mg) * 255)
    b = round(_delinearize(mb) * 255)
    return "#{:02x}{:02x}{:02x}".format(max(0, min(255, r)),
                                        max(0, min(255, g)),
                                        max(0, min(255, b)))

# ---------------------------------------------------------------- oklab

def _to_oklab(h):
    r, g, b = (_linearize(c) for c in hex_to_srgb(h))
    l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
    m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
    s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
    l_, m_, s_ = (v ** (1 / 3) if v >= 0 else -((-v) ** (1 / 3)) for v in (l, m, s))
    return (0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
            1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
            0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_)

def _from_oklab(lab):
    L, A, B = lab
    l_ = L + 0.3963377774 * A + 0.2158037573 * B
    m_ = L - 0.1055613458 * A - 0.0638541728 * B
    s_ = L - 0.0894841775 * A - 1.2914855480 * B
    l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
    def _delin(c):
        c = 12.92 * c if c <= 0.0031308 else 1.055 * (max(c, 0.0) ** (1 / 2.4)) - 0.055
        return max(0, min(255, round(c * 255)))
    return "#{:02x}{:02x}{:02x}".format(
        _delin(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
        _delin(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
        _delin(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s))

def oklab_mix(a_hex, b_hex, t):
    """Perceptual mix, the one makeover derives with. t=0 => a, t=1 => b."""
    x, y = _to_oklab(a_hex), _to_oklab(b_hex)
    return _from_oklab(tuple(x[i] + (y[i] - x[i]) * t for i in range(3)))

# ---------------------------------------------------------------- derivation

# makeover's tonal-step constants (makeover `src/emphasis.rs`). The ratios are
# a starting point: each step is pushed further toward the page until it clears
# STEP_FLOOR against the ink, so a theme gets a step that can be seen rather
# than a step of the agreed size.
STEP_RATIO = {"secondary": 0.12, "muted": 0.42}
STEP_FLOOR = 1.21
PROBE      = 0.005

def tonal_steps(ink, page):
    """content.secondary and content.muted, as makeover derives them at load.

    They are not authored. A theme file names `content.primary` and nothing
    else in that family, so this reproduces `makeover::derive_tonal_steps`
    rather than reading keys that are no longer in the file. The two must agree;
    makeover is the source of truth and this is the port.
    """
    out, reached = {}, 0.0
    for key in ("secondary", "muted"):
        ratio = max(STEP_RATIO[key], reached)
        while True:
            color = oklab_mix(ink, page, min(max(ratio, 0.0), 1.0))
            if contrast(color, ink) >= STEP_FLOOR or ratio >= 1.0:
                break
            ratio += PROBE
        out[key], reached = color, ratio
    return out

def derive(theme):
    """Compute Alloy's extended tokens from a makeover theme.

    makeover ships one border tone (line.border); Alloy renders
    three tiers via mix. Formula lives here (not in the theme file)
    so any makeover .toml downloaded from the wild gets a full
    Alloy-shaped token map.
    """
    border  = theme["line"]["border"]
    surface = theme["surface"]["page"]
    primary = theme["content"]["primary"]
    # border-strong needs 3.0:1 against page for focus rings / selected
    # rows. Themes vary widely in border softness; mixing 65% toward
    # text gets there on both crisp (dark border) and soft (Akari-tier)
    # borders. border-subtle is decorative — 60% toward surface reads
    # as "hint of a divider" without adding contrast.
    out = {
        "border-subtle": mix_hex(border, surface, 0.60),
        "border-strong": mix_hex(border, primary, 0.65),
    }
    out.update(tonal_steps(primary, surface))
    return out

# ---------------------------------------------------------------- reporting

TEXT_TARGET = 4.5
UI_TARGET   = 3.0

def _tag(ratio):
    if ratio >= 7.0: return "AAA"
    if ratio >= 4.5: return "AA-text"
    if ratio >= 3.0: return "AA-UI"
    return "sub-3"

def _row(name, ratio, target):
    status = "PASS" if ratio >= target else "FAIL"
    print(f"  {status}  {ratio:6.2f}:1  [{_tag(ratio):8s}]  {name}")

def audit(theme_path):
    theme = _load_toml(theme_path)
    meta  = theme.get("meta", {})
    surf  = theme["surface"]
    text  = theme["content"]
    line  = theme["line"]
    act   = theme["action"]
    stat  = theme["status"]
    derived = derive(theme)

    name    = meta.get("name", os.path.basename(theme_path))
    variant = meta.get("variant", "?")
    print(f"\n============ {name} ({variant}) ============\n")

    # Text on surfaces
    print("Text on surfaces (target >= 4.5 for text; muted target 3.0)")
    for key in ("primary", "secondary", "muted"):
        target = UI_TARGET if key == "muted" else TEXT_TARGET
        ink = text[key] if key == "primary" else derived[key]
        for s_key in ("page", "raised", "sunken", "overlay"):
            r = contrast(ink, surf[s_key])
            _row(f"content.{key} on surface.{s_key}", r, target)

    # Borders on surfaces
    print("\nBorders on surfaces (border-strong target 3.0; others decorative)")
    for b_name, b_hex in (
        ("border-strong", derived["border-strong"]),
        ("line.border",   line["border"]),
        ("border-subtle", derived["border-subtle"]),
    ):
        target = UI_TARGET if b_name == "border-strong" else 0.0
        for s_key in ("page", "raised", "overlay"):
            r = contrast(b_hex, surf[s_key])
            _row(f"{b_name} on surface.{s_key}", r, target)

    # Accents on surfaces
    print("\nAccents on surfaces (target >= 4.5 text, or >= 3.0 for glyphs)")
    accents = [("action.primary", act["primary"])]
    accents += [(f"status.{k}", stat[k]) for k in ("danger", "success", "warning", "info")]
    for a_name, a_hex in accents:
        for s_key in ("page", "raised", "sunken", "overlay"):
            r = contrast(a_hex, surf[s_key])
            _row(f"{a_name} on surface.{s_key}", r, TEXT_TARGET)

    # Surface elevation deltas (perceptual)
    print("\nSurface elevation deltas (perceptual; not WCAG)")
    tiers = ("sunken", "page", "raised", "overlay")
    for a, b in zip(tiers, tiers[1:]):
        Ya = relative_luminance_hex(surf[a])
        Yb = relative_luminance_hex(surf[b])
        r  = contrast(surf[a], surf[b])
        print(f"  surface.{a:8s} -> surface.{b:8s}  ratio {r:5.2f}   dY {Yb-Ya:+.4f}")

    # Derived tokens (for downstream consumers wanting to eyeball)
    print(f"\nDerived tokens:")
    print(f"  border-subtle  = {derived['border-subtle']}   (mix border, surface.page 60%)")
    print(f"  border-strong  = {derived['border-strong']}   (mix border, content.primary 65%)")

if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("usage: wcag_audit.py <path/to/theme.toml>")
    audit(sys.argv[1])
