<?php

namespace App\Domain\Shared\Branding;

/**
 * A full 50…900 shade ramp derived from one tenant colour, plus a foreground
 * colour that actually contrasts with the base.
 *
 * The ERP hardcoded `#fff` as the text colour on the brand accent
 * (components/layouts/sidebar.blade.php:184). A tenant whose brand is yellow
 * got white text on yellow — about 1.3:1, unreadable, and no amount of design
 * work downstream can rescue it because the value is baked into the markup.
 *
 * The ramp is generated in OKLCH, where lightness is perceptual: stepping L
 * evenly produces shades that look evenly spaced, which stepping HSL lightness
 * does not. It is computed once per branding_version in PHP and emitted as
 * custom properties, never with color-mix() at render time — a color-mix()
 * against a non-inline @theme variable resolves against the build-time copy,
 * not the tenant's runtime value.
 */
final readonly class ColorRamp
{
    /**
     * @param  array<int, string>  $shades  50…900 → oklch() strings
     * @param  string  $foreground  black or white, whichever contrasts with base
     */
    public function __construct(
        public string $base,
        public array $shades,
        public string $foreground,
        public string $interactive,
        public string $interactiveOnDark,
    ) {}

    /**
     * step => [how far to travel from the base colour, share of its chroma].
     *
     * The lightness is anchored on the tenant's own colour and travels toward
     * a near-white or near-black limit, rather than aiming at fixed targets.
     * Fixed targets break for any brand that is inherently light or dark: a
     * yellow sits at L 0.86, so a table that puts 400 at L 0.70 makes 400
     * *darker* than 500 and the ramp stops meaning anything.
     *
     * The chroma share is deliberately steep at the pale end. At L 0.97 a
     * chroma of 0.10 is outside sRGB entirely, and the browser clips it to
     * something muddy — the pale shades of a real ramp keep only a few percent
     * of the base saturation.
     *
     * 500 is the tenant's own colour, untouched.
     */
    private const LIGHT_LIMIT = 0.985;
    private const DARK_LIMIT = 0.180;

    private const STEPS = [
        50 => [-0.92, 0.06],
        100 => [-0.82, 0.12],
        200 => [-0.66, 0.22],
        300 => [-0.46, 0.40],
        400 => [-0.24, 0.70],
        500 => [0.00, 1.00],
        600 => [0.13, 0.98],
        700 => [0.30, 0.90],
        800 => [0.50, 0.78],
        900 => [0.70, 0.62],
    ];

    public static function fromHex(string $hex): self
    {
        [$r, $g, $b] = self::hexToRgb($hex);
        [$l, $c, $h] = self::rgbToOklch($r, $g, $b);

        $shades = [];

        foreach (self::STEPS as $step => [$travel, $chromaShare]) {
            $lightness = $travel < 0
                ? $l + (self::LIGHT_LIMIT - $l) * -$travel
                : $l - ($l - self::DARK_LIMIT) * $travel;

            $shades[$step] = self::oklch($lightness, $c * $chromaShare, $h);
        }

        return new self(
            base: self::normalizeHex($hex),
            shades: $shades,
            foreground: self::readableForeground($r, $g, $b),
            interactive: self::interactive($l, $c, $h),
            interactiveOnDark: self::interactiveOnDark($l, $c, $h),
        );
    }

    /** The darkest surface the product paints, used as the dark-mode reference. */
    public const DARK_SURFACE = '#1a1d24';

    /**
     * The colour a link, an active tab or a chosen segment is drawn in, on a
     * light surface.
     *
     * Shade 600 was doing this job, and it cannot: the ramp is anchored on the
     * tenant's own colour, so for a navy at L 0.20 every shade from 500 up sits
     * within 0.02 of it and the "link" renders as black text. A member cannot
     * tell it apart from a heading. At the other end a yellow tenant's 600 is
     * L 0.78 — about 2:1 on white, illegible.
     *
     * So this is not a shade. It is the brand's hue and chroma placed at a
     * lightness that is unmistakably a colour and still passes 4.5:1 against
     * the surface it is read on, walked down in small steps until it does.
     * A brand that is already in that band comes back unchanged.
     */
    private static function interactive(float $l, float $c, float $h): string
    {
        // The floor is 0.42, not something closer to the body text: --portal-ink
        // sits at L 0.22, and a "link" at L 0.36 is a barely lighter shade of
        // the paragraph around it. A member has to be able to see that it is a
        // different colour, not merely measure that it is.
        $lightness = max(0.42, min(0.52, $l));

        for ($i = 0; $i < 12; $i++) {
            [$r, $g, $b] = self::oklchToRgb($lightness, self::linkChroma($c), $h);

            $hex = sprintf('#%02x%02x%02x', $r, $g, $b);

            if (self::contrastRatio($hex, '#ffffff') >= 4.5) {
                return $hex;
            }

            $lightness -= 0.02;
        }

        // Twelve steps down from 0.52 is L 0.28, which is dark enough on white
        // for any hue. Reaching here means the conversion is wrong, not the
        // colour, and a legible near-black beats an illegible brand colour.
        return '#1f2937';
    }

    /**
     * The same colour for a dark surface, walked the other way.
     *
     * A dark-mode portal painting links in the light-mode value gives a navy
     * tenant navy-on-near-black. The reference surface is the darkest card the
     * product paints, so a value that passes here passes on every lighter one.
     */
    private static function interactiveOnDark(float $l, float $c, float $h): string
    {
        $lightness = max(0.68, min(0.86, $l));

        for ($i = 0; $i < 12; $i++) {
            [$r, $g, $b] = self::oklchToRgb($lightness, self::linkChroma($c), $h);
            $hex = sprintf('#%02x%02x%02x', $r, $g, $b);

            if (self::contrastRatio($hex, self::DARK_SURFACE) >= 4.5) {
                return $hex;
            }

            $lightness += 0.02;
        }

        return '#e5e7eb';
    }

    /**
     * Chroma is held up at the pale end — dropping a yellow to L 0.45 with its
     * own chroma reads as brown, and the point of the colour is that a member
     * recognises it as the academy's. A brand that is genuinely achromatic
     * (a black or white primary) stays grey rather than being handed a hue it
     * never had.
     */
    private static function linkChroma(float $c): float
    {
        return $c < 0.02 ? $c : max($c, 0.06);
    }

    /**
     * A colour made legible *as text* on a given surface, keeping its hue.
     *
     * The semantic palette is chosen to be seen as a fill: amber #f59e0b is
     * about 2:1 on white and red #ef4444 about 3.7:1, so every error message,
     * overdue figure and status chip in the product was drawn below AA while
     * the colour itself was doing its job perfectly on a filled button. This
     * darkens (or, on a dark surface, lightens) until it clears 4.5:1 and stops
     * the moment it does — a colour that already passes is returned untouched.
     */
    public static function ink(string $hex, string $surface = '#ffffff'): string
    {
        if (self::contrastRatio($hex, $surface) >= 4.5) {
            return self::normalizeHex($hex);
        }

        [$r, $g, $b] = self::hexToRgb($hex);
        [$l, $c, $h] = self::rgbToOklch($r, $g, $b);

        [$sr, $sg, $sb] = self::hexToRgb($surface);
        $towardsDark = self::relativeLuminance($sr, $sg, $sb) > 0.18;
        $step = $towardsDark ? -0.03 : 0.03;

        for ($i = 0; $i < 22; $i++) {
            $l += $step;

            if ($l <= 0.0 || $l >= 1.0) {
                break;
            }

            [$r, $g, $b] = self::oklchToRgb($l, $c, $h);
            $candidate = sprintf('#%02x%02x%02x', $r, $g, $b);

            if (self::contrastRatio($candidate, $surface) >= 4.5) {
                return $candidate;
            }
        }

        return $towardsDark ? '#1f2937' : '#e5e7eb';
    }

    /**
     * WCAG relative luminance, then whichever of black or white gives the
     * higher contrast ratio against it. Both candidates are checked rather
     * than assuming a luminance threshold, because the threshold sits at a
     * different place for different hues.
     */
    public static function readableForeground(int $r, int $g, int $b): string
    {
        $luminance = self::relativeLuminance($r, $g, $b);

        $againstWhite = 1.05 / ($luminance + 0.05);
        $againstBlack = ($luminance + 0.05) / 0.05;

        return $againstBlack >= $againstWhite ? '#111827' : '#ffffff';
    }

    public static function contrastRatio(string $hexA, string $hexB): float
    {
        [$r1, $g1, $b1] = self::hexToRgb($hexA);
        [$r2, $g2, $b2] = self::hexToRgb($hexB);

        $l1 = self::relativeLuminance($r1, $g1, $b1);
        $l2 = self::relativeLuminance($r2, $g2, $b2);

        [$light, $dark] = $l1 >= $l2 ? [$l1, $l2] : [$l2, $l1];

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

    private static function relativeLuminance(int $r, int $g, int $b): float
    {
        $channel = function (int $v): float {
            $s = $v / 255;

            return $s <= 0.03928 ? $s / 12.92 : (($s + 0.055) / 1.055) ** 2.4;
        };

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

    /** @return array{int, int, int} */
    public static function hexToRgb(string $hex): array
    {
        $hex = ltrim(trim($hex), '#');

        if (strlen($hex) === 3) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }

        if (! preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
            $hex = '2563eb';
        }

        return [
            (int) hexdec(substr($hex, 0, 2)),
            (int) hexdec(substr($hex, 2, 2)),
            (int) hexdec(substr($hex, 4, 2)),
        ];
    }

    public static function normalizeHex(string $hex): string
    {
        [$r, $g, $b] = self::hexToRgb($hex);

        return sprintf('#%02x%02x%02x', $r, $g, $b);
    }

    /**
     * sRGB → linear → CIE XYZ (D65) → OKLab → OKLCH.
     *
     * @return array{float, float, float} [L 0..1, C, H degrees]
     */
    public static function rgbToOklch(int $r, int $g, int $b): array
    {
        $lin = function (int $v): float {
            $s = $v / 255;

            return $s <= 0.04045 ? $s / 12.92 : (($s + 0.055) / 1.055) ** 2.4;
        };

        $lr = $lin($r);
        $lg = $lin($g);
        $lb = $lin($b);

        $l = 0.4122214708 * $lr + 0.5363325363 * $lg + 0.0514459929 * $lb;
        $m = 0.2119034982 * $lr + 0.6806995451 * $lg + 0.1073969566 * $lb;
        $s = 0.0883024619 * $lr + 0.2817188376 * $lg + 0.6299787005 * $lb;

        $l_ = $l ** (1 / 3);
        $m_ = $m ** (1 / 3);
        $s_ = $s ** (1 / 3);

        $okL = 0.2104542553 * $l_ + 0.7936177850 * $m_ - 0.0040720468 * $s_;
        $okA = 1.9779984951 * $l_ - 2.4285922050 * $m_ + 0.4505937099 * $s_;
        $okB = 0.0259040371 * $l_ + 0.7827717662 * $m_ - 0.8086757660 * $s_;

        $chroma = sqrt($okA ** 2 + $okB ** 2);
        $hue = rad2deg(atan2($okB, $okA));

        if ($hue < 0) {
            $hue += 360;
        }

        return [$okL, $chroma, $hue];
    }

    private static function oklch(float $l, float $c, float $h): string
    {
        return sprintf('oklch(%.4f %.4f %.2f)', max(0, min(1, $l)), max(0, $c), $h);
    }

    /**
     * OKLCH → OKLab → LMS → linear sRGB → sRGB, the exact inverse of
     * rgbToOklch(). Out-of-gamut channels are clamped, which is what a browser
     * does with an oklch() it cannot display.
     *
     * @return array{int, int, int}
     */
    public static function oklchToRgb(float $l, float $c, float $h): array
    {
        $a = $c * cos(deg2rad($h));
        $b = $c * sin(deg2rad($h));

        $l_ = $l + 0.3963377774 * $a + 0.2158037573 * $b;
        $m_ = $l - 0.1055613458 * $a - 0.0638541728 * $b;
        $s_ = $l - 0.0894841775 * $a - 1.2914855480 * $b;

        $lms = [$l_ ** 3, $m_ ** 3, $s_ ** 3];

        $linear = [
            4.0767416621 * $lms[0] - 3.3077115913 * $lms[1] + 0.2309699292 * $lms[2],
            -1.2684380046 * $lms[0] + 2.6097574011 * $lms[1] - 0.3413193965 * $lms[2],
            -0.0041960863 * $lms[0] - 0.7034186147 * $lms[1] + 1.7076147010 * $lms[2],
        ];

        return array_map(function (float $v): int {
            $v = max(0.0, min(1.0, $v));
            $encoded = $v <= 0.0031308 ? 12.92 * $v : 1.055 * $v ** (1 / 2.4) - 0.055;

            return (int) round(max(0.0, min(1.0, $encoded)) * 255);
        }, $linear);
    }
}
