<?php

declare(strict_types=1);

require_once dirname(__DIR__, 2) . '/modules/identity/RoleScopeProjector.php';

final class MyronCsrfFailureException extends RuntimeException
{
}

final class MyronEnv
{
    private static bool $loaded = false;

    public static function load(string $file): void
    {
        if (self::$loaded || !is_file($file)) {
            self::$loaded = true;
            return;
        }

        $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        if ($lines === false) {
            self::$loaded = true;
            return;
        }

        foreach ($lines as $line) {
            $trimmed = trim($line);
            if ($trimmed === '' || str_starts_with($trimmed, '#')) {
                continue;
            }

            $parts = explode('=', $trimmed, 2);
            if (count($parts) !== 2) {
                continue;
            }

            $name = trim($parts[0]);
            $value = trim($parts[1]);

            if ($name === '' || getenv($name) !== false) {
                continue;
            }

            if (
                (str_starts_with($value, '"') && str_ends_with($value, '"')) ||
                (str_starts_with($value, "'") && str_ends_with($value, "'"))
            ) {
                $value = substr($value, 1, -1);
            }

            putenv($name . '=' . $value);
            $_ENV[$name] = $value;
            $_SERVER[$name] = $value;
        }

        self::$loaded = true;
    }
}

function myronEscape(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

function myronNow(): string
{
    return gmdate('Y-m-d H:i:s');
}

function myronGenerateId(string $prefix): string
{
    return $prefix . '_' . bin2hex(random_bytes(8));
}

function myronEnsureSession(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        return;
    }

    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
        'cookie_secure' => myronRequestIsHttps(),
        'use_strict_mode' => true,
    ]);
}

function myronRequestIsHttps(): bool
{
    $https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
    if ($https !== '' && $https !== 'off') {
        return true;
    }

    foreach (['HTTP_X_FORWARDED_PROTO', 'HTTP_X_FORWARDED_SSL', 'HTTP_FRONT_END_HTTPS'] as $key) {
        $value = strtolower(trim((string) ($_SERVER[$key] ?? '')));
        if (in_array($value, ['https', 'on', '1'], true)) {
            return true;
        }
    }

    return false;
}

function myronSendBaselineSecurityHeaders(): void
{
    if (headers_sent()) {
        return;
    }
    header_remove('X-Powered-By');
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: DENY');
    header("Content-Security-Policy: frame-ancestors 'none'");
    header('Referrer-Policy: strict-origin-when-cross-origin');
    header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), screen-wake-lock=(), usb=(), xr-spatial-tracking=()');
    if (myronRequestIsHttps()) {
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
    }
}

function myronSessionCookiePresent(): bool
{
    $sessionName = session_name();
    if ($sessionName === '') {
        $sessionName = 'PHPSESSID';
    }

    $cookie = $_COOKIE[$sessionName] ?? null;
    return is_string($cookie) && trim($cookie) !== '';
}

function myronIsPublicSiteRequest(): bool
{
    return defined('MYRON_PUBLIC_SITE_REQUEST') && MYRON_PUBLIC_SITE_REQUEST === true;
}

function myronShouldAutoMigrateDatabase(): bool
{
    // APO-271 test override: when MYRON_DISABLE_AUTO_MIGRATE=1, the auto-migrate is
    // skipped even on CLI runs. This lets verify-* tests faithfully simulate a public-
    // site request where the new schema hasn't been applied yet. Never set in production.
    if ((string) (getenv('MYRON_DISABLE_AUTO_MIGRATE') ?: '') === '1') {
        return false;
    }
    if (PHP_SAPI === 'cli') {
        return true;
    }

    return !myronIsPublicSiteRequest();
}

function myronShouldAutoSeedContent(): bool
{
    if (PHP_SAPI === 'cli') {
        return true;
    }

    return !myronIsPublicSiteRequest();
}

/**
 * Returns true if $table exists in the connected database. False otherwise — including
 * when the underlying probe raises a PDOException. Result is cached per-request keyed
 * by (driver, connection identity, table). Use this from any service that depends on
 * a table introduced by a recent migration so the service can degrade silently on a
 * public-site request (which skips MyronMigrationRunner::migrate — see
 * myronShouldAutoMigrateDatabase()) until an admin/CLI run applies the migration.
 * Canonical consumer: MyronSearchIndexService::isAvailable() (APO-271).
 */
function myronSchemaTablePresent(PDO $pdo, string $table): bool
{
    static $cache = [];
    $table = trim($table);
    if ($table === '') {
        return false;
    }
    try {
        $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
    } catch (Throwable) {
        return false;
    }
    $key = $driver . '|' . spl_object_hash($pdo) . '|' . $table;
    if (array_key_exists($key, $cache)) {
        return $cache[$key];
    }
    try {
        if ($driver === 'sqlite') {
            $statement = $pdo->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = :table LIMIT 1");
            $statement->execute([':table' => $table]);
            $cache[$key] = $statement->fetchColumn() !== false;
            return $cache[$key];
        }
        // MySQL / MariaDB path. SHOW TABLES LIKE accepts SQL LIKE patterns; escape % and _
        // so a literal table name with those characters doesn't match unexpectedly.
        $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $table);
        $statement = $pdo->prepare('SHOW TABLES LIKE :table');
        $statement->execute([':table' => $escaped]);
        $cache[$key] = $statement->fetchColumn() !== false;
        return $cache[$key];
    } catch (Throwable) {
        $cache[$key] = false;
        return false;
    }
}

function myronCsrfToken(): string
{
    myronEnsureSession();

    if (!isset($_SESSION['myron_csrf_token']) || !is_string($_SESSION['myron_csrf_token'])) {
        $_SESSION['myron_csrf_token'] = bin2hex(random_bytes(24));
    }

    return $_SESSION['myron_csrf_token'];
}

function myronRequireCsrf(?string $token): void
{
    myronEnsureSession();

    $expected = $_SESSION['myron_csrf_token'] ?? null;
    if (!is_string($expected) || !is_string($token) || !hash_equals($expected, $token)) {
        throw new MyronCsrfFailureException('Security check failed. Refresh the page and try again.');
    }
}

function myronRenderSecurityFailure(string $message = 'Security check failed. Refresh the page and try again.', int $status = 403): void
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    echo $message;
}

function myronRedirect(string $location): never
{
    header('Location: ' . $location);
    exit;
}

function myronNormalizeEmail(string $email): string
{
    return strtolower(trim($email));
}

function myronNormalizeUsername(string $value): string
{
    $normalized = strtolower(trim($value));
    $normalized = preg_replace('/[^a-z0-9._-]+/', '-', $normalized) ?? '';
    $normalized = trim($normalized, '-._');

    return $normalized === '' ? 'user' : $normalized;
}

function myronNormalizeSlug(string $value): string
{
    $normalized = strtolower(trim($value));
    $normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized) ?? '';
    $normalized = trim($normalized, '-');

    return $normalized === '' ? 'page' : $normalized;
}

function myronNormalizePath(string $path): string
{
    $trimmed = trim($path);
    if ($trimmed === '') {
        throw new InvalidArgumentException('Path is required.');
    }

    $normalized = '/' . ltrim($trimmed, '/');
    $normalized = preg_replace('#/+#', '/', $normalized) ?: '/';

    if ($normalized !== '/' && str_ends_with($normalized, '/')) {
        $normalized = rtrim($normalized, '/');
    }

    if (in_array($normalized, ['/sitemap.xml', '/llms.txt', '/robots.txt'], true)) {
        return $normalized;
    }

    if (!preg_match('#^/[A-Za-z0-9/_-]*$#', $normalized)) {
        throw new InvalidArgumentException('Paths may contain only letters, numbers, hyphens, underscores, and forward slashes.');
    }

    return $normalized;
}

function myronSlugFromPath(string $path): string
{
    if ($path === '/') {
        return 'home';
    }

    $slug = trim(str_replace('/', '-', $path), '-');
    return myronNormalizeSlug($slug);
}

/**
 * @param array<string, mixed> $section
 */
function myronSectionInstanceKey(array $section, int $position): string
{
    $explicit = trim((string) ($section['instanceKey'] ?? $section['slotKey'] ?? ''));
    $identity = $explicit !== ''
        ? $explicit
        : trim((string) (($section['sectionKey'] ?? '') ?: ($section['componentId'] ?? 'section')));
    $variant = trim((string) ($section['variant'] ?? ''));
    $suffix = myronNormalizeSlug($identity . ($variant !== '' ? '-' . $variant : ''));

    return 's' . (string) ($position + 1) . '-' . ($suffix !== '' ? $suffix : 'section');
}

/**
 * @param array<string, mixed> $section
 */
function myronSectionUuid(array $section, int $position): string
{
    $explicit = trim((string) ($section['sectionUuid'] ?? $section['sectionUUID'] ?? ''));
    if ($explicit !== '' && preg_match('/^[A-Za-z0-9][A-Za-z0-9_-]{7,80}$/', $explicit)) {
        return $explicit;
    }

    $seed = implode('|', [
        myronSectionInstanceKey($section, $position),
        (string) ($section['sectionKey'] ?? ''),
        (string) ($section['componentId'] ?? ''),
        (string) ($section['variant'] ?? ''),
    ]);

    return 'sec_' . substr(sha1($seed), 0, 16);
}

/**
 * @param array<string, mixed> $section
 * @return array<string, string>
 */
function myronSectionIdentityAttributes(array $section, int $position): array
{
    return [
        'sectionUuid' => myronSectionUuid($section, $position),
        'sectionKey' => trim((string) ($section['sectionKey'] ?? '')),
        'instanceKey' => myronSectionInstanceKey($section, $position),
    ];
}

/**
 * @param array<string, mixed> $input
 * @return array<string, string>
 */
function myronNormalizeTokenOverrideInput(array $input): array
{
    $overrides = [];
    foreach ($input as $token => $value) {
        $token = trim((string) $token);
        $value = trim((string) $value);
        if ($token === '' || $value === '') {
            continue;
        }
        if (!preg_match('/^--myron-[a-z0-9_-]+$/', $token)) {
            continue;
        }
        $overrides[$token] = $value;
    }

    ksort($overrides);
    return $overrides;
}

/**
 * @param array<string, mixed> $input
 * @param list<array<string, mixed>> $editableTokens
 * @return array<string, string>
 */
function myronNormalizeEditableTokenOverrideInput(array $input, array $editableTokens): array
{
    $allowed = [];
    foreach ($editableTokens as $token) {
        if (!is_array($token)) {
            continue;
        }
        $cssVar = trim((string) ($token['cssVar'] ?? $token['token'] ?? ''));
        if ($cssVar === '' || !preg_match('/^--myron-[a-z0-9_-]+$/', $cssVar)) {
            continue;
        }
        $allowed[$cssVar] = (string) ($token['controlType'] ?? 'text');
    }

    $normalized = [];
    $invalidTokens = [];
    foreach ($input as $token => $value) {
        $token = trim((string) $token);
        $value = trim((string) $value);
        if ($token === '' || $value === '') {
            continue;
        }
        if (!isset($allowed[$token])) {
            $invalidTokens[$token] = true;
            continue;
        }
        if ($allowed[$token] === 'lengthPx' && is_numeric($value)) {
            $value .= 'px';
        }
        $normalized[$token] = $value;
    }

    if ($invalidTokens !== []) {
        throw new InvalidArgumentException(
            'Token overrides are not editable for this component: ' . implode(', ', array_keys($invalidTokens)) . '.'
        );
    }

    ksort($normalized);
    return $normalized;
}

/**
 * @param array<string, mixed> $overrides
 */
function myronSectionTokenOverrideStyle(array $overrides): string
{
    $parts = [];
    foreach ($overrides as $token => $value) {
        $token = trim((string) $token);
        $value = trim((string) $value);
        if ($token === '' || $value === '' || !preg_match('/^--myron-[a-z0-9_-]+$/', $token)) {
            continue;
        }
        $parts[] = $token . ': ' . str_replace('"', '', $value);
    }

    return implode('; ', $parts);
}

/**
 * @param array<string, mixed> $content
 * @param array<string, mixed> $section
 * @return array<string, mixed>
 */
function myronSectionContentForInstance(array $content, array $section, int $position): array
{
    $flat = [
        'eyebrow' => trim((string) ($content['eyebrow'] ?? '')),
        'headline' => trim((string) ($content['headline'] ?? '')),
        'intro' => trim((string) ($content['intro'] ?? '')),
        'body' => trim((string) ($content['body'] ?? '')),
        'ctaLabel' => trim((string) ($content['ctaLabel'] ?? '')),
        'ctaHref' => trim((string) ($content['ctaHref'] ?? '')),
    ];
    $sections = is_array($content['sections'] ?? null) ? $content['sections'] : [];
    $instanceKey = myronSectionInstanceKey($section, $position);
    $instanceContent = is_array($sections[$instanceKey] ?? null) ? $sections[$instanceKey] : [];

    return array_merge($flat, $instanceContent);
}

/**
 * Return only content owned by the addressed section instance.
 *
 * Editor surfaces must use this boundary so a legacy flat compatibility value
 * from one section cannot appear as authored content in every other section.
 *
 * @param array<string, mixed> $content
 * @param array<string, mixed> $section
 * @return array<string, mixed>
 */
function myronSectionOwnedContentForInstance(array $content, array $section, int $position): array
{
    $sections = is_array($content['sections'] ?? null) ? $content['sections'] : [];
    $instanceKey = myronSectionInstanceKey($section, $position);
    if (is_array($sections[$instanceKey] ?? null)) {
        return $sections[$instanceKey];
    }

    // Pre-section-addressed pages owned one flat copy envelope. Surface that
    // compatibility content once, on the leading section, instead of allowing
    // it to appear as authored content in every section.
    if ((int) ($content['_schemaVersion'] ?? 1) < 2 && $position === 0) {
        $legacy = [];
        foreach (['eyebrow', 'headline', 'intro', 'body', 'ctaLabel', 'ctaHref'] as $key) {
            if (array_key_exists($key, $content)) {
                $legacy[$key] = is_array($content[$key]) ? $content[$key] : trim((string) $content[$key]);
            }
        }
        return $legacy;
    }

    return [];
}

/**
 * @param list<array<string, mixed>> $sections
 * @param array<string, mixed>|null $existingContent
 * @return array<string, mixed>
 */
function myronContentPayloadFromInput(array $input, array $sections = [], ?array $existingContent = null): array
{
    $mergeExisting = $existingContent !== null;
    $payload = $existingContent ?? [
        'eyebrow' => '',
        'headline' => '',
        'intro' => '',
        'body' => '',
        'ctaLabel' => '',
        'ctaHref' => '',
    ];
    $flatInputKeys = [
        'eyebrow' => 'eyebrow',
        'headline' => 'headline',
        'intro' => 'intro',
        'body' => 'body',
        'cta_label' => 'ctaLabel',
        'cta_href' => 'ctaHref',
    ];
    foreach ($flatInputKeys as $inputKey => $contentKey) {
        if (!$mergeExisting || array_key_exists($inputKey, $input)) {
            $payload[$contentKey] = trim((string) ($input[$inputKey] ?? ''));
        }
    }

    $sectionInput = is_array($input['section_content'] ?? null) ? $input['section_content'] : [];
    $sectionPayload = $mergeExisting && is_array($payload['sections'] ?? null) ? $payload['sections'] : [];
    $promotedFlatKeys = [];
    foreach ($sections as $position => $section) {
        if (!is_array($section)) {
            continue;
        }

        $instanceKey = myronSectionInstanceKey($section, (int) $position);
        if ($mergeExisting && !array_key_exists($instanceKey, $sectionInput)) {
            continue;
        }
        $values = is_array($sectionInput[$instanceKey] ?? null) ? $sectionInput[$instanceKey] : [];
        $normalized = myronNormalizeSectionContentInput($values, $mergeExisting);

        if ($mergeExisting) {
            $owned = is_array($sectionPayload[$instanceKey] ?? null) ? $sectionPayload[$instanceKey] : [];
            $sectionPayload[$instanceKey] = array_replace($owned, $normalized);
        } elseif ($normalized !== []) {
            $sectionPayload[$instanceKey] = $normalized;
            foreach (array_intersect_key($normalized, array_flip(['eyebrow', 'headline', 'intro', 'body', 'ctaLabel', 'ctaHref'])) as $key => $value) {
                if ($value !== '' && !isset($promotedFlatKeys[$key])) {
                    $payload[$key] = $value;
                    $promotedFlatKeys[$key] = true;
                }
            }
        }
    }

    if ($sectionPayload !== [] || $mergeExisting) {
        $payload['_schemaVersion'] = 2;
        $payload['sections'] = $sectionPayload;
    }

    return $payload;
}

/**
 * @param array<string, mixed> $values
 * @return array<string, mixed>
 */
function myronNormalizeSectionContentInput(array $values, bool $preserveExplicitEmpty = false): array
{
    $normalized = [];
    foreach ($values as $key => $value) {
        $normalizedKey = trim((string) $key);
        if ($normalizedKey === '') {
            continue;
        }

        $canonicalKey = match ($normalizedKey) {
            'cta_label' => 'ctaLabel',
            'cta_href' => 'ctaHref',
            default => $normalizedKey,
        };

        if (is_array($value)) {
            $items = myronNormalizeRepeatableSectionItemsInput($value);
            if ($items !== [] || $preserveExplicitEmpty) {
                $normalized[$canonicalKey] = $items;
            }
            continue;
        }

        $normalized[$canonicalKey] = trim((string) $value);
    }

    return $preserveExplicitEmpty
        ? $normalized
        : array_filter(
            $normalized,
            static fn (mixed $value): bool => is_array($value) ? $value !== [] : trim((string) $value) !== ''
        );
}

/**
 * @param array<mixed> $values
 * @return list<array<string, string>>
 */
function myronNormalizeRepeatableSectionItemsInput(array $values): array
{
    $items = [];
    $position = 0;
    foreach ($values as $value) {
        if (!is_array($value)) {
            continue;
        }

        $remove = filter_var($value['__remove'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false;
        if ($remove) {
            continue;
        }

        $order = is_numeric($value['__order'] ?? null) ? (int) $value['__order'] : $position;
        $item = [];
        foreach ($value as $itemKey => $itemValue) {
            $normalizedKey = trim((string) $itemKey);
            if ($normalizedKey === '' || str_starts_with($normalizedKey, '__') || is_array($itemValue)) {
                continue;
            }

            $trimmedValue = trim((string) $itemValue);
            if ($trimmedValue !== '') {
                $item[$normalizedKey] = $trimmedValue;
            }
        }

        if ($item !== []) {
            $items[] = [
                '__order' => $order,
                '__position' => $position,
                'item' => $item,
            ];
        }
        $position++;
    }

    usort(
        $items,
        static fn (array $left, array $right): int => ((int) $left['__order'] <=> (int) $right['__order'])
            ?: ((int) $left['__position'] <=> (int) $right['__position'])
    );

    return array_values(array_map(
        static fn (array $record): array => is_array($record['item'] ?? null) ? $record['item'] : [],
        $items
    ));
}

function myronNormalizeMountPath(string $path): string
{
    $trimmed = trim($path);
    if ($trimmed === '' || $trimmed === '/') {
        return '';
    }

    $normalized = '/' . trim($trimmed, '/');
    return preg_replace('#/+#', '/', $normalized) ?: '';
}

function myronPublicBasePath(): string
{
    $configured = getenv('MYRON_PUBLIC_BASE_PATH');
    $raw = is_string($configured) && $configured !== '' ? $configured : '/app/site';
    return myronNormalizeMountPath($raw);
}

function myronAdminBasePath(): string
{
    $configured = getenv('MYRON_ADMIN_BASE_PATH');
    $raw = is_string($configured) && $configured !== '' ? $configured : '/app';
    return myronNormalizeMountPath($raw);
}

/**
 * @return array<string, string>
 */
function myronAppRouteMap(): array
{
    $routes = [
        'dashboard' => 'dashboard',
        'dashboard.notesSave' => 'dashboard/notes/save',
        'dashboard.notesDelete' => 'dashboard/notes/delete',
        'dashboard.messagesSend' => 'dashboard/messages/send',
        'dashboard.messagesRead' => 'dashboard/messages/read',
        'dashboard.linksSave' => 'dashboard/links/save',
        'dashboard.linksDelete' => 'dashboard/links/delete',
        'design.components' => 'design/components',
        'design.componentPreview' => 'design/components/preview',
        'design.layout' => 'design/layout',
        'design.navigation' => 'design/navigation',
        'design.footers' => 'design/footers',
        'design.rails' => 'design/rails',
        'design.forms' => 'design/forms',
        'design.primitives' => 'design/primitives',
        'design.customCss' => 'design/custom-css',
        'design.customInteractions' => 'design/custom-interactions',
        'design.tokens' => 'design/tokens',
        'design.tokenDesigner' => 'design/token-designer',
        'design.templates' => 'design/templates',
        'design.templateStudio' => 'design/template-studio',
        'design.templateStudioSiteTemplates' => 'design/template-studio/site-templates',
        'design.templateStudioPageTemplates' => 'design/template-studio/page-templates',
        'design.templateStudioDocumentTemplates' => 'design/template-studio/document-templates',
        'design.siteTypeStudio' => 'design/site-type-studio',
        'design.templatesManager' => 'design/templates/manager',
        'design.templatePreview' => 'design/templates/preview',
        'design.sectionsManager' => 'design/sections',
        'design.creativeLibrary' => 'design/creative-library',
        'design.sectionVariants' => 'design/section-variants',
        'design.systems' => 'design/systems',
        'design.imageManager' => 'design/assets/images',
        'design.videoManager' => 'design/assets/videos',
        'design.audioManager' => 'design/assets/audio',
        'content.pages' => 'content/pages',
        'content.pageEditor' => 'content/pages/editor',
        'content.categories' => 'content/categories',
        'content.comments' => 'content/comments',
        'content.templates' => 'content/templates',
        'content.contentBanks' => 'content/content-banks',
        'content.sections' => 'content/sections',
        'content.files' => 'content/files',
        'content.fileStream' => 'content/files/stream',
        'content.imageViewer' => 'content/assets/images',
        'content.videoViewer' => 'content/assets/videos',
        'content.audioViewer' => 'content/assets/audio',
        'content.documentManager' => 'content/assets/documents',
        'content.menus' => 'administration/menus',
        'administration.menus' => 'administration/menus',
        'administration.siteSetup' => 'administration/site-setup',
        'administration.siteSettings' => 'administration/site-settings',
        'administration.formProtection' => 'administration/form-protection',
        'administration.analytics' => 'administration/analytics',
        'administration.snapshot' => 'administration/snapshot',
        'administration.license' => 'administration/license',
        'administration.licenseManager' => 'administration/license-manager',
        'administration.mail' => 'administration/mail',
        'administration.notificationEmails' => 'administration/notification-emails',
        'administration.aiConnections' => 'administration/ai-connections',
        'administration.auditLog' => 'administration/audit-log',
        'administration.rolesUsers' => 'administration/roles-users',
        'administration.changelog' => 'administration/changelog',
        'extensions.register' => 'extensions/register',
        'extensions.marketplace' => 'extensions/marketplace',
        'extensions.installed' => 'extensions',
        'x.myronSampleHello' => 'x/myron_sample/hello',
        'identity.users' => 'identity/users',
        'identity.profile' => 'identity/profile',
        'auth.login' => 'auth/login',
        'auth.logout' => 'auth/logout',
        'auth.register' => 'auth/register',
        'auth.passwordReset' => 'auth/password-reset',
    ];

    if (function_exists('myronModuleRegistry')) {
        $routes = array_replace($routes, myronModuleRegistry()->adminUrlRoutes());
    }

    return $routes;
}

/**
 * @return array<string, string>
 */
function myronLegacyAppRouteAliases(): array
{
    $aliases = [
        'index.html' => 'design.primitives',
        'primitives.html' => 'design.primitives',
        'templates.html' => 'design.templates',
        'components.php' => 'design.components',
        'components.html' => 'design.components',
        'templates-manager.php' => 'design.templatesManager',
        'navigation.php' => 'design.navigation',
        'footers.php' => 'design.footers',
        'rails.php' => 'design.rails',
        'changelog.html' => 'administration.changelog',
        'content.html' => 'content.pages',
        'content.php' => 'content.pages',
        'content-templates.php' => 'content.templates',
        'page-editor.php' => 'content.pageEditor',
        'files.php' => 'content.files',
        'file.php' => 'content.fileStream',
        'menu-manager.php' => 'administration.menus',
        'site-setup.php' => 'administration.siteSetup',
        'form-protection.php' => 'administration.formProtection',
        'license.php' => 'administration.license',
        'license-manager.php' => 'administration.licenseManager',
        'index.php' => 'design.primitives',
        'primitives.php' => 'design.primitives',
        'templates.php' => 'design.templates',
        'design-systems.php' => 'design.systems',
        'layout.php' => 'design.layout',
        'custom-css.php' => 'design.customCss',
        'mail-setup.php' => 'administration.mail',
        'changelog.php' => 'administration.changelog',
        'users.php' => 'identity.users',
        'roles-users.php' => 'administration.rolesUsers',
        'profile.php' => 'identity.profile',
        'login.php' => 'auth.login',
        'logout.php' => 'auth.logout',
        'register.php' => 'auth.register',
        'password-reset.php' => 'auth.passwordReset',
    ];

    if (function_exists('myronModuleRegistry')) {
        $aliases = array_replace($aliases, myronModuleRegistry()->legacyAdminAliases());
    }

    return $aliases;
}

function myronAppRoutePath(string $route): string
{
    $trimmed = trim($route);
    if ($trimmed === '') {
        return '';
    }

    $map = myronAppRouteMap();
    if (isset($map[$trimmed])) {
        return $map[$trimmed];
    }

    $legacyAliases = myronLegacyAppRouteAliases();
    if (isset($legacyAliases[$trimmed]) && isset($map[$legacyAliases[$trimmed]])) {
        return $map[$legacyAliases[$trimmed]];
    }

    return ltrim($trimmed, '/');
}

function myronBuildPublicPageUrl(string $path): string
{
    $rawPath = '/' . ltrim(trim($path), '/');
    if (in_array($rawPath, ['/sitemap.xml', '/llms.txt', '/robots.txt'], true)) {
        $basePath = myronPublicBasePath();
        return ($basePath === '' ? '' : $basePath) . $rawPath;
    }

    $normalizedPath = myronNormalizePath($path);
    $basePath = myronPublicBasePath();

    if ($normalizedPath === '/') {
        return $basePath === '' ? '/' : $basePath . '/';
    }

    return ($basePath === '' ? '' : $basePath) . $normalizedPath;
}

/**
 * @param array<string, scalar|null> $query
 */
function myronBuildPublicRouteUrl(string $path, array $query = []): string
{
    $url = myronBuildPublicPageUrl($path);
    $filteredQuery = [];
    foreach ($query as $key => $value) {
        if ($value === null) {
            continue;
        }
        $filteredQuery[$key] = $value;
    }

    if ($filteredQuery !== []) {
        $url .= '?' . http_build_query($filteredQuery);
    }

    return $url;
}

/**
 * @param array<string, scalar|null> $query
 */
function myronBuildAdminUrl(string $route = 'content.pages', array $query = []): string
{
    $trimmed = trim($route);
    [$routePart, $inlineQuery] = array_pad(explode('?', $trimmed, 2), 2, '');
    $relative = myronAppRoutePath($routePart);
    $basePath = myronAdminBasePath();

    if ($relative === '') {
        $url = $basePath === '' ? '/' : $basePath . '/';
    } else {
        $url = ($basePath === '' ? '' : $basePath . '/') . trim($relative, '/');
    }

    $mergedQuery = [];
    if ($inlineQuery !== '') {
        parse_str($inlineQuery, $mergedQuery);
    }

    foreach ($query as $key => $value) {
        if ($value === null) {
            continue;
        }
        $mergedQuery[$key] = $value;
    }

    if ($mergedQuery !== []) {
        $url .= '?' . http_build_query($mergedQuery);
    }

    return $url;
}

function myronBuildAssetUrl(string $relativePath): string
{
    $relative = ltrim(trim($relativePath), '/');
    if ($relative === '') {
        throw new InvalidArgumentException('Asset path is required.');
    }

    $basePath = myronAdminBasePath();
    if (preg_match('#^modules/([a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)*)/(.+)$#', $relative, $matches) === 1) {
        $moduleReference = myronResolveModuleAssetReference($relative);
        $moduleSlug = (string) ($moduleReference['moduleSlug'] ?? $matches[1]);
        $assetPath = ltrim((string) ($moduleReference['assetPath'] ?? $matches[2]), '/');
        if ($assetPath === '' || str_contains($assetPath, '..')) {
            throw new InvalidArgumentException('Module asset path is invalid.');
        }

        $url = ($basePath === '' ? '' : $basePath) . '/modules/' . $moduleSlug . '/assets/' . $assetPath;
        $localPath = is_string($moduleReference['localPath'] ?? null)
            ? (string) $moduleReference['localPath']
            : myronRuntimeAssetLocalPath($relative);
        return myronMaybeFingerprintAssetUrl($url, $localPath);
    }

    $url = ($basePath === '' ? '' : $basePath) . '/assets/' . $relative;
    $localPath = myronRuntimeAssetLocalPath($relative);
    return myronMaybeFingerprintAssetUrl($url, $localPath);
}

/**
 * @return array{moduleSlug:string,assetPath:string,localPath:string}|null
 */
function myronResolveModuleAssetReference(string $relativePath): ?array
{
    $relative = ltrim(trim($relativePath), '/');
    if (!str_starts_with($relative, 'modules/') || str_contains($relative, '..')) {
        return null;
    }

    $remaining = substr($relative, strlen('modules/'));
    $segments = array_values(array_filter(explode('/', $remaining), static fn (string $segment): bool => $segment !== ''));
    if (count($segments) < 2) {
        return null;
    }

    $appRoot = function_exists('myronAppRoot') ? myronAppRoot() : dirname(__DIR__, 2);
    for ($length = count($segments) - 1; $length >= 1; $length--) {
        $moduleSlug = implode('/', array_slice($segments, 0, $length));
        $assetPath = implode('/', array_slice($segments, $length));
        if ($assetPath === '') {
            continue;
        }

        $localPath = $appRoot . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $moduleSlug)
            . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $assetPath);
        if (is_file($localPath)) {
            return [
                'moduleSlug' => $moduleSlug,
                'assetPath' => $assetPath,
                'localPath' => $localPath,
            ];
        }
    }

    return null;
}

function myronRuntimeAssetLocalPath(string $relativePath): ?string
{
    $relative = ltrim(trim($relativePath), '/');
    if ($relative === '' || str_contains($relative, '..')) {
        return null;
    }

    $appRoot = function_exists('myronAppRoot') ? myronAppRoot() : dirname(__DIR__, 2);
    $moduleReference = myronResolveModuleAssetReference($relative);
    if (is_array($moduleReference)) {
        return $moduleReference['localPath'];
    }

    return $appRoot . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
}

function myronMaybeFingerprintAssetUrl(string $url, ?string $localPath): string
{
    if (!is_string($localPath) || !is_file($localPath)) {
        return $url;
    }

    $extension = strtolower((string) pathinfo($localPath, PATHINFO_EXTENSION));
    if (!in_array($extension, ['css', 'js'], true)) {
        return $url;
    }

    $hash = hash_file('sha256', $localPath);
    if (!is_string($hash) || $hash === '') {
        return $url;
    }

    return $url . (str_contains($url, '?') ? '&' : '?') . 'v=' . substr($hash, 0, 16);
}

function myronAuthenticatedUserId(): ?string
{
    if (session_status() !== PHP_SESSION_ACTIVE && !myronSessionCookiePresent()) {
        return null;
    }

    myronEnsureSession();

    $userId = $_SESSION['myron_authenticated_user_id'] ?? null;
    return is_string($userId) && trim($userId) !== '' ? $userId : null;
}

function myronSetAuthenticatedUserId(string $userId): void
{
    myronEnsureSession();
    $_SESSION['myron_authenticated_user_id'] = $userId;
}

function myronClearAuthenticatedUser(): void
{
    myronEnsureSession();
    unset($_SESSION['myron_authenticated_user_id']);
}

/**
 * @return list<array{input: string, normalized: string, runtimeRoles: list<string>, lossy: bool, notes: string}>
 */
function myronRoleProjectionCatalog(): array
{
    return MyronRoleScopeProjector::catalog();
}

/**
 * @return list<string>
 */
function myronProjectRoleTerm(string $role): array
{
    return MyronRoleScopeProjector::projectRole($role);
}

/**
 * @param array<string, mixed>|null $roleScope
 * @return array<string, list<string>>|null
 */
function myronProjectRoleScope(?array $roleScope): ?array
{
    return MyronRoleScopeProjector::projectRoleScope($roleScope);
}

/**
 * @param list<string> $roleTerms
 */
function myronUserMatchesRoleTerms(array $user, array $roleTerms): bool
{
    return MyronRoleScopeProjector::userMatchesRoles($user, $roleTerms);
}

function myronUserInitials(string $displayName): string
{
    $parts = preg_split('/\s+/', trim($displayName)) ?: [];
    $initials = '';

    foreach ($parts as $part) {
        if ($part === '') {
            continue;
        }

        $initials .= strtoupper(substr($part, 0, 1));
        if (strlen($initials) >= 2) {
            break;
        }
    }

    return $initials === '' ? 'MC' : $initials;
}

function myronSanitizeAdminNext(?string $next): string
{
    $candidate = is_string($next) ? trim($next) : '';
    if ($candidate === '') {
        return myronBuildAdminUrl('content.pages');
    }

    $path = parse_url($candidate, PHP_URL_PATH);
    if (!is_string($path) || $path === '') {
        return myronBuildAdminUrl('content.pages');
    }

    if (preg_match('#^https?://#i', $candidate) === 1 || str_starts_with($candidate, '//')) {
        return myronBuildAdminUrl('content.pages');
    }

    $adminBase = myronAdminBasePath();
    if ($adminBase !== '' && ($path === $adminBase || str_starts_with($path, $adminBase . '/'))) {
        return $candidate;
    }

    $relative = ltrim($path, '/');
    if ($relative === '') {
        return myronBuildAdminUrl('content.pages');
    }

    return myronBuildAdminUrl($relative);
}

function myronSanitizePublicReturn(?string $returnTo): ?string
{
    $candidate = is_string($returnTo) ? trim($returnTo) : '';
    if ($candidate === '') {
        return null;
    }

    if (str_starts_with($candidate, '//')) {
        return null;
    }

    if (preg_match('#^https?://#i', $candidate) === 1) {
        $candidateOrigin = parse_url($candidate);
        $appOrigin = parse_url(myronAppUrl());
        if (
            !is_array($candidateOrigin)
            || !is_array($appOrigin)
            || strtolower((string) ($candidateOrigin['scheme'] ?? '')) !== strtolower((string) ($appOrigin['scheme'] ?? ''))
            || strtolower((string) ($candidateOrigin['host'] ?? '')) !== strtolower((string) ($appOrigin['host'] ?? ''))
            || (string) ($candidateOrigin['port'] ?? '') !== (string) ($appOrigin['port'] ?? '')
        ) {
            return null;
        }
    }

    $path = parse_url($candidate, PHP_URL_PATH);
    if (!is_string($path) || $path === '' || !str_starts_with($path, '/')) {
        return null;
    }

    $normalizedPath = preg_replace('#/+#', '/', '/' . ltrim($path, '/')) ?: '/';
    $publicBase = myronPublicBasePath();
    if ($publicBase !== '') {
        if ($normalizedPath !== $publicBase && !str_starts_with($normalizedPath, $publicBase . '/')) {
            return null;
        }

        $target = $normalizedPath === $publicBase ? $publicBase . '/' : $normalizedPath;
    } else {
        $adminBase = myronAdminBasePath();
        if ($adminBase !== '' && ($normalizedPath === $adminBase || str_starts_with($normalizedPath, $adminBase . '/'))) {
            return null;
        }
        if ($normalizedPath === '/app' || str_starts_with($normalizedPath, '/app/')) {
            return null;
        }

        $target = $normalizedPath;
    }

    $query = parse_url($candidate, PHP_URL_QUERY);
    if (is_string($query) && $query !== '') {
        $target .= '?' . $query;
    }

    return $target;
}

function myronResolveAppRequestPath(string $requestUri): string
{
    $requestPath = parse_url($requestUri, PHP_URL_PATH);
    $resolvedPath = is_string($requestPath) ? $requestPath : '/';
    $basePath = myronAdminBasePath();

    if ($basePath !== '' && ($resolvedPath === $basePath || str_starts_with($resolvedPath, $basePath . '/'))) {
        $resolvedPath = substr($resolvedPath, strlen($basePath)) ?: '/';
    }

    $resolvedPath = '/' . ltrim($resolvedPath, '/');
    $resolvedPath = preg_replace('#/+#', '/', $resolvedPath) ?: '/';

    if ($resolvedPath === '/index.php') {
        $resolvedPath = '/';
    }

    if ($resolvedPath !== '/' && str_ends_with($resolvedPath, '/')) {
        $resolvedPath = rtrim($resolvedPath, '/');
    }

    return $resolvedPath;
}

function myronResolvePublicRequestPath(string $requestUri): string
{
    $requestPath = parse_url($requestUri, PHP_URL_PATH);
    $resolvedPath = is_string($requestPath) ? $requestPath : '/';
    $basePath = myronPublicBasePath();

    if ($basePath !== '' && ($resolvedPath === $basePath || str_starts_with($resolvedPath, $basePath . '/'))) {
        $resolvedPath = substr($resolvedPath, strlen($basePath)) ?: '/';
    }

    $resolvedPath = '/' . ltrim($resolvedPath, '/');
    $resolvedPath = preg_replace('#/+#', '/', $resolvedPath) ?: '/';

    if ($resolvedPath === '/index.php') {
        $resolvedPath = '/';
    }

    if ($resolvedPath !== '/' && str_ends_with($resolvedPath, '/')) {
        $resolvedPath = rtrim($resolvedPath, '/');
    }

    if (preg_match('#^/[A-Za-z0-9/_-]+\.[A-Za-z0-9]+$#', $resolvedPath) === 1) {
        return $resolvedPath;
    }

    if (in_array($resolvedPath, ['/sitemap.xml', '/llms.txt', '/robots.txt'], true)) {
        return $resolvedPath;
    }

    return myronNormalizePath($resolvedPath);
}

function myronAppUrl(): string
{
    $configured = trim((string) (getenv('MYRON_APP_URL') ?: ''));
    if ($configured !== '') {
        return rtrim($configured, '/');
    }

    $requestOrigin = myronRequestOrigin();
    if ($requestOrigin !== null) {
        return $requestOrigin;
    }

    return 'https://myroncms:8890';
}

/**
 * @param array<string, scalar|null> $query
 */
function myronBuildAbsoluteAdminUrl(string $route = 'content.pages', array $query = []): string
{
    return myronAppUrl() . myronBuildAdminUrl($route, $query);
}

/**
 * @return array<string, string>
 */
/**
 * Renders a small subset of inline Markdown for prose authored in the Pass 1 audit
 * markdown files (rendered in the Site Type Studio Architectural Guidance sidesheet
 * via STN-012-014).
 *
 * Supported:
 *   **bold**                 → <strong>bold</strong>
 *   *italic*                 → <em>italic</em>  (only when not adjacent to ** boundaries)
 *   [[path/to/file|alias]]   → <code>alias</code>
 *   [[path/to/file]]         → <code>path/to/file</code>
 *   newlines                 → <br>
 *
 * Output is HTML-safe: the input is escaped first, then known Markdown patterns are
 * promoted to HTML elements. Authors cannot inject raw HTML.
 *
 * Future enhancement: resolve [[]] links to actual cross-template URLs when the
 * Studio supports template-detail navigation by template id.
 */
function myronRenderGuidanceProse(string $content): string
{
    $escaped = myronEscape($content);

    // Obsidian-style link with alias: [[path|alias]] → <code>alias</code>
    $rendered = (string) preg_replace_callback(
        '/\[\[([^\]|]+)\|([^\]]+)\]\]/',
        static fn (array $m): string => '<code>' . trim($m[2]) . '</code>',
        $escaped
    );

    // Obsidian-style link without alias: [[path]] → <code>path</code>
    $rendered = (string) preg_replace_callback(
        '/\[\[([^\]]+)\]\]/',
        static fn (array $m): string => '<code>' . trim($m[1]) . '</code>',
        $rendered
    );

    // Bold: **text** → <strong>text</strong>  (run before italic so it claims the doubled asterisks)
    $rendered = (string) preg_replace(
        '/\*\*([^*\n]+?)\*\*/',
        '<strong>$1</strong>',
        $rendered
    );

    // Italic: *text* → <em>text</em>  (single-asterisk, non-doubled)
    // Negative look-around to avoid eating leftover bold sequences. Mostly defensive.
    $rendered = (string) preg_replace(
        '/(?<!\*)\*([^*\n]+?)\*(?!\*)/',
        '<em>$1</em>',
        $rendered
    );

    return nl2br($rendered);
}

/**
 * Renders the body of the Architectural Guidance sidesheet (STN-012-014, STN-012-016)
 * given a hydrated guidance record (the `guidance` field on a TemplateDefinitionRepository
 * row). Returns an empty string when the record is null or empty — callers should use that
 * to gate the toolbar button visibility.
 *
 * Shared between the Site Type Studio (Designer) and Content Templates (Content operator)
 * surfaces. The Designer can edit Page Template defaults elsewhere in the Studio; this
 * sidesheet is read-only on both sides because the architectural guidance itself is
 * platform-shipped (per the four-class taxonomy in DECISIONS.md).
 *
 * @param array<string, mixed>|null $guidance
 */
function myronRenderTemplateGuidanceSidesheetBody(?array $guidance): string
{
    if (!is_array($guidance)) {
        return '';
    }
    $guidanceSections = is_array($guidance['sections'] ?? null) ? $guidance['sections'] : [];
    $templatePurpose = trim((string) ($guidance['templatePurpose'] ?? ''));
    $templateAuditNotes = is_array($guidance['templateAuditNotes'] ?? null) ? $guidance['templateAuditNotes'] : [];

    if ($templatePurpose === '' && $guidanceSections === [] && $templateAuditNotes === []) {
        return '';
    }

    ob_start();
    ?>
    <div class="mc-template-guidance" data-myron-template-guidance>
      <?php if ($templatePurpose !== ''): ?>
      <section class="sg-card">
        <h3 class="sg-card__title">Template Purpose</h3>
        <div class="sg-card__text"><?= myronRenderGuidanceProse($templatePurpose) ?></div>
      </section>
      <?php endif; ?>
      <?php if ($guidanceSections !== []): ?>
      <section class="sg-card">
        <h3 class="sg-card__title">Section Guidance</h3>
        <p class="sg-card__text" style="margin-top:0.25rem;color:var(--color-fg-muted,#666);">Design notes, editorial guidelines, and audit observations for each section of this template.</p>
        <?php foreach ($guidanceSections as $sectionKey => $sectionGuidance): ?>
        <?php if (!is_array($sectionGuidance)) { continue; } ?>
        <article class="mc-template-guidance__section" style="margin-top:1.25rem;padding-top:1rem;border-top:1px solid var(--color-border-subtle,#e5e5e5);">
          <header style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:baseline;">
            <h4 style="margin:0;font-size:1rem;"><?= myronEscape((string) ($sectionGuidance['title'] ?? $sectionKey)) ?></h4>
            <code style="font-size:0.75rem;color:var(--color-fg-muted,#666);"><?= myronEscape((string) $sectionKey) ?></code>
            <?php if (!empty($sectionGuidance['componentId'])): ?>
            <span class="sg-pill"><?= myronEscape((string) $sectionGuidance['componentId']) ?><?php if (!empty($sectionGuidance['variant'])): ?>&nbsp;/&nbsp;<?= myronEscape((string) $sectionGuidance['variant']) ?><?php endif; ?></span>
            <?php endif; ?>
            <?php if (!empty($sectionGuidance['derivedCards'])): ?>
            <span class="sg-pill" style="background:var(--color-accent-bg,#eef);" title="Section renders cards from a canonical source per the audit pattern">#derivedCards</span>
            <?php endif; ?>
            <?php if (!empty($sectionGuidance['derivedContent'])): ?>
            <span class="sg-pill" style="background:var(--color-accent-bg,#eef);" title="Section renders structured data that could derive from a central library per the audit pattern">#derivedContent</span>
            <?php endif; ?>
          </header>
          <?php if (trim((string) ($sectionGuidance['designNote'] ?? '')) !== ''): ?>
          <h5 style="margin:0.75rem 0 0.25rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--color-fg-muted,#666);">Design note</h5>
          <div class="sg-card__text" style="margin:0;"><?= myronRenderGuidanceProse((string) $sectionGuidance['designNote']) ?></div>
          <?php endif; ?>
          <?php if (trim((string) ($sectionGuidance['editorialGuideline'] ?? '')) !== ''): ?>
          <h5 style="margin:0.75rem 0 0.25rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--color-fg-muted,#666);">Editorial guideline</h5>
          <div class="sg-card__text" style="margin:0;"><?= myronRenderGuidanceProse((string) $sectionGuidance['editorialGuideline']) ?></div>
          <?php endif; ?>
          <?php $fields = is_array($sectionGuidance['fields'] ?? null) ? $sectionGuidance['fields'] : []; ?>
          <?php if ($fields !== []): ?>
          <h5 style="margin:0.75rem 0 0.25rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--color-fg-muted,#666);">Fields</h5>
          <ul style="margin:0;padding-left:1.25rem;">
            <?php foreach ($fields as $field): ?>
            <li><?= myronEscape((string) $field) ?></li>
            <?php endforeach; ?>
          </ul>
          <?php endif; ?>
          <?php $sectionAuditNotes = is_array($sectionGuidance['auditNotes'] ?? null) ? $sectionGuidance['auditNotes'] : []; ?>
          <?php if ($sectionAuditNotes !== []): ?>
          <h5 style="margin:0.75rem 0 0.25rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--color-fg-muted,#666);">Audit Notes</h5>
          <?php foreach ($sectionAuditNotes as $note): ?>
          <div class="sg-card__text" style="margin:0 0 0.5rem;padding-left:0.75rem;border-left:3px solid var(--color-accent,#f80);"><?= myronRenderGuidanceProse((string) $note) ?></div>
          <?php endforeach; ?>
          <?php endif; ?>
        </article>
        <?php endforeach; ?>
      </section>
      <?php endif; ?>
      <?php if ($templateAuditNotes !== []): ?>
      <section class="sg-card">
        <h3 class="sg-card__title">Template Audit Notes</h3>
        <p class="sg-card__text" style="margin-top:0.25rem;color:var(--color-fg-muted,#666);">Top-level audit observations spanning the whole template.</p>
        <?php foreach ($templateAuditNotes as $note): ?>
        <div class="sg-card__text" style="margin:0.75rem 0 0;padding-left:0.75rem;border-left:3px solid var(--color-accent,#f80);"><?= myronRenderGuidanceProse((string) $note) ?></div>
        <?php endforeach; ?>
      </section>
      <?php endif; ?>
    </div>
    <?php
    return (string) ob_get_clean();
}

function myronSiteLanguageOptions(): array
{
    return [
        'en-US' => 'English (United States)',
        'en-GB' => 'English (United Kingdom)',
        'es-ES' => 'Spanish (Spain)',
        'fr-FR' => 'French (France)',
        'de-DE' => 'German (Germany)',
        'it-IT' => 'Italian (Italy)',
        'pt-BR' => 'Portuguese (Brazil)',
        'nl-NL' => 'Dutch (Netherlands)',
        'ja-JP' => 'Japanese (Japan)',
    ];
}

/**
 * @return array<string, string>
 */
function myronSiteDateFormatOptions(): array
{
    return [
        'F j, Y' => 'April 23, 2026',
        'Y-m-d' => '2026-04-23',
        'm/d/Y' => '04/23/2026',
        'd/m/Y' => '23/04/2026',
        'd.m.Y' => '23.04.2026',
    ];
}

/**
 * @return array<string, string>
 */
function myronSiteTimeFormatOptions(): array
{
    return [
        'g:i a' => '3:45 pm',
        'g:i A' => '3:45 PM',
        'H:i' => '15:45',
    ];
}

function myronPreferredTimezone(): string
{
    if (function_exists('myronSiteTimezone')) {
        $candidate = trim((string) myronSiteTimezone());
        if ($candidate !== '') {
            return $candidate;
        }
    }

    return date_default_timezone_get() ?: 'UTC';
}

function myronPreferredDateFormat(): string
{
    if (function_exists('myronSiteDateFormat')) {
        $candidate = trim((string) myronSiteDateFormat());
        if ($candidate !== '') {
            return $candidate;
        }
    }

    return 'M j, Y';
}

function myronPreferredTimeFormat(): string
{
    if (function_exists('myronSiteTimeFormat')) {
        $candidate = trim((string) myronSiteTimeFormat());
        if ($candidate !== '') {
            return $candidate;
        }
    }

    return 'g:i A';
}

function myronRequestOrigin(): ?string
{
    $host = trim((string) ($_SERVER['HTTP_HOST'] ?? ''));
    if ($host === '') {
        return null;
    }

    $scheme = myronRequestScheme();
    return $scheme . '://' . $host;
}

function myronRequestScheme(): string
{
    $forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
    if ($forwarded !== '') {
        $parts = explode(',', $forwarded);
        $candidate = trim((string) ($parts[0] ?? ''));
        if (in_array($candidate, ['http', 'https'], true)) {
            return $candidate;
        }
    }

    $requestScheme = strtolower(trim((string) ($_SERVER['REQUEST_SCHEME'] ?? '')));
    if (in_array($requestScheme, ['http', 'https'], true)) {
        return $requestScheme;
    }

    $https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
    if ($https !== '' && $https !== 'off') {
        return 'https';
    }

    $port = (string) ($_SERVER['SERVER_PORT'] ?? '');
    if ($port === '443') {
        return 'https';
    }

    return 'http';
}

function myronUploadsDir(): string
{
    $configured = trim((string) (getenv('MYRON_UPLOADS_PATH') ?: ''));
    if ($configured !== '') {
        return rtrim($configured, DIRECTORY_SEPARATOR);
    }

    if ((string) getenv('MYRON_APP_ENV') === 'test') {
        return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'myron-uploads';
    }

    return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'uploads';
}

function myronBuildUploadUrl(string $storedFilename): string
{
    return myronBuildAdminUrl('uploads/' . rawurlencode($storedFilename));
}

function myronBuildFileStreamUrl(string $fileId, bool $download = false, ?string $rendition = null, ?string $revision = null): string
{
    return myronBuildPublicRouteUrl('/media/files/stream', [
        'id' => $fileId,
        'rendition' => $rendition !== null && trim($rendition) !== '' ? trim($rendition) : null,
        'v' => $revision !== null && trim($revision) !== '' ? trim($revision) : null,
        'download' => $download ? 1 : null,
    ]);
}

/**
 * @param array<string, mixed> $file
 */
function myronFileRevision(array $file): string
{
    return substr(sha1(implode('|', [
        (string) ($file['id'] ?? ''),
        (string) ($file['stored_filename'] ?? ''),
        (string) ($file['file_size_bytes'] ?? ''),
        (string) ($file['updated_at'] ?? ''),
    ])), 0, 12);
}

/**
 * @param array<string, mixed> $file
 */
function myronBuildFileRevisionedStreamUrl(array $file, ?string $rendition = null, bool $download = false): string
{
    $fileId = trim((string) ($file['id'] ?? ''));
    if ($fileId === '') {
        return '';
    }

    return myronBuildFileStreamUrl($fileId, $download, $rendition, myronFileRevision($file));
}

function myronRequestExpectsJson(): bool
{
    $requestedWith = strtolower(trim((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')));
    if ($requestedWith === 'xmlhttprequest') {
        return true;
    }

    $accept = strtolower(trim((string) ($_SERVER['HTTP_ACCEPT'] ?? '')));
    return str_contains($accept, 'application/json');
}

/**
 * @param array<string, mixed> $payload
 */
function myronJsonResponse(array $payload, int $status = 200): never
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    exit;
}

/**
 * @param array<string, mixed> $file
 * @return array<string, mixed>
 */
function myronAssetPickerPayload(array $file): array
{
    $title = trim((string) (($file['title'] ?? '') ?: ($file['original_filename'] ?? 'Asset')));
    $altText = trim((string) (($file['alt_text'] ?? '') ?: $title));

    return [
        'id' => (string) ($file['id'] ?? ''),
        'file_id' => (string) ($file['id'] ?? ''),
        'file_url' => (string) ($file['file_url'] ?? ''),
        'title' => $title,
        'original_filename' => (string) (($file['original_filename'] ?? '') ?: $title),
        'dimensions_label' => (string) ($file['dimensions_label'] ?? ''),
        'file_extension' => (string) ($file['file_extension'] ?? ''),
        'alt_text' => $altText,
        'is_image' => (bool) ($file['is_image'] ?? false),
    ];
}

/**
 * @param array<string, mixed> $file
 * @return array{cacheControl:string,etag:string,lastModified:string,lastModifiedTimestamp:int,size:int}
 */
function myronFileStreamCacheMetadata(array $file, string $absolutePath, bool $download = false, ?string $variant = null): array
{
    $size = max(0, (int) filesize($absolutePath));
    $modified = (int) filemtime($absolutePath);
    if ($modified <= 0) {
        $modified = time();
    }

    $etagSeed = implode('|', [
        (string) ($file['id'] ?? ''),
        (string) ($file['stored_filename'] ?? ''),
        (string) ($file['mime_type'] ?? ''),
        (string) $size,
        (string) $modified,
        $variant !== null && $variant !== '' ? 'variant=' . $variant : '',
    ]);

    return [
        'cacheControl' => $download ? 'private, max-age=0, must-revalidate' : 'public, max-age=31536000, immutable',
        'etag' => '"' . sha1($etagSeed) . '"',
        'lastModified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
        'lastModifiedTimestamp' => $modified,
        'size' => $size,
    ];
}

function myronAcceptIncludesWebp(string $acceptHeader): bool
{
    $trimmed = trim($acceptHeader);
    if ($trimmed === '') {
        return false;
    }

    foreach (explode(',', $trimmed) as $part) {
        $segments = array_map('trim', explode(';', $part));
        $mediaType = strtolower((string) array_shift($segments));
        if ($mediaType !== 'image/webp' && $mediaType !== 'image/*') {
            continue;
        }
        $quality = 1.0;
        foreach ($segments as $segment) {
            if (stripos($segment, 'q=') === 0) {
                $quality = (float) substr($segment, 2);
            }
        }
        if ($quality > 0.0) {
            return true;
        }
    }

    return false;
}

/**
 * @param array<string, mixed> $server
 */
function myronRequestMatchesFileStreamCache(array $server, string $etag, int $lastModifiedTimestamp): bool
{
    $ifNoneMatch = trim((string) ($server['HTTP_IF_NONE_MATCH'] ?? ''));
    if ($ifNoneMatch !== '') {
        foreach (array_map('trim', explode(',', $ifNoneMatch)) as $candidate) {
            if ($candidate === '*') {
                return true;
            }
            if (str_starts_with($candidate, 'W/')) {
                $candidate = trim(substr($candidate, 2));
            }
            if ($candidate === $etag) {
                return true;
            }
        }

        return false;
    }

    $ifModifiedSince = trim((string) ($server['HTTP_IF_MODIFIED_SINCE'] ?? ''));
    if ($ifModifiedSince === '') {
        return false;
    }

    $modifiedSince = strtotime($ifModifiedSince);
    return is_int($modifiedSince) && $modifiedSince >= $lastModifiedTimestamp;
}

/**
 * @param array{cacheControl:string,etag:string,lastModified:string,lastModifiedTimestamp:int,size:int} $metadata
 */
function myronSendFileStreamCacheHeaders(array $metadata): void
{
    header_remove('Cache-Control');
    header('Cache-Control: ' . $metadata['cacheControl']);
    header('ETag: ' . $metadata['etag']);
    header('Last-Modified: ' . $metadata['lastModified']);
}

function myronStreamFileById(string $fileId, bool $download = false, string $rendition = ''): never
{
    $trimmedFileId = trim($fileId);
    if ($trimmedFileId === '') {
        http_response_code(404);
        exit('File not found.');
    }

    $file = myronFileRepository()->getFileById($trimmedFileId);
    if (!is_array($file)) {
        http_response_code(404);
        exit('File not found.');
    }

    $originalPath = myronUploadsDir() . DIRECTORY_SEPARATOR . (string) $file['stored_filename'];
    if (!is_file($originalPath)) {
        http_response_code(404);
        exit('File not found.');
    }

    $absolutePath = $originalPath;
    $contentType = (string) ($file['mime_type'] ?? 'application/octet-stream');
    $variant = null;
    $rendition = trim($rendition);
    if (!$download && $rendition !== '') {
        try {
            $resolved = myronFileRepository()->resolveImageRendition($file, $rendition);
        } catch (Throwable) {
            http_response_code(404);
            exit('File not found.');
        }
        if (is_array($resolved)) {
            $absolutePath = (string) ($resolved['path'] ?? $absolutePath);
            $contentType = (string) ($resolved['mime'] ?? $contentType);
            $variant = (string) ($resolved['variant'] ?? $rendition);
        }
    }
    if (!$download) {
        $accept = (string) ($_SERVER['HTTP_ACCEPT'] ?? '');
        $sidecarPath = $originalPath . '.webp';
        if ($absolutePath !== $originalPath) {
            $sidecarPath = $absolutePath . '.webp';
        }
        if (myronAcceptIncludesWebp($accept) && is_file($sidecarPath)) {
            $absolutePath = $sidecarPath;
            $contentType = 'image/webp';
            $variant = $variant !== null && $variant !== '' ? $variant . '+webp' : 'webp';
        }
    }

    $filename = basename((string) ($file['original_filename'] ?? $file['stored_filename']));
    $disposition = $download ? 'attachment' : 'inline';
    $cacheMetadata = myronFileStreamCacheMetadata($file, $absolutePath, $download, $variant);

    header('Content-Type: ' . $contentType);
    header('Content-Disposition: ' . $disposition . '; filename="' . addslashes($filename) . '"');
    header('X-Content-Type-Options: nosniff');
    header('Vary: Accept');
    myronSendFileStreamCacheHeaders($cacheMetadata);

    if (myronRequestMatchesFileStreamCache($_SERVER, $cacheMetadata['etag'], $cacheMetadata['lastModifiedTimestamp'])) {
        http_response_code(304);
        exit;
    }

    header('Content-Length: ' . (string) $cacheMetadata['size']);
    if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD') {
        exit;
    }

    readfile($absolutePath);
    exit;
}

function myronEnsureDirectory(string $path): void
{
    if (is_dir($path)) {
        return;
    }

    if (!mkdir($path, 0775, true) && !is_dir($path)) {
        throw new RuntimeException('Failed to create required directory: ' . $path);
    }
}

function myronFormatFileSize(int $bytes): string
{
    if ($bytes < 1024) {
        return $bytes . ' B';
    }

    $units = ['KB', 'MB', 'GB'];
    $value = $bytes / 1024;
    foreach ($units as $index => $unit) {
        if ($value < 1024 || $index === count($units) - 1) {
            return rtrim(rtrim(number_format($value, $value >= 100 ? 0 : 1), '0'), '.') . ' ' . $unit;
        }

        $value /= 1024;
    }

    return number_format($bytes / 1024 / 1024 / 1024, 1) . ' GB';
}

function myronAppKeyAvailable(): bool
{
    $value = trim((string) (getenv('MYRON_APP_KEY') ?: getenv('MYRON_APP_SECRET') ?: ''));
    return $value !== '';
}

function myronEncryptionKey(): string
{
    $rawKey = trim((string) (getenv('MYRON_APP_KEY') ?: getenv('MYRON_APP_SECRET') ?: ''));
    if ($rawKey === '') {
        throw new RuntimeException('MYRON_APP_KEY must be configured before provider secrets can be saved.');
    }

    return hash('sha256', $rawKey, true);
}

function myronEncryptSecret(string $plaintext): string
{
    if ($plaintext === '') {
        return '';
    }

    if (!function_exists('openssl_encrypt')) {
        throw new RuntimeException('OpenSSL is required for secret encryption.');
    }

    $iv = random_bytes(16);
    $ciphertext = openssl_encrypt(
        $plaintext,
        'aes-256-cbc',
        myronEncryptionKey(),
        OPENSSL_RAW_DATA,
        $iv
    );

    if (!is_string($ciphertext) || $ciphertext === '') {
        throw new RuntimeException('Failed to encrypt secret value.');
    }

    return base64_encode(json_encode([
        'v' => 1,
        'iv' => base64_encode($iv),
        'ciphertext' => base64_encode($ciphertext),
    ], JSON_THROW_ON_ERROR));
}

function myronDecryptSecret(?string $payload): string
{
    $encoded = trim((string) $payload);
    if ($encoded === '') {
        return '';
    }

    if (!function_exists('openssl_decrypt')) {
        throw new RuntimeException('OpenSSL is required for secret decryption.');
    }

    $decoded = base64_decode($encoded, true);
    if (!is_string($decoded) || $decoded === '') {
        throw new RuntimeException('Stored secret payload is invalid.');
    }

    $data = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($data)) {
        throw new RuntimeException('Stored secret payload is malformed.');
    }

    $iv = base64_decode((string) ($data['iv'] ?? ''), true);
    $ciphertext = base64_decode((string) ($data['ciphertext'] ?? ''), true);
    if (!is_string($iv) || !is_string($ciphertext) || $iv === '' || $ciphertext === '') {
        throw new RuntimeException('Stored secret payload is incomplete.');
    }

    $plaintext = openssl_decrypt(
        $ciphertext,
        'aes-256-cbc',
        myronEncryptionKey(),
        OPENSSL_RAW_DATA,
        $iv
    );

    if (!is_string($plaintext)) {
        throw new RuntimeException('Stored secret could not be decrypted.');
    }

    return $plaintext;
}

function myronMaskSecret(?string $payload): string
{
    return trim((string) $payload) === '' ? 'Not set' : 'Stored securely';
}

function myronFormatDatetimeForInput(?string $utcValue): string
{
    $value = trim((string) $utcValue);
    if ($value === '') {
        return '';
    }

    $timezone = new DateTimeZone(myronPreferredTimezone());
    $date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
    return $date->setTimezone($timezone)->format('Y-m-d\TH:i');
}

function myronFormatAdminTimestamp(?string $utcValue): string
{
    $value = trim((string) $utcValue);
    if ($value === '') {
        return '—';
    }

    $timezone = new DateTimeZone(myronPreferredTimezone());
    $date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
    return $date->setTimezone($timezone)->format(myronPreferredDateFormat() . ' ' . myronPreferredTimeFormat());
}

function myronNormalizePublishedAtInput(?string $value): ?string
{
    $raw = trim((string) $value);
    if ($raw === '') {
        return null;
    }

    $timezone = new DateTimeZone(myronPreferredTimezone());
    $date = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $raw, $timezone);
    if (!$date instanceof DateTimeImmutable) {
        throw new InvalidArgumentException('Publish date must use a valid date/time value.');
    }

    return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}

function myronBase64UrlEncode(string $value): string
{
    return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}

function myronBase64UrlDecode(string $value): string
{
    $padded = strtr($value, '-_', '+/');
    $padding = strlen($padded) % 4;
    if ($padding > 0) {
        $padded .= str_repeat('=', 4 - $padding);
    }

    $decoded = base64_decode($padded, true);
    if (!is_string($decoded)) {
        throw new InvalidArgumentException('Invalid token encoding.');
    }

    return $decoded;
}

require_once dirname(__DIR__) . '/media/PublicMediaRenditionService.php';
require_once dirname(__DIR__) . '/performance/PublicPageCache.php';
require_once dirname(__DIR__) . '/performance/PublicPageCacheWarmer.php';
require_once dirname(__DIR__) . '/performance/PublicPageCacheWarmCoordinator.php';
