#!/usr/bin/env python3
"""cc_check.py: read-only WCAG contrast checker for CSS (standard library only).

Usage:
  python3 cc_check.py FILE.css [FILE2.css ...] [--page-bg #fff] [--dark-bg #121212] [--aaa] [--strict]
  python3 cc_check.py --pair FG BG [--large] [--ui] [--suggest]

Exit 0 = no errors, 1 = errors (or warnings with --strict), 2 = bad input.
It reads files and prints; it writes nothing and makes no network calls.
"""
import re, sys, math, colorsys

NAMED = {"black": "#000000", "white": "#ffffff", "red": "#ff0000", "green": "#008000", "blue": "#0000ff",
         "gray": "#808080", "grey": "#808080", "silver": "#c0c0c0", "navy": "#000080", "teal": "#008080",
         "orange": "#ffa500", "yellow": "#ffff00", "purple": "#800080", "maroon": "#800000", "lime": "#00ff00",
         "aqua": "#00ffff", "fuchsia": "#ff00ff", "olive": "#808000", "lightgray": "#d3d3d3", "lightgrey": "#d3d3d3",
         "darkgray": "#a9a9a9", "darkgrey": "#a9a9a9", "transparent": "#00000000"}


def parse_color(s):
    """Return (r, g, b, a) with r,g,b 0-255 and a 0-1, or None if it cannot be resolved."""
    s = s.strip().lower().replace("!important", "").strip()
    s = NAMED.get(s, s)
    m = re.fullmatch(r"#([0-9a-f]{3,8})", s)
    if m:
        h = m.group(1)
        if len(h) in (3, 4):
            h = "".join(c * 2 for c in h)
        if len(h) == 6:
            h += "ff"
        if len(h) != 8:
            return None
        v = [int(h[i:i + 2], 16) for i in (0, 2, 4, 6)]
        return (v[0], v[1], v[2], v[3] / 255)
    m = re.fullmatch(r"(rgba?|hsla?)\(\s*(.*?)\s*\)", s)
    if not m:
        return None
    fn, body = m.groups()
    parts = [p for p in re.split(r"[\s,/]+", body) if p]
    if len(parts) not in (3, 4):
        return None
    try:
        a = 1.0
        if len(parts) == 4:
            a = float(parts[3][:-1]) / 100 if parts[3].endswith("%") else float(parts[3])
        if fn.startswith("rgb"):
            v = [float(p[:-1]) * 2.55 if p.endswith("%") else float(p) for p in parts[:3]]
            return (v[0], v[1], v[2], a)
        h = float(re.sub(r"deg$", "", parts[0])) % 360 / 360
        sat = float(parts[1].rstrip("%")) / 100
        lig = float(parts[2].rstrip("%")) / 100
        r, g, b = colorsys.hls_to_rgb(h, lig, sat)
        return (r * 255, g * 255, b * 255, a)
    except ValueError:
        return None


def lin(c):
    c = c / 255
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def lum(rgb):
    return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2])


def ratio(a, b):
    la, lb = lum(a), lum(b)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)


def blend(fg, bg, alpha=None):
    a = fg[3] if alpha is None else alpha
    return tuple(fg[i] * a + bg[i] * (1 - a) for i in range(3))


def floor2(x):
    return math.floor(x * 100) / 100  # never round a ratio up (W3C: 2.999 fails)


def to_hex(rgb):
    return "#%02x%02x%02x" % tuple(max(0, min(255, round(v))) for v in rgb[:3])


def suggest(fg, bg, need):
    """Move fg lightness in HSL until the ratio reaches `need`; return hex or None."""
    h, l, s = colorsys.rgb_to_hls(fg[0] / 255, fg[1] / 255, fg[2] / 255)
    best = None
    for direction in (-1, 1):
        for step in range(1, 101):
            nl = l + direction * step / 100
            if not 0 <= nl <= 1:
                break
            r, g, b = colorsys.hls_to_rgb(h, nl, s)
            cand = (r * 255, g * 255, b * 255)
            if ratio(cand, bg) >= need:
                if best is None or step < best[0]:
                    best = (step, to_hex(cand))
                break
    return best[1] if best else None


# ---------- CSS parsing ----------
def strip_comments(t):
    return re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), t, flags=re.S)


def parse_blocks(text, ctx="", base=0, out=None):
    """Yield (selector, declarations dict, line, media-context)."""
    if out is None:
        out = []
    i, n = 0, len(text)
    while i < n:
        j = text.find("{", i)
        if j < 0:
            break
        head = text[i:j].strip()
        depth, k = 1, j + 1
        while k < n and depth:
            depth += {"{": 1, "}": -1}.get(text[k], 0)
            k += 1
        body = text[j + 1:k - 1]
        line = text.count("\n", 0, j) + 1 + base
        if head.startswith("@media") or head.startswith("@supports") or head.startswith("@layer"):
            parse_blocks(body, ctx + " " + head, text.count("\n", 0, j) + base, out)
        elif not head.startswith("@"):
            decls = {}
            for d in body.split(";"):
                if ":" in d:
                    p, v = d.split(":", 1)
                    decls[p.strip().lower()] = v.strip()
            out.append((re.sub(r"\s+", " ", head), decls, line, ctx))
        i = k
    return out


def resolve(val, vars_, depth=0):
    """Resolve var(--x, fallback). Returns resolved string or None."""
    if val is None:
        return None
    for _ in range(6):
        m = re.search(r"var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)", val)
        if not m:
            return val
        rep = vars_.get(m.group(1), m.group(2))
        if rep is None:
            return None
        val = val[:m.start()] + rep.strip() + val[m.end():]
    return val


def font_px(decls, vars_):
    fs = resolve(decls.get("font-size"), vars_)
    if not fs:
        return None
    m = re.fullmatch(r"([\d.]+)(px|rem|em|pt)", fs.strip())
    if not m:
        return None
    v, u = float(m.group(1)), m.group(2)
    return v * {"px": 1, "rem": 16, "em": 16, "pt": 4 / 3}[u]


def is_large(decls, vars_):
    px = font_px(decls, vars_)
    if px is None:
        return False
    bold = decls.get("font-weight", "400").strip() in ("bold", "600", "700", "800", "900")
    return px >= 24 or (bold and px >= 18.66)


def main(argv):
    flags = {"--aaa", "--strict", "--large", "--ui", "--suggest"}
    opts = {"--page-bg": "#ffffff", "--dark-bg": "#121212"}
    files, i, pair = [], 0, None
    while i < len(argv):
        a = argv[i]
        if a == "--pair":
            pair = argv[i + 1:i + 3]
            i += 3
            continue
        if a in opts:
            opts[a] = argv[i + 1]
            i += 2
            continue
        if a in flags:
            opts[a] = True
        else:
            files.append(a)
        i += 1
    if pair is not None:
        fg, bg = parse_color(pair[0]), parse_color(pair[1]) if len(pair) == 2 else None
        if not fg or not bg:
            print("error: cannot parse colours; use hex, rgb(), hsl() or a basic name"); return 2
        eff = blend(fg, bg)
        r = ratio(eff, bg)
        need = 3.0 if (opts.get("--large") or opts.get("--ui")) else 4.5
        if opts.get("--aaa") and not opts.get("--ui"):
            need = 4.5 if opts.get("--large") else 7.0
        ok = r >= need
        print("ratio %.2f:1 (shown rounded down; exact %.4f) need %.1f -> %s" % (floor2(r), r, need, "PASS" if ok else "FAIL"))
        if fg[3] < 1:
            print("note: foreground alpha %.2f blended over background gives %s" % (fg[3], to_hex(eff)))
        if not ok and opts.get("--suggest"):
            s = suggest(eff, bg, need)
            print("nearest passing foreground (same hue and saturation): %s" % (s or "none found; change the background"))
        return 0 if ok else 1
    if not files:
        print(__doc__); return 2
    page_bg, dark_bg = parse_color(opts["--page-bg"]), parse_color(opts["--dark-bg"])
    if not page_bg or not dark_bg:
        print("error: bad --page-bg or --dark-bg"); return 2
    counts = {"error": 0, "warn": 0, "info": 0}
    buf = []
    for path in files:
        try:
            text = strip_comments(open(path, encoding="utf-8", errors="replace").read())
        except OSError as e:
            print("error: %s" % e); return 2
        blocks = parse_blocks(text)
        vars_, dvars = {}, {}
        for sel, decls, line, ctx in blocks:
            for k, v in decls.items():
                if k.startswith("--"):
                    (dvars if "prefers-color-scheme: dark" in ctx or "prefers-color-scheme:dark" in ctx or "dark" in sel and ":root" in sel else vars_)[k] = v
        for sel, decls, line, ctx in blocks:
            dark = "prefers-color-scheme: dark" in ctx or "prefers-color-scheme:dark" in ctx
            vs = {**vars_, **(dvars if dark else {})}
            bgp = dark_bg if dark else page_bg
            label = "%s:%d" % (path.split("/")[-1], line)

            def emit(level, code, msg):
                counts[level] += 1
                buf.append("%s  %s  %s  %s: %s" % (label, level, code, sel[:60], msg))
            if re.search(r":disabled|\[disabled|aria-disabled", sel):
                continue
            fgv = resolve(decls.get("color"), vs)
            bgv = resolve(decls.get("background-color") or decls.get("background"), vs)
            imgbg = bool(re.search(r"gradient|url\(", (decls.get("background", "") + decls.get("background-image", ""))))
            if imgbg:
                emit("info", "image-bg", "gradient or image background: text check skipped; test the lowest-contrast spot by hand")
                fgv = None
                bgv = None
            bg = parse_color(bgv) if bgv else None
            if bgv and not bg and not re.search(r"gradient|url\(", bgv):
                emit("info", "unresolved", "background '%s' cannot be parsed statically; measure in the browser (scripts/probe.js)" % bgv[:30])
            base_bg = blend(bg, bgp) if bg and bg[3] < 1 else (bg[:3] if bg else bgp[:3])
            assumed = bg is None
            if fgv:
                fg = parse_color(fgv)
                if not fg:
                    emit("info", "unresolved", "color '%s' cannot be parsed statically (oklch, color-mix, currentcolor or unknown var); measure in the browser" % fgv[:30])
                else:
                    op = 1.0
                    try:
                        op = float(resolve(decls.get("opacity", "1"), vs))
                    except (TypeError, ValueError):
                        pass
                    eff = blend(fg, base_bg, fg[3] * op)
                    r = ratio(eff, base_bg)
                    large = is_large(decls, vs)
                    need = (4.5 if large else 7.0) if opts.get("--aaa") else (3.0 if large else 4.5)
                    isph = "::placeholder" in sel
                    if r < need:
                        why = " (alpha/opacity blended)" if fg[3] < 1 or op < 1 else ""
                        lvl = "warn" if assumed else "error"
                        code = "text-low-assumed-bg" if assumed else ("placeholder-low" if isph else "text-low")
                        fix = suggest(eff, base_bg, need)
                        emit(lvl, code, "%s on %s = %.2f, need %.1f%s%s. Nearest passing text colour %s" % (
                            to_hex(eff), to_hex(base_bg), floor2(r), need, " (large text)" if large else "", why, fix or "none; change background"))
                    elif fg[3] < 1 or op < 1:
                        emit("info", "alpha-text", "alpha or opacity used; blended ratio %.2f passes %.1f. Re-test if the background changes" % (floor2(r), need))
            for prop in ("border-color", "outline-color", "border", "outline"):
                v = decls.get(prop)
                m = re.search(r"(#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|var\([^)]*\))", v or "")
                if not m:
                    continue
                c = parse_color(resolve(m.group(1), vs) or "")
                if not c:
                    continue
                if "focus" in sel or prop.startswith("outline"):
                    r = ratio(blend(c, base_bg), base_bg)
                    if r < 3:
                        emit("warn", "ui-low", "%s %s on %s = %.2f, need 3.0 (focus or UI indicator, SC 1.4.11)" % (prop, to_hex(blend(c, base_bg)), to_hex(base_bg), floor2(r)))
                elif re.search(r"input|select|textarea|checkbox|radio|button", sel):
                    r = ratio(blend(c, base_bg), base_bg)
                    if r < 3:
                        emit("info", "ui-border-low", "%s %.2f against its background; needs 3.0 only if the border is the sole cue that this is a control" % (prop, floor2(r)))
    print("%d errors, %d warnings, %d info" % (counts["error"], counts["warn"], counts["info"]))
    order = {"error": 0, "warn": 1, "info": 2}
    for ln in sorted(buf, key=lambda x: order[x.split()[1]]):
        print(ln)
    return 1 if counts["error"] or (opts.get("--strict") and counts["warn"]) else 0


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except BrokenPipeError:
        sys.exit(1)
