#!/usr/bin/env python3
"""WCAG 2.2 sRGB text contrast arithmetic, with a rounding failure case.

Run: python3 text-contrast-lab.py --json text-contrast-results.json \
     --svg text-contrast-samples.svg
Only opaque #RRGGBB sRGB colors are supported. No CSS parsing, alpha
compositing, DOM inspection, browser rendering, or accessibility audit.
Uses the formula documented at https://www.w3.org/WAI/WCAG22/Techniques/general/G18
"""

import argparse
from datetime import datetime
import html
import json
import math
from pathlib import Path
import platform
import re


def parse_hex(color):
    if not isinstance(color, str) or re.fullmatch(r"#[0-9a-fA-F]{6}", color) is None:
        raise ValueError("expected one opaque sRGB color in #RRGGBB format")
    return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))


def linear_channel(channel):
    value = channel / 255
    return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4


def luminance(color):
    red, green, blue = (linear_channel(channel) for channel in parse_hex(color))
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue


def contrast_ratio(foreground, background):
    darker, lighter = sorted((luminance(foreground), luminance(background)))
    return (lighter + 0.05) / (darker + 0.05)


def meets_threshold(raw_ratio, threshold=4.5):
    return raw_ratio >= threshold


def rejected(color):
    try:
        parse_hex(color)
    except ValueError:
        return
    raise AssertionError("unsupported color was accepted: " + repr(color))


def write_samples(path, cases):
    selected = [case for case in cases if case["case"] in
                ("black", "gray_118", "gray_119", "rounding_trap", "red", "blue")]
    height = 148 + 56 * len(selected)
    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="{height}" viewBox="0 0 960 {height}">',
             '<title>Opaque sRGB text color pairs on white</title>',
             '<desc>Compare raw color-pair ratios. A displayed 4.50 may still be below 4.5.</desc>',
             '<rect width="960" height="100%" fill="#ffffff"/>',
             '<g font-family="Arial, sans-serif" fill="#111111">',
             '<text x="28" y="36" font-size="24" font-weight="bold">Same white background. Different contrast results.</text>',
             '<text x="28" y="65" font-size="15">Decision: unrounded ratio &gt;= 4.5. Preview text is illustrative.</text>',
             '<text x="28" y="105" font-size="16">Foreground</text>',
             '<text x="218" y="105" font-size="16">Sample text</text>',
             '<text x="580" y="105" font-size="16">Ratio (6 decimals)</text>',
             '<text x="800" y="105" font-size="16">4.5 threshold</text>']
    for index, case in enumerate(selected):
        y = 145 + index * 56
        color = html.escape(case["foreground"])
        result = "PASS" if case["meets_4_5"] else "FAIL"
        parts.extend([f'<line x1="28" y1="{y + 15}" x2="932" y2="{y + 15}" stroke="#dddddd"/>',
                      f'<text x="28" y="{y}" font-size="18">{color}</text>',
                      f'<text x="218" y="{y}" font-size="20" fill="{color}">Gumi / Sample text</text>',
                      f'<text x="580" y="{y}" font-size="18">{case["ratio_raw"]:.6f}:1</text>',
                      f'<text x="800" y="{y}" font-size="18" font-weight="bold">{result}</text>'])
    parts.extend(['</g>', '</svg>'])
    path.write_text('\n'.join(parts) + '\n', encoding="utf-8")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", type=Path)
    parser.add_argument("--svg", type=Path)
    options = parser.parse_args()
    pairs = [
        ("black", "#000000", 21.0, True),
        ("gray_118", "#767676", 4.542224959605253, True),
        ("gray_119", "#777777", 4.478089453577214, False),
        ("rounding_trap", "#707972", 4.49979546274635, False),
        ("gray_136", "#888888", 3.5448862152994005, False),
        ("red", "#FF0000", 3.9984767707539985, False),
        ("green", "#00FF00", 1.3721902770517513, False),
        ("blue", "#0000FF", 8.592471358428805, True),
    ]
    cases = []
    for name, foreground, expected, normal_pass in pairs:
        raw = contrast_ratio(foreground, "#FFFFFF")
        assert math.isclose(raw, expected, rel_tol=0, abs_tol=1e-12)
        assert meets_threshold(raw) is normal_pass
        cases.append({"case": name, "foreground": foreground, "background": "#FFFFFF",
                      "foreground_luminance": luminance(foreground), "ratio_raw": raw,
                      "ratio_display_2dp": f"{raw:.2f}", "meets_4_5": meets_threshold(raw),
                      "meets_3_0": meets_threshold(raw, 3.0)})
    trap = next(case for case in cases if case["case"] == "rounding_trap")
    assert trap["ratio_raw"] < 4.5 and float(trap["ratio_display_2dp"]) >= 4.5
    assert meets_threshold(4.5) and not meets_threshold(4.499)
    assert not meets_threshold(math.nextafter(4.5, 0.0))
    assert contrast_ratio("#FFFFFF", "#FFFFFF") == 1.0
    assert contrast_ratio("#000000", "#FFFFFF") == 21.0
    assert contrast_ratio("#707972", "#FFFFFF") == contrast_ratio("#FFFFFF", "#707972")
    assert math.isclose(linear_channel(10), (10 / 255) / 12.92, abs_tol=1e-15)
    assert math.isclose(linear_channel(11), ((11 / 255 + 0.055) / 1.055) ** 2.4, abs_tol=1e-15)
    gray_ratios = [contrast_ratio(f"#{channel:02x}{channel:02x}{channel:02x}", "#FFFFFF")
                   for channel in range(256)]
    assert all(a > b for a, b in zip(gray_ratios, gray_ratios[1:]))
    assert contrast_ratio("#ffffff", "#000000") == 21.0
    for color in ("#GG0000", "#000", "000000", None):
        rejected(color)
    rejected("#00000080")
    rejected("color(display-p3 1 0 0)")
    primary_means = [sum(parse_hex(color)) / 3 for color in ("#FF0000", "#00FF00", "#0000FF")]
    assert primary_means == [85.0, 85.0, 85.0]
    encoded_example = {"color": "#808080", "normalized_channel": 128 / 255,
                       "relative_luminance": luminance("#808080"),
                       "correct_ratio_on_white": contrast_ratio("#808080", "#FFFFFF"),
                       "incorrect_ratio_using_encoded_channel": 1.05 / (128 / 255 + 0.05)}
    assert math.isclose(encoded_example["correct_ratio_on_white"], 3.9494396480491156, abs_tol=1e-12)
    assert math.isclose(encoded_example["incorrect_ratio_using_encoded_channel"], 1.9023090586145648, abs_tol=1e-12)
    checks = ["exact 4.5 accepted", "4.499 rejected", "next float below 4.5 rejected",
              "identical colors give 1", "black and white give 21", "foreground/background symmetry",
              "both transfer-function branches", "256 gray levels decrease monotonically",
              "lowercase hex accepted", "malformed or unsupported hex rejected", "alpha input rejected",
              "Display P3 input rejected", "rounded-display trap reproduced", "RGB mean counterexample",
              "encoded-channel counterexample"]
    report = {"run_date": datetime.now().astimezone().date().isoformat(),
              "python": platform.python_version(), "platform": platform.system(),
              "standard": "WCAG 2.2", "scope": "opaque 8-bit #RRGGBB sRGB pairs; arithmetic only",
              "formula_source": "https://www.w3.org/WAI/WCAG22/Techniques/general/G18",
              "cases": cases, "boundary_checks": checks,
              "rgb_mean_counterexample": {"colors": ["#FF0000", "#00FF00", "#0000FF"],
                                           "means": primary_means},
              "encoding_counterexample": encoded_example}
    print(f"Python {report['python']} on {report['platform']}")
    for case in cases:
        print(f"{case['foreground']} on white: {case['ratio_raw']:.12f}:1; "
              f"shown={case['ratio_display_2dp']}; >=4.5={case['meets_4_5']}; >=3={case['meets_3_0']}")
    print(f"All {len(cases)} color cases and {len(checks)} boundary checks passed.")
    if options.json:
        options.json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding="utf-8")
        print("JSON written:", options.json)
    if options.svg:
        write_samples(options.svg, cases)
        print("SVG written:", options.svg)


if __name__ == "__main__":
    main()
