<?php

namespace App\Services\Ai;

/**
 * Builds a complete, render-ready palette from an AI-generated spec, with
 * validation + graceful per-field fallback to a curated mood palette.
 *
 * The output shape matches what ThemeGenerator/ThemeRecipes consume:
 *   bg (linear), bgAlt (solid), fg, fgAlt, accent, accent2, muted,
 *   frame_iphone / frame_ipad / frame_android (+ _inv variants).
 *
 * Frame colours are DERIVED from surface luminance (light surface → dark frame,
 * dark surface → light frame) rather than hard-coded per mood, so a bespoke AI
 * background always gets a frame that actually contrasts against it.
 */
class PaletteFactory
{
    /** Minimum WCAG-ish contrast ratio for primary text vs its background.
     *  4.5 = AA for normal text — keeps headlines crisp instead of letting a
     *  medium grey-blue ink pass and read washed-out. */
    private const TEXT_CONTRAST = 4.5;

    /**
     * Curated, hand-balanced background presets. The HYBRID palette path selects
     * one by the app's surface lightness + the accent's temperature, then drives
     * the rest of the palette off the preset's bg while overriding the accent with
     * the app's own (tempered) brand colour — preset-grade harmony + brand match.
     *
     * @var array<string, array<string, string>>
     */
    public const PRESETS = [
        'clean-light'    => ['bg' => '#F6F1EA', 'accent' => '#5B7CFA'],
        'dark-bold'      => ['bg' => '#0B1020', 'accent' => '#8B5CF6'],
        'warm-editorial' => ['bg' => '#F7E8DA', 'accent' => '#D97706'],
        'ocean-fresh'    => ['bg' => '#E0F2FE', 'accent' => '#0284C7'],
        'bloom-roast'    => ['bg' => '#F2ECE2', 'accent' => '#B8794A'],
    ];

    /**
     * @param  array<string, mixed>|null  $aiPalette  AI spec: {bg:{from,to,angle}, bgAlt, fg, fgMuted, accent, accent2}
     * @param  string  $mood  fallback mood key for invalid/missing fields
     * @param  string|null  $userAccent  user brand colour — wins over AI accent when present
     * @return array<string, mixed>
     */
    public static function build(?array $aiPalette, string $mood, ?string $userAccent = null, ?string $brandColor = null, ?string $surfaceColor = null): array
    {
        $fallback = ThemeRecipes::palette($mood);
        $ai       = is_array($aiPalette) ? $aiPalette : [];
        $brand    = self::firstValidHex([$brandColor]);
        $surface  = self::firstValidHex([$surfaceColor]);

        // ── Accent (base): the single brand pop colour. Priority: user >
        // extracted brand > AI guess > fallback. Resolved BEFORE the bg so a
        // neutral surface can breathe a faint hint of it into the gradient. ──
        $userSet = self::firstValidHex([$userAccent]) !== null;
        $accent  = self::clampAccent(
            self::firstValidHex([$userAccent, $brand, $ai['accent'] ?? null, self::solidValue($fallback['accent'])])
            ?? '#5B7CFA'
        );

        // ── Background = the app's actual SURFACE colour, as a gentle gradient.
        // A TINTED surface (cream / dusk) just deepens — keeps its own warmth.
        // A NEUTRAL surface (white / grey) fades to clinical grey when plainly
        // darkened, so the FAR stop breathes a faint hint of the accent — a
        // subtle, designed gradient instead of white→grey. The near stop stays
        // the true surface so the bg still reads as the app's own colour.
        // (We never tint the WHOLE bg from the accent — that turned cream into
        // blush pink — only the far corner, and only when the surface is neutral.)
        // Falls back to AI bg, then mood when no surface was extracted. ──
        if ($surface !== null) {
            // Neutral test via RGB CHROMA (max-min), not HSL saturation — HSL sat
            // is unstable near white/black (#fdfefe falsely reads ~0.33 saturated),
            // which let pure-white surfaces skip the tint and fade to grey.
            [$sr, $sg, $sb] = self::rgb($surface);
            $chroma = max($sr, $sg, $sb) - min($sr, $sg, $sb);
            $far = self::darken($surface, 0.08);
            if ($chroma < 12) {
                $far = self::mix($far, $accent, 0.10);
            }
            $bg = ['type' => 'linear', 'angle' => 160, 'stops' => [
                ['color' => $surface, 'offset' => 0],
                ['color' => $far, 'offset' => 1],
            ]];
        } else {
            $bg = self::resolveBg($ai['bg'] ?? null, $fallback['bg']);
        }

        $bgLight = self::surfaceLuminance($bg) >= 0.5;
        $bgRef   = self::surfaceRefColor($bg);
        [$bgHue] = self::hexToHsl($bgRef);

        // Rotate the accent to pop when the bg is a saturated MID-tone of the same
        // hue (monochrome-brand app, e.g. an all-blue app → blue accent on blue bg).
        // Light/dark surfaces already let the accent pop via lightness, so the
        // lightness gate protects schemes like "cream bg + terracotta accent". A
        // user-set accent is never rotated.
        [, $bgS, $bgL] = self::hexToHsl($bgRef);
        if (! $userSet
            && $bgL > 0.30 && $bgL < 0.72 && $bgS > 0.30
            && self::hueDistance($accent, $bgRef) < 32.0
        ) {
            [, $as, $al] = self::hexToHsl($accent);
            $accent = self::clampAccent(self::hslToHex(fmod($bgHue + 165.0, 360.0), max($as, 0.62), $al));
        }
        // Temper harsh (cool) accents so the brand pop reads refined, not cold.
        $accent = self::temperHarshAccent($accent);
        // Secondary accent for gradient depth — only honoured when meaningfully
        // different from the primary, else derived (the model often returns a
        // near-duplicate, e.g. #ff3d00 / #ff3c00, which makes gradients look flat).
        $a2      = self::firstValidHex([$ai['accent2'] ?? null]);
        $accent2 = ($a2 !== null && self::hueDistance($a2, $accent) >= 18.0)
            ? self::clampAccent($a2)
            : self::shiftHue($accent, 32.0);

        // ── Foreground: a NEUTRAL tinted faintly by the bg hue. We deliberately
        // IGNORE the AI's fg — a saturated text colour clashes with the accent
        // (e.g. teal headline + orange accent). Cohesive design = neutral text +
        // one accent. ──
        $fg    = $bgLight ? self::hslToHex($bgHue, 0.16, 0.13) : self::hslToHex($bgHue, 0.08, 0.96);
        $muted = $bgLight ? self::hslToHex($bgHue, 0.12, 0.40) : self::hslToHex($bgHue, 0.10, 0.72);
        if (self::contrast($fg, $bgRef) < self::TEXT_CONTRAST) {
            $fg = $bgLight ? '#15120f' : '#f6f8fb';
        }

        // ── Inverted surface (used by dark-UI screenshots) ─────────────────
        // bgAlt must be the OPPOSITE polarity to bg so an inverted screen reads.
        $bgAltHex = self::firstValidHex([self::solidValue($ai['bgAlt'] ?? null)]);
        $bgAltLum = $bgAltHex !== null ? self::luminance($bgAltHex) : null;
        if ($bgAltHex === null || ($bgLight && $bgAltLum >= 0.4) || (! $bgLight && $bgAltLum < 0.6)) {
            // derive: flip polarity from bg
            $bgAltHex = $bgLight ? self::darken($bgRef, 0.86) : self::lighten($bgRef, 0.92);
        }
        $bgAltLight = self::luminance($bgAltHex) >= 0.5;
        $fgAlt      = $bgAltLight ? '#16130F' : '#F5F7FB';

        // Emphasis-word contrast guard (the "_QUALITY_BAR" rule): the accent is the
        // brand POP (fills, buttons, blob source) and may be a light/pale brand
        // colour that fails as TEXT. `accentText` is the same hue darkened/lightened
        // until it clears WCAG AA (4.5:1) on the bg — used for coloured emphasis
        // words (eyebrow / stat) so they're always legible, while `accent` keeps
        // its brand pop for fills. (Inversion is disabled in AI gen, so the
        // emphasis word always sits on the main bg.)
        $accentText = self::ensureReadable($accent, $bgRef, $bgLight, 4.5);

        return [
            'bg'         => $bg,
            'bgAlt'      => ['type' => 'solid', 'value' => $bgAltHex],
            'fg'         => $fg,
            'fgAlt'      => $fgAlt,
            'accent'     => $accent,
            'accentText' => $accentText,
            'accent2'    => $accent2,
            'muted'      => $muted,

            // Accent PANEL — the color-block / platform behind the device, a
            // second depth plane. On LIGHT bg: a soft accent tint block (sits
            // between bg + accent, never a saturated sticker). On DARK bg: a
            // LIFTED surface (lighter than bg, faint accent tint) so it reads as
            // an elevated card instead of vanishing into the dark. `panel2` is a
            // touch stronger for a gentle gradient.
            'panel'      => $bgLight ? self::mix($accent, $bgRef, 0.72) : self::mix(self::lighten($bgRef, 0.16), $accent, 0.20),
            'panel2'     => $bgLight ? self::mix($accent, $bgRef, 0.58) : self::mix(self::lighten($bgRef, 0.24), $accent, 0.28),

            // Glyph colour for the accent-filled icon tile — readable ON the accent.
            'onAccent'   => self::luminance($accent) >= 0.6 ? '#15120F' : '#FFFFFF',

            // Frame colour by surface luminance — light bg → darkest frame the
            // device offers, dark bg → light frame. Slugs MUST be valid per device
            // (resolveRealFrameColor falls back to the first option otherwise →
            // an orange iPhone). iPhone: cosmic-orange|deep-blue|silver (no black,
            // so deep-blue is its dark); iPad: silver|space-black; Android:
            // black|real-light|real-gold|light|dark.
            'frame_iphone'      => $bgLight ? 'deep-blue' : 'silver',
            'frame_ipad'        => $bgLight ? 'space-black' : 'silver',
            'frame_android'     => $bgLight ? 'black' : 'real-light',
            'frame_iphone_inv'  => $bgAltLight ? 'deep-blue' : 'silver',
            'frame_ipad_inv'    => $bgAltLight ? 'space-black' : 'silver',
            'frame_android_inv' => $bgAltLight ? 'black' : 'real-light',
        ];
    }

    /**
     * Build the render palette DIRECTLY from an art-director model spec — the model
     * chose every colour (having SEEN the screenshots), so we trust its hexes and
     * only derive the things it shouldn't pick: frame colours (by bg luminance),
     * the contrast-guarded emphasis colour, and neutral fallbacks. No extraction,
     * no preset, no harmony heuristics — the model is the brain.
     *
     * @param  array<string, mixed>  $spec  {bg:{from,to,angle}, accent, accent2?, ink?, muted?, decoration?}
     * @return array<string, mixed>
     */
    public static function fromSpec(array $spec): array
    {
        $bgSpec = is_array($spec['bg'] ?? null) ? $spec['bg'] : [];
        if (isset($bgSpec['type']) && is_array($bgSpec['stops'] ?? null) && $bgSpec['stops'] !== []) {
            // Pre-built fill (e.g. brandSpec's radial bloom) — use it verbatim.
            $bg = $bgSpec;
        } else {
            $from  = self::firstValidHex([$bgSpec['from'] ?? null]) ?? '#F6F1EA';
            $to    = self::firstValidHex([$bgSpec['to'] ?? null]) ?? self::darken($from, 0.08);
            $angle = is_numeric($bgSpec['angle'] ?? null) ? (float) $bgSpec['angle'] : 160.0;
            $bg    = ['type' => 'linear', 'angle' => $angle, 'stops' => [
                ['color' => $from, 'offset' => 0],
                ['color' => $to, 'offset' => 1],
            ]];
        }

        $bgRef   = self::surfaceRefColor($bg);
        $bgLight = self::luminance($bgRef) >= 0.5;
        [$bgHue] = self::hexToHsl($bgRef);

        $accent     = self::clampAccent(self::firstValidHex([$spec['accent'] ?? null]) ?? '#5B7CFA');
        // Harmony guard: a saturated accent that clashes with the bg hue (e.g. a
        // yellow accent on a blue bg — pulled from app artwork) is snapped back
        // into the brand family. Skipped when the user locked an explicit accent.
        if (empty($spec['_lock_accent'])) {
            $accent = self::harmonizeToHue($accent, $bgHue);
        }
        $accentText = self::ensureReadable($accent, $bgRef, $bgLight, 4.5);
        $accent2    = self::firstValidHex([$spec['accent2'] ?? null]) ?? self::shiftHue($accent, 32.0);
        $accent2    = self::harmonizeToHue($accent2, $bgHue);
        $decoration = self::firstValidHex([$spec['decoration'] ?? null]) ?? $accent;
        $decoration = self::harmonizeToHue($decoration, $bgHue);

        // Text: trust the model's ink/muted if given + readable, else derive neutral.
        $fg = self::firstValidHex([$spec['ink'] ?? null])
            ?? ($bgLight ? self::hslToHex($bgHue, 0.16, 0.13) : self::hslToHex($bgHue, 0.08, 0.96));
        if (self::contrast($fg, $bgRef) < self::TEXT_CONTRAST) {
            $fg = $bgLight ? '#15120f' : '#f6f8fb';
        }
        // Subheads must stay legible — 2.8 let a muted blue-grey sink into a navy
        // bg. 4.0 keeps secondary copy readable without making it shout.
        $muted = self::firstValidHex([$spec['muted'] ?? null])
            ?? ($bgLight ? self::hslToHex($bgHue, 0.12, 0.40) : self::hslToHex($bgHue, 0.10, 0.72));
        if (self::contrast($muted, $bgRef) < 4.0) {
            $muted = $bgLight ? self::darken($bgRef, 0.62) : self::lighten($bgRef, 0.62);
        }

        $bgAltHex   = $bgLight ? self::darken($bgRef, 0.86) : self::lighten($bgRef, 0.92);
        $bgAltLight = self::luminance($bgAltHex) >= 0.5;

        return [
            'bg'         => $bg,
            'bgAlt'      => ['type' => 'solid', 'value' => $bgAltHex],
            'fg'         => $fg,
            'fgAlt'      => $bgAltLight ? '#16130F' : '#F5F7FB',
            'accent'     => $accent,
            'accentText' => $accentText,
            'accent2'    => $accent2,
            'muted'      => $muted,
            'panel'      => $bgLight ? self::mix($accent, $bgRef, 0.72) : self::mix(self::lighten($bgRef, 0.16), $accent, 0.20),
            'panel2'     => $bgLight ? self::mix($accent, $bgRef, 0.58) : self::mix(self::lighten($bgRef, 0.24), $accent, 0.28),
            'onAccent'   => self::luminance($accent) >= 0.6 ? '#15120F' : '#FFFFFF',
            'decoration' => $decoration,
            'frame_iphone'      => $bgLight ? 'deep-blue' : 'silver',
            'frame_ipad'        => $bgLight ? 'space-black' : 'silver',
            'frame_android'     => $bgLight ? 'black' : 'real-light',
            'frame_iphone_inv'  => $bgAltLight ? 'deep-blue' : 'silver',
            'frame_ipad_inv'    => $bgAltLight ? 'space-black' : 'silver',
            'frame_android_inv' => $bgAltLight ? 'black' : 'real-light',
        ];
    }

    /**
     * HYBRID preset selection. Lightness comes from the app's SURFACE (a dark app
     * gets the dark preset); temperature comes from the ACCENT when available — so
     * a cool brand gets a cool bg and a warm brand a warm bg, which kills the
     * warm-bg/cool-accent clash that makes blue results look harsh. Falls back to
     * the surface's own temperature, then a clean neutral.
     *
     * @return array{bg:string, accent:string}
     */
    public static function selectPreset(?string $surface, ?string $accent = null): array
    {
        $p = self::PRESETS;

        // Dark app surface → the one dark preset.
        if ($surface !== null && ! self::isLight($surface)) {
            return $p['dark-bold'];
        }

        // Light (or unknown) surface: pick the bg temperature to match the accent.
        $ref = $accent ?? $surface;
        if ($ref === null) {
            return $p['clean-light'];
        }
        [$h] = self::hexToHsl($ref);
        [$r, $g, $b] = self::rgb($ref);
        if ((max($r, $g, $b) - min($r, $g, $b)) < 18) {
            return $p['clean-light'];                       // near-neutral → clean
        }
        if ($h >= 175.0 && $h <= 290.0) return $p['ocean-fresh'];     // cool: blue/cyan/teal
        if ($h >= 20.0 && $h <= 50.0)   return $p['warm-editorial'];  // vivid warm: orange/amber
        if ($h > 50.0 && $h < 175.0)    return $p['bloom-roast'];     // green / earthy
        if ($h >= 330.0 || $h < 20.0)   return $p['bloom-roast'];     // red / brown / pink
        return $p['clean-light'];                                      // purple / magenta
    }

    /**
     * Gently soften a HARSH (cool) accent so the brand pop isn't cold/intense —
     * cuts saturation up to ~18% and lifts lightness a hair, scaled by how cool the
     * hue is (peak at ~230° blue). Warm hues are returned untouched. Applied after
     * `clampAccent` so the visibility floor is set first, then tempered.
     */
    public static function temperHarshAccent(string $hex): string
    {
        [$h, $s, $l] = self::hexToHsl($hex);
        $cool = ($h >= 180.0 && $h <= 290.0) ? max(0.0, 1.0 - abs($h - 230.0) / 60.0) : 0.0;
        if ($cool <= 0.0) {
            return $hex;
        }

        return self::hslToHex($h, $s * (1.0 - 0.18 * $cool), min(0.60, $l + 0.04 * $cool));
    }

    /**
     * Return `$color` if it already clears `$min` contrast on `$bg`; otherwise the
     * SAME hue stepped darker (on a light bg) or lighter (on a dark bg) until it
     * does. Falls back to near-black / near-white if the hue can't reach it.
     */
    private static function ensureReadable(string $color, string $bg, bool $bgLight, float $min): string
    {
        if (self::contrast($color, $bg) >= $min) {
            return $color;
        }
        [$h, $s, $l] = self::hexToHsl($color);
        for ($i = 0; $i < 24; $i++) {
            $l = $bgLight ? $l - 0.03 : $l + 0.03;
            if ($l <= 0.0 || $l >= 1.0) {
                break;
            }
            $cand = self::hslToHex($h, $s, $l);
            if (self::contrast($cand, $bg) >= $min) {
                return $cand;
            }
        }

        return $bgLight ? '#15120f' : '#f6f8fb';
    }

    /**
     * Resolve the AI bg spec into a linear-gradient Color, or fall back.
     *
     * @param  mixed  $spec
     * @param  mixed  $fallback
     * @return array<string, mixed>
     */
    private static function resolveBg($spec, $fallback): array
    {
        if (is_array($spec)) {
            $from = self::validHex($spec['from'] ?? null);
            $to   = self::validHex($spec['to'] ?? null);
            if ($from !== null) {
                $to    = $to ?? self::darken($from, 0.94);
                $angle = is_numeric($spec['angle'] ?? null) ? (float) $spec['angle'] : 160.0;
                $angle = max(0.0, min(360.0, $angle));

                return ['type' => 'linear', 'angle' => $angle, 'stops' => [
                    ['color' => $from, 'offset' => 0],
                    ['color' => $to, 'offset' => 1],
                ]];
            }
        }

        return is_array($fallback) ? $fallback : ['type' => 'solid', 'value' => '#F6F1EA'];
    }

    // ── Accent clamping ────────────────────────────────────────────────────

    /** Enforce min saturation 50% / max lightness 50% so accents stay readable. */
    public static function clampAccent(string $hex): string
    {
        [$h, $s, $l] = self::hexToHsl($hex);

        return self::hslToHex($h, max($s, 0.50), min($l, 0.50));
    }

    /** Rotate hue by $deg, keeping saturation/lightness — for deriving accent2. */
    public static function shiftHue(string $hex, float $deg): string
    {
        [$h, $s, $l] = self::hexToHsl($hex);

        return self::hslToHex(fmod($h + $deg + 360.0, 360.0), $s, $l);
    }

    /**
     * Keep a colour in the background's hue family. A SATURATED colour whose hue
     * sits more than $maxDist from the bg hue is a clash (e.g. a yellow accent on
     * a blue bg) — rotate it onto the bg hue, preserving its saturation/lightness
     * so it stays vivid, just on-brand. Near-neutrals (low saturation: whites,
     * greys, charcoals) are left alone — they harmonise with anything.
     */
    public static function harmonizeToHue(string $hex, float $bgHue, float $maxDist = 90.0): string
    {
        [$h, $s, $l] = self::hexToHsl($hex);
        if ($s < 0.22) {
            return $hex; // neutral — no hue to clash
        }
        $d = fmod(abs($h - $bgHue), 360.0);
        $d = $d > 180.0 ? 360.0 - $d : $d;
        if ($d <= $maxDist) {
            return $hex; // already in/near the family
        }

        return self::hslToHex($bgHue, $s, $l);
    }

    /**
     * Re-derive the cohesive fields (neutral fg/muted + distinct accent2) on an
     * already-built palette from its bg + accent. Lets /ai/shuffle upgrade a
     * project that was generated before the neutral-text rule, with no model call.
     *
     * @param  array<string, mixed>  $p
     * @return array<string, mixed>
     */
    public static function reharmonize(array $p): array
    {
        $bg = $p['bg'] ?? null;
        if ($bg === null) {
            return $p;
        }
        $bgLight     = self::isLight($bg);
        $bgRef       = self::surfaceRefColor($bg);
        [$bgHue]     = self::hexToHsl($bgRef);
        $p['fg']     = $bgLight ? self::hslToHex($bgHue, 0.16, 0.13) : self::hslToHex($bgHue, 0.08, 0.96);
        $p['muted']  = $bgLight ? self::hslToHex($bgHue, 0.12, 0.40) : self::hslToHex($bgHue, 0.10, 0.72);

        // Make the accent pop against the bg ONLY when the bg is a muddy mid-tone
        // of the same hue (true monochrome). A light/dark tint bg already lets the
        // accent pop via lightness — rotating there would knock an on-brand accent
        // off its hue, so leave it.
        $accent          = self::clampAccent(is_string($p['accent'] ?? null) ? $p['accent'] : '#5B7CFA');
        [, $bgS, $bgL]   = self::hexToHsl($bgRef);
        if ($bgL > 0.30 && $bgL < 0.72 && $bgS > 0.30 && self::hueDistance($accent, $bgRef) < 32.0) {
            [, $as, $al] = self::hexToHsl($accent);
            $accent = self::clampAccent(self::hslToHex(fmod($bgHue + 165.0, 360.0), max($as, 0.62), $al));
        }
        $p['accent'] = $accent;

        $a2 = $p['accent2'] ?? null;
        if (! is_string($a2) || self::hueDistance($a2, $accent) < 18.0) {
            $p['accent2'] = self::shiftHue($accent, 32.0);
        }

        return $p;
    }

    /**
     * Build an art-director spec ({bg gradient, accent}) DIRECTLY from a brand
     * colour scanned off the app screenshots (AiController::dominantBrandColor).
     * This is the source of truth for the deck's colour: the canvas is a LIGHT tint
     * of the real brand hue with genuine depth (a clear light→deeper sweep of the
     * SAME hue), and the accent is the brand colour itself — so a navy app gets a
     * navy-family canvas, an orange app a warm one, with no model guessing and no
     * hue thresholds. fromSpec() then derives the neutral text, accent2 + frames.
     *
     * The canvas is a soft RADIAL LIGHT BLOOM, not a flat linear ramp: a bright,
     * airy core high on the screen falls off to a gentle brand tint at the edges —
     * luminous and alive, never a heavy dead slab. It carries the brand's CHARACTER
     * from the scanned colour's real saturation (vivid brand → punchier bloom; muted
     * → soft) and shifts hue a few degrees out to the edge for extra life, while
     * staying light everywhere so the dark headline reads cleanly.
     *
     * @return array{bg: array<string, mixed>, accent: string}
     */
    public static function brandSpec(string $brandHex): array
    {
        $brand    = self::firstValidHex([$brandHex]) ?? '#5B7CFA';
        [$h, $s]  = self::hexToHsl($brand);

        // Saturation scales with the brand's vividness; lightness stays high (airy).
        $core = self::hslToHex($h, max(0.07, min(0.22, $s * 0.28)), 0.975);
        $mid  = self::hslToHex(self::wrapHue($h + 6), max(0.13, min(0.38, $s * 0.46)), 0.93);
        $edge = self::hslToHex(self::wrapHue($h + 14), max(0.18, min(0.50, $s * 0.58)), 0.86);

        return [
            'bg' => [
                'type'   => 'radial',
                'cx'     => 0.5,
                'cy'     => 0.3,    // bloom high, behind the headline / device top
                'radius' => 1.25,   // reach the far corners (radius × max(w,h))
                'stops'  => [
                    ['color' => $core, 'offset' => 0.0],
                    ['color' => $mid,  'offset' => 0.55],
                    ['color' => $edge, 'offset' => 1.0],
                ],
            ],
            'accent' => self::clampAccent($brand),
        ];
    }

    /** Wrap a hue into [0,360). */
    private static function wrapHue(float $h): float
    {
        return fmod($h + 360.0, 360.0);
    }

    /** Shortest angular distance (0-180°) between two colours' hues. */
    public static function hueDistance(string $a, string $b): float
    {
        [$ha] = self::hexToHsl($a);
        [$hb] = self::hexToHsl($b);
        $d = abs($ha - $hb);

        return $d > 180.0 ? 360.0 - $d : $d;
    }

    // ── Surface helpers ────────────────────────────────────────────────────

    /** @param mixed $color */
    private static function solidValue($color): ?string
    {
        if (is_string($color)) {
            return $color;
        }
        if (is_array($color) && ($color['type'] ?? null) === 'solid') {
            return is_string($color['value'] ?? null) ? $color['value'] : null;
        }

        return null;
    }

    /** Public: representative single colour for a (possibly gradient) surface. */
    public static function surfaceRef($surface): string
    {
        return self::surfaceRefColor($surface);
    }

    /** Public: is this (possibly gradient) surface light? */
    public static function isLight($surface): bool
    {
        return self::surfaceLuminance($surface) >= 0.5;
    }

    /** Representative single colour for a (possibly gradient) surface. */
    private static function surfaceRefColor($surface): string
    {
        if (is_array($surface) && in_array($surface['type'] ?? null, ['linear', 'radial'], true)) {
            $stops = is_array($surface['stops'] ?? null) ? $surface['stops'] : [];
            if ($stops !== []) {
                $a = $stops[0]['color'] ?? '#FFFFFF';
                $b = $stops[count($stops) - 1]['color'] ?? $a;

                return self::mix($a, $b, 0.5);
            }
        }

        return self::solidValue($surface) ?? '#FFFFFF';
    }

    private static function surfaceLuminance($surface): float
    {
        return self::luminance(self::surfaceRefColor($surface));
    }

    // ── Validation ─────────────────────────────────────────────────────────

    private static function validHex($v): ?string
    {
        return is_string($v) && preg_match('/^#[0-9a-fA-F]{6}$/', $v) === 1 ? strtolower($v) : null;
    }

    /** @param array<int, mixed> $candidates */
    private static function firstValidHex(array $candidates): ?string
    {
        foreach ($candidates as $c) {
            $hex = self::validHex($c);
            if ($hex !== null && strtolower((string) $c) !== 'auto') {
                return $hex;
            }
        }

        return null;
    }

    // ── Colour maths ───────────────────────────────────────────────────────

    /** @return array{0:int,1:int,2:int} */
    private static function rgb(string $hex): array
    {
        $hex = ltrim($hex, '#');

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

    /** Relative luminance (0 dark … 1 light), simple sRGB approximation. */
    private static function luminance(string $hex): float
    {
        [$r, $g, $b] = self::rgb($hex);

        return (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255.0;
    }

    /** Public perceived-luminance (0 dark … 1 light) of a hex colour. */
    public static function relativeLuminance(string $hex): float
    {
        return self::luminance($hex);
    }

    /** WCAG contrast ratio between two colours (1 … 21). */
    private static function contrast(string $a, string $b): float
    {
        $la = self::wcagLum($a);
        $lb = self::wcagLum($b);
        $hi = max($la, $lb);
        $lo = min($la, $lb);

        return ($hi + 0.05) / ($lo + 0.05);
    }

    private static function wcagLum(string $hex): float
    {
        [$r, $g, $b] = self::rgb($hex);
        $f = static function (float $c): float {
            $c /= 255.0;

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

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

    /** Linear blend $a→$b by $t (0=a, 1=b). */
    public static function mix(string $a, string $b, float $t): string
    {
        [$ar, $ag, $ab] = self::rgb($a);
        [$br, $bg, $bb] = self::rgb($b);

        return sprintf('#%02x%02x%02x',
            (int) round($ar + ($br - $ar) * $t),
            (int) round($ag + ($bg - $ag) * $t),
            (int) round($ab + ($bb - $ab) * $t),
        );
    }

    public static function darken(string $hex, float $amount): string
    {
        return self::mix($hex, '#000000', $amount);
    }

    public static function lighten(string $hex, float $amount): string
    {
        return self::mix($hex, '#ffffff', $amount);
    }

    /** @return array{0:float,1:float,2:float} [H 0-360, S 0-1, L 0-1] */
    public static function hexToHsl(string $hex): array
    {
        [$r, $g, $b] = array_map(fn ($v) => $v / 255.0, self::rgb($hex));

        $cmax  = max($r, $g, $b);
        $cmin  = min($r, $g, $b);
        $delta = $cmax - $cmin;

        $l = ($cmax + $cmin) / 2.0;
        $s = $delta < 1e-9 ? 0.0 : $delta / (1.0 - abs(2.0 * $l - 1.0));

        if ($delta < 1e-9) {
            $h = 0.0;
        } elseif ($cmax === $r) {
            $h = fmod(($g - $b) / $delta, 6.0) * 60.0;
        } elseif ($cmax === $g) {
            $h = ((($b - $r) / $delta) + 2.0) * 60.0;
        } else {
            $h = ((($r - $g) / $delta) + 4.0) * 60.0;
        }

        return [fmod($h + 360.0, 360.0), $s, $l];
    }

    public static function hslToHex(float $h, float $s, float $l): string
    {
        $c = (1.0 - abs(2.0 * $l - 1.0)) * $s;
        $x = $c * (1.0 - abs(fmod($h / 60.0, 2.0) - 1.0));
        $m = $l - $c / 2.0;

        if ($h < 60.0)       { [$r, $g, $b] = [$c, $x, 0.0]; }
        elseif ($h < 120.0)  { [$r, $g, $b] = [$x, $c, 0.0]; }
        elseif ($h < 180.0)  { [$r, $g, $b] = [0.0, $c, $x]; }
        elseif ($h < 240.0)  { [$r, $g, $b] = [0.0, $x, $c]; }
        elseif ($h < 300.0)  { [$r, $g, $b] = [$x, 0.0, $c]; }
        else                 { [$r, $g, $b] = [$c, 0.0, $x]; }

        return sprintf('#%02x%02x%02x',
            (int) round(($r + $m) * 255),
            (int) round(($g + $m) * 255),
            (int) round(($b + $m) * 255),
        );
    }

    /** Convert a hex + alpha (0-1) to an rgba() string for gradient stops. */
    public static function rgba(string $hex, float $alpha): string
    {
        [$r, $g, $b] = self::rgb($hex);

        return sprintf('rgba(%d, %d, %d, %.3f)', $r, $g, $b, max(0.0, min(1.0, $alpha)));
    }
}
