<?php
declare(strict_types=1);

namespace App\Theme;

use App\Security\UrlPolicy;
use InvalidArgumentException;

final class ThemeAppearanceService
{
    /** @var list<string> */
    private const COLORS = [
        'primary', 'primary_dark', 'accent', 'surface', 'surface_soft',
        'ink', 'ink_muted', 'line', 'danger',
    ];

    /** @var array<string, array{min:float,max:float}> */
    private const NUMERIC_RANGES = [
        'radius_md' => ['min' => 0.25, 'max' => 1.5],
        'radius_lg' => ['min' => 0.5, 'max' => 2.5],
        'container_width' => ['min' => 56.0, 'max' => 100.0],
        'narrow_width' => ['min' => 32.0, 'max' => 64.0],
        'font_size_base' => ['min' => 0.875, 'max' => 1.25],
    ];

    /** @var array<string,string> */
    private const DEFAULTS = [
        'primary' => '#0E6F5C',
        'primary_dark' => '#0A5546',
        'accent' => '#C99A3B',
        'surface' => '#FFFFFF',
        'surface_soft' => '#F6F7F9',
        'ink' => '#171A21',
        'ink_muted' => '#5B6270',
        'line' => '#E7E9EE',
        'danger' => '#A84848',
        'radius_md' => '0.75',
        'radius_lg' => '1',
        'container_width' => '70',
        'narrow_width' => '48',
        'font_stack' => 'system',
        'font_size_base' => '1',
        'brand_text' => '',
        'brand_display_mode' => 'logo_text',
        'mobile_bottom_nav' => '0',
        'logo_url' => '',
        'logo_name' => '',
        'favicon_url' => '',
        'custom_css' => '',
    ];

    /**
     * How the brand mark is composed in the header.
     *
     * `logo_text` keeps the logo beside the visible site name, which is why the
     * image carries an empty alt: the adjacent text already names the link, and
     * duplicating it makes a screen reader announce the site twice. Under
     * `logo_only` the name is hidden visually but kept in the accessibility
     * tree, so the link still has exactly one accessible name.
     */
    public const BRAND_DISPLAY_MODES = ['logo_text', 'logo_only', 'text_only'];

    /** A checkbox posts nothing when unticked, so absence has to mean off. */
    private static function flag(mixed $value, string $fallback = '0'): string
    {
        if ($value === null) {
            return $fallback;
        }
        $candidate = strtolower(trim((string) $value));
        return in_array($candidate, ['1', 'on', 'true', 'yes'], true) ? '1' : '0';
    }

    /** @return array<string,string> */
    public static function defaults(array $config = []): array
    {
        $defaults = self::DEFAULTS;
        foreach (self::COLORS as $key) {
            $candidate = trim((string) ($config[$key] ?? ''));
            if (preg_match('/^#[0-9A-Fa-f]{6}$/', $candidate) === 1) {
                $defaults[$key] = strtoupper($candidate);
            }
        }

        foreach (self::NUMERIC_RANGES as $key => $range) {
            $candidate = trim((string) ($config[$key] ?? ''));
            if ($candidate !== '' && is_numeric($candidate)) {
                $number = (float) $candidate;
                if (is_finite($number) && $number >= $range['min'] && $number <= $range['max']) {
                    $defaults[$key] = self::formatNumber($number);
                }
            }
        }
        $fontStack = self::resolveFontStack((string) ($config['font_stack'] ?? ''));
        if (in_array($fontStack, self::fontStacks(), true)) {
            $defaults['font_stack'] = $fontStack;
        }
        $brandText = trim((string) ($config['brand_text'] ?? ''));
        if ($brandText !== '' && self::length($brandText) <= 64) {
            $defaults['brand_text'] = $brandText;
        }
        foreach (['logo_url', 'favicon_url'] as $key) {
            $candidate = trim((string) ($config[$key] ?? ''));
            if ($candidate === '' || (strlen($candidate) <= 500 && UrlPolicy::isAllowed($candidate))) {
                $defaults[$key] = $candidate;
            }
        }
        $displayMode = trim((string) ($config['brand_display_mode'] ?? ''));
        if (in_array($displayMode, self::BRAND_DISPLAY_MODES, true)) {
            $defaults['brand_display_mode'] = $displayMode;
        }
        // Off by default: a theme designed around its own header should not have
        // a second navigation bar imposed on it by an upgrade.
        $defaults['mobile_bottom_nav'] = self::flag($config['mobile_bottom_nav'] ?? null, $defaults['mobile_bottom_nav']);
        // The stored file keeps its generated name, so the original one is kept
        // only as a label for the panel; it never touches the filesystem.
        $logoName = trim((string) ($config['logo_name'] ?? ''));
        if ($logoName !== '' && self::length($logoName) <= 120) {
            $defaults['logo_name'] = $logoName;
        }

        return $defaults;
    }

    /** @param array<string,mixed> $input @param array<string,mixed> $fallback @return array<string,string> */
    public static function normalize(array $input, array $fallback = []): array
    {
        $base = self::defaults($fallback);
        $normalized = $base;

        foreach (self::COLORS as $key) {
            if (!array_key_exists($key, $input)) {
                continue;
            }
            $value = trim((string) $input[$key]);
            if (preg_match('/^#[0-9A-Fa-f]{6}$/', $value) !== 1) {
                throw new InvalidArgumentException('Theme color is invalid.');
            }
            $normalized[$key] = strtoupper($value);
        }

        foreach (self::NUMERIC_RANGES as $key => $range) {
            if (!array_key_exists($key, $input)) {
                continue;
            }
            $raw = trim((string) $input[$key]);
            if ($raw === '' || !is_numeric($raw)) {
                throw new InvalidArgumentException('Theme dimension is invalid.');
            }
            $number = (float) $raw;
            if (!is_finite($number) || $number < $range['min'] || $number > $range['max']) {
                throw new InvalidArgumentException('Theme dimension is outside the allowed range.');
            }
            $normalized[$key] = self::formatNumber($number);
        }

        if (array_key_exists('font_stack', $input)) {
            $fontStack = self::resolveFontStack((string) $input['font_stack']);
            if (!in_array($fontStack, self::fontStacks(), true)) {
                throw new InvalidArgumentException('Theme font stack is invalid.');
            }
            $normalized['font_stack'] = $fontStack;
        }

        if (array_key_exists('brand_text', $input)) {
            $brandText = trim((string) $input['brand_text']);
            if (self::length($brandText) > 64) {
                throw new InvalidArgumentException('Theme brand text is invalid.');
            }
            // Empty means "derive the mark from the site name", which is the
            // default since the literal 'Q' stopped doubling as the unset
            // marker. Rejecting it threw from inside quaeriva_apply_runtime_settings(),
            // whose catch then discarded the site name, description, timezone and
            // panel path along with it -- the public pages fell back to the
            // shipped defaults while the panel, which reads settings directly,
            // showed the operator's real values.
            $normalized['brand_text'] = $brandText;
        }

        foreach (['logo_url', 'favicon_url'] as $key) {
            if (!array_key_exists($key, $input)) {
                continue;
            }
            $url = trim((string) $input[$key]);
            if ($url !== '' && !UrlPolicy::isAllowed($url)) {
                throw new InvalidArgumentException('Theme asset URL is invalid.');
            }
            if (strlen($url) > 500) {
                throw new InvalidArgumentException('Theme asset URL is too long.');
            }
            $normalized[$key] = $url;
        }

        if (array_key_exists('mobile_bottom_nav', $input)) {
            $normalized['mobile_bottom_nav'] = self::flag($input['mobile_bottom_nav'], '0');
        }

        if (array_key_exists('brand_display_mode', $input)) {
            $displayMode = trim((string) $input['brand_display_mode']);
            if (!in_array($displayMode, self::BRAND_DISPLAY_MODES, true)) {
                throw new InvalidArgumentException('Theme brand display mode is invalid.');
            }
            $normalized['brand_display_mode'] = $displayMode;
        }

        if (array_key_exists('logo_name', $input)) {
            $logoName = trim((string) $input['logo_name']);
            if (self::length($logoName) > 120) {
                throw new InvalidArgumentException('Theme logo name is too long.');
            }
            // Only ever rendered as escaped text in the panel, never used to
            // build a path, so the basename is enough to keep it harmless.
            $normalized['logo_name'] = $logoName === '' ? '' : basename($logoName);
        }

        if (array_key_exists('custom_css', $input)) {
            $customCss = (string) $input['custom_css'];
            if (strlen($customCss) > 50000 || stripos($customCss, '</style') !== false) {
                throw new InvalidArgumentException('Theme custom CSS is invalid.');
            }
            $normalized['custom_css'] = $customCss;
        }

        return $normalized;
    }

    /** @return list<string> */
    public static function fontStacks(): array
    {
        return ['system', 'inter', 'cairo', 'noto_kufi'];
    }

    /**
     * Stacks that were offered once and are still stored on live sites.
     *
     * 'noto_sans' paired Noto Sans Arabic with Noto Sans, which cost 197 KB of
     * webfont for a face that is a fallback everywhere else and a display choice
     * nowhere. Sites that chose it keep rendering -- through Cairo, which covers
     * the same ground in 63 KB -- instead of hitting the invalid-stack throw.
     */
    private const FONT_STACK_ALIASES = ['noto_sans' => 'cairo'];

    public static function resolveFontStack(string $token): string
    {
        $token = strtolower(trim($token));

        return self::FONT_STACK_ALIASES[$token] ?? $token;
    }

    public static function fontStackCss(string $token): string
    {
        return match (self::resolveFontStack($token)) {
            'cairo' => 'Cairo, ui-sans-serif, system-ui, sans-serif',
            'inter' => 'Inter, Cairo, ui-sans-serif, system-ui, sans-serif',
            'noto_kufi' => '"Noto Kufi Arabic", Cairo, ui-sans-serif, system-ui, sans-serif',
            default => 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
        };
    }

    /**
     * The font stylesheet is served from this site's own origin.
     *
     * Linking fonts.googleapis.com sent every visitor's IP and user agent to a
     * third party before the page rendered, and stalled the first paint for a
     * full DNS timeout wherever Google is unreachable. The files are shipped in
     * public/assets/fonts and refreshed with bin/fetch-fonts.php.
     */
    public static function fontHeadTags(string $token): string
    {
        $token = self::resolveFontStack($token);

        $file = match ($token) {
            'cairo', 'inter', 'noto_kufi' => $token . '.css',
            default => '',
        };

        if ($file === '') {
            return '';
        }

        $relative = 'assets/fonts/' . $file;

        // An operator who deleted the font assets gets the system stack rather
        // than a stylesheet link that 404s on every page.
        if (function_exists('app_root_path') && !is_file(app_root_path('public', 'assets', 'fonts', $file))) {
            return '';
        }

        $url = function_exists('quaeriva_url')
            ? quaeriva_url($relative, function_exists('quaeriva_asset_version') ? ['v' => quaeriva_asset_version($relative)] : [])
            : '/' . $relative;

        $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');

        // Same-origin and a couple of kilobytes: a plain stylesheet link is
        // correct here. The preload/onload dance and the preconnect hints existed
        // only to survive a third-party host that is no longer contacted, and
        // each @font-face carries its own unicode-range, so a browser downloads
        // only the subset the page actually renders.
        return '<link rel="stylesheet" href="' . $safeUrl . '">';
    }

    public static function inlineStyle(array $theme): string
    {
        try {
            $safe = self::normalize($theme, self::DEFAULTS);
        } catch (InvalidArgumentException) {
            $safe = self::defaults();
        }

        $variables = [
            '--teal' => $safe['primary'],
            '--teal-dark' => $safe['primary_dark'],
            '--gold' => $safe['accent'],
            '--surface' => $safe['surface'],
            '--surface-soft' => $safe['surface_soft'],
            '--ink' => $safe['ink'],
            '--ink-muted' => $safe['ink_muted'],
            '--line' => $safe['line'],
            '--danger' => $safe['danger'],
            '--radius-md' => self::rem($safe['radius_md']),
            '--radius-lg' => self::rem($safe['radius_lg']),
            '--container' => self::rem($safe['container_width']),
            '--narrow' => self::rem($safe['narrow_width']),
            '--font-size-base' => self::rem($safe['font_size_base']),
            '--font-stack' => self::fontStackCss($safe['font_stack']),
        ];

        $declarations = [];
        foreach ($variables as $property => $value) {
            $declarations[] = $property . ':' . $value;
        }

        return ':root{' . implode(';', $declarations) . '}';
    }

    public static function customCss(array $theme): string
    {
        try {
            return self::normalize(['custom_css' => (string) ($theme['custom_css'] ?? '')], self::defaults())['custom_css'];
        } catch (InvalidArgumentException) {
            return '';
        }
    }

    private static function rem(string $value): string
    {
        return self::formatNumber((float) $value) . 'rem';
    }

    private static function formatNumber(float $value): string
    {
        $formatted = number_format($value, 2, '.', '');
        return rtrim(rtrim($formatted, '0'), '.') ?: '0';
    }

    private static function length(string $value): int
    {
        return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
    }
}
