<?php

declare(strict_types=1);

final class MyronCustomCssRepository
{
    public const INTERACTION_LIBRARY_MAX_BYTES = 1_000_000;
    public const FONT_FILE_MAX_BYTES = 2_000_000;

    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    public function siteCss(): string
    {
        $statement = $this->pdo->prepare(
            "SELECT css_text FROM custom_css_settings WHERE scope_type = 'site' AND scope_key = 'global' LIMIT 1"
        );
        $statement->execute();
        $value = $statement->fetchColumn();

        return is_string($value) ? $value : '';
    }

    public function saveSiteCss(string $css, ?string $userId = null): void
    {
        $this->save('site', 'global', $css, $userId);
    }

    public const CONTEXT_PUBLIC = 'public';
    public const CONTEXT_ADMIN_PREVIEW = 'adminPreview';
    private const ADMIN_PREVIEW_PREFIX = '[data-mc-visual-editor-preview]';

    public function renderSiteStyleTag(string $context = self::CONTEXT_PUBLIC): string
    {
        $css = $this->siteCss();
        if (trim($css) === '') {
            return '';
        }

        $payload = $context === self::CONTEXT_ADMIN_PREVIEW
            ? self::ADMIN_PREVIEW_PREFIX . ' {' . "\n" . $this->normalizeCss($css) . "\n" . '}'
            : $css;

        return '<style data-myron-custom-css="site" data-context="' . $this->escapeAttr($context) . '">' . "\n"
            . $this->cssLayer($payload) . "\n"
            . '</style>';
    }

    public function renderSectionStyleTag(string $sectionUuid, string $css, string $context = self::CONTEXT_PUBLIC): string
    {
        return $this->renderSectionStylesTag([['sectionUuid' => $sectionUuid, 'css' => $css]], '', $context);
    }

    /**
     * @param list<array{sectionUuid:string,css:string}> $entries
     */
    public function renderSectionStylesTag(array $entries, string $containerSelector = '', string $context = self::CONTEXT_PUBLIC): string
    {
        $blocks = $this->buildSectionBlocks($entries, $containerSelector, $context);
        if ($blocks === []) {
            return '';
        }

        return '<style data-myron-custom-css="sections" data-context="' . $this->escapeAttr($context) . '">' . "\n"
            . $this->cssLayer(implode("\n\n", $blocks)) . "\n"
            . '</style>';
    }

    /**
     * Single consolidated <style> block per page combining site-wide CSS and per-section CSS
     * in deterministic order (site first, then sections in entry-array order). Honors the
     * locked architecture decision: one style block per page, wrapped in @layer custom (placed
     * last so operator CSS wins by default), with admin-preview container prefix when the
     * caller is the admin shell.
     *
     * @param list<array{sectionUuid:string,css:string}> $sectionEntries
     */
    public function renderConsolidatedStyleTag(string $siteCss, array $sectionEntries, string $containerSelector = '', string $context = self::CONTEXT_PUBLIC): string
    {
        $segments = [];

        $siteCss = $this->normalizeCss($siteCss);
        if ($siteCss !== '') {
            $segments[] = $context === self::CONTEXT_ADMIN_PREVIEW
                ? self::ADMIN_PREVIEW_PREFIX . ' {' . "\n" . $siteCss . "\n" . '}'
                : $siteCss;
        }

        $sectionBlocks = $this->buildSectionBlocks($sectionEntries, $containerSelector, $context);
        if ($sectionBlocks !== []) {
            $segments[] = implode("\n\n", $sectionBlocks);
        }

        if ($segments === []) {
            return '';
        }

        return '<style data-myron-custom-css="consolidated" data-context="' . $this->escapeAttr($context) . '">' . "\n"
            . $this->cssLayer(implode("\n\n", $segments)) . "\n"
            . '</style>';
    }

    public function cssLayer(string $css): string
    {
        $css = $this->normalizeCss($css);
        if ($css === '') {
            return '';
        }

        return "@layer custom {\n" . $css . "\n}";
    }

    /**
     * @param list<array{sectionUuid:string,css:string}> $entries
     * @return list<string>
     */
    private function buildSectionBlocks(array $entries, string $containerSelector, string $context): array
    {
        $blocks = [];
        $containerSelector = trim($containerSelector);
        $adminPrefix = $context === self::CONTEXT_ADMIN_PREVIEW ? self::ADMIN_PREVIEW_PREFIX : '';

        foreach ($entries as $entry) {
            $sectionUuid = trim((string) ($entry['sectionUuid'] ?? ''));
            $css = trim((string) ($entry['css'] ?? ''));
            if ($sectionUuid === '' || $css === '') {
                continue;
            }

            $selector = '[data-section-uuid="' . addcslashes($sectionUuid, "\\\"") . '"]';
            if ($containerSelector !== '') {
                $selector = $containerSelector . ' ' . $selector;
            }
            if ($adminPrefix !== '') {
                $selector = $adminPrefix . ' ' . $selector;
            }
            $blocks[] = str_contains($css, '{')
                ? '@scope (' . $selector . ') {' . "\n" . $css . "\n" . '}'
                : $selector . ' {' . "\n" . $css . "\n" . '}';
        }

        return $blocks;
    }

    private function escapeAttr(string $value): string
    {
        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }

    private function save(string $scopeType, string $scopeKey, string $css, ?string $userId): void
    {
        $scopeType = trim($scopeType);
        $scopeKey = trim($scopeKey);
        if ($scopeType === '' || $scopeKey === '') {
            throw new InvalidArgumentException('Custom CSS settings require a scope.');
        }

        $now = myronNow();
        $existing = $this->pdo->prepare(
            'SELECT id FROM custom_css_settings WHERE scope_type = :scope_type AND scope_key = :scope_key LIMIT 1'
        );
        $existing->execute([':scope_type' => $scopeType, ':scope_key' => $scopeKey]);
        $id = $existing->fetchColumn();

            if (is_string($id) && $id !== '') {
                $statement = $this->pdo->prepare(
                'UPDATE custom_css_settings
                 SET css_text = :css_text, updated_by = :updated_by, updated_at = :updated_at
                 WHERE id = :id'
            );
            $statement->execute([
                ':css_text' => $this->normalizeCss($css),
                ':updated_by' => $userId,
                    ':updated_at' => $now,
                    ':id' => $id,
                ]);
            myronInvalidatePublicPageCache('custom interaction settings changed');
                return;
            }

        $statement = $this->pdo->prepare(
            'INSERT INTO custom_css_settings (
                id, scope_type, scope_key, css_text, updated_by, created_at, updated_at
             ) VALUES (
                :id, :scope_type, :scope_key, :css_text, :updated_by, :created_at, :updated_at
             )'
        );
            $statement->execute([
                ':id' => myronGenerateId('css'),
                ':scope_type' => $scopeType,
                ':scope_key' => $scopeKey,
                ':css_text' => $this->normalizeCss($css),
                ':updated_by' => $userId,
                ':created_at' => $now,
                ':updated_at' => $now,
            ]);
        myronInvalidatePublicPageCache('custom interaction settings changed');
        }

    /**
     * Returns the saved creative dependency list, or [] if none.
     * Each entry remains compatible with the older shape:
     * {name: string, cdnUrl: string, sri: string}
     *
     * @return list<array<string, mixed>>
     */
    public function creativeDepsSetting(): array
    {
        return $this->externalLibrariesSetting();
    }

    public function saveCreativeDeps(array $deps, ?string $userId = null): void
    {
        $this->saveExternalLibraries($deps, $userId);
    }

    /**
     * @return array{css:string,externalLibraries:list<array<string,mixed>>,uploadedLibraries:list<array<string,mixed>>,customJs:string,externalFonts:list<array<string,mixed>>,uploadedFonts:list<array<string,mixed>>}
     */
    public function customInteractions(): array
    {
        return [
            'css' => $this->siteCss(),
            'externalLibraries' => $this->externalLibrariesSetting(),
            'uploadedLibraries' => $this->uploadedLibrariesSetting(),
            'customJs' => $this->customJsSetting(),
            'externalFonts' => $this->externalFontStylesheetsSetting(),
            'uploadedFonts' => $this->uploadedFontsSetting(),
        ];
    }

    /**
     * @param list<array<string, mixed>> $libraries
     */
    public function saveExternalLibraries(array $libraries, ?string $userId = null): void
    {
        $this->save('site', 'creative_deps', (string) json_encode($this->normalizeExternalLibraries($libraries), JSON_THROW_ON_ERROR), $userId);
    }

    public function customJsSetting(): string
    {
        return $this->stringSetting('site', 'custom_js');
    }

    public function saveCustomJs(string $js, ?string $userId = null): void
    {
        $this->save('site', 'custom_js', $this->normalizeJs($js), $userId);
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function uploadedLibrariesSetting(): array
    {
        $decoded = $this->jsonListSetting('site', 'uploaded_js_libraries');
        return $this->normalizeUploadedLibraries($decoded);
    }

    /**
     * @param list<array<string, mixed>> $libraries
     */
    public function saveUploadedLibraries(array $libraries, ?string $userId = null): void
    {
        $this->save('site', 'uploaded_js_libraries', (string) json_encode($this->normalizeUploadedLibraries($libraries), JSON_THROW_ON_ERROR), $userId);
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function externalFontStylesheetsSetting(): array
    {
        return $this->normalizeExternalFontStylesheets($this->jsonListSetting('site', 'external_font_stylesheets'));
    }

    /**
     * @param list<array<string, mixed>> $stylesheets
     */
    public function saveExternalFontStylesheets(array $stylesheets, ?string $userId = null): void
    {
        $this->save('site', 'external_font_stylesheets', (string) json_encode($this->normalizeExternalFontStylesheets($stylesheets), JSON_THROW_ON_ERROR), $userId);
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function uploadedFontsSetting(): array
    {
        return $this->normalizeUploadedFonts($this->jsonListSetting('site', 'uploaded_font_files'));
    }

    /**
     * @param list<array<string, mixed>> $fonts
     */
    public function saveUploadedFonts(array $fonts, ?string $userId = null): void
    {
        $this->save('site', 'uploaded_font_files', (string) json_encode($this->normalizeUploadedFonts($fonts), JSON_THROW_ON_ERROR), $userId);
    }

    /**
     * @param array<string, mixed> $uploadedFile
     * @return array<string, mixed>
     */
    public function uploadInteractionLibrary(array $uploadedFile, ?string $userId = null, string $label = ''): array
    {
        $error = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) {
            throw new RuntimeException(match ($error) {
                UPLOAD_ERR_NO_FILE => 'Choose a JavaScript library to upload.',
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded JavaScript library exceeds the size limit.',
                default => 'JavaScript library upload failed.',
            });
        }

        $tmpName = trim((string) ($uploadedFile['tmp_name'] ?? ''));
        if ($tmpName === '' || !is_file($tmpName)) {
            throw new RuntimeException('Uploaded JavaScript payload is invalid.');
        }

        $originalFilename = $this->sanitizeJsFilename((string) ($uploadedFile['name'] ?? 'library.js'));
        if (!str_ends_with(strtolower($originalFilename), '.js')) {
            throw new RuntimeException('Uploaded interaction libraries must use the .js extension.');
        }

        $fileSize = (int) filesize($tmpName);
        if ($fileSize <= 0) {
            throw new RuntimeException('Uploaded JavaScript library is empty.');
        }
        if ($fileSize > self::INTERACTION_LIBRARY_MAX_BYTES) {
            throw new RuntimeException('Uploaded JavaScript libraries must be 1 MB or smaller.');
        }

        $bytes = (string) file_get_contents($tmpName);
        $this->assertSafeJsPayload($bytes);
        $sha256 = hash('sha256', $bytes);
        $storedFilename = 'myron-interaction-' . substr($sha256, 0, 16) . '.js';
        $relativePath = 'interactions/' . $storedFilename;
        $absolutePath = $this->interactionUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        myronEnsureDirectory(dirname($absolutePath));
        if (file_put_contents($absolutePath, $bytes) !== strlen($bytes)) {
            throw new RuntimeException('Uploaded JavaScript library could not be stored.');
        }

        $entry = [
            'id' => 'jslib_' . substr($sha256, 0, 12),
            'name' => trim($label) !== '' ? trim($label) : preg_replace('/\.js$/i', '', $originalFilename),
            'originalFilename' => $originalFilename,
            'storedFilename' => $storedFilename,
            'path' => $relativePath,
            'url' => $this->interactionLibraryUrl($storedFilename),
            'sha256' => $sha256,
            'sizeBytes' => $fileSize,
            'enabled' => true,
            'order' => count($this->uploadedLibrariesSetting()) + 1,
            'uploadedBy' => $userId ?? '',
            'uploadedAt' => myronNow(),
        ];

        $libraries = $this->uploadedLibrariesSetting();
        $libraries = array_values(array_filter(
            $libraries,
            static fn (array $existing): bool => (string) ($existing['sha256'] ?? '') !== $sha256
        ));
        $libraries[] = $entry;
        $this->saveUploadedLibraries($libraries, $userId);

        return $entry;
    }

    /**
     * @return array{libraryId:string,deleted:bool,storedFilename:string,fileDeleted:bool,fileMissing:bool}
     */
    public function deleteInteractionLibrary(string $libraryId, ?string $userId = null): array
    {
        $libraryId = trim($libraryId);
        if ($libraryId === '') {
            throw new InvalidArgumentException('Uploaded JavaScript library id is required.');
        }

        $libraries = $this->uploadedLibrariesSetting();
        $target = null;
        $remaining = [];
        foreach ($libraries as $library) {
            if ((string) ($library['id'] ?? '') === $libraryId) {
                $target = $library;
                continue;
            }
            $remaining[] = $library;
        }

        if ($target === null) {
            throw new InvalidArgumentException('Uploaded JavaScript library was not found.');
        }

        $storedFilename = basename(trim((string) ($target['storedFilename'] ?? '')));
        if ($storedFilename === '' || !preg_match('/^myron-interaction-[a-f0-9]{16}\.js$/', $storedFilename)) {
            throw new RuntimeException('Uploaded JavaScript library storage reference is invalid.');
        }

        $absolutePath = $this->interactionUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        $fileDeleted = false;
        $fileMissing = !is_file($absolutePath);
        if (!$fileMissing) {
            if (!unlink($absolutePath)) {
                throw new RuntimeException('Uploaded JavaScript library could not be deleted from storage.');
            }
            $fileDeleted = true;
        }

        $this->saveUploadedLibraries($remaining, $userId);

        return [
            'libraryId' => $libraryId,
            'deleted' => true,
            'storedFilename' => $storedFilename,
            'fileDeleted' => $fileDeleted,
            'fileMissing' => $fileMissing,
        ];
    }

    /**
     * @param array<string, mixed> $uploadedFile
     * @return array<string, mixed>
     */
    public function uploadInteractionFont(
        array $uploadedFile,
        ?string $userId = null,
        string $label = '',
        string $familyName = '',
        string $weight = '400',
        string $style = 'normal',
        string $display = 'swap'
    ): array {
        $error = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) {
            throw new RuntimeException(match ($error) {
                UPLOAD_ERR_NO_FILE => 'Choose a web font file to upload.',
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded web font exceeds the size limit.',
                default => 'Web font upload failed.',
            });
        }

        $tmpName = trim((string) ($uploadedFile['tmp_name'] ?? ''));
        if ($tmpName === '' || !is_file($tmpName)) {
            throw new RuntimeException('Uploaded web font payload is invalid.');
        }

        $originalFilename = $this->sanitizeFontFilename((string) ($uploadedFile['name'] ?? 'font.woff2'));
        $extension = strtolower(pathinfo($originalFilename, PATHINFO_EXTENSION));
        if (!in_array($extension, ['woff2', 'woff', 'ttf', 'otf'], true)) {
            throw new RuntimeException('Uploaded web fonts must use the .woff2, .woff, .ttf, or .otf extension.');
        }

        $fileSize = (int) filesize($tmpName);
        if ($fileSize <= 0) {
            throw new RuntimeException('Uploaded web font is empty.');
        }
        if ($fileSize > self::FONT_FILE_MAX_BYTES) {
            throw new RuntimeException('Uploaded web fonts must be 2 MB or smaller.');
        }

        $bytes = (string) file_get_contents($tmpName);
        $this->assertSafeFontPayload($bytes, $extension);
        $sha256 = hash('sha256', $bytes);
        $storedFilename = 'myron-font-' . substr($sha256, 0, 16) . '.' . $extension;
        $relativePath = 'fonts/' . $storedFilename;
        $absolutePath = $this->fontUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        myronEnsureDirectory(dirname($absolutePath));
        if (file_put_contents($absolutePath, $bytes) !== strlen($bytes)) {
            throw new RuntimeException('Uploaded web font could not be stored.');
        }

        $entry = [
            'id' => 'font_' . substr($sha256, 0, 12),
            'label' => trim($label) !== '' ? trim($label) : preg_replace('/\.[A-Za-z0-9]+$/', '', $originalFilename),
            'familyName' => $this->normalizeFontFamilyName($familyName !== '' ? $familyName : preg_replace('/\.[A-Za-z0-9]+$/', '', $originalFilename)),
            'weight' => $this->normalizeFontWeight($weight),
            'style' => $this->normalizeFontStyle($style),
            'display' => $this->normalizeFontDisplay($display),
            'originalFilename' => $originalFilename,
            'storedFilename' => $storedFilename,
            'path' => $relativePath,
            'url' => $this->fontFileUrl($storedFilename),
            'format' => $this->fontFormatForExtension($extension),
            'mime' => $this->fontMimeForExtension($extension),
            'sha256' => $sha256,
            'sizeBytes' => $fileSize,
            'enabled' => true,
            'order' => count($this->uploadedFontsSetting()) + 1,
            'uploadedBy' => $userId ?? '',
            'uploadedAt' => myronNow(),
        ];

        $fonts = $this->uploadedFontsSetting();
        $fonts = array_values(array_filter(
            $fonts,
            static fn (array $existing): bool => (string) ($existing['sha256'] ?? '') !== $sha256
        ));
        $fonts[] = $entry;
        $this->saveUploadedFonts($fonts, $userId);

        return $entry;
    }

    /**
     * @return array{fontId:string,deleted:bool,storedFilename:string,fileDeleted:bool,fileMissing:bool,remainingFonts:int}
     */
    public function deleteInteractionFont(string $fontId, ?string $userId = null): array
    {
        $fontId = trim($fontId);
        if ($fontId === '') {
            throw new InvalidArgumentException('Uploaded web font id is required.');
        }

        $fonts = $this->uploadedFontsSetting();
        $target = null;
        $remaining = [];
        foreach ($fonts as $font) {
            if ((string) ($font['id'] ?? '') === $fontId) {
                $target = $font;
                continue;
            }
            $remaining[] = $font;
        }

        if ($target === null) {
            throw new InvalidArgumentException('Uploaded web font was not found.');
        }

        $storedFilename = basename(trim((string) ($target['storedFilename'] ?? '')));
        if ($storedFilename === '' || !preg_match('/^myron-font-[a-f0-9]{16}\.(?:woff2|woff|ttf|otf)$/', $storedFilename)) {
            throw new RuntimeException('Uploaded web font storage reference is invalid.');
        }

        $absolutePath = $this->fontUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        $fileDeleted = false;
        $fileMissing = !is_file($absolutePath);
        if (!$fileMissing) {
            if (!unlink($absolutePath)) {
                throw new RuntimeException('Uploaded web font could not be deleted from storage.');
            }
            $fileDeleted = true;
        }

        $this->saveUploadedFonts($remaining, $userId);

        return [
            'fontId' => $fontId,
            'deleted' => true,
            'storedFilename' => $storedFilename,
            'fileDeleted' => $fileDeleted,
            'fileMissing' => $fileMissing,
            'remainingFonts' => count($remaining),
        ];
    }

    public function renderCustomInteractionsScriptTags(): string
    {
        $segments = [];
        foreach ($this->enabledExternalLibraries() as $library) {
            $url = (string) ($library['url'] ?? $library['cdnUrl'] ?? '');
            if ($url === '') {
                continue;
            }
            $integrity = trim((string) ($library['integrity'] ?? $library['sri'] ?? ''));
            $crossorigin = trim((string) ($library['crossorigin'] ?? ''));
            $attrs = ' src="' . $this->escapeAttr($url) . '"';
            if ($integrity !== '') {
                $attrs .= ' integrity="' . $this->escapeAttr($integrity) . '"';
            }
            if ($crossorigin !== '') {
                $attrs .= ' crossorigin="' . $this->escapeAttr($crossorigin) . '"';
            }
            $segments[] = '<script data-myron-custom-interaction="external-library"' . $attrs . ' defer></script>';
        }

        foreach ($this->enabledUploadedLibraries() as $library) {
            $url = trim((string) ($library['url'] ?? ''));
            if ($url === '') {
                continue;
            }
            $segments[] = '<script data-myron-custom-interaction="uploaded-library" src="' . $this->escapeAttr($url) . '" defer></script>';
        }

        $customJs = $this->customJsSetting();
        if ($customJs !== '') {
            $segments[] = '<script data-myron-custom-interaction="custom-js">' . "\n" . $customJs . "\n" . '</script>';
        }

        return implode("\n", $segments);
    }

    public function renderCustomFontHeadTags(): string
    {
        $segments = [];
        foreach ($this->enabledExternalFontStylesheets() as $stylesheet) {
            $url = trim((string) ($stylesheet['url'] ?? ''));
            if ($url === '') {
                continue;
            }
            $integrity = trim((string) ($stylesheet['integrity'] ?? ''));
            $crossorigin = trim((string) ($stylesheet['crossorigin'] ?? ''));
            $attrs = ' rel="stylesheet" href="' . $this->escapeAttr($url) . '"';
            if ($integrity !== '') {
                $attrs .= ' integrity="' . $this->escapeAttr($integrity) . '"';
            }
            if ($crossorigin !== '') {
                $attrs .= ' crossorigin="' . $this->escapeAttr($crossorigin) . '"';
            }
            $segments[] = '<link data-myron-custom-font="external-stylesheet"' . $attrs . '>';
        }

        $rules = [];
        foreach ($this->enabledUploadedFonts() as $font) {
            $family = trim((string) ($font['familyName'] ?? ''));
            $url = trim((string) ($font['url'] ?? ''));
            $format = trim((string) ($font['format'] ?? ''));
            if ($family === '' || $url === '' || $format === '') {
                continue;
            }
            $rules[] = '@font-face {' . "\n"
                . '  font-family: "' . $this->escapeCssString($family) . '";' . "\n"
                . '  src: url("' . $this->escapeCssString($url) . '") format("' . $this->escapeCssString($format) . '");' . "\n"
                . '  font-weight: ' . $this->normalizeFontWeight((string) ($font['weight'] ?? '400')) . ';' . "\n"
                . '  font-style: ' . $this->normalizeFontStyle((string) ($font['style'] ?? 'normal')) . ';' . "\n"
                . '  font-display: ' . $this->normalizeFontDisplay((string) ($font['display'] ?? 'swap')) . ';' . "\n"
                . '}';
        }

        if ($rules !== []) {
            $segments[] = '<style data-myron-custom-font="uploaded-fonts">' . "\n"
                . implode("\n\n", $rules) . "\n"
                . '</style>';
        }

        return implode("\n", $segments);
    }

    public function streamInteractionLibrary(string $storedFilename): void
    {
        $storedFilename = basename(trim($storedFilename));
        if (!preg_match('/^myron-interaction-[a-f0-9]{16}\.js$/', $storedFilename)) {
            http_response_code(404);
            return;
        }
        $path = $this->interactionUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        if (!is_file($path)) {
            http_response_code(404);
            return;
        }
        header('Content-Type: application/javascript; charset=utf-8');
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: public, max-age=31536000, immutable');
        header('Content-Length: ' . (string) filesize($path));
        readfile($path);
    }

    public function streamFontFile(string $storedFilename): void
    {
        $storedFilename = trim($storedFilename);
        if ($storedFilename === '' || str_contains($storedFilename, '/') || str_contains($storedFilename, '\\')) {
            http_response_code(404);
            return;
        }
        $storedFilename = basename($storedFilename);
        if (!preg_match('/^myron-font-[a-f0-9]{16}\.(woff2|woff|ttf|otf)$/', $storedFilename, $matches)) {
            http_response_code(404);
            return;
        }
        $path = $this->fontUploadDir() . DIRECTORY_SEPARATOR . $storedFilename;
        if (!is_file($path)) {
            http_response_code(404);
            return;
        }
        header('Content-Type: ' . $this->fontMimeForExtension((string) $matches[1]));
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: public, max-age=31536000, immutable');
        header('Content-Length: ' . (string) filesize($path));
        readfile($path);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function externalLibrariesSetting(): array
    {
        return $this->normalizeExternalLibraries($this->jsonListSetting('site', 'creative_deps'));
    }

    private function stringSetting(string $scopeType, string $scopeKey): string
    {
        $statement = $this->pdo->prepare(
            'SELECT css_text FROM custom_css_settings WHERE scope_type = :scope_type AND scope_key = :scope_key LIMIT 1'
        );
        $statement->execute([':scope_type' => $scopeType, ':scope_key' => $scopeKey]);
        $raw = $statement->fetchColumn();
        return is_string($raw) ? $raw : '';
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function jsonListSetting(string $scopeType, string $scopeKey): array
    {
        $raw = $this->stringSetting($scopeType, $scopeKey);
        if ($raw === '') {
            return [];
        }
        try {
            $decoded = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
        } catch (Throwable) {
            return [];
        }
        return is_array($decoded) ? array_values(array_filter($decoded, 'is_array')) : [];
    }

    /**
     * @param list<array<string, mixed>> $libraries
     * @return list<array<string, mixed>>
     */
    private function normalizeExternalLibraries(array $libraries): array
    {
        $normalized = [];
        $order = 1;
        foreach ($libraries as $library) {
            if (!is_array($library)) {
                continue;
            }
            $url = trim((string) ($library['url'] ?? $library['cdnUrl'] ?? ''));
            if ($url === '') {
                continue;
            }
            $this->assertHttpsScriptUrl($url);
            $crossorigin = trim((string) ($library['crossorigin'] ?? ''));
            if (!in_array($crossorigin, ['', 'anonymous', 'use-credentials'], true)) {
                throw new InvalidArgumentException('crossorigin must be blank, anonymous, or use-credentials.');
            }
            $configuredOrder = (int) ($library['order'] ?? $order);
            $normalized[] = [
                'id' => trim((string) ($library['id'] ?? '')) !== '' ? trim((string) $library['id']) : 'ext_' . substr(hash('sha256', $url), 0, 12),
                'name' => trim((string) ($library['name'] ?? $library['label'] ?? 'External library')),
                'url' => $url,
                'cdnUrl' => $url,
                'integrity' => trim((string) ($library['integrity'] ?? $library['sri'] ?? '')),
                'sri' => trim((string) ($library['integrity'] ?? $library['sri'] ?? '')),
                'crossorigin' => $crossorigin,
                'enabled' => array_key_exists('enabled', $library) ? (bool) $library['enabled'] : true,
                'order' => $configuredOrder > 0 ? $configuredOrder : $order,
            ];
            $order++;
        }
        return $this->sortLibraries($normalized);
    }

    /**
     * @param list<array<string, mixed>> $libraries
     * @return list<array<string, mixed>>
     */
    private function normalizeUploadedLibraries(array $libraries): array
    {
        $normalized = [];
        $order = 1;
        foreach ($libraries as $library) {
            if (!is_array($library)) {
                continue;
            }
            $stored = basename(trim((string) ($library['storedFilename'] ?? '')));
            $sha = strtolower(trim((string) ($library['sha256'] ?? '')));
            if ($stored === '' || !preg_match('/^myron-interaction-[a-f0-9]{16}\.js$/', $stored)) {
                continue;
            }
            if ($sha !== '' && preg_match('/^[a-f0-9]{64}$/', $sha) !== 1) {
                continue;
            }
            $configuredOrder = (int) ($library['order'] ?? $order);
            $normalized[] = [
                'id' => trim((string) ($library['id'] ?? '')) !== '' ? trim((string) $library['id']) : 'jslib_' . substr($sha !== '' ? $sha : hash('sha256', $stored), 0, 12),
                'name' => trim((string) ($library['name'] ?? 'Uploaded library')),
                'originalFilename' => trim((string) ($library['originalFilename'] ?? $stored)),
                'storedFilename' => $stored,
                'path' => 'interactions/' . $stored,
                'url' => $this->interactionLibraryUrl($stored),
                'sha256' => $sha,
                'sizeBytes' => (int) ($library['sizeBytes'] ?? 0),
                'enabled' => array_key_exists('enabled', $library) ? (bool) $library['enabled'] : true,
                'order' => $configuredOrder > 0 ? $configuredOrder : $order,
                'uploadedBy' => trim((string) ($library['uploadedBy'] ?? '')),
                'uploadedAt' => trim((string) ($library['uploadedAt'] ?? '')),
            ];
            $order++;
        }
        return $this->sortLibraries($normalized);
    }

    /**
     * @param list<array<string, mixed>> $stylesheets
     * @return list<array<string, mixed>>
     */
    private function normalizeExternalFontStylesheets(array $stylesheets): array
    {
        $normalized = [];
        $order = 1;
        foreach ($stylesheets as $stylesheet) {
            if (!is_array($stylesheet)) {
                continue;
            }
            $url = trim((string) ($stylesheet['url'] ?? ''));
            if ($url === '') {
                continue;
            }
            $this->assertHttpsFontStylesheetUrl($url);
            $crossorigin = trim((string) ($stylesheet['crossorigin'] ?? ''));
            if (!in_array($crossorigin, ['', 'anonymous', 'use-credentials'], true)) {
                throw new InvalidArgumentException('font crossorigin must be blank, anonymous, or use-credentials.');
            }
            $configuredOrder = (int) ($stylesheet['order'] ?? $order);
            $normalized[] = [
                'id' => trim((string) ($stylesheet['id'] ?? '')) !== '' ? trim((string) $stylesheet['id']) : 'fontcss_' . substr(hash('sha256', $url), 0, 12),
                'name' => trim((string) ($stylesheet['name'] ?? $stylesheet['label'] ?? 'External font stylesheet')),
                'url' => $url,
                'integrity' => trim((string) ($stylesheet['integrity'] ?? '')),
                'crossorigin' => $crossorigin,
                'enabled' => array_key_exists('enabled', $stylesheet) ? (bool) $stylesheet['enabled'] : true,
                'order' => $configuredOrder > 0 ? $configuredOrder : $order,
            ];
            $order++;
        }
        return $this->sortLibraries($normalized);
    }

    /**
     * @param list<array<string, mixed>> $fonts
     * @return list<array<string, mixed>>
     */
    private function normalizeUploadedFonts(array $fonts): array
    {
        $normalized = [];
        $order = 1;
        foreach ($fonts as $font) {
            if (!is_array($font)) {
                continue;
            }
            $stored = basename(trim((string) ($font['storedFilename'] ?? '')));
            if ($stored === '' || !preg_match('/^myron-font-[a-f0-9]{16}\.(woff2|woff|ttf|otf)$/', $stored, $matches)) {
                continue;
            }
            $sha = strtolower(trim((string) ($font['sha256'] ?? '')));
            if ($sha !== '' && preg_match('/^[a-f0-9]{64}$/', $sha) !== 1) {
                continue;
            }
            $configuredOrder = (int) ($font['order'] ?? $order);
            $extension = strtolower((string) $matches[1]);
            $normalized[] = [
                'id' => trim((string) ($font['id'] ?? '')) !== '' ? trim((string) $font['id']) : 'font_' . substr($sha !== '' ? $sha : hash('sha256', $stored), 0, 12),
                'label' => trim((string) ($font['label'] ?? $font['name'] ?? 'Uploaded font')),
                'familyName' => $this->normalizeFontFamilyName((string) ($font['familyName'] ?? $font['family'] ?? 'Uploaded Font')),
                'weight' => $this->normalizeFontWeight((string) ($font['weight'] ?? '400')),
                'style' => $this->normalizeFontStyle((string) ($font['style'] ?? 'normal')),
                'display' => $this->normalizeFontDisplay((string) ($font['display'] ?? 'swap')),
                'originalFilename' => trim((string) ($font['originalFilename'] ?? $stored)),
                'storedFilename' => $stored,
                'path' => 'fonts/' . $stored,
                'url' => $this->fontFileUrl($stored),
                'format' => $this->fontFormatForExtension($extension),
                'mime' => $this->fontMimeForExtension($extension),
                'sha256' => $sha,
                'sizeBytes' => (int) ($font['sizeBytes'] ?? 0),
                'enabled' => array_key_exists('enabled', $font) ? (bool) $font['enabled'] : true,
                'order' => $configuredOrder > 0 ? $configuredOrder : $order,
                'uploadedBy' => trim((string) ($font['uploadedBy'] ?? '')),
                'uploadedAt' => trim((string) ($font['uploadedAt'] ?? '')),
            ];
            $order++;
        }
        return $this->sortLibraries($normalized);
    }

    /**
     * @param list<array<string, mixed>> $libraries
     * @return list<array<string, mixed>>
     */
    private function sortLibraries(array $libraries): array
    {
        usort($libraries, static fn (array $a, array $b): int => ((int) ($a['order'] ?? 0)) <=> ((int) ($b['order'] ?? 0)));
        return array_values($libraries);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function enabledExternalLibraries(): array
    {
        return array_values(array_filter($this->externalLibrariesSetting(), static fn (array $library): bool => (bool) ($library['enabled'] ?? true)));
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function enabledUploadedLibraries(): array
    {
        return array_values(array_filter($this->uploadedLibrariesSetting(), static fn (array $library): bool => (bool) ($library['enabled'] ?? true)));
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function enabledExternalFontStylesheets(): array
    {
        return array_values(array_filter($this->externalFontStylesheetsSetting(), static fn (array $stylesheet): bool => (bool) ($stylesheet['enabled'] ?? true)));
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function enabledUploadedFonts(): array
    {
        return array_values(array_filter($this->uploadedFontsSetting(), static fn (array $font): bool => (bool) ($font['enabled'] ?? true)));
    }

    private function assertHttpsScriptUrl(string $url): void
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false || strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https') {
            throw new InvalidArgumentException('External interaction library URLs must be valid HTTPS URLs.');
        }
    }

    private function assertHttpsFontStylesheetUrl(string $url): void
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false || strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https') {
            throw new InvalidArgumentException('External font stylesheet URLs must be valid HTTPS URLs.');
        }
    }

    private function interactionUploadDir(): string
    {
        return myronUploadsDir() . DIRECTORY_SEPARATOR . 'interactions';
    }

    private function interactionLibraryUrl(string $storedFilename): string
    {
        return myronBuildPublicRouteUrl('/media/interactions/stream', ['file' => $storedFilename]);
    }

    private function fontUploadDir(): string
    {
        return myronUploadsDir() . DIRECTORY_SEPARATOR . 'fonts';
    }

    private function fontFileUrl(string $storedFilename): string
    {
        return myronBuildPublicRouteUrl('/media/fonts/stream', ['file' => $storedFilename]);
    }

    private function sanitizeJsFilename(string $filename): string
    {
        $filename = trim(basename($filename));
        $filename = (string) preg_replace('/[^A-Za-z0-9._ -]/', '-', $filename);
        return $filename !== '' ? $filename : 'library.js';
    }

    private function assertSafeJsPayload(string $bytes): void
    {
        if (str_contains($bytes, "\0")) {
            throw new RuntimeException('Uploaded JavaScript libraries must be text files.');
        }
        if (preg_match('/<\\?(?:php|=)?/i', $bytes) === 1) {
            throw new RuntimeException('Uploaded JavaScript libraries must not contain PHP code.');
        }
    }

    private function sanitizeFontFilename(string $filename): string
    {
        $filename = trim(basename($filename));
        $filename = (string) preg_replace('/[^A-Za-z0-9._ -]/', '-', $filename);
        return $filename !== '' ? $filename : 'font.woff2';
    }

    private function assertSafeFontPayload(string $bytes, string $extension): void
    {
        if (preg_match('/<\\?(?:php|=)?/i', $bytes) === 1 || preg_match('/<script\\b/i', $bytes) === 1) {
            throw new RuntimeException('Uploaded web fonts must not contain script or PHP code.');
        }

        $signature = substr($bytes, 0, 4);
        $valid = match ($extension) {
            'woff2' => $signature === 'wOF2',
            'woff' => $signature === 'wOFF',
            'ttf' => $signature === "\x00\x01\x00\x00" || $signature === 'true',
            'otf' => $signature === 'OTTO',
            default => false,
        };
        if (!$valid) {
            throw new RuntimeException('Uploaded web font payload does not match its file extension.');
        }
    }

    private function normalizeFontFamilyName(string $familyName): string
    {
        $familyName = trim(strip_tags($familyName));
        $familyName = (string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $familyName);
        $familyName = trim((string) preg_replace('/\s+/', ' ', $familyName));
        if ($familyName === '') {
            throw new InvalidArgumentException('Font family name is required.');
        }
        if (strlen($familyName) > 120) {
            throw new InvalidArgumentException('Font family name must be 120 characters or fewer.');
        }
        return $familyName;
    }

    private function normalizeFontWeight(string $weight): string
    {
        $weight = strtolower(trim($weight));
        if (in_array($weight, ['normal', 'bold'], true)) {
            return $weight;
        }
        if (preg_match('/^(\d{1,4})(?:\s+(\d{1,4}))?$/', $weight, $matches) === 1) {
            $first = (int) $matches[1];
            $second = isset($matches[2]) ? (int) $matches[2] : $first;
            if ($first >= 1 && $first <= 1000 && $second >= $first && $second <= 1000) {
                return isset($matches[2]) ? $first . ' ' . $second : (string) $first;
            }
        }
        throw new InvalidArgumentException('Font weight must be normal, bold, a number from 1 to 1000, or a valid numeric range.');
    }

    private function normalizeFontStyle(string $style): string
    {
        $style = strtolower(trim($style));
        if (in_array($style, ['normal', 'italic', 'oblique'], true)) {
            return $style;
        }
        throw new InvalidArgumentException('Font style must be normal, italic, or oblique.');
    }

    private function normalizeFontDisplay(string $display): string
    {
        $display = strtolower(trim($display));
        if (in_array($display, ['auto', 'block', 'swap', 'fallback', 'optional'], true)) {
            return $display;
        }
        throw new InvalidArgumentException('Font display must be auto, block, swap, fallback, or optional.');
    }

    private function fontFormatForExtension(string $extension): string
    {
        return match (strtolower($extension)) {
            'woff2' => 'woff2',
            'woff' => 'woff',
            'ttf' => 'truetype',
            'otf' => 'opentype',
            default => '',
        };
    }

    private function fontMimeForExtension(string $extension): string
    {
        return match (strtolower($extension)) {
            'woff2' => 'font/woff2',
            'woff' => 'font/woff',
            'ttf' => 'font/ttf',
            'otf' => 'font/otf',
            default => 'application/octet-stream',
        };
    }

    private function escapeCssString(string $value): string
    {
        $value = str_replace(["\\", "\"", "\n", "\r", "\f"], ["\\\\", "\\\"", "\\A ", '', ''], $value);
        return (string) preg_replace('/[\x00-\x08\x0B\x0E-\x1F\x7F]/', '', $value);
    }

    private function normalizeJs(string $js): string
    {
        $js = trim($js);
        $js = (string) preg_replace('#</?script\\b[^>]*>#i', '', $js);
        return trim($js);
    }

    private function normalizeCss(string $css): string
    {
        $css = trim($css);
        $css = (string) preg_replace('#</?style\b[^>]*>#i', '', $css);

        return trim($css);
    }
}
