<?php

namespace App\Support;

/**
 * Pure stateless theme derivation. Given a small `knobs` array (the admin
 * inputs in the appearance editor) returns the fully expanded CSS variable
 * map for both light and dark modes plus a small `fonts` descriptor.
 *
 * Two design choices worth flagging:
 *
 * 1. Output strings are CSS `oklch(...)` expressions, not hex. Browsers ship
 *    OKLCH (2023+) so we don't need a sRGB conversion step; this also keeps
 *    the eventual TS twin byte-identical to PHP without a JS color library.
 *
 * 2. The 11-step accent and neutral scales are derived via a fixed lightness
 *    curve + chroma falloff. Step 500 reuses `accent.l` directly so the
 *    seeded default (#0052FF) survives the round-trip — admin picks a base
 *    OKLCH triple, step 500 is exactly that triple, the scale fans out
 *    perceptually-uniform around it.
 *
 * Mirror in TS: `resources/js/lib/themePalette.ts` (owned by another agent).
 * The two implementations must stay byte-equivalent — every string this
 * emits must match the TS twin character-for-character so SSR and client
 * recomputation produce identical CSS, otherwise the live preview FOUCs.
 */
class ThemePalette
{
    /** Lightness for each step. step 500 is null = use base accent.l directly. */
    private const LIGHTNESS = [
        50 => 0.97,
        100 => 0.93,
        200 => 0.86,
        300 => 0.77,
        400 => 0.65,
        500 => null,
        600 => 0.42,
        700 => 0.34,
        800 => 0.26,
        900 => 0.18,
        950 => 0.12,
    ];

    /** Chroma multiplier — peaks at step 500, eases toward both ends. */
    private const CHROMA_FALLOFF = [
        50 => 0.35,
        100 => 0.50,
        200 => 0.70,
        300 => 0.85,
        400 => 0.95,
        500 => 1.00,
        600 => 0.95,
        700 => 0.85,
        800 => 0.70,
        900 => 0.55,
        950 => 0.40,
    ];

    private const PALETTE_VERSION = 1;

    /**
     * @param  array<string, mixed>  $knobs
     * @return array{
     *     css_vars_light: array<string, string>,
     *     css_vars_dark: array<string, string>,
     *     fonts: array{sans: string, display: string},
     *     meta: array{token_count: int, version: int}
     * }
     */
    public static function derive(array $knobs): array
    {
        $accent = self::accentFromKnobs($knobs);
        [$neutralH, $neutralCMax, $neutralBaseL] = self::neutralBase($knobs);

        $accentScale = self::buildScale($accent['l'], $accent['c'], $accent['h']);
        // Neutral base L is 0.5 for presets (only consulted at step 500;
        // LIGHTNESS table overrides the rest of the scale). 'custom' passes
        // through the picked color's actual L so the mid-step matches.
        $neutralScale = self::buildScale($neutralBaseL, $neutralCMax, $neutralH);

        $light = self::lightTokens($accentScale, $neutralScale, $neutralH);
        $dark = self::darkTokens($accentScale, $neutralScale, $neutralH);

        // Shape, density, elevation, button shape — same values in light + dark.
        $shape = self::shapeTokens((string) ($knobs['radius'] ?? 'default'));
        $density = self::densityTokens((string) ($knobs['density'] ?? 'default'));
        $elevation = self::elevationTokens((string) ($knobs['elevation'] ?? 'layered'));
        $button = self::buttonShapeTokens((string) ($knobs['button_shape'] ?? 'rounded'));

        // `font_sans` / `font_display` / `font_subheading` are free-form
        // Google Fonts family names. UI labels them as Body / Heading /
        // Subheading; keys keep internal names for backward compat with
        // stored themes. 'system' is a sentinel that skips the Google
        // Fonts <link> and uses the OS stack only. Subheading falls back
        // to display if missing.
        $sansStack = self::stackFor((string) ($knobs['font_sans'] ?? 'Inter'), false);
        $displayStack = self::stackFor((string) ($knobs['font_display'] ?? 'Inter'), true);
        $subheadingStack = self::stackFor(
            (string) ($knobs['font_subheading'] ?? $knobs['font_display'] ?? 'Inter'),
            true,
        );

        $fonts = ['sans' => $sansStack, 'display' => $displayStack];
        $fontVars = [
            '--font-sans' => $sansStack,
            '--font-display' => $displayStack,
            '--font-subheading' => $subheadingStack,
            '--font-mono' => "'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace",
        ];

        $typography = self::typographyTokens($knobs);

        // Order matches the TS twin's spread order in `derive()` so the
        // PHP and TS outputs stay key-order-identical (assertSame parity).
        // Typography vars live AFTER fonts + buttons (per the spec) so the
        // existing key sequence up through `--button-radius` is preserved.
        $modeAgnostic = array_merge($shape, $density, $elevation, $fontVars, $button, $typography);

        $light = array_merge($light, $modeAgnostic);
        $dark = array_merge($dark, $modeAgnostic);

        return [
            'css_vars_light' => $light,
            'css_vars_dark' => $dark,
            'fonts' => $fonts,
            'meta' => [
                'token_count' => count($light),
                'version' => self::PALETTE_VERSION,
            ],
        ];
    }

    /**
     * @param  array<string, mixed>  $knobs
     * @return array{l: float, c: float, h: float}
     */
    private static function accentFromKnobs(array $knobs): array
    {
        $a = $knobs['accent'] ?? [];

        return [
            'l' => (float) ($a['l'] ?? 0.484),
            'c' => (float) ($a['c'] ?? 0.235),
            'h' => (float) ($a['h'] ?? 264),
        ];
    }

    /**
     * Resolve the neutral base L/C/H triple. Presets return fixed warm/cool/
     * true values; `'custom'` reads `neutral_custom_hex` from $knobs and
     * converts hex → OKLCH using the standard sRGB → linear → LMS → OKLab
     * pipeline (Björn Ottosson's matrix). Identical to the JS twin in
     * `themePalette.ts` so server-derived CSS vars match what the live
     * preview produced. Unparseable hex falls back to warm so derive()
     * never receives NaN.
     *
     * @param  array<string, mixed>  $knobs
     * @return array{0: float, 1: float, 2: float}  [h, cMax, l]
     */
    private static function neutralBase(array $knobs): array
    {
        $temp = (string) ($knobs['neutral_temp'] ?? 'warm');

        if ($temp === 'custom') {
            $hex = is_string($knobs['neutral_custom_hex'] ?? null)
                ? $knobs['neutral_custom_hex']
                : '';
            $triple = self::hexToOklch($hex);
            if ($triple !== null) {
                return [$triple[2], $triple[1], $triple[0]];
            }
            // Fall through to warm on unparseable hex.
        }

        // Lightness 0.5 matches the JS preset shape (used only for step 500;
        // the LIGHTNESS table overrides the rest).
        return match ($temp) {
            'cool' => [240.0, 0.008, 0.5],
            'true' => [0.0, 0.0, 0.5],
            'custom' => [75.0, 0.008, 0.5], // unreachable when hex parsed
            default => [75.0, 0.008, 0.5], // warm
        };
    }

    /**
     * sRGB hex → OKLCH triple. Returns null on unparseable input.
     *
     * @return array{0: float, 1: float, 2: float}|null  [L, C, H]
     */
    private static function hexToOklch(string $hex): ?array
    {
        if (! preg_match('/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/', trim($hex), $m)) {
            return null;
        }
        $raw = $m[1];
        if (strlen($raw) === 3) {
            $raw = $raw[0].$raw[0].$raw[1].$raw[1].$raw[2].$raw[2];
        }
        $r8 = hexdec(substr($raw, 0, 2)) / 255;
        $g8 = hexdec(substr($raw, 2, 2)) / 255;
        $b8 = hexdec(substr($raw, 4, 2)) / 255;

        $lin = static fn (float $c): float => $c <= 0.04045
            ? $c / 12.92
            : ((($c + 0.055) / 1.055) ** 2.4);
        $r = $lin($r8);
        $g = $lin($g8);
        $b = $lin($b8);

        $lLms = 0.4122214708 * $r + 0.5363325363 * $g + 0.0514459929 * $b;
        $mLms = 0.2119034982 * $r + 0.6806995451 * $g + 0.1073969566 * $b;
        $sLms = 0.0883024619 * $r + 0.2817188376 * $g + 0.6299787005 * $b;

        // cbrt — PHP's pow with 1/3 misbehaves on negatives, but LMS values
        // out of valid sRGB are always non-negative.
        $lp = $lLms ** (1 / 3);
        $mp = $mLms ** (1 / 3);
        $sp = $sLms ** (1 / 3);

        $L = 0.2104542553 * $lp + 0.793617785 * $mp - 0.0040720468 * $sp;
        $a = 1.9779984951 * $lp - 2.428592205 * $mp + 0.4505937099 * $sp;
        $bLab = 0.0259040371 * $lp + 0.7827717662 * $mp - 0.808675766 * $sp;

        $C = sqrt($a * $a + $bLab * $bLab);
        $H = atan2($bLab, $a) * 180 / M_PI;
        if ($H < 0) {
            $H += 360;
        }

        return [$L, $C, $H];
    }

    /**
     * Build an 11-step scale keyed by 50…950. Step 500 uses `$baseL`
     * directly; other steps take their lightness from `LIGHTNESS`. Chroma
     * is `$baseC * CHROMA_FALLOFF[$step]`, hue is `$baseH` verbatim.
     *
     * @return array<int, array{l: float, c: float, h: float}>
     */
    private static function buildScale(float $baseL, float $baseC, float $baseH): array
    {
        $out = [];
        foreach (self::LIGHTNESS as $step => $l) {
            $resolvedL = $l ?? $baseL;
            $resolvedC = self::round3($baseC * self::CHROMA_FALLOFF[$step]);
            $out[$step] = [
                'l' => self::round3($resolvedL),
                'c' => $resolvedC,
                'h' => $baseH,
            ];
        }

        return $out;
    }

    /**
     * Round half-up on the actual FP value (matches JS `Math.round(x*1000)/1000`).
     * PHP's built-in `round()` rationalizes FP noise — `round(0.16449999998, 3)`
     * returns 0.165, but JS yields 0.164. The TS twin is canonical so PHP must
     * mirror its rounding direction.
     */
    private static function round3(float $n): float
    {
        return floor($n * 1000 + 0.5) / 1000;
    }

    /**
     * Format an OKLCH triple as a CSS expression. Hue is emitted with no
     * decimals when integer-equivalent so `... 264)` not `... 264.0)`.
     *
     * @param  array{l: float, c: float, h: float}  $oklch
     */
    private static function fmt(array $oklch): string
    {
        $l = self::trim($oklch['l']);
        $c = self::trim($oklch['c']);
        $h = self::trim($oklch['h']);

        return "oklch({$l} {$c} {$h})";
    }

    private static function trim(float $n): string
    {
        // Drop trailing zeroes; keep `0` not `0.0`. PHP's float-to-string
        // already does this for integer-valued floats; we just guard against
        // locale-dependent decimal separators by forcing `.`.
        $s = rtrim(rtrim(number_format($n, 6, '.', ''), '0'), '.');

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

    /**
     * @param  array<int, array{l: float, c: float, h: float}>  $accent
     * @param  array<int, array{l: float, c: float, h: float}>  $neutral
     * @return array<string, string>
     */
    private static function lightTokens(array $accent, array $neutral, float $neutralH): array
    {
        $vars = [];
        foreach ($accent as $step => $color) {
            $vars["--color-accent-{$step}"] = self::fmt($color);
        }
        foreach ($neutral as $step => $color) {
            $vars["--color-neutral-{$step}"] = self::fmt($color);
        }

        $vars['--color-background'] = $vars['--color-neutral-50'];
        $vars['--color-foreground'] = $vars['--color-neutral-900'];
        $vars['--color-muted'] = $vars['--color-neutral-100'];
        $vars['--color-muted-foreground'] = $vars['--color-neutral-500'];
        $vars['--color-border'] = $vars['--color-neutral-200'];
        $vars['--color-card'] = 'oklch(1 0 0)';
        $vars['--color-card-foreground'] = $vars['--color-foreground'];
        $vars['--color-ring'] = $vars['--color-accent-500'];
        $vars['--color-accent'] = $vars['--color-accent-500'];
        $vars['--color-accent-secondary'] = $vars['--color-accent-400'];
        $vars['--color-accent-foreground'] = 'oklch(1 0 0)';

        return array_merge($vars, self::systemColors(false), self::destructiveAlias(false));
    }

    /**
     * @param  array<int, array{l: float, c: float, h: float}>  $accent
     * @param  array<int, array{l: float, c: float, h: float}>  $neutral
     * @return array<string, string>
     */
    private static function darkTokens(array $accent, array $neutral, float $neutralH): array
    {
        $vars = [];
        foreach ($accent as $step => $color) {
            $vars["--color-accent-{$step}"] = self::fmt($color);
        }
        foreach ($neutral as $step => $color) {
            $vars["--color-neutral-{$step}"] = self::fmt($color);
        }

        $vars['--color-background'] = $vars['--color-neutral-950'];
        $vars['--color-foreground'] = $vars['--color-neutral-100'];
        $vars['--color-muted'] = $vars['--color-neutral-900'];
        $vars['--color-muted-foreground'] = $vars['--color-neutral-400'];
        $vars['--color-border'] = $vars['--color-neutral-800'];

        // Card surface in dark = a very-near-black tinted by the neutral hue.
        $vars['--color-card'] = self::fmt(['l' => 0.22, 'c' => 0.005, 'h' => $neutralH]);
        $vars['--color-card-foreground'] = $vars['--color-foreground'];
        $vars['--color-ring'] = $vars['--color-accent-400'];
        // Dark-mode auto-tune: lift accent one step (lighter, slightly less
        // saturated) so it pops against dark surfaces. Linear/Stripe pattern.
        $vars['--color-accent'] = $vars['--color-accent-400'];
        $vars['--color-accent-secondary'] = $vars['--color-accent-300'];
        $vars['--color-accent-foreground'] = 'oklch(0.1 0 0)';

        return array_merge($vars, self::systemColors(true), self::destructiveAlias(true));
    }

    /**
     * Semantic system colors. Fixed across themes — admin can't recolor
     * "danger" to mean something other than red.
     *
     * @return array<string, string>
     */
    private static function systemColors(bool $dark): array
    {
        if ($dark) {
            return [
                '--color-success' => 'oklch(0.7 0.14 145)',
                '--color-success-foreground' => 'oklch(1 0 0)',
                '--color-warning' => 'oklch(0.82 0.16 75)',
                '--color-warning-foreground' => 'oklch(0.2 0 0)',
                '--color-danger' => 'oklch(0.68 0.19 25)',
                '--color-danger-foreground' => 'oklch(1 0 0)',
                '--color-info' => 'oklch(0.7 0.13 240)',
                '--color-info-foreground' => 'oklch(1 0 0)',
            ];
        }

        return [
            '--color-success' => 'oklch(0.65 0.16 145)',
            '--color-success-foreground' => 'oklch(1 0 0)',
            '--color-warning' => 'oklch(0.78 0.18 75)',
            '--color-warning-foreground' => 'oklch(0.2 0 0)',
            '--color-danger' => 'oklch(0.62 0.21 25)',
            '--color-danger-foreground' => 'oklch(1 0 0)',
            '--color-info' => 'oklch(0.65 0.15 240)',
            '--color-info-foreground' => 'oklch(1 0 0)',
        ];
    }

    /**
     * Existing code references `bg-destructive`/`text-destructive-foreground`;
     * we keep the alias pointing at the new `--color-danger` so we don't have
     * to sweep 883 class refs across the codebase in one sprint.
     *
     * @return array<string, string>
     */
    private static function destructiveAlias(bool $dark): array
    {
        $sys = self::systemColors($dark);

        return [
            '--color-destructive' => $sys['--color-danger'],
            '--color-destructive-foreground' => $sys['--color-danger-foreground'],
        ];
    }

    /**
     * @return array<string, string>
     */
    private static function shapeTokens(string $radius): array
    {
        $base = match ($radius) {
            'tight' => 0.25,
            'rounded' => 0.75,
            default => 0.5,
        };

        $sm = $base * 0.5;
        $md = $base * 1.0;
        $lg = $base * 1.5;
        $xl = $base * 2.0;

        return [
            '--radius-base' => self::trim($base).'rem',
            '--radius-sm' => self::trim($sm).'rem',
            '--radius-md' => self::trim($md).'rem',
            '--radius-lg' => self::trim($lg).'rem',
            '--radius-xl' => self::trim($xl).'rem',
            '--radius-full' => '9999px',
        ];
    }

    /**
     * @return array<string, string>
     */
    private static function densityTokens(string $density): array
    {
        return match ($density) {
            'compact' => [
                '--space-unit' => '0.875rem',
                '--text-scale' => '0.9375',
            ],
            'comfortable' => [
                '--space-unit' => '1.125rem',
                '--text-scale' => '1.0625',
            ],
            default => [
                '--space-unit' => '1rem',
                '--text-scale' => '1',
            ],
        };
    }

    /**
     * @return array<string, string>
     */
    private static function elevationTokens(string $elevation): array
    {
        return match ($elevation) {
            'flat' => [
                '--shadow-sm' => '0 1px 2px rgb(0 0 0 / 0.04)',
                '--shadow-md' => '0 1px 3px rgb(0 0 0 / 0.06)',
                '--shadow-lg' => '0 2px 6px rgb(0 0 0 / 0.08)',
                '--shadow-xl' => '0 4px 12px rgb(0 0 0 / 0.10)',
            ],
            'dramatic' => [
                '--shadow-sm' => '0 1px 3px rgb(0 0 0 / 0.10)',
                '--shadow-md' => '0 6px 14px rgb(0 0 0 / 0.14)',
                '--shadow-lg' => '0 14px 28px rgb(0 0 0 / 0.18)',
                '--shadow-xl' => '0 24px 56px rgb(0 0 0 / 0.22)',
            ],
            default => [ // layered
                '--shadow-sm' => '0 1px 2px rgb(0 0 0 / 0.06)',
                '--shadow-md' => '0 4px 8px rgb(0 0 0 / 0.08)',
                '--shadow-lg' => '0 8px 16px rgb(0 0 0 / 0.10)',
                '--shadow-xl' => '0 16px 32px rgb(0 0 0 / 0.12)',
            ],
        };
    }

    /**
     * @return array<string, string>
     */
    private static function buttonShapeTokens(string $shape): array
    {
        return match ($shape) {
            'pill' => ['--button-radius' => '9999px'],
            'square' => ['--button-radius' => '0'],
            default => ['--button-radius' => 'var(--radius-md)'],
        };
    }

    /**
     * Typography weight + type-scale tokens. Same values in light + dark —
     * type rhythm is mode-independent. Knob shape:
     *   weight_body / weight_subheading / weight_heading / weight_display: int
     *   scale_h1 / scale_h2 / scale_h3: { size: float, line: float, tracking: float }
     *   scale_body: { size: float, line: float }   // no tracking for body
     *
     * Emit order is fixed:
     *   weights (body, subheading, heading, display)
     *   then h1 size/line/tracking, h2 size/line/tracking, h3 size/line/tracking,
     *   then body size/line.
     * Mirror this order in TS exactly.
     *
     * @param  array<string, mixed>  $knobs
     * @return array<string, string>
     */
    private static function typographyTokens(array $knobs): array
    {
        $weightBody = (int) ($knobs['weight_body'] ?? 400);
        $weightSubheading = (int) ($knobs['weight_subheading'] ?? 600);
        $weightHeading = (int) ($knobs['weight_heading'] ?? 700);
        $weightDisplay = (int) ($knobs['weight_display'] ?? 800);

        $h1 = self::scaleTriple($knobs['scale_h1'] ?? null, 48.0, 1.1, -0.02);
        $h2 = self::scaleTriple($knobs['scale_h2'] ?? null, 36.0, 1.15, -0.015);
        $h3 = self::scaleTriple($knobs['scale_h3'] ?? null, 24.0, 1.25, 0.0);
        $body = self::scaleTriple($knobs['scale_body'] ?? null, 16.0, 1.5, 0.0);

        return [
            '--font-weight-body' => (string) $weightBody,
            '--font-weight-subheading' => (string) $weightSubheading,
            '--font-weight-heading' => (string) $weightHeading,
            '--font-weight-display' => (string) $weightDisplay,
            '--text-h1' => self::trim($h1['size']).'px',
            '--text-h1-line' => self::trim($h1['line']),
            '--text-h1-tracking' => self::trim($h1['tracking']).'em',
            '--text-h2' => self::trim($h2['size']).'px',
            '--text-h2-line' => self::trim($h2['line']),
            '--text-h2-tracking' => self::trim($h2['tracking']).'em',
            '--text-h3' => self::trim($h3['size']).'px',
            '--text-h3-line' => self::trim($h3['line']),
            '--text-h3-tracking' => self::trim($h3['tracking']).'em',
            '--text-body' => self::trim($body['size']).'px',
            '--text-body-line' => self::trim($body['line']),
        ];
    }

    /**
     * @param  mixed  $value
     * @return array{size: float, line: float, tracking: float}
     */
    private static function scaleTriple($value, float $sizeDefault, float $lineDefault, float $trackingDefault): array
    {
        $arr = is_array($value) ? $value : [];

        return [
            'size' => (float) ($arr['size'] ?? $sizeDefault),
            'line' => (float) ($arr['line'] ?? $lineDefault),
            'tracking' => (float) ($arr['tracking'] ?? $trackingDefault),
        ];
    }

    /**
     * Build a CSS font-family stack from a knob value. `'system'` (or any
     * empty/whitespace value) skips quoting and emits the OS stack only.
     * Otherwise the family name is quoted and the OS stack is appended as
     * a fallback so a slow Google Fonts load doesn't leave text invisible.
     */
    private static function stackFor(string $family, bool $isDisplay): string
    {
        $clean = trim($family);
        if ($clean === '' || strcasecmp($clean, 'system') === 0) {
            return "system-ui, -apple-system, 'Segoe UI', sans-serif";
        }
        $serifFallback = $isDisplay ? ', Georgia, serif' : '';
        $quoted = "'".htmlspecialchars($clean, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')."'";

        return "{$quoted}, system-ui, -apple-system, 'Segoe UI', sans-serif{$serifFallback}";
    }
}
