<?php

namespace App\Support\Theme;

/**
 * Resolves a tenant's saved theme section into the concrete artefacts the app
 * needs to render it: a CSS block for the document head, the shade ramps
 * Filament's own components are built on, and a serialisable array for the
 * Master Control live preview.
 *
 * This is the single place where "what the landlord saved" becomes "what the
 * browser sees", so the Master Control live preview and the tenant's real app
 * are guaranteed to agree.
 */
final class TenantTheme
{
    /**
     * @param  array<string, mixed>  $theme  The `theme` section of TenantSetting::toNestedArray()
     * @param  array<string, mixed>  $branding  The `branding` section, for the panel colour override
     */
    public function __construct(
        private readonly array $theme = [],
        private readonly array $branding = [],
    ) {}

    public function presetKey(): string
    {
        $key = $this->theme['preset'] ?? null;

        return ThemePresets::has($key) ? (string) $key : ThemePresets::DEFAULT_KEY;
    }

    private function fontKey(): string
    {
        $key = $this->theme['font_body'] ?? null;

        return ThemeFonts::has((string) $key) ? (string) $key : ThemeFonts::DEFAULT_BODY;
    }

    private function headingFontKey(): ?string
    {
        $key = $this->theme['font_heading'] ?? null;

        // Null means "same as body" — the common case, so it is the default.
        return ThemeFonts::has((string) $key) ? (string) $key : null;
    }

    /**
     * Token sets with the branding panel override folded in.
     *
     * @return array{light: array<string, string>, dark: array<string, string>}
     */
    public function tokens(): array
    {
        $tokens = ThemePresets::tokens($this->presetKey());

        // An explicit panel colour still wins over the preset's, so tenants that
        // already set one keep it — but it now recomputes the panel's text and
        // border layers instead of leaving them hardcoded.
        $override = trim((string) ($this->branding['panel_color'] ?? ''));

        if ($override !== '') {
            $panel = ThemePalette::loginPanelFromHex($override);

            if ($panel !== null) {
                $tokens['light'] = array_merge($tokens['light'], $panel);
                $tokens['dark'] = array_merge($tokens['dark'], $panel);
            }
        }

        return $tokens;
    }

    /**
     * Typography tokens, applied identically in light and dark mode.
     *
     * @return array<string, string>
     */
    public function typography(): array
    {
        $heading = $this->headingFontKey();

        // Named *-family rather than the bare Tailwind theme keys on purpose.
        // app.css declares `@theme { --font-sans: 'Instrument Sans', … }` — a
        // plain @theme, not @theme inline, so Tailwind compiles the font-sans
        // utilities against that LITERAL rather than through a var(). Emitting
        // --font-sans here would not reach them.
        //
        // All four are live. The families reach the shell through the .fi-body
        // rule {@see self::typeCss()}; --radius reaches it by way of the derived
        // Tailwind steps below; --root-font-size is applied to html by css().
        $radius = ThemeFonts::radius($this->theme['radius'] ?? null);

        return array_merge([
            '--font-sans-family' => ThemeFonts::stack($this->fontKey()),
            '--font-heading-family' => ThemeFonts::stack($heading ?? $this->fontKey()),
            '--radius' => $radius,
            '--root-font-size' => ThemeFonts::scale($this->theme['scale'] ?? null),
        ], self::radiusScale());
    }

    /**
     * The tenant's corner radius, restated as Tailwind's radius steps.
     *
     * Filament's compiled stylesheet rounds every surface through those steps —
     * var(--radius-lg) on inputs and buttons, var(--radius-xl) on sections and
     * the sign-in card — so overriding them is what makes the setting reach the
     * app. Emitting --radius alone did nothing, which is why the control looked
     * broken: it saved, and nothing moved.
     *
     * The chosen value anchors the `lg` step (its 0.5rem default is exactly
     * Tailwind's) and the neighbours are derived from it, so the relationship
     * between a button and the card holding it is preserved at every setting —
     * and "Sharp" collapses the whole scale to 0 rather than leaving cards
     * rounded around square buttons. --radius-full is deliberately untouched:
     * avatars and badges are pills at every radius.
     *
     * @return array<string, string>
     */
    private static function radiusScale(): array
    {
        return [
            '--radius-sm' => 'calc(var(--radius) * 0.5)',
            '--radius-md' => 'calc(var(--radius) * 0.75)',
            '--radius-lg' => 'var(--radius)',
            '--radius-xl' => 'calc(var(--radius) * 1.5)',
            '--radius-2xl' => 'calc(var(--radius) * 2)',
        ];
    }

    /**
     * The stylesheet URL for the selected families, or null when everything
     * resolves to a system font.
     */
    public function fontUrl(): ?string
    {
        $families = array_values(array_unique(array_filter([
            ThemeFonts::bunny($this->fontKey()),
            ThemeFonts::bunny($this->headingFontKey()),
        ])));

        if ($families === []) {
            return null;
        }

        return 'https://fonts.bunny.net/css?family='.implode('|', $families);
    }

    /**
     * The preset's brand and neutral ramps, in the shape Filament's colour
     * registry expects.
     *
     * Registering these (rather than only emitting CSS) is what makes the
     * framework's own components agree with the theme: Filament resolves button
     * backgrounds and — critically — button LABEL colours in PHP at render time,
     * by walking the registered ramp for a stop that clears its contrast
     * threshold. A preset with a pale brand colour (Amber Glow, Mint Fresh) gets
     * dark button text automatically; one that never saw the ramp would keep the
     * default white and be unreadable.
     *
     * @return array{primary: array<int, string>, gray: array<int, string>}
     */
    public function filamentColors(): array
    {
        return ThemePalette::filament(ThemePresets::spec($this->presetKey()));
    }

    /**
     * Every custom property the Master Control live preview needs, per mode.
     *
     * The preview can't load the tenant panel's compiled stylesheet, so it
     * restates the shell's chrome in scoped CSS of its own. That only stays
     * honest if it reads the SAME variables the real chrome reads — and the
     * framework paints its surfaces off the neutral ramp (--gray-*) and its
     * buttons off the brand ramp (--primary-*), not off the shadcn tokens. So
     * both ramps are folded in here next to the tokens, with the same two
     * remaps {@see self::filamentCss()} applies to the real document.
     *
     * @return array{light: array<string, string>, dark: array<string, string>}
     */
    public function previewVariables(): array
    {
        $sets = self::previewColourVars($this->presetKey());

        // The branding panel override is folded in last, exactly as tokens()
        // applies it to the real document — so a landlord who set one keeps it
        // even when the preset below it changes.
        $override = trim((string) ($this->branding['panel_color'] ?? ''));

        if ($override !== '') {
            $panel = ThemePalette::loginPanelFromHex($override);

            if ($panel !== null) {
                $sets['light'] = array_merge($sets['light'], $panel);
                $sets['dark'] = array_merge($sets['dark'], $panel);
            }
        }

        $typography = $this->typography();

        return [
            'light' => array_merge($sets['light'], $typography),
            'dark' => array_merge($sets['dark'], $typography),
        ];
    }

    /**
     * The colour-only variable sets for a preset key, in the shape
     * {@see self::previewVariables()} returns before typography and the
     * branding override are folded in.
     *
     * Built once per request for EVERY preset and embedded in the Master
     * Control form, so its live preview can repaint itself client-side when
     * the landlord picks another preset — no server round-trip needed.
     *
     * @return array{light: array<string, string>, dark: array<string, string>}
     */
    public static function previewColourVars(string $presetKey): array
    {
        $light = ThemePresets::tokens($presetKey)['light'];
        $dark = ThemePresets::tokens($presetKey)['dark'];

        // One registered ramp serves both modes; dark mode just reads different
        // stops of it. Only the neutrals are re-anchored below.
        foreach (ThemePalette::filament(ThemePresets::spec($presetKey)) as $name => $shades) {
            foreach ($shades as $shade => $value) {
                $light["--{$name}-{$shade}"] = $value;
                $dark["--{$name}-{$shade}"] = $value;
            }
        }

        // Light tracks --card, as filamentCss() does. Dark is left at the literal
        // the framework keeps there, because under .dark this variable is a
        // FOREGROUND — it is what paints white heading text — and the preview
        // needs the same white the real page uses, not the preset's near-white.
        $light['--color-white'] = $light['--card'];
        $dark['--color-white'] = '#fff';

        return [
            'light' => $light,
            'dark' => array_merge($dark, self::darkRamp($dark)),
        ];
    }

    /**
     * The <style> body injected into the document head.
     *
     * Rendered SERVER-SIDE, unlike the old TenantThemeProvider which set six
     * properties from a useEffect after hydration. That ran too late to style the
     * first paint and — because it read an Inertia prop — never applied at all on
     * a hard refresh of the login page before React mounted.
     *
     * Emits three layers:
     *
     * 1. The shadcn-vocabulary tokens (--background, --sidebar, --card …) plus
     *    the typography set, including the Tailwind radius steps the tenant's
     *    corner-radius choice is expressed through. These are what the Master
     *    Control live preview and any custom Blade read.
     * 2. Filament's dark-mode neutral ramp. The LIGHT ramp arrives via the colour
     *    registry, which can only carry one ramp per alias — but --gray-950 is
     *    body text in light mode and the page background in dark mode, so dark
     *    mode needs its own. This block lands at the HEAD_END render hook, after
     *
     *    @filamentStyles, and both write at :root/.dark specificity — so being
     *    later in source order is exactly what lets it win.
     * 3. Shell overrides for the surfaces Filament paints from a fixed literal
     *    rather than a ramp stop (the sidebar is --color-white in light mode),
     *    which is the only way each preset's sidebar treatment survives.
     * 4. The chosen typefaces {@see self::typeCss()} and the login backdrop
     *    {@see self::authCss()}, both of which Filament otherwise hardcodes past.
     */
    public function css(): string
    {
        $tokens = $this->tokens();
        $typography = $this->typography();

        $root = array_merge($tokens['light'], $typography);

        // Typography is mode-independent, so only the colour tokens repeat in .dark.
        return ':root{'.self::declarations($root).'}'
            .'.dark{'.self::declarations($tokens['dark']).'}'
            .'html{font-size:var(--root-font-size);}'
            .self::filamentCss($tokens);
    }

    /**
     * Bridge the shadcn token set onto the variables and class names Filament's
     * compiled stylesheet actually reads.
     *
     * @param  array{light: array<string, string>, dark: array<string, string>}  $tokens
     */
    private static function filamentCss(array $tokens): string
    {
        $light = $tokens['light'];
        $dark = $tokens['dark'];

        $darkRamp = self::darkRamp($dark);

        // --color-white is the light shell's card/topbar/modal surface, so it
        // tracks --card to keep those surfaces on the preset. Today every preset's
        // light --card is oklch(1 0 h) — literally white — which makes this a
        // no-op; it exists so a future tinted card cannot silently desync.
        //
        // Only :root, never .dark. Under .dark the framework reuses this same
        // variable as a FOREGROUND (body text, and the white 5%/10% mixes behind
        // hover states and rings), so redefining it there would darken text
        // instead of tinting a surface. That asymmetry is why the light value must
        // stay near-white for the dark shell to hold up.
        $whiteSurface = ['--color-white' => $light['--card']];

        return ':root{'.self::declarations($whiteSurface).'}'
            .'.dark{'.self::declarations($darkRamp).'}'
            .self::typeCss()
            .self::sidebarCss()
            .self::authCss();
    }

    /**
     * The chosen families, applied.
     *
     * fontUrl() has always downloaded the webfont and typography() has always
     * emitted the stack, but nothing wore it: Tailwind compiled the font-sans
     * utilities against app.css's literal, so every tenant read in Instrument
     * Sans whatever they picked. One rule on the body fixes it, because
     * font-family inherits and Filament's components set their own almost
     * nowhere — headings included, hence the short list below rather than a
     * rule per component.
     *
     * .fi-body is on <body> in every layout, authenticated and simple alike, so
     * the login page is covered by the same rule.
     */
    private static function typeCss(): string
    {
        return '.fi-body{font-family:var(--font-sans-family);}'
            .'.fi-body :is(.fi-header-heading,.fi-section-header-heading,'
            .'.fi-simple-header-heading,.fi-modal-heading,.fi-logo)'
            .'{font-family:var(--font-heading-family);}';
    }

    /**
     * Filament's dark surfaces come off the neutral ramp at stops that mean
     * something different from their light-mode role, so the ramp is restated
     * from the dark token set instead of reused.
     *
     * @param  array<string, string>  $dark  The dark token set
     * @return array<string, string>
     */
    private static function darkRamp(array $dark): array
    {
        return [
            '--gray-50' => $dark['--foreground'],
            '--gray-100' => $dark['--foreground'],
            '--gray-200' => $dark['--secondary-foreground'],
            '--gray-300' => $dark['--secondary-foreground'],
            '--gray-400' => $dark['--muted-foreground'],
            '--gray-500' => $dark['--muted-foreground'],
            '--gray-600' => $dark['--border'],
            '--gray-700' => $dark['--border'],
            '--gray-800' => $dark['--secondary'],
            '--gray-900' => $dark['--card'],
            '--gray-950' => $dark['--background'],
        ];
    }

    /**
     * The login backdrop.
     *
     * ThemePalette has always produced a --login-panel set, and Master Control
     * has always offered `branding.panel_color` to override it, but nothing in
     * the app read either — the login page rendered on Filament's stock
     * bg-gray-50 and the setting was inert. These two rules are what make it
     * true: the branded surface is the page BEHIND the sign-in card, not a
     * split panel beside it, because Filament's auth pages are SimplePage —
     * one centred card, no second column. The Master Control preview draws
     * exactly this.
     *
     * .fi-simple-layout is every unauthenticated page (sign-in, password
     * reset, e-mail verification), which is precisely the set that should
     * carry the brand. The copyright line sits inside that layout, so it moves
     * off Filament's gray-400 onto the panel's own muted token — the one
     * ThemePalette already derives from the panel's lightness, so it stays
     * legible whether the tenant picked ink or a pale brand colour.
     */
    private static function authCss(): string
    {
        return '.fi-body .fi-simple-layout{background-color:var(--login-panel);}'
            .'.fi-body .fi-simple-layout .fi-copyright-footer p{color:var(--login-panel-muted);}';
    }

    /**
     * The sidebar is the one surface Filament refuses to derive: it paints it
     * var(--color-white) in light mode and var(--gray-900) in dark, both fixed
     * literals. Presets carry a sidebar TREATMENT (dark / ink / deep / light)
     * that is a large part of what distinguishes them in the preview, so each
     * layer is restated against the preset's own --sidebar* tokens.
     *
     * These rules reference the tokens through var() rather than baking in
     * literals, so ONE rule set covers both modes — the :root/.dark blocks above
     * have already redefined --sidebar* by the time these resolve.
     *
     * Two things make these win, and it is worth being precise about which does
     * the work. The framework's sidebar rules live inside `@layer components`,
     * and this block is injected unlayered, so it outranks them outright —
     * unlayered normal declarations beat layered ones no matter the specificity.
     * The .fi-body prefix (always on the shell's <body>) is belt-and-braces: it
     * also clears the vendor selectors on specificity, which matters because
     * those are deceptively deep — the active item's label is styled at
     * .fi-sidebar-item.fi-active > .fi-sidebar-item-btn > .fi-sidebar-item-label,
     * i.e. (0,4,0). If Filament ever ships these unlayered, this still holds.
     */
    private static function sidebarCss(): string
    {
        $rules = [
            // The sidebar edge is a Tailwind ring, not a border, so border-color
            // would do nothing. --tw-ring-color is registered @property
            // inherits:false, so setting it here cannot leak into the focus
            // rings of inputs and buttons nested inside the sidebar.
            '.fi-body .fi-sidebar, .fi-body .fi-sidebar.fi-sidebar-open, .fi-body .fi-sidebar .fi-sidebar-header' => [
                'background-color' => 'var(--sidebar)',
                '--tw-ring-color' => 'var(--sidebar-border)',
            ],
            // Defensive, and currently inert: this panel has a topbar, and with
            // one Filament sets .fi-sidebar-header to display:none, so the brand
            // renders on the topbar's --color-white rather than in here. It
            // matters the moment a panel drops the topbar — the header above
            // then takes the --sidebar surface, and Filament would still paint
            // .fi-logo var(--gray-950): black on a dark or ink sidebar, i.e. the
            // tenant's own name invisible.
            '.fi-body .fi-sidebar .fi-logo' => [
                'color' => 'var(--sidebar-foreground)',
            ],
            '.fi-body .fi-sidebar-item-btn > .fi-sidebar-item-label' => [
                'color' => 'var(--sidebar-foreground)',
            ],
            // Idle icons sit at --gray-400 against a --gray-700 label upstream;
            // fading the same token keeps that hierarchy on every preset.
            '.fi-body .fi-sidebar-item-btn > .fi-icon' => [
                'color' => 'color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)',
            ],
            '.fi-body .fi-sidebar .fi-sidebar-group-label' => [
                'color' => 'color-mix(in oklab, var(--sidebar-foreground) 65%, transparent)',
            ],
            '.fi-body .fi-sidebar-item.fi-active > .fi-sidebar-item-btn' => [
                'background-color' => 'var(--sidebar-accent)',
            ],
            '.fi-body .fi-sidebar-item.fi-active > .fi-sidebar-item-btn > .fi-sidebar-item-label, .fi-body .fi-sidebar-item.fi-active > .fi-sidebar-item-btn > .fi-icon' => [
                'color' => 'var(--sidebar-accent-foreground)',
            ],
            '.fi-body .fi-sidebar-item.fi-sidebar-item-has-url > .fi-sidebar-item-btn:hover, .fi-body .fi-sidebar-item.fi-sidebar-item-has-url > .fi-sidebar-item-btn:focus-visible' => [
                'background-color' => 'color-mix(in oklab, var(--sidebar-accent) 60%, transparent)',
            ],
        ];

        $css = '';

        foreach ($rules as $selector => $declarations) {
            $css .= $selector.'{'.self::declarations($declarations).'}';
        }

        // Filament nulls the sidebar background at the desktop breakpoint so the
        // page shows through, so the surface has to be restated inside the same
        // breakpoint. 64rem verbatim: media-query rem resolves against the
        // browser's initial font size, NOT the app's --root-font-size, so
        // spelling it 1024px would silently disagree if that ever changed.
        return $css.'@media(min-width:64rem){'
            .'.fi-body .fi-sidebar{background-color:var(--sidebar);}'
            .'}';
    }

    /**
     * @param  array<string, string>  $declarations  Property => value; custom
     *                                               properties arrive already
     *                                               carrying their '--' prefix.
     */
    private static function declarations(array $declarations): string
    {
        $out = '';

        foreach ($declarations as $property => $value) {
            $out .= $property.':'.self::sanitise($value).';';
        }

        return $out;
    }

    /**
     * Values are generated by ThemePalette (oklch literals) or picked from the
     * fixed ThemeFonts catalogue, so they are already trusted. Strip anything
     * that could terminate the declaration or the block regardless, since the
     * panel override originates from user input.
     */
    private static function sanitise(string $value): string
    {
        return str_replace(['<', '>', '{', '}', ';'], '', $value);
    }

    /**
     * Shape shared to the frontend on every tenant request.
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return [
            'preset' => $this->presetKey(),
            'preset_name' => ThemePresets::name($this->presetKey()),
            'font_body' => $this->fontKey(),
            'font_heading' => $this->headingFontKey(),
            'radius' => $this->theme['radius'] ?? ThemeFonts::DEFAULT_RADIUS,
            'scale' => $this->theme['scale'] ?? ThemeFonts::DEFAULT_SCALE,
        ];
    }

    /**
     * Build from a nested settings array (the shape TenantSetting exposes).
     *
     * @param  array<string, mixed>  $settings
     */
    public static function fromSettings(array $settings): self
    {
        return new self($settings['theme'] ?? [], $settings['branding'] ?? []);
    }
}
