<?php

namespace App\Support;

/**
 * Pure stateless WCAG 2.1 contrast math. Takes CSS `oklch(L C H)` color
 * strings (the only color form `ThemePalette::derive` emits), converts to
 * relative luminance via the canonical OKLCH → OKLab → linear sRGB path
 * (Björn Ottosson reference), and returns the WCAG 2.1 contrast ratio.
 *
 * Mirror in TS: `resources/js/lib/colorContrast.ts` — same math, same
 * result. Sprint 1's ThemePalette parity discipline applies: any rounding
 * here must match the TS twin's rounding direction so PHP and TS produce
 * the same ratio for the same input within FP tolerance.
 *
 * The OKLab → linear sRGB matrix is the inverse of the LMS → linear sRGB
 * forward matrix; we keep linear sRGB and skip the gamma-encoding step
 * because WCAG relative luminance is computed on linear values.
 */
class ColorContrast
{
    /**
     * Parse a CSS `oklch(L C H)` expression into a [L, C, H] float triple.
     * Tolerates the legacy comma-separated form too. Returns null on parse
     * failure (caller decides whether to treat that as a "fail" badge or a
     * silent skip).
     *
     * @return array{0: float, 1: float, 2: float}|null
     */
    public static function parseOklch(string $css): ?array
    {
        $normalised = trim($css);
        // Accept both space- and comma-separated forms; CSS Color 4 spec
        // allows either. Strip the optional `/ alpha` suffix — we don't
        // factor alpha into contrast (background blending isn't WCAG
        // territory).
        if (! preg_match(
            '/oklch\(\s*([-+\d.]+%?)\s*[,\s]\s*([-+\d.]+)\s*[,\s]\s*([-+\d.]+)(?:\s*\/\s*[-+\d.]+%?)?\s*\)/i',
            $normalised,
            $m,
        )) {
            return null;
        }
        $rawL = $m[1];
        $l = str_ends_with($rawL, '%')
            ? ((float) substr($rawL, 0, -1)) / 100.0
            : (float) $rawL;
        $c = (float) $m[2];
        $h = (float) $m[3];

        if (! is_finite($l) || ! is_finite($c) || ! is_finite($h)) {
            return null;
        }

        return [$l, $c, $h];
    }

    /**
     * OKLCH → linear sRGB. Reference: https://bottosson.github.io/posts/oklab/
     * H is in degrees in CSS oklch and gets converted to radians. The cube
     * step `(L + ka * a + kb * b) ** 3` recovers LMS from L'M'S'; the matrix
     * step then maps LMS → linear sRGB (NOT gamma-encoded sRGB — WCAG luminance
     * is on linear values).
     *
     * @return array{0: float, 1: float, 2: float}
     */
    public static function oklchToLinearSrgb(float $l, float $c, float $h): array
    {
        $hRad = deg2rad($h);
        $a = $c * cos($hRad);
        $b = $c * sin($hRad);

        $lLms = $l + 0.3963377774 * $a + 0.2158037573 * $b;
        $mLms = $l - 0.1055613458 * $a - 0.0638541728 * $b;
        $sLms = $l - 0.0894841775 * $a - 1.2914855480 * $b;

        $lLms3 = $lLms * $lLms * $lLms;
        $mLms3 = $mLms * $mLms * $mLms;
        $sLms3 = $sLms * $sLms * $sLms;

        $r = 4.0767416621 * $lLms3 - 3.3077115913 * $mLms3 + 0.2309699292 * $sLms3;
        $g = -1.2684380046 * $lLms3 + 2.6097574011 * $mLms3 - 0.3413193965 * $sLms3;
        $bLin = -0.0041960863 * $lLms3 - 0.7034186147 * $mLms3 + 1.7076147010 * $sLms3;

        return [$r, $g, $bLin];
    }

    /**
     * Linear sRGB → relative luminance per WCAG 2.1.
     * Y = 0.2126 R + 0.7152 G + 0.0722 B.
     * The triple is already in linear space (from OKLab path) so no gamma
     * decode is needed. Clamps negatives to 0 — out-of-gamut colors can
     * produce slightly-negative components which would otherwise drag the
     * luminance below the WCAG-defined [0, 1] range.
     *
     * @param  array{0: float, 1: float, 2: float}  $rgb
     */
    public static function relativeLuminance(array $rgb): float
    {
        $r = max(0.0, min(1.0, $rgb[0]));
        $g = max(0.0, min(1.0, $rgb[1]));
        $b = max(0.0, min(1.0, $rgb[2]));

        return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
    }

    /**
     * Two CSS color strings → contrast ratio per WCAG 2.1.
     * Ratio is symmetric (max+0.05) / (min+0.05). Returns 1.0 on parse
     * failure — equal luminance is the most-conservative reading because
     * any threshold check will fail open.
     */
    public static function ratio(string $fg, string $bg): float
    {
        $fgLum = self::luminanceOf($fg);
        $bgLum = self::luminanceOf($bg);
        if ($fgLum === null || $bgLum === null) {
            return 1.0;
        }

        $light = max($fgLum, $bgLum);
        $dark = min($fgLum, $bgLum);

        return ($light + 0.05) / ($dark + 0.05);
    }

    /**
     * Ratio → grade. Body text thresholds:
     *   ≥ 7   → AAA
     *   ≥ 4.5 → AA
     *   ≥ 3   → AA-large (only valid for ≥ 18px or ≥ 14px bold text)
     *   else  → fail
     */
    public static function grade(float $ratio): string
    {
        if ($ratio >= 7.0) {
            return 'AAA';
        }
        if ($ratio >= 4.5) {
            return 'AA';
        }
        if ($ratio >= 3.0) {
            return 'AA-large';
        }

        return 'fail';
    }

    /**
     * Convert any supported CSS color string to its WCAG relative
     * luminance, or null on parse failure. Currently only accepts
     * `oklch(...)` — that's the only form `ThemePalette` emits.
     */
    private static function luminanceOf(string $css): ?float
    {
        $parsed = self::parseOklch($css);
        if ($parsed === null) {
            return null;
        }
        $rgb = self::oklchToLinearSrgb($parsed[0], $parsed[1], $parsed[2]);

        return self::relativeLuminance($rgb);
    }
}
