#!/usr/bin/env python3
"""Guard against invisible-text palettes (BACKLOG items 25 + 27).

Asserts, for every themes/*.json, the WCAG contrast ratio of each
hex-on-hex fg/bg pairing the generated swaync/waybar/rofi CSS uses.
Pairings built from alpha(@text) / #RRGGBBAA text tints are
contrast-by-construction and need no assert. Status glyph accents
(good/warn/bad on base) are deliberately NOT held to a text ratio —
several palettes sit at 2.0–2.7 there by design.

Wired as `checks.theme-contrast` in flake.nix; also runnable directly:
    python3 tools/check-theme-contrast.py themes/
"""
import json
import sys
from pathlib import Path

# (fg role, bg role, minimum ratio, consumer). Text pairings only —
# borders (@accent/@bad on @base in swaync) are decorative and not held
# to a text ratio (miasma's bad-on-base is 2.3, fine for a border).
PAIRINGS = [
    ("text", "base",   4.5, "swaync body / waybar modules / rofi rows"),
    # symmetric ratio also covers accent-on-base (waybar updates, rofi prompt)
    ("base", "accent", 3.0, "swaync button:hover / waybar active ws / rofi selected row"),
    # Adopted from the item-28 design audit: summer-day/flexoki-light
    # shipped subtext == base (ratio 1.0 — invisible secondary text; the
    # palette-level cause of items 25/27). Never again.
    ("subtext", "base", 3.0, "secondary text (tooltips, hints, fastfetch labels)"),
    # Item-28 P2 floors (all 21 themes retuned to pass, 2026-07-05).
    # warn IS held to a floor — it's the bar's 25%-battery color and the
    # low-battery toast tint; good/bad stay audit-only (identity themes
    # sit at 2.0–2.3 there by design — see tools/audit-theme-design.py).
    ("muted",   "base",    2.0, "dimmed text (inactive workspaces, muted volume)"),
    ("text",    "surface", 4.5, "text on chips / raised rows"),
    ("accentAlt", "base",  3.0, "alt-accent glyphs"),
    ("warn",    "base",    2.5, "battery warning tint (bar + toast)"),
]


# GTK accent-button chips (#129/#130). Everything above is role-on-role, which
# is why #98 shipped an invisible button with every check green: the button's
# background is not a palette role at all, it is a colour GTK computes —
# mix(anchor, accent, f) — from modules/home/stylix.nix. So this half models
# that computation instead of ignoring it.
#
# It only works because the CSS pins BOTH ends. A pinned label on a background
# adw-gtk3 derives from currentColor is unknowable from here — that is the #98
# trap, and the rule this check enforces by construction: pin the pair, or pin
# neither. If a future rule pins a label and leaves the background to the sheet,
# this check will keep passing and mean nothing; that is the one way to defeat
# it, so do not.
#
# Keep in lockstep with stylix.nix: same anchor rule, same factor, same roles.
CHIP_FACTOR = 0.70   # `chip` in stylix.nix — rest state, the worst case
CHIP_MIN = 4.5       # WCAG AA body text; button labels are body text
CHIP_ROLES = [("accent", "suggested-action / .default"), ("bad", "destructive-action")]


def mix(c1: str, c2: str, f: float) -> str:
    """GTK's mix(): color1 + (color2 - color1) * factor (gtkcsscolorvalue.c).

    Note the direction — f is the weight of the SECOND colour, so
    mix(white, accent, 0.7) is 70% accent, not 70% white.
    """
    a = [int(c1.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
    b = [int(c2.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
    return "#%02X%02X%02X" % tuple(
        round(a[i] + (b[i] - a[i]) * f) for i in range(3)
    )


def chip(colors: dict, role: str, mode: str) -> str:
    """The rendered button background: the accent pulled away from the label.

    The label is the palette's base — dark on a dark theme, cream on a light
    one — so the chip moves toward white or black accordingly.
    """
    anchor = "#000000" if mode == "light" else "#FFFFFF"
    return mix(anchor, colors[role], CHIP_FACTOR)


def channel(c: int) -> float:
    c /= 255
    return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4


def luminance(hexstr: str) -> float:
    hexstr = hexstr.lstrip("#")
    r, g, b = (int(hexstr[i:i + 2], 16) for i in (0, 2, 4))
    return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)


def ratio(fg: str, bg: str) -> float:
    hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
    return (hi + 0.05) / (lo + 0.05)


def main() -> int:
    themes_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "themes")
    failures = []
    themes = sorted(themes_dir.glob("*.json"))
    if not themes:
        print(f"no theme JSONs found under {themes_dir}", file=sys.stderr)
        return 1
    for path in themes:
        theme = json.loads(path.read_text())
        colors = theme["colors"]
        for fg, bg, minimum, consumer in PAIRINGS:
            r = ratio(colors[fg], colors[bg])
            if r < minimum:
                failures.append(
                    f"{path.stem}: {fg} on {bg} = {r:.2f} < {minimum}"
                    f" ({consumer}; {colors[fg]} on {colors[bg]})"
                )
        mode = theme.get("mode", "dark")
        for role, consumer in CHIP_ROLES:
            bg = chip(colors, role, mode)
            r = ratio(colors["base"], bg)
            if r < CHIP_MIN:
                failures.append(
                    f"{path.stem}: base on the {role} chip = {r:.2f} < {CHIP_MIN}"
                    f" (GTK {consumer}; {colors['base']} on {bg}"
                    f" = mix({'black' if mode == 'light' else 'white'},"
                    f" {colors[role]}, {CHIP_FACTOR}))"
                )
    if failures:
        print("theme contrast failures:", file=sys.stderr)
        for f in failures:
            print(f"  {f}", file=sys.stderr)
        return 1
    print(f"{len(themes)} themes x {len(PAIRINGS)} pairings"
          f" + {len(CHIP_ROLES)} GTK accent chips: all pass")
    return 0


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