<?php

namespace App\Support\Theme;

use Filament\Support\Colors\Color;
use Filament\Support\View\Components\ButtonComponent;

/**
 * Derives a COMPLETE token set — every custom property declared in
 * resources/css/app.css — for both light and dark mode from a compact preset
 * spec (hue / chroma / sidebar treatment).
 *
 * Two deliberate choices here, both fixing real defects in the old implementation:
 *
 * 1. Output is oklch(), matching app.css. The old Theme Manager wrote hex into
 *    --primary while the base tokens were oklch; Tailwind opacity modifiers and
 *    color-mix() then produced muddy or unexpected results.
 * 2. Foreground pairs are DERIVED, never picked. The old version let a landlord
 *    choose a pale primary while --primary-foreground stayed near-white, giving
 *    unreadable button labels. {@see self::onColor()} flips to dark text once the
 *    background passes the readability threshold.
 */
final class ThemePalette
{
    /** Dark neutral sidebar, faintly tinted with the brand hue (the current app default). */
    public const SIDEBAR_DARK = 'dark';

    /** Near-black neutral sidebar — maximum contrast, no brand tint. */
    public const SIDEBAR_INK = 'ink';

    /** Saturated brand-coloured sidebar. */
    public const SIDEBAR_DEEP = 'deep';

    /** Pale sidebar with dark text, for airy/minimal presets. */
    public const SIDEBAR_LIGHT = 'light';

    /**
     * Status colours are intentionally IDENTICAL across all presets. Red must read
     * as an error and green as success regardless of branding, so these are not
     * derived from the brand hue.
     *
     * @var array<string, array{0: float, 1: float, 2: float}>
     */
    private const STATUS = [
        'destructive' => [0.58, 0.22, 27],
        'success' => [0.62, 0.16, 150],
        'warning' => [0.75, 0.16, 75],
        'info' => [0.65, 0.13, 230],
    ];

    /** @var array<string, array{0: float, 1: float, 2: float}> */
    private const STATUS_DARK = [
        'destructive' => [0.62, 0.22, 27],
        'success' => [0.65, 0.16, 150],
        'warning' => [0.78, 0.16, 75],
        'info' => [0.68, 0.13, 230],
    ];

    /**
     * Lightness cutoff used only to decide which DIRECTION to shift a
     * background's muted/border layers (lighten vs darken toward the
     * opposite end) — a coarse categorisation, not a contrast promise. The
     * text colour itself always goes through {@see self::onColor()}, which
     * computes the real WCAG ratio instead of trusting this cutoff.
     */
    private const LIGHT_SURFACE_THRESHOLD = 0.70;

    /**
     * Chroma multiplier applied to the brand colour in dark mode, and equally to
     * the 400 stop of the Filament ramp — the two must agree, because that stop
     * IS the dark-mode brand colour as far as Filament's components are concerned.
     */
    private const DARK_PRIMARY_CHROMA = 0.94;

    /**
     * Chroma taper for a Filament shade ramp: full saturation across the middle,
     * easing at both ends so the pale tints stay believable and the darkest stops
     * do not go muddy. 600 is 1.0 and 400 is {@see self::DARK_PRIMARY_CHROMA}
     * deliberately — those are the two stops the ramp is anchored to.
     *
     * @var array<int, float>
     */
    private const RAMP_CHROMA = [
        50 => 0.16, 100 => 0.30, 200 => 0.50, 300 => 0.72, 400 => self::DARK_PRIMARY_CHROMA,
        500 => 1.0, 600 => 1.0, 700 => 0.96, 800 => 0.88, 900 => 0.80, 950 => 0.60,
    ];

    /** How far each tint travels from the 400 stop towards near-white. */
    private const RAMP_TINTS = [300 => 0.33, 200 => 0.58, 100 => 0.75, 50 => 0.90];

    /** How far each shade travels from the 600 stop towards {@see self::RAMP_FLOOR}. */
    private const RAMP_SHADES = [700 => 0.16, 800 => 0.34, 900 => 0.47, 950 => 0.75];

    /**
     * The 950 travel used instead for a PALE preset — one whose 600 stop cannot
     * carry white text at AA ({@see self::needsDarkLabel()}).
     *
     * Filament resolves button LABEL colours in PHP, not CSS: ButtonComponentColorMap
     * walks this ramp for the first stop >= 800 clearing 4.5:1 against the background
     * stop, then tries 50, then gives up and uses white. At the normal 0.75 travel a
     * pale preset has no qualifying stop, so its dark-mode button renders white text
     * on a mid-tone 600 background — Amber Glow measured 3.71:1, Mint Fresh 3.97:1,
     * Sky Breeze 4.16:1, Cyan Electric 4.20:1. Full travel gives those presets a
     * legible dark label.
     *
     * Applied ONLY to pale presets on purpose. Deepening 950 for everything also
     * earns shade 500 a dark label, and pickBackgroundShades() then abandons its
     * preferred (bg 600, hover 500) candidate for the paler (bg 400, hover 300)
     * fallback — so a mid-tone preset's buttons would render lighter than the
     * Master Control preview, which paints them from --primary at the 600 stop.
     */
    private const RAMP_SHADES_PALE_950 = 1.0;

    /**
     * The darkest lightness any brand ramp reaches, and the anchor the shade
     * fractions above are measured against. Matches the dark token set's
     * --background so a 950 surface and the page behind it agree.
     */
    private const RAMP_FLOOR = 0.16;

    /**
     * The neutral ramp, as [lightness, chroma multiple of neutral_chroma].
     *
     * Unlike the brand ramp this is a fixed curve, and it is deliberately shaped
     * so that ONE ramp satisfies both modes — which is all Filament allows. The
     * stops that carry real weight, verified against the compiled framework CSS:
     * 50 is the light page background, 200 the light border, 500 the light muted
     * text, 900 the dark card/topbar surface, and 950 does double duty as light
     * body text and the dark page background (0.18 and 0.16 in the token sets, so
     * a single 0.17 serves both).
     *
     * @var array<int, array{0: float, 1: float}>
     */
    private const RAMP_GRAY = [
        50 => [0.985, 0.5], 100 => [0.965, 0.8], 200 => [0.92, 1.0], 300 => [0.87, 1.2],
        400 => [0.68, 2.0], 500 => [0.50, 2.5], 600 => [0.42, 2.5], 700 => [0.35, 2.8],
        800 => [0.27, 3.0], 900 => [0.21, 3.0], 950 => [0.17, 2.5],
    ];

    /**
     * Normalise a preset spec, filling in the optional knobs.
     *
     * @param  array<string, mixed>  $spec
     * @return array{hue: float, chroma: float, primary_l: float, neutral_hue: float, neutral_chroma: float, accent_hue: float, sidebar: string}
     */
    private static function spec(array $spec): array
    {
        $hue = (float) ($spec['hue'] ?? 258);

        return [
            'hue' => $hue,
            'chroma' => (float) ($spec['chroma'] ?? 0.18),
            'primary_l' => (float) ($spec['primary_l'] ?? 0.52),
            'neutral_hue' => (float) ($spec['neutral_hue'] ?? $hue),
            'neutral_chroma' => (float) ($spec['neutral_chroma'] ?? 0.01),
            'accent_hue' => (float) ($spec['accent_hue'] ?? $hue),
            'sidebar' => (string) ($spec['sidebar'] ?? self::SIDEBAR_DARK),
        ];
    }

    /** Format an oklch triple, trimming trailing zeros so the emitted CSS stays readable. */
    private static function oklch(float $l, float $c, float $h): string
    {
        return sprintf('oklch(%s %s %s)', self::num($l), self::num(max($c, 0)), self::num(self::wrapHue($h)));
    }

    private static function num(float $value): string
    {
        $formatted = rtrim(rtrim(number_format($value, 4, '.', ''), '0'), '.');

        return $formatted === '' || $formatted === '-' ? '0' : $formatted;
    }

    private static function wrapHue(float $hue): float
    {
        return fmod(fmod($hue, 360) + 360, 360);
    }

    private static function clamp(float $value, float $min, float $max): float
    {
        return max($min, min($max, $value));
    }

    /**
     * The readable text colour to sit on top of a given background: whichever
     * of near-black or near-white actually wins on WCAG contrast ratio.
     *
     * A prior version picked purely on whether the OKLab lightness crossed a
     * fixed 0.70 threshold. That is only a rough proxy — a saturated, mid-lightness
     * colour (a chart-bright green or blue around L 0.6–0.7 is the common case)
     * can fail 4.5:1 against BOTH black and white, and the threshold had no way to
     * notice it had picked the worse of two bad options. Comparing real contrast
     * ratios and taking the winner fixed every such case found in testing (see
     * ThemePaletteTest) without needing to touch any preset's actual hue/chroma/L.
     */
    private static function onColor(float $backgroundL, float $hue, float $chroma): string
    {
        $darkChroma = min($chroma * 0.16, 0.04);
        $backgroundLum = self::relativeLuminance($backgroundL, $chroma, $hue);
        $darkLum = self::relativeLuminance(0.16, $darkChroma, $hue);
        $lightLum = self::relativeLuminance(0.99, 0.005, $hue);

        return self::contrastRatio($backgroundLum, $darkLum) >= self::contrastRatio($backgroundLum, $lightLum)
            ? self::oklch(0.16, $darkChroma, $hue)
            : self::oklch(0.99, 0.005, $hue);
    }

    /**
     * WCAG relative luminance of an OKLCH colour — converts OKLCH → OKLab →
     * LMS → linear sRGB (Björn Ottosson's inverse matrices, the reverse
     * direction of {@see self::hexToOklch}) and applies the standard
     * Rec. 709 luminance weights to the linear channels.
     */
    private static function relativeLuminance(float $l, float $c, float $h): float
    {
        $hRad = deg2rad($h);
        $a = $c * cos($hRad);
        $b = $c * sin($hRad);

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

        [$lin_l, $lin_m, $lin_s] = [$l_ ** 3, $m_ ** 3, $s_ ** 3];

        $r = 4.0767416621 * $lin_l - 3.3077115913 * $lin_m + 0.2309699292 * $lin_s;
        $g = -1.2684380046 * $lin_l + 2.6097574011 * $lin_m - 0.3413193965 * $lin_s;
        $bl = -0.0041960863 * $lin_l - 0.7034186147 * $lin_m + 1.7076147010 * $lin_s;

        // Out-of-gamut components clamp to 0 rather than going negative — same
        // treatment as sRGB rendering would give.
        return 0.2126 * max(0, $r) + 0.7152 * max(0, $g) + 0.0722 * max(0, $bl);
    }

    /** WCAG contrast ratio between two relative luminances, always >= 1. */
    private static function contrastRatio(float $lum1, float $lum2): float
    {
        $lighter = max($lum1, $lum2);
        $darker = min($lum1, $lum2);

        return ($lighter + 0.05) / ($darker + 0.05);
    }

    /**
     * Light-mode token set.
     *
     * @param  array<string, mixed>  $spec
     * @return array<string, string>
     */
    public static function light(array $spec): array
    {
        $s = self::spec($spec);
        $nh = $s['neutral_hue'];
        $nc = $s['neutral_chroma'];
        $hue = $s['hue'];
        $chroma = $s['chroma'];
        $primaryL = $s['primary_l'];

        // Page background sits a touch off pure white and cards sit above it, so
        // elevation reads without needing heavy shadows.
        $tokens = [
            '--background' => self::oklch(0.985, $nc * 0.5, $nh),
            '--foreground' => self::oklch(0.18, $nc * 3, $nh),
            '--card' => self::oklch(1, 0, $nh),
            '--card-foreground' => self::oklch(0.18, $nc * 3, $nh),
            '--popover' => self::oklch(1, 0, $nh),
            '--popover-foreground' => self::oklch(0.18, $nc * 3, $nh),

            '--primary' => self::oklch($primaryL, $chroma, $hue),
            '--primary-foreground' => self::onColor($primaryL, $hue, $chroma),

            '--secondary' => self::oklch(0.96, $nc, $nh),
            '--secondary-foreground' => self::oklch(0.25, $chroma * 0.28, $hue),

            '--muted' => self::oklch(0.96, $nc * 0.8, $nh),
            '--muted-foreground' => self::oklch(0.5, $nc * 2.5, $nh),

            '--accent' => self::oklch(0.94, $chroma * 0.17, $s['accent_hue']),
            '--accent-foreground' => self::oklch(0.25, $chroma * 0.45, $s['accent_hue']),

            '--border' => self::oklch(0.92, $nc, $nh),
            '--input' => self::oklch(0.92, $nc, $nh),
            '--ring' => self::oklch($primaryL, $chroma, $hue),
        ];

        foreach (self::STATUS as $name => [$l, $c, $h]) {
            $tokens["--{$name}"] = self::oklch($l, $c, $h);
            $tokens["--{$name}-foreground"] = self::onColor($l, $h, $c);
        }

        return array_merge($tokens, self::sidebar($s, false), self::loginPanel($s), self::charts($s, false));
    }

    /**
     * Dark-mode token set for the same preset. Brand lightness is lifted and
     * chroma eased so the hue stays recognisable against a dark ground.
     *
     * @param  array<string, mixed>  $spec
     * @return array<string, string>
     */
    public static function dark(array $spec): array
    {
        $s = self::spec($spec);
        $nh = $s['neutral_hue'];
        $nc = $s['neutral_chroma'];
        $hue = $s['hue'];
        $chroma = $s['chroma'];

        // Lift towards the mid range: a 0.52-lightness brand colour is close to
        // unreadable on a near-black surface.
        $primaryL = self::darkPrimaryLightness($s);
        $primaryC = $chroma * self::DARK_PRIMARY_CHROMA;

        $tokens = [
            '--background' => self::oklch(0.16, $nc * 2, $nh),
            '--foreground' => self::oklch(0.96, $nc * 0.5, $nh),
            '--card' => self::oklch(0.21, $nc * 3, $nh),
            '--card-foreground' => self::oklch(0.96, $nc * 0.5, $nh),
            '--popover' => self::oklch(0.21, $nc * 3, $nh),
            '--popover-foreground' => self::oklch(0.96, $nc * 0.5, $nh),

            '--primary' => self::oklch($primaryL, $primaryC, $hue),
            '--primary-foreground' => self::onColor($primaryL, $hue, $primaryC),

            '--secondary' => self::oklch(0.25, $nc * 3, $nh),
            '--secondary-foreground' => self::oklch(0.9, $nc, $nh),

            '--muted' => self::oklch(0.22, $nc * 2.5, $nh),
            '--muted-foreground' => self::oklch(0.62, $nc * 2, $nh),

            '--accent' => self::oklch(0.27, $chroma * 0.22, $s['accent_hue']),
            '--accent-foreground' => self::oklch(0.92, $nc, $s['accent_hue']),

            '--border' => self::oklch(0.28, $nc * 3, $nh),
            '--input' => self::oklch(0.28, $nc * 3, $nh),
            '--ring' => self::oklch($primaryL, $primaryC, $hue),
        ];

        foreach (self::STATUS_DARK as $name => [$l, $c, $h]) {
            $tokens["--{$name}"] = self::oklch($l, $c, $h);
            $tokens["--{$name}-foreground"] = self::onColor($l, $h, $c);
        }

        return array_merge($tokens, self::sidebar($s, true), self::loginPanel($s), self::charts($s, true));
    }

    /**
     * Sidebar tokens — the gap that made the old Theme Manager feel broken. It
     * never emitted a single --sidebar* property, so the navigation stayed the
     * stock indigo no matter which theme was applied.
     *
     * @param  array{hue: float, chroma: float, primary_l: float, neutral_hue: float, neutral_chroma: float, accent_hue: float, sidebar: string}  $s
     * @return array<string, string>
     */
    private static function sidebar(array $s, bool $dark): array
    {
        $hue = $s['hue'];
        $chroma = $s['chroma'];
        $nh = $s['neutral_hue'];

        // A pale sidebar is a light-mode idea only; in dark mode it collapses to
        // a slightly raised dark surface so the shell stays coherent.
        $mode = $s['sidebar'];
        if ($dark && $mode === self::SIDEBAR_LIGHT) {
            $mode = 'raised';
        }

        [$l, $c, $h, $isLight] = match ($mode) {
            self::SIDEBAR_INK => $dark ? [0.14, $s['neutral_chroma'], $nh, false] : [0.17, $s['neutral_chroma'] * 1.2, $nh, false],
            self::SIDEBAR_DEEP => $dark ? [0.22, min($chroma * 0.8, 0.11), $hue, false] : [0.30, min($chroma * 0.75, 0.13), $hue, false],
            self::SIDEBAR_LIGHT => [0.97, $s['neutral_chroma'] * 1.5, $nh, true],
            'raised' => [0.20, $s['neutral_chroma'] * 3, $nh, false],
            default => $dark ? [0.18, min($chroma * 0.2, 0.03), $hue, false] : [0.21, min($chroma * 0.22, 0.04), $hue, false],
        };

        $brandL = $isLight ? $s['primary_l'] : max($s['primary_l'] + 0.10, 0.62);

        return [
            '--sidebar' => self::oklch($l, $c, $h),
            '--sidebar-foreground' => $isLight ? self::oklch(0.32, $s['neutral_chroma'] * 3, $nh) : self::oklch(0.85, 0.02, $h),
            '--sidebar-primary' => self::oklch($brandL, $chroma, $hue),
            '--sidebar-primary-foreground' => self::onColor($brandL, $hue, $chroma),
            '--sidebar-accent' => $isLight ? self::oklch($l - 0.05, $c * 1.6, $h) : self::oklch($l + 0.06, $c * 1.25, $h),
            '--sidebar-accent-foreground' => $isLight ? self::oklch(0.22, $chroma * 0.4, $hue) : self::oklch(0.98, 0.005, $h),
            '--sidebar-border' => $isLight ? self::oklch($l - 0.07, $c * 1.4, $h) : self::oklch($l + 0.06, $c * 1.1, $h),
            '--sidebar-ring' => self::oklch($brandL, $chroma, $hue),
        ];
    }

    /**
     * The login page's branding panel. Previously a single hex column with
     * hardcoded text-white / text-slate-400 alongside it, so a light panel colour
     * produced invisible text. Every layer of the panel is now a token, and the
     * text colours are derived from the panel's own lightness.
     *
     * Shared between light and dark mode: the login panel is a branded surface,
     * not a themeable background, so it should not invert with the OS setting.
     *
     * @param  array{hue: float, chroma: float, primary_l: float, neutral_hue: float, neutral_chroma: float, accent_hue: float, sidebar: string}  $s
     * @return array<string, string>
     */
    private static function loginPanel(array $s): array
    {
        $hue = $s['hue'];
        $chroma = $s['chroma'];

        [$l, $c] = match ($s['sidebar']) {
            self::SIDEBAR_INK => [0.16, $s['neutral_chroma'] * 1.2],
            self::SIDEBAR_DEEP => [0.28, min($chroma * 0.8, 0.13)],
            // A pale sidebar still gets a saturated login panel: the login page is
            // the brand moment, and a white panel would swallow most logos.
            self::SIDEBAR_LIGHT => [self::clamp($s['primary_l'] - 0.06, 0.34, 0.52), $chroma * 0.92],
            default => [0.22, min($chroma * 0.55, 0.09)],
        };

        $isLight = $l >= self::LIGHT_SURFACE_THRESHOLD;

        return [
            '--login-panel' => self::oklch($l, $c, $hue),
            '--login-panel-foreground' => self::onColor($l, $hue, $c),
            '--login-panel-muted' => $isLight ? self::oklch(0.38, $c * 0.8, $hue) : self::oklch(min($l + 0.48, 0.78), $c * 0.6, $hue),
            '--login-panel-border' => $isLight ? self::oklch($l - 0.12, $c, $hue) : self::oklch($l + 0.12, $c * 1.2, $hue),
        ];
    }

    /**
     * Chart series. chart-1 is always the brand colour; the rest fan out around
     * the hue wheel so a report stays legible under any preset.
     *
     * @param  array{hue: float, chroma: float, primary_l: float, neutral_hue: float, neutral_chroma: float, accent_hue: float, sidebar: string}  $s
     * @return array<string, string>
     */
    private static function charts(array $s, bool $dark): array
    {
        $base = $dark ? max($s['primary_l'] + 0.13, 0.62) : self::clamp($s['primary_l'] + 0.03, 0.5, 0.7);
        $offsets = [0, 130, 205, 62, 285];
        $lightness = [$base, $base + 0.08, $base + 0.13, $base + 0.05, $base + 0.02];
        $chromas = [$s['chroma'], 0.16, 0.17, 0.2, 0.18];

        $tokens = [];
        foreach ($offsets as $i => $offset) {
            $tokens['--chart-'.($i + 1)] = self::oklch(
                self::clamp($lightness[$i], 0.45, 0.82),
                $chromas[$i],
                $s['hue'] + $offset,
            );
        }

        return $tokens;
    }

    /**
     * The brand colour as an 11-stop Filament ramp.
     *
     * Filament allows exactly ONE ramp per colour alias and its components pick a
     * different stop per mode, so the ramp is anchored at the two stops that
     * actually decide what a tenant sees:
     *
     *  - 600 is the light-mode brand colour. It backs primary buttons, links and
     *    focus rings ({@see ButtonComponent}
     *    asks for `lightBackground(bg: 600)`), so it is set to exactly the
     *    `--primary` that {@see self::light()} produces and the live preview
     *    paints its "Primary action" button with.
     *  - 400 is the dark-mode brand colour — the framework switches to it for
     *    text, icons and active navigation under `.dark`. It is set to exactly
     *    the lifted `--primary` from {@see self::dark()}.
     *
     * Everything else interpolates outwards from those anchors, which keeps the
     * ramp monotonic for any preset (including pale ones like Amber Glow, where
     * the two anchors nearly meet) without the caller having to think about it.
     *
     * Note this is NOT {@see Color::generatePalette()}:
     * that keeps only hue and chroma, throws the preset's `primary_l` away and
     * zeroes chroma below 0.03 — which would collapse Graphite Mono, Warm Stone
     * and Charcoal Ink into the same achromatic grey.
     *
     * @param  array<string, mixed>  $spec
     * @return array<int, string>
     */
    public static function filamentPrimary(array $spec): array
    {
        $s = self::spec($spec);

        $lightL = $s['primary_l'];
        $darkL = self::darkPrimaryLightness($s);

        return self::ramp($lightL, $darkL, $s['chroma'], $s['hue']);
    }

    /**
     * The neutral ramp Filament paints every surface, border and secondary text
     * with. Derived from the preset's neutral hue/chroma so the brand tint
     * carries through the whole shell exactly as the live preview shows it,
     * rather than falling back to the framework's stock Zinc.
     *
     * @param  array<string, mixed>  $spec
     * @return array<int, string>
     */
    public static function filamentGray(array $spec): array
    {
        $s = self::spec($spec);

        $out = [];

        foreach (self::RAMP_GRAY as $shade => [$l, $chromaMultiple]) {
            $out[$shade] = self::oklch($l, $s['neutral_chroma'] * $chromaMultiple, $s['neutral_hue']);
        }

        return $out;
    }

    /**
     * Both ramps plus the full named status palette, in the shape Filament's
     * colour registry expects.
     *
     * @param  array<string, mixed>  $spec
     * @return array<string, array<int, string>>
     */
    public static function filament(array $spec): array
    {
        return array_merge(
            [
                'primary' => self::filamentPrimary($spec),
                'gray' => self::filamentGray($spec),
            ],
            self::filamentStatusColors(),
        );
    }

    /**
     * The named colour ramps behind every DB-seeded status badge.
     *
     * Filament's ColorManager::DEFAULT_COLORS registers only danger/gray/info/
     * primary/success/warning. Any other name resolves to an empty ramp and its
     * component classes render colourless — which is exactly what happened to
     * the seeded statuses (blue, amber, indigo, purple, orange, green, red,
     * see database/seeders/LookupSeeder.php): ~51 of ~60 rows rendered as
     * unstyled pills because only the gray-named rows resolved by coincidence.
     *
     * Registering the catalogue here — merged AFTER the tenant's own primary/
     * gray, so per-tenant theming still wins for those two names — makes every
     * seeded status colour render. The tenant remains free to recolour any
     * status row; the ramp names only need to exist.
     *
     * @return array<string, array<int, string>>
     */
    public static function filamentStatusColors(): array
    {
        return [
            'blue' => Color::Blue,
            'amber' => Color::Amber,
            'indigo' => Color::Indigo,
            'purple' => Color::Purple,
            'slate' => Color::Slate,
            'orange' => Color::Orange,
            'green' => Color::Green,
            'red' => Color::Red,
            'teal' => Color::Teal,
            'rose' => Color::Rose,
            'cyan' => Color::Cyan,
            'emerald' => Color::Emerald,
            'violet' => Color::Violet,
            'pink' => Color::Pink,
            'lime' => Color::Lime,
            'sky' => Color::Sky,
            'fuchsia' => Color::Fuchsia,
            'stone' => Color::Stone,
            'zinc' => Color::Zinc,
            'neutral' => Color::Neutral,
        ];
    }

    /**
     * Dark mode lifts the brand colour towards the mid range — a 0.52-lightness
     * blue is close to unreadable as text on a near-black surface. Shared by
     * {@see self::dark()} and {@see self::filamentPrimary()} so the token set and
     * the ramp's 400 stop can never drift apart.
     *
     * @param  array{hue: float, chroma: float, primary_l: float, neutral_hue: float, neutral_chroma: float, accent_hue: float, sidebar: string}  $s
     */
    private static function darkPrimaryLightness(array $s): float
    {
        return self::clamp(max($s['primary_l'] + 0.13, 0.62), 0.55, 0.80);
    }

    /**
     * Whether a brand colour is too pale to carry white text at WCAG AA, and so
     * needs a dark stop at the bottom of its ramp for Filament to label buttons
     * with. Amber Glow, Mint Fresh, Sky Breeze and Cyan Electric qualify.
     *
     * Deliberately the same question Filament asks, so the ramp cannot be shaped
     * for one answer while the framework acts on another. Filament's own
     * Color::calculateContrastRatio() uses a 2.2-gamma approximation where
     * {@see self::relativeLuminance()} is true-linear WCAG; across all 30 presets
     * the two agree on every classification, with the nearest (Cyan Electric,
     * Lavender Haze) sitting 0.30 clear of the threshold.
     */
    private static function needsDarkLabel(float $l600, float $chroma, float $hue): bool
    {
        return self::contrastRatio(
            self::relativeLuminance($l600, $chroma, $hue),
            self::relativeLuminance(1.0, 0.0, $hue),
        ) < 4.5;
    }

    /**
     * Interpolate an 11-stop ramp from its 600 and 400 anchors.
     *
     * @return array<int, string>
     */
    private static function ramp(float $l600, float $l400, float $chroma, float $hue): array
    {
        // A pale preset can push the dark anchor below the light one (Amber Glow
        // sits at 0.62 light / 0.75 dark, but Desert Sand at 0.55/0.68 is nearly
        // flat). Keep a floor between them so the ramp never doubles back.
        $l400 = max($l400, $l600 + 0.04);
        $l500 = ($l600 + $l400) / 2;

        $lightness = [600 => $l600, 500 => $l500, 400 => $l400];

        foreach (self::RAMP_TINTS as $shade => $fraction) {
            $lightness[$shade] = $l400 + (0.985 - $l400) * $fraction;
        }

        foreach (self::RAMP_SHADES as $shade => $fraction) {
            if ($shade === 950 && self::needsDarkLabel($l600, $chroma, $hue)) {
                $fraction = self::RAMP_SHADES_PALE_950;
            }

            $lightness[$shade] = $l600 - ($l600 - self::RAMP_FLOOR) * $fraction;
        }

        $out = [];

        foreach (self::RAMP_CHROMA as $shade => $chromaMultiple) {
            $out[$shade] = self::oklch(
                self::clamp($lightness[$shade], 0.10, 0.985),
                $chroma * $chromaMultiple,
                $hue,
            );
        }

        return $out;
    }

    /**
     * Compact swatch list for the preset grid's mini representation — brand,
     * page, sidebar and accent, in the order the card paints them.
     *
     * @param  array<string, mixed>  $spec
     * @return array{primary: string, background: string, card: string, sidebar: string, sidebar_accent: string, accent: string, foreground: string, panel: string}
     */
    public static function swatches(array $spec): array
    {
        $light = self::light($spec);

        return [
            'primary' => $light['--primary'],
            'background' => $light['--background'],
            'card' => $light['--card'],
            'sidebar' => $light['--sidebar'],
            'sidebar_accent' => $light['--sidebar-accent'],
            'accent' => $light['--accent'],
            'foreground' => $light['--foreground'],
            'panel' => $light['--login-panel'],
        ];
    }

    /**
     * Login-panel tokens derived from an explicit hex override (the tenant's
     * `branding.panel_color`), rather than from the preset.
     *
     * Going through OKLCH rather than just dropping the hex into --login-panel is
     * what makes a light panel colour usable: the foreground, muted and border
     * layers are recomputed from the override's real perceptual lightness, so
     * choosing a pale brand colour no longer yields white-on-white text.
     *
     * @return array<string, string>|null Null when the hex is unparseable.
     */
    public static function loginPanelFromHex(string $hex): ?array
    {
        $oklch = self::hexToOklch($hex);

        if ($oklch === null) {
            return null;
        }

        [$l, $c, $h] = $oklch;
        $isLight = $l >= self::LIGHT_SURFACE_THRESHOLD;

        return [
            '--login-panel' => self::oklch($l, $c, $h),
            '--login-panel-foreground' => self::onColor($l, $h, $c),
            '--login-panel-muted' => $isLight ? self::oklch(0.38, $c * 0.8, $h) : self::oklch(min($l + 0.48, 0.78), max($c * 0.6, 0.01), $h),
            '--login-panel-border' => $isLight ? self::oklch(max($l - 0.12, 0), $c, $h) : self::oklch($l + 0.12, $c * 1.2, $h),
        ];
    }

    /**
     * Convert `#rgb` / `#rrggbb` to an OKLCH triple via linear sRGB and OKLab
     * (Björn Ottosson's matrices).
     *
     * @return array{0: float, 1: float, 2: float}|null
     */
    public static function hexToOklch(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)) {
            return null;
        }

        // sRGB → linear light
        $toLinear = static function (float $channel): float {
            return $channel <= 0.04045
                ? $channel / 12.92
                : (($channel + 0.055) / 1.055) ** 2.4;
        };

        $r = $toLinear(hexdec(substr($hex, 0, 2)) / 255);
        $g = $toLinear(hexdec(substr($hex, 2, 2)) / 255);
        $b = $toLinear(hexdec(substr($hex, 4, 2)) / 255);

        // Linear sRGB → LMS
        $lms = [
            0.4122214708 * $r + 0.5363325363 * $g + 0.0514459929 * $b,
            0.2119034982 * $r + 0.6806995451 * $g + 0.1073969566 * $b,
            0.0883024619 * $r + 0.2817188376 * $g + 0.6299787005 * $b,
        ];

        // Cube root, preserving sign for out-of-gamut inputs
        [$l_, $m_, $s_] = array_map(
            static fn (float $v): float => $v < 0 ? -((-$v) ** (1 / 3)) : $v ** (1 / 3),
            $lms,
        );

        // LMS → OKLab
        $lightness = 0.2104542553 * $l_ + 0.7936177850 * $m_ - 0.0040720468 * $s_;
        $labA = 1.9779984951 * $l_ - 2.4285922050 * $m_ + 0.4505937099 * $s_;
        $labB = 0.0259040371 * $l_ + 0.7827717662 * $m_ - 0.8086757660 * $s_;

        $chroma = sqrt($labA ** 2 + $labB ** 2);
        $hue = $chroma < 0.0001 ? 0.0 : self::wrapHue(atan2($labB, $labA) * 180 / M_PI);

        return [self::clamp($lightness, 0, 1), $chroma, $hue];
    }
}
