<?php

/**
 * ITSWEBER CMS – Global Helper Functions
 */

/**
 * Escape output for HTML context.
 */
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

/**
 * Sanitize HTML content by allowing only safe tags and attributes.
 *
 * Allowed tags: <a>, <strong>, <em>, <br>, <span>, <p>, <ul>, <ol>, <li>
 * Allowed attributes:
 *   - <a>: href, target, rel, title
 *   - <span>: class, style (limited)
 *   - Other tags: no attributes
 *
 * Prevents XSS by stripping dangerous attributes and protocols.
 *
 * @param string $html Raw HTML to sanitize
 * @return string Sanitized HTML safe to output
 */
function sanitize_html(string $html): string
{
    // Step 1: Allow only safe tags
    $allowed = '<a><strong><em><b><i><br><span><p><ul><ol><li>';
    $html = strip_tags($html, $allowed);

    // Step 2: Remove dangerous attributes from <a> tags
    // Only allow: href, target, rel, title
    $html = preg_replace_callback(
        '/<a\s+([^>]*)>/i',
        function ($matches) {
            $attrs = $matches[1];
            $result = '<a';

            // Extract allowed attributes using regex
            if (preg_match('/href=(["\']?)([^\s"\']*)\1/i', $attrs, $m)) {
                $href = $m[2];
                // Prevent javascript: protocol
                if (!preg_match('/^javascript:/i', $href)) {
                    $result .= ' href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '"';
                }
            }

            if (preg_match('/target=(["\']?)(_blank|_self|_parent|_top)\1/i', $attrs, $m)) {
                $result .= ' target="' . htmlspecialchars($m[2], ENT_QUOTES, 'UTF-8') . '"';
            }

            if (preg_match('/rel=(["\']?)([^"\']*)\1/i', $attrs, $m)) {
                $rel = $m[2];
                // Only allow safe rel values
                $allowedRels = ['nofollow', 'noopener', 'noreferrer'];
                $relValues = array_intersect(explode(' ', strtolower($rel)), $allowedRels);
                if ($relValues) {
                    $result .= ' rel="' . htmlspecialchars(implode(' ', $relValues), ENT_QUOTES, 'UTF-8') . '"';
                }
            }

            if (preg_match('/title=(["\']?)([^"\']*)\1/i', $attrs, $m)) {
                $result .= ' title="' . htmlspecialchars($m[2], ENT_QUOTES, 'UTF-8') . '"';
            }

            $result .= '>';
            return $result;
        },
        $html
    );

    // Step 3: Remove all attributes from <span> tags except class and limited style
    $html = preg_replace_callback(
        '/<span\s+([^>]*)>/i',
        function ($matches) {
            $attrs = $matches[1];
            $result = '<span';

            if (preg_match('/class=(["\']?)([^"\']*)\1/i', $attrs, $m)) {
                $class = $m[2];
                // Only allow alphanumeric, dash, underscore in classes
                $class = preg_replace('/[^a-z0-9\s\-_]/i', '', $class);
                if ($class) {
                    $result .= ' class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . '"';
                }
            }

            $result .= '>';
            return $result;
        },
        $html
    );

    return $html;
}

/**
 * Translate a key using the central Translator service.
 *
 * Keys use dot-notation: "file.section.key"
 * Example: __('admin.nav.dashboard')
 *
 * @param string $key    Translation key
 * @param array  $params Placeholder replacements [':name' => 'value']
 * @return string Translated string or the key itself as fallback
 */
function __(string $key, array $params = []): string
{
    $app = \App\Core\App::getInstance();
    if ($app && $app->hasService('translator')) {
        return $app->service('translator')->get($key, $params);
    }
    // Before translator is available, return key with placeholder replacement
    $text = $key;
    foreach ($params as $placeholder => $value) {
        $token = str_starts_with($placeholder, ':') ? $placeholder : ':' . $placeholder;
        $text = str_replace($token, (string) $value, $text);
    }
    return $text;
}

/**
 * Translate and HTML-escape a key.
 *
 * Convenience shortcut for e(__($key, $params)).
 */
function _e(string $key, array $params = []): string
{
    return e(__($key, $params));
}

/**
 * Get the current system locale.
 *
 * @return string 'de' or 'en'
 */
function current_locale(): string
{
    $app = \App\Core\App::getInstance();
    if ($app && $app->hasService('translator')) {
        return $app->service('translator')->getLocale();
    }
    return 'de';
}

/**
 * Get the application instance.
 */
function app(): \App\Core\App
{
    return \App\Core\App::getInstance();
}

/**
 * Get a config value.
 */
function config(string $key, mixed $default = null): mixed
{
    return app()->config()->get($key, $default);
}

/**
 * Return the installed CMS version string.
 *
 * Reads from app/Config/version.php (canonical, writable by the CMS updater)
 * and falls back to the 'version' key in app/Config/app.php for installations
 * that were set up before this version file existed (e.g. fresh installs without
 * an initial update applied yet).
 *
 * This is the single authoritative call-site for the installed version.
 * Use it in views, controllers, and services instead of hard-coding the version.
 */
function cms_version(): string
{
    $basePath    = app()->basePath();
    $versionFile = $basePath . '/app/Config/version.php';

    if (is_file($versionFile)) {
        // phpcs:ignore
        $v = include $versionFile;
        if (is_string($v) && $v !== '') {
            return $v;
        }
    }

    // Fallback: read from app/Config/app.php (protected – may be stale after updates)
    return config('version', '1.0.0');
}

/**
 * Get a setting from the database.
 */
function setting(string $key, string $default = ''): string
{
    return app()->getSetting($key, $default);
}

/**
 * Get the mail configuration array for the Mailer service.
 *
 * Reads from iw_settings (group 'mail') with fallback to app.php config.
 * This allows the admin to configure mail via the UI while keeping
 * the config file as default for fresh installs.
 */
function mail_config(): array
{
    $dbTransport = \App\Models\Setting::get('mail_transport', '');

    // If DB has mail settings, use them
    if ($dbTransport !== '') {
        return [
            'transport'       => $dbTransport,
            'from_address'    => \App\Models\Setting::get('mail_from_address', 'noreply@localhost'),
            'from_name'       => \App\Models\Setting::get('mail_from_name', 'ITSWEBER CMS'),
            'reply_to'        => \App\Models\Setting::get('mail_reply_to', ''),
            'smtp_host'       => \App\Models\Setting::get('mail_smtp_host', ''),
            'smtp_port'       => (int) \App\Models\Setting::get('mail_smtp_port', '587'),
            'smtp_username'   => \App\Models\Setting::get('mail_smtp_username', ''),
            'smtp_password'   => \App\Models\Setting::get('mail_smtp_password', ''),
            'smtp_encryption' => \App\Models\Setting::get('mail_smtp_encryption', 'tls'),
        ];
    }

    // Fallback to config file
    return config('mail', []);
}

/**
 * Get the URL to a theme asset.
 *
 * When asset minification is enabled, CSS/JS files are served from
 * a minified cache. Already-minified files (.min.css/.min.js) are skipped.
 */
function theme_asset(string $path): string
{
    $view = app()->service('view');
    $url  = $view->themeAsset($path);

    // Route through asset minifier if enabled
    if (setting('asset_minification_enabled', '0') === '1') {
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
        if (in_array($ext, ['css', 'js'], true) && !preg_match('/\.min\.(css|js)$/i', $path)) {
            $app = app();
            $minifier = new \App\Services\AssetMinifier($app->basePath());
            $publicPath = ltrim($url, '/');
            return $minifier->getMinifiedUrl($publicPath);
        }
    }

    return $url;
}

/**
 * Resolve a theme-specific admin partial for a homepage section.
 *
 * If the active theme provides its own admin form partial at
 * themes/{theme}/admin/sections/{section_key}.php, return its absolute path.
 * Otherwise return null, so the caller can fall back to the core default form.
 *
 * This allows premium themes to customize admin forms for sections (e.g. hero)
 * without modifying core code. When the theme is uninstalled or deactivated,
 * the core default form is used automatically.
 *
 * @param string $sectionKey The section key (e.g. 'hero', 'intro')
 * @return string|null Absolute path to the partial, or null if not found
 */
function theme_admin_partial(string $sectionKey): ?string
{
    $view = app()->service('view');
    $theme = $view->activeTheme();

    // Core themes use the built-in admin form, no override needed
    if (in_array($theme, ['default', 'business', 'creative'], true)) {
        return null;
    }

    $themesPath = dirname(__DIR__, 2) . '/themes';
    $partial    = $themesPath . '/' . $theme . '/admin/sections/' . $sectionKey . '.php';

    return file_exists($partial) ? $partial : null;
}

/**
 * Get extra validated section data from the active theme.
 *
 * If the active theme provides a data extension file at
 * themes/{theme}/admin/sections/{section_key}_data.php, include it.
 * The file must return a callable(Request $request): array that
 * validates and returns theme-specific extra fields.
 *
 * This is the data counterpart to theme_admin_partial(): the partial
 * controls which fields are shown in the admin, and the data extension
 * controls which extra fields are validated and saved.
 *
 * When the theme is deactivated or uninstalled, no extra fields are saved.
 * Existing extra fields in the JSON remain harmless – core themes and
 * the core admin simply ignore keys they don't know about.
 *
 * @param string                   $sectionKey The section key (e.g. 'hero')
 * @param \App\Core\Request        $request    The current POST request
 * @return array Extra validated fields to merge, or empty array
 */
function theme_section_extra_data(string $sectionKey, \App\Core\Request $request): array
{
    $view = app()->service('view');
    $theme = $view->activeTheme();

    // Core themes have no extra fields
    if (in_array($theme, ['default', 'business', 'creative'], true)) {
        return [];
    }

    $themesPath = dirname(__DIR__, 2) . '/themes';
    $dataFile   = $themesPath . '/' . $theme . '/admin/sections/' . $sectionKey . '_data.php';

    if (!file_exists($dataFile)) {
        return [];
    }

    $factory = include $dataFile;
    if (!is_callable($factory)) {
        return [];
    }

    $extra = $factory($request);
    return is_array($extra) ? $extra : [];
}

/**
 * Get the URL to a public asset (generic passthrough).
 * Use theme_asset() for theme assets and admin_asset() for admin assets.
 * This helper is reserved for future shared public assets outside those areas.
 */
function asset(string $path): string
{
    return '/' . ltrim($path, '/');
}

/**
 * Get the URL to an admin asset.
 */
function admin_asset(string $path): string
{
    return '/admin/assets/' . ltrim($path, '/');
}

/**
 * Generate the CSRF hidden field.
 */
function csrf_field(): string
{
    $csrf = app()->service('csrf');
    return $csrf->field();
}

/**
 * Get the CSRF token value.
 */
function csrf_token(): string
{
    $csrf = app()->service('csrf');
    return $csrf->token();
}

/**
 * Get a flash message.
 */
function flash(string $key, mixed $default = null): mixed
{
    $session = app()->service('session');
    return $session->getFlash($key, $default);
}

/**
 * Set a flash message.
 */
function set_flash(string $key, mixed $value): void
{
    $session = app()->service('session');
    $session->flash($key, $value);
}

/**
 * Build an absolute URL, using base_url from config or auto-detected scheme+host.
 *
 * base_url() with no argument returns the base (e.g. "https://example.com").
 * base_url('/blog/my-post') returns "https://example.com/blog/my-post".
 */
function base_url(string $path = ''): string
{
    $base = rtrim(config('base_url', ''), '/');

    if ($base === '') {
        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
        $base   = $scheme . '://' . $host;
    }

    if ($path !== '') {
        return $base . '/' . ltrim($path, '/');
    }

    return $base;
}

/**
 * Generate a URL to a page by slug.
 */
function page_url(string $slug): string
{
    return '/' . ltrim($slug, '/');
}

/**
 * Generate a URL to a blog post.
 */
function blog_url(string $slug): string
{
    return '/blog/' . ltrim($slug, '/');
}

/**
 * Truncate a string to a given length.
 */
function str_truncate(string $text, int $length = 150, string $suffix = '...'): string
{
    if (mb_strlen($text) <= $length) {
        return $text;
    }
    return mb_substr($text, 0, $length) . $suffix;
}

/**
 * Generate a slug from a string.
 * Pure-PHP implementation — no intl extension required.
 */
function slugify(string $text): string
{
    // Transliteration map: covers German umlauts, common Latin accents, and typographic chars
    $map = [
        // German
        'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue', 'ß' => 'ss',
        // French / Spanish / Italian / Portuguese
        'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'å' => 'a', 'æ' => 'ae',
        'À' => 'a', 'Á' => 'a', 'Â' => 'a', 'Ã' => 'a', 'Å' => 'a', 'Æ' => 'ae',
        'ç' => 'c', 'Ç' => 'c',
        'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
        'È' => 'e', 'É' => 'e', 'Ê' => 'e', 'Ë' => 'e',
        'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
        'Ì' => 'i', 'Í' => 'i', 'Î' => 'i', 'Ï' => 'i',
        'ñ' => 'n', 'Ñ' => 'n',
        'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ø' => 'o',
        'Ò' => 'o', 'Ó' => 'o', 'Ô' => 'o', 'Õ' => 'o', 'Ø' => 'o',
        'ù' => 'u', 'ú' => 'u', 'û' => 'u',
        'Ù' => 'u', 'Ú' => 'u', 'Û' => 'u',
        'ý' => 'y', 'ÿ' => 'y', 'Ý' => 'y',
        // Central / Eastern European
        'č' => 'c', 'ć' => 'c', 'Č' => 'c', 'Ć' => 'c',
        'đ' => 'd', 'Đ' => 'd',
        'š' => 's', 'Š' => 's',
        'ž' => 'z', 'Ž' => 'z',
        'ř' => 'r', 'Ř' => 'r',
        'ů' => 'u', 'Ů' => 'u',
        'ł' => 'l', 'Ł' => 'l',
        // Typographic
        '&'  => 'and', '@' => 'at',
        '€'  => 'euro', '£' => 'pound', '$' => 'dollar',
        '–'  => '-', '—' => '-', '\'' => '', '"' => '', '"' => '', '"' => '',
    ];

    $text = strtr($text, $map);
    $text = strtolower($text);
    $text = preg_replace('/[^a-z0-9]+/', '-', $text);
    return trim($text, '-');
}

/**
 * Check if the current user is authenticated.
 */
function auth_check(): bool
{
    if (!app()->hasService('auth')) {
        return false;
    }
    return app()->service('auth')->check();
}

/**
 * Format a date string using locale-aware defaults.
 *
 * When no explicit $format is given, the format is chosen based on the
 * CMS system language: German uses "DD.MM.YYYY", English uses "MM/DD/YYYY".
 */
function format_date(string $date, ?string $format = null): string
{
    if ($format === null) {
        $locale = setting('locale', setting('system_language', 'de'));
        $format = ($locale === 'en') ? 'm/d/Y' : 'd.m.Y';
    }
    return date($format, strtotime($date));
}

/**
 * Format a date+time string using locale-aware defaults.
 */
function format_datetime(string $date, ?string $format = null): string
{
    if ($format === null) {
        $locale = setting('locale', setting('system_language', 'de'));
        $format = ($locale === 'en') ? 'm/d/Y h:i A' : 'd.m.Y H:i';
    }
    return date($format, strtotime($date));
}

/**
 * Format a number using locale-aware separators.
 *
 * German: 1.234,56  |  English: 1,234.56
 */
function format_number(float $number, int $decimals = 0): string
{
    $locale = setting('locale', setting('system_language', 'de'));
    if ($locale === 'en') {
        return number_format($number, $decimals, '.', ',');
    }
    return number_format($number, $decimals, ',', '.');
}

/**
 * Return the public URL for the thumbnail of an uploaded image.
 *
 * Thumbnail convention: {subdir}/thumbs/{filename}
 * Example: "gallery/img_abc.jpg" -> "/uploads/gallery/thumbs/img_abc.jpg"
 *
 * If no thumbnail exists (e.g. images uploaded before Phase 3c), the URL
 * of the original image is returned as a transparent fallback.
 *
 * @param string|null $relativePath Relative path as stored in the DB
 *                                  (e.g. "blog/blog_abc.jpg")
 * @return string                   Absolute URL ready for use in <img src="">
 */
function thumb_url(?string $relativePath): string
{
    if (empty($relativePath)) {
        return '';
    }

    $thumbPath = \App\Services\UploadService::thumbPath($relativePath);

    if ($thumbPath !== null) {
        $uploadsDir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/uploads/';
        $thumbFull  = $uploadsDir . $thumbPath;

        if (is_file($thumbFull)) {
            return '/uploads/' . $thumbPath;
        }
    }

    // Fallback: return the original image URL
    return '/uploads/' . ltrim($relativePath, '/');
}

/**
 * Return the public URL for the WebP companion of an uploaded image (or thumbnail).
 *
 * WebP convention: same path as the original/thumb, but with .webp extension.
 * Example: "gallery/img_abc.jpg"        → "/uploads/gallery/img_abc.webp"
 *          "gallery/thumbs/img_abc.jpg" → "/uploads/gallery/thumbs/img_abc.webp"
 *
 * Returns null if:
 * – $relativePath is empty
 * – no WebP file exists on disk (e.g. legacy upload without WebP, or GD unavailable)
 *
 * This allows callers to gracefully skip the <source> element when WebP is not present.
 *
 * @param string|null $relativePath Relative path as stored in the DB, or a thumb path
 * @return string|null              Absolute WebP URL, or null when unavailable
 */
function webp_url(?string $relativePath): ?string
{
    if (empty($relativePath)) {
        return null;
    }

    $webpPath = \App\Services\UploadService::webpPath($relativePath);
    if ($webpPath === null) {
        return null;
    }

    $uploadsDir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/uploads/';
    $webpFull   = $uploadsDir . $webpPath;

    if (is_file($webpFull)) {
        return '/uploads/' . $webpPath;
    }

    return null;
}

/**
 * Check whether an AVIF companion exists on disk and return its public URL.
 *
 * AVIF convention: same path as the original/thumb, but with .avif extension.
 * Example: "gallery/img_abc.jpg"        → "/uploads/gallery/img_abc.avif"
 *          "gallery/thumbs/img_abc.jpg" → "/uploads/gallery/thumbs/img_abc.avif"
 *
 * Returns null if:
 * – $relativePath is empty
 * – no AVIF file exists on disk (e.g. legacy upload, or GD without AVIF support)
 *
 * @param string|null $relativePath Relative path as stored in the DB, or a thumb path
 * @return string|null              Absolute AVIF URL, or null when unavailable
 */
function avif_url(?string $relativePath): ?string
{
    if (empty($relativePath)) {
        return null;
    }

    $avifPath = \App\Services\UploadService::avifPath($relativePath);
    if ($avifPath === null) {
        return null;
    }

    $uploadsDir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/uploads/';
    $avifFull   = $uploadsDir . $avifPath;

    if (is_file($avifFull)) {
        return '/uploads/' . $avifPath;
    }

    return null;
}

/**
 * Resolve the best available logo URL for a given placement ('header' or 'footer').
 *
 * Resolution order:
 *   1. logo_{placement} setting  (e.g. setting('logo_header'))
 *   2. logo_header setting       (footer falls back to header logo)
 *   3. logo setting              (legacy single-logo)
 *   4. null                      (caller uses theme asset or text fallback)
 *
 * @param  string      $placement  'header' | 'footer'
 * @return string|null  Absolute URL ready for use in <img src>, or null.
 */
function branding_logo_url(string $placement = 'header'): ?string
{
    $specific = setting('logo_' . $placement);
    if ($specific !== '') {
        return '/uploads/' . ltrim($specific, '/');
    }

    // Footer falls back to header logo
    if ($placement === 'footer') {
        $header = setting('logo_header');
        if ($header !== '') {
            return '/uploads/' . ltrim($header, '/');
        }
    }

    return null;
}

/**
 * Resolve the logo display mode for header or footer.
 *
 * Returns the setting value ('original' | 'filtered').
 * Falls back to 'original' when the setting is not set or empty.
 *
 * @param  string $placement  'header' | 'footer'
 * @return string             'original' | 'filtered'
 */
function branding_logo_mode(string $placement = 'header'): string
{
    $mode = setting('logo_' . $placement . '_mode', 'original');
    return in_array($mode, ['original', 'filtered'], true) ? $mode : 'original';
}

/**
 * Render the <img> tag for a logo with the correct mode CSS class.
 *
 * The mode class (logo-mode-original / logo-mode-filtered) lets each theme
 * define the visual interpretation of each mode via CSS.
 *
 * @param  string      $placement  'header' | 'footer'
 * @param  string|null $themeFallbackUrl   URL of the bundled theme logo (e.g. from theme_asset())
 * @param  string      $cssClass   Additional CSS class for the <img>
 * @return string      Ready-to-echo HTML string (not double-escaped)
 */
function branding_logo_img(string $placement, ?string $themeFallbackUrl = null, string $cssClass = ''): string
{
    $url  = branding_logo_url($placement);
    $mode = branding_logo_mode($placement);
    $alt  = setting('logo_alt') ?: setting('site_name', 'Logo');

    $modeClass = 'logo-mode-' . $mode;
    $classes   = trim($modeClass . ($cssClass !== '' ? ' ' . $cssClass : ''));
    $classAttr = ' class="' . e($classes) . '"';

    if ($url !== null) {
        return '<img src="' . e($url) . '" alt="' . e($alt) . '"' . $classAttr . '>';
    }

    if ($themeFallbackUrl !== null) {
        return '<img src="' . e($themeFallbackUrl) . '" alt="' . e($alt) . '"' . $classAttr . '>';
    }

    // Text-only fallback when no logo is available at all
    return '<span class="logo-text-fallback">' . e(setting('site_name', 'CMS')) . '</span>';
}

/**
 * Returns true when the breadcrumb navigation is enabled via CMS settings.
 * Default is '1' (enabled) for backward compatibility with existing installations.
 */
function breadcrumbs_enabled(): bool
{
    return setting('breadcrumbs_enabled', '1') === '1';
}

/**
 * Returns true when the consent banner is enabled via CMS settings.
 */
function consent_enabled(): bool
{
    return setting('consent_enabled', '0') === '1';
}

/**
 * Render the consent banner HTML (inline scripts + HTML fragment).
 *
 * Returns an empty string when consent is disabled.
 * Designed to be included just before </body>.
 */
function consent_banner(): string
{
    if (!consent_enabled()) {
        return '';
    }

    $title         = e(setting('consent_title',          'Datenschutz & Cookies'));
    $text          = e(setting('consent_text',           ''));
    $acceptLabel   = e(setting('consent_accept_label',   'Alle akzeptieren'));
    $rejectLabel   = e(setting('consent_reject_label',   'Nur notwendige'));
    $settingsLabel = e(setting('consent_settings_label', 'Einstellungen'));
    $saveLabel     = e(setting('consent_save_label',     'Auswahl speichern'));
    $privacyUrl    = e(setting('consent_privacy_url',    '/datenschutz'));
    $privacyLabel  = e(setting('consent_privacy_label',  'Datenschutzerklärung'));
    $cookieName    = e(setting('consent_cookie_name',    'iw_consent'));
    $cookieDays    = (int) setting('consent_cookie_days', '365');
    $showLogo      = setting('consent_show_logo', '0') === '1';

    $catNecLabel = e(setting('consent_cat_necessary_label', 'Technisch notwendig'));
    $catNecDesc  = e(setting('consent_cat_necessary_desc',  ''));
    $catFunLabel = e(setting('consent_cat_functional_label','Funktional'));
    $catFunDesc  = e(setting('consent_cat_functional_desc', ''));

    $logoHtml = '';
    if ($showLogo) {
        $logoUrl = branding_logo_url('header');
        if ($logoUrl !== null) {
            $logoAlt  = e(setting('logo_alt') ?: setting('site_name', 'Logo'));
            $logoHtml = '<div class="iw-consent-logo"><img src="' . e($logoUrl) . '" alt="' . $logoAlt . '"></div>';
        }
    }

    return <<<HTML
<div id="iw-consent-banner" class="iw-consent-banner" role="dialog" aria-modal="true"
     aria-label="{$title}" style="display:none">
  <div class="iw-consent-backdrop"></div>
  <div class="iw-consent-box">
    {$logoHtml}
    <h2 class="iw-consent-title">{$title}</h2>
    <p class="iw-consent-text">{$text}</p>

    <div class="iw-consent-categories" id="iw-consent-details" style="display:none">
      <div class="iw-consent-category">
        <label class="iw-consent-category-label">
          <input type="checkbox" checked disabled>
          <span>{$catNecLabel}</span>
          <span class="iw-consent-badge">Immer aktiv</span>
        </label>
        <p class="iw-consent-category-desc">{$catNecDesc}</p>
      </div>
      <div class="iw-consent-category">
        <label class="iw-consent-category-label">
          <input type="checkbox" id="iw-consent-functional" name="functional">
          <span>{$catFunLabel}</span>
        </label>
        <p class="iw-consent-category-desc">{$catFunDesc}</p>
      </div>
    </div>

    <div class="iw-consent-actions">
      <button class="iw-consent-btn iw-consent-btn-primary" onclick="iwConsent.acceptAll()">{$acceptLabel}</button>
      <button class="iw-consent-btn iw-consent-btn-secondary" onclick="iwConsent.acceptNecessary()">{$rejectLabel}</button>
      <button class="iw-consent-btn iw-consent-btn-ghost" onclick="iwConsent.toggleDetails()">{$settingsLabel}</button>
    </div>

    <div class="iw-consent-save-row" id="iw-consent-save-row" style="display:none">
      <button class="iw-consent-btn iw-consent-btn-primary" onclick="iwConsent.saveSelection()">{$saveLabel}</button>
    </div>

    <p class="iw-consent-privacy">
      <a href="{$privacyUrl}">{$privacyLabel}</a>
    </p>
  </div>
</div>
<script>
var iwConsent = (function() {
  var COOKIE  = '{$cookieName}';
  var DAYS    = {$cookieDays};
  var banner  = null;
  var details = null;
  var saveRow = null;

  function _setCookie(val) {
    var d = new Date();
    d.setTime(d.getTime() + DAYS * 86400000);
    document.cookie = COOKIE + '=' + encodeURIComponent(val)
      + '; expires=' + d.toUTCString() + '; path=/; SameSite=Lax';
  }

  function _getCookie() {
    var name = COOKIE + '=';
    var ca   = document.cookie.split(';');
    for (var i = 0; i < ca.length; i++) {
      var c = ca[i].trim();
      if (c.indexOf(name) === 0) {
        try { return JSON.parse(decodeURIComponent(c.substring(name.length))); }
        catch(e) { return null; }
      }
    }
    return null;
  }

  function _hide() {
    if (banner) banner.style.display = 'none';
    document.body.classList.remove('iw-consent-open');
  }

  function init() {
    banner  = document.getElementById('iw-consent-banner');
    details = document.getElementById('iw-consent-details');
    saveRow = document.getElementById('iw-consent-save-row');
    if (!banner) return;
    if (!_getCookie()) {
      banner.style.display = '';
      document.body.classList.add('iw-consent-open');
    }
  }

  function open() {
    if (!banner) {
      banner  = document.getElementById('iw-consent-banner');
      details = document.getElementById('iw-consent-details');
      saveRow = document.getElementById('iw-consent-save-row');
    }
    if (banner) {
      banner.style.display = '';
      document.body.classList.add('iw-consent-open');
    }
  }

  function acceptAll() {
    _setCookie(JSON.stringify({necessary: true, functional: true}));
    _hide();
    document.dispatchEvent(new CustomEvent('iwConsentUpdate', {detail: {necessary:true,functional:true}}));
  }

  function acceptNecessary() {
    _setCookie(JSON.stringify({necessary: true, functional: false}));
    _hide();
    document.dispatchEvent(new CustomEvent('iwConsentUpdate', {detail: {necessary:true,functional:false}}));
  }

  function saveSelection() {
    var fn = document.getElementById('iw-consent-functional');
    var functional = fn ? fn.checked : false;
    _setCookie(JSON.stringify({necessary: true, functional: functional}));
    _hide();
    document.dispatchEvent(new CustomEvent('iwConsentUpdate', {detail: {necessary:true,functional:functional}}));
  }

  function toggleDetails() {
    if (!details) return;
    var visible = details.style.display !== 'none';
    details.style.display = visible ? 'none' : '';
    if (saveRow) saveRow.style.display = visible ? 'none' : '';
  }

  function getCategory(name) {
    var c = _getCookie();
    return c ? !!c[name] : false;
  }

  document.addEventListener('DOMContentLoaded', init);

  return { init: init, open: open, acceptAll: acceptAll, acceptNecessary: acceptNecessary,
           saveSelection: saveSelection, toggleDetails: toggleDetails, getCategory: getCategory };
}());
</script>
HTML;
}

/**
 * Render a small "Cookie-Einstellungen" reopener link/button.
 * Intended for use in the footer. Returns empty string when consent is disabled
 * or the reopen link is turned off in settings.
 */
function consent_reopen_link(): string
{
    if (!consent_enabled()) {
        return '';
    }
    if (setting('consent_show_reopen', '1') !== '1') {
        return '';
    }
    $label = e(setting('consent_reopen_label', 'Cookie-Einstellungen'));
    return '<button type="button" class="iw-consent-reopen" onclick="iwConsent && iwConsent.open()">'
         . $label . '</button>';
}

/**
 * Render a responsive <picture> element with WebP source + <img> fallback.
 *
 * When a WebP companion exists on disk, the output is:
 *   <picture>
 *     <source srcset="/uploads/…/img.webp" type="image/webp">
 *     <img src="/uploads/…/img.jpg" alt="…" class="…" …>
 *   </picture>
 *
 * When no WebP companion is found (legacy uploads, GD without imagewebp,
 * original already .webp), the output is a plain <img> tag — identical to
 * what was rendered before Phase 3d. This ensures full backwards compatibility.
 *
 * @param string|null $relativePath DB-relative path (e.g. "blog/blog_abc.jpg")
 * @param string      $alt          Alt text for the <img>
 * @param string      $class        CSS class(es) applied to the inner <img>
 * @param bool        $useThumb     When true, the <img src> is the thumbnail URL
 *                                  (via thumb_url()); the WebP source is the WebP
 *                                  companion of that thumbnail. When false, the full
 *                                  original is used.
 * @param array       $extraAttrs   Additional HTML attributes for <img>
 *                                  (e.g. ['loading' => 'lazy', 'data-index' => '0'])
 * @return string                   Ready-to-echo HTML string (not escaped again)
 */
function picture_tag(
    ?string $relativePath,
    string  $alt        = '',
    string  $class      = '',
    bool    $useThumb   = false,
    array   $extraAttrs = []
): string {
    if (empty($relativePath)) {
        return '';
    }

    // Resolve the fallback <img src> (JPEG/PNG/GIF original or thumbnail)
    $imgSrc = $useThumb
        ? thumb_url($relativePath)
        : '/uploads/' . ltrim($relativePath, '/');

    // Resolve modern-format srcsets: use thumb variants when showing thumbs,
    // or the original otherwise. AVIF takes priority over WebP (better compression).
    if ($useThumb) {
        $thumbRel = \App\Services\UploadService::thumbPath($relativePath);
        $webpSrc  = $thumbRel !== null ? webp_url($thumbRel) : webp_url($relativePath);
        $avifSrc  = $thumbRel !== null ? avif_url($thumbRel) : avif_url($relativePath);
    } else {
        $webpSrc = webp_url($relativePath);
        $avifSrc = avif_url($relativePath);
    }

    // Enable native lazy loading by default (can be overridden via extraAttrs)
    if (!isset($extraAttrs['loading'])) {
        $extraAttrs['loading'] = 'lazy';
    }

    // Build extra attribute string for <img>
    $attrStr = '';
    foreach ($extraAttrs as $key => $val) {
        $attrStr .= ' ' . htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8')
                  . '="' . e((string) $val) . '"';
    }

    $classAttr = $class !== '' ? ' class="' . e($class) . '"' : '';
    $altAttr   = ' alt="' . e($alt) . '"';
    $imgTag    = '<img src="' . e($imgSrc) . '"' . $altAttr . $classAttr . $attrStr . '>';

    // Build <picture> when at least one modern format is available
    if ($avifSrc !== null || $webpSrc !== null) {
        $sources = '';
        if ($avifSrc !== null) {
            $sources .= '<source srcset="' . e($avifSrc) . '" type="image/avif">';
        }
        if ($webpSrc !== null) {
            $sources .= '<source srcset="' . e($webpSrc) . '" type="image/webp">';
        }
        return '<picture>' . $sources . $imgTag . '</picture>';
    }

    // No modern format available – plain <img> (backwards-compatible fallback)
    return $imgTag;
}

// ─────────────────────────────────────────────────────────────────────────────
// JSON-LD Structured Data helpers (v3.2.0)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Output a JSON-LD <script> block for structured data.
 *
 * @param string $type   Schema.org type (e.g. 'Organization', 'Article')
 * @param array  $data   Associative array of Schema.org properties
 * @return string        Ready-to-echo <script type="application/ld+json"> block
 */
function json_ld(string $type, array $data): string
{
    $schema = array_merge(['@context' => 'https://schema.org', '@type' => $type], $data);

    $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($json === false) {
        return '';
    }

    return '<script type="application/ld+json">' . $json . '</script>';
}

/**
 * Build WebSite + SearchAction JSON-LD for the homepage.
 *
 * @return string JSON-LD script block
 */
function json_ld_website(): string
{
    $siteName = setting('site_name', '');
    if ($siteName === '') {
        return '';
    }

    return json_ld('WebSite', [
        'name' => $siteName,
        'url'  => base_url('/'),
    ]);
}

/**
 * Build Organization JSON-LD from CMS settings.
 *
 * @return string JSON-LD script block
 */
function json_ld_organization(): string
{
    $siteName = setting('site_name', '');
    if ($siteName === '') {
        return '';
    }

    $org = [
        'name' => $siteName,
        'url'  => base_url('/'),
    ];

    $logoUrl = branding_logo_url('header');
    if ($logoUrl !== null) {
        $org['logo'] = base_url($logoUrl);
    }

    $seoDesc = setting('seo_description', '');
    if ($seoDesc !== '') {
        $org['description'] = $seoDesc;
    }

    return json_ld('Organization', $org);
}

/**
 * Build Article JSON-LD from a blog post array.
 *
 * @param array $post Blog post row (title, content, published_at, author_name, featured_image, slug)
 * @return string     JSON-LD script block
 */
function json_ld_article(array $post): string
{
    $article = [
        'headline'      => $post['title'] ?? '',
        'url'           => base_url('/blog/' . ($post['slug'] ?? '')),
        'datePublished' => !empty($post['published_at']) ? date('c', strtotime($post['published_at'])) : '',
    ];

    if (!empty($post['updated_at'])) {
        $article['dateModified'] = date('c', strtotime($post['updated_at']));
    }

    if (!empty($post['author_name'])) {
        $article['author'] = [
            '@type' => 'Person',
            'name'  => $post['author_name'],
        ];
    }

    if (!empty($post['featured_image'])) {
        $article['image'] = base_url('/uploads/' . ltrim($post['featured_image'], '/'));
    }

    if (!empty($post['excerpt'])) {
        $article['description'] = $post['excerpt'];
    }

    $siteName = setting('site_name', '');
    if ($siteName !== '') {
        $article['publisher'] = [
            '@type' => 'Organization',
            'name'  => $siteName,
        ];
        $logoUrl = branding_logo_url('header');
        if ($logoUrl !== null) {
            $article['publisher']['logo'] = [
                '@type' => 'ImageObject',
                'url'   => base_url($logoUrl),
            ];
        }
    }

    return json_ld('Article', $article);
}

/**
 * Build Event JSON-LD from an event array.
 *
 * @param array $event Event row (title, start_date, start_time, end_date, location, description, slug)
 * @return string      JSON-LD script block
 */
function json_ld_event(array $event): string
{
    $startDate = $event['start_date'] ?? '';
    if (empty($startDate)) {
        return '';
    }

    $startIso = $startDate;
    if (!empty($event['start_time'])) {
        $startIso .= 'T' . $event['start_time'];
    }

    $ev = [
        'name'      => $event['title'] ?? '',
        'startDate' => $startIso,
        'url'       => base_url('/events/' . ($event['slug'] ?? '')),
    ];

    if (!empty($event['end_date'])) {
        $endIso = $event['end_date'];
        if (!empty($event['end_time'])) {
            $endIso .= 'T' . $event['end_time'];
        }
        $ev['endDate'] = $endIso;
    }

    if (!empty($event['location'])) {
        $ev['location'] = [
            '@type'   => 'Place',
            'name'    => $event['location'],
        ];
        if (!empty($event['address'])) {
            $ev['location']['address'] = $event['address'];
        }
    }

    if (!empty($event['description'])) {
        $ev['description'] = strip_tags($event['description']);
    }

    $status = $event['event_status'] ?? 'scheduled';
    $statusMap = [
        'scheduled' => 'https://schema.org/EventScheduled',
        'cancelled' => 'https://schema.org/EventCancelled',
        'postponed' => 'https://schema.org/EventPostponed',
    ];
    if (isset($statusMap[$status])) {
        $ev['eventStatus'] = $statusMap[$status];
    }

    if (!empty($event['media_path'])) {
        $ev['image'] = base_url('/uploads/' . ltrim($event['media_path'], '/'));
    }

    $siteName = setting('site_name', '');
    if ($siteName !== '') {
        $ev['organizer'] = [
            '@type' => 'Organization',
            'name'  => $siteName,
            'url'   => base_url('/'),
        ];
    }

    return json_ld('Event', $ev);
}

/**
 * Build BreadcrumbList JSON-LD from an array of breadcrumb items.
 *
 * @param array $items Array of ['label' => '...', 'url' => '...'] items
 * @return string      JSON-LD script block
 */
function json_ld_breadcrumbs(array $items): string
{
    if (count($items) < 2) {
        return '';
    }

    $listItems = [];
    foreach ($items as $pos => $item) {
        $listItem = [
            '@type'    => 'ListItem',
            'position' => $pos + 1,
            'name'     => $item['label'] ?? $item['title'] ?? '',
        ];
        if (!empty($item['url'])) {
            $listItem['item'] = base_url($item['url']);
        }
        $listItems[] = $listItem;
    }

    return json_ld('BreadcrumbList', [
        'itemListElement' => $listItems,
    ]);
}

/**
 * Build LocalBusiness JSON-LD from CMS settings.
 *
 * Outputs a complete LocalBusiness (or subtype) schema using the
 * address, contact, and geo settings from the admin panel.
 *
 * @return string JSON-LD script block (empty string if disabled or no data)
 */
function json_ld_local_business(): string
{
    if (setting('local_business_enabled', '0') !== '1') {
        return '';
    }

    $siteName = setting('site_name', '');
    if ($siteName === '') {
        return '';
    }

    $type = setting('local_business_type', 'LocalBusiness');

    $biz = [
        'name' => $siteName,
        'url'  => base_url('/'),
    ];

    // Logo
    $logoUrl = branding_logo_url('header');
    if ($logoUrl !== null) {
        $biz['image'] = base_url($logoUrl);
    }

    // Description
    $desc = setting('seo_description', '');
    if ($desc !== '') {
        $biz['description'] = $desc;
    }

    // Address
    $street  = setting('local_business_street', '');
    $city    = setting('local_business_city', '');
    $postal  = setting('local_business_postal', '');
    $country = setting('local_business_country', '');

    if ($street !== '' || $city !== '') {
        $address = ['@type' => 'PostalAddress'];
        if ($street !== '')  $address['streetAddress']   = $street;
        if ($city !== '')    $address['addressLocality'] = $city;
        if ($postal !== '')  $address['postalCode']      = $postal;
        if ($country !== '') $address['addressCountry']  = $country;
        $biz['address'] = $address;
    }

    // Contact
    $phone = setting('contact_phone', '');
    $email = setting('contact_email', '');
    if ($phone !== '') $biz['telephone'] = $phone;
    if ($email !== '') $biz['email']     = $email;

    // Geo coordinates
    $lat = setting('local_business_lat', '');
    $lng = setting('local_business_lng', '');
    if ($lat !== '' && $lng !== '') {
        $biz['geo'] = [
            '@type'     => 'GeoCoordinates',
            'latitude'  => (float) $lat,
            'longitude' => (float) $lng,
        ];
    }

    // Opening hours
    $hours = setting('local_business_hours', '');
    if ($hours !== '') {
        // Expects comma-separated OpeningHoursSpecification strings
        // e.g. "Mo-Fr 09:00-17:00, Sa 10:00-14:00"
        $biz['openingHours'] = array_map('trim', explode(',', $hours));
    }

    return json_ld($type, $biz);
}

/**
 * Build FAQPage JSON-LD from an array of question/answer pairs.
 *
 * @param array $faqs Array of ['question' => '...', 'answer' => '...']
 * @return string     JSON-LD script block
 */
function json_ld_faq_page(array $faqs): string
{
    if (empty($faqs) || setting('faq_schema_enabled', '1') !== '1') {
        return '';
    }

    $entities = [];
    foreach ($faqs as $faq) {
        if (empty($faq['question']) || empty($faq['answer'])) {
            continue;
        }
        $entities[] = [
            '@type' => 'Question',
            'name'  => $faq['question'],
            'acceptedAnswer' => [
                '@type' => 'Answer',
                'text'  => strip_tags($faq['answer']),
            ],
        ];
    }

    if (empty($entities)) {
        return '';
    }

    return json_ld('FAQPage', [
        'mainEntity' => $entities,
    ]);
}

// ─────────────────────────────────────────────────────────────────────────────
// Hook helpers (Phase 17) – thin wrappers around App\Core\Hooks
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Register a callback for a named hook.
 *
 * @param string   $name     Hook name
 * @param callable $callback PHP callable
 * @param int      $priority Lower runs first (default: 10)
 */
function add_hook(string $name, callable $callback, int $priority = 10): void
{
    \App\Core\Hooks::add($name, $callback, $priority);
}

/**
 * Fire an action hook – all registered callbacks are called in priority order.
 * Return values are ignored.
 *
 * @param string $name   Hook name
 * @param mixed  ...$args Arguments forwarded to each callback
 */
function run_hook(string $name, mixed ...$args): void
{
    \App\Core\Hooks::run($name, ...$args);
}

/**
 * Fire a filter hook – callbacks receive and must return the value.
 * Returns the (possibly modified) value. If no callbacks, original is returned.
 *
 * @param string $name  Hook name
 * @param mixed  $value The value to filter
 * @param mixed  ...$args Additional read-only context arguments
 * @return mixed
 */
function apply_hook(string $name, mixed $value, mixed ...$args): mixed
{
    return \App\Core\Hooks::filter($name, $value, ...$args);
}

// =========================================================================
// Page Builder – Frontend Rendering (3.2.0)
// =========================================================================

/**
 * Generate a unique HTML ID for a section (anchor links / deep-linking).
 *
 * Rules:
 *  - Custom instance_label → slugified label
 *  - Single-instance first occurrence: type slug without number (e.g. "hero")
 *  - Multi-instance further occurrences: type slug + number (e.g. "video-2")
 */
function section_html_id(array $section, array $allSections): string
{
    $type  = $section['section_type'];
    $label = $section['instance_label'] ?? '';

    // Custom label has priority (slugified)
    if ($label !== '') {
        return slugify($label);
    }

    // Count how many sections of this type come BEFORE this one
    $typeIndex = 0;
    foreach ($allSections as $s) {
        if ((int) $s['id'] === (int) $section['id']) {
            break;
        }
        if ($s['section_type'] === $type && !empty($s['is_active'])) {
            $typeIndex++;
        }
    }

    $slug = str_replace('_', '-', $type);
    return $typeIndex === 0 ? $slug : $slug . '-' . ($typeIndex + 1);
}

/**
 * Render a single page section.
 * Resolution order: Theme override → Default theme → Plugin template.
 */
function render_page_section(array $section, array $allSections = []): string
{
    $type      = $section['section_type'];
    $data      = json_decode($section['content'] ?? '{}', true) ?: [];
    $sectionId = section_html_id($section, $allSections);
    $theme     = app()->service('view')->activeTheme();
    $basePath  = defined('BASE_PATH') ? BASE_PATH : dirname(__DIR__, 2);

    // 1. Theme override
    $themePath = $basePath . '/themes/' . $theme . '/templates/sections/' . $type . '.php';
    if (file_exists($themePath)) {
        return render_section_file($themePath, $data, $section, $sectionId);
    }

    // 2. Default theme template
    if ($theme !== 'default') {
        $defaultPath = $basePath . '/themes/default/templates/sections/' . $type . '.php';
        if (file_exists($defaultPath)) {
            return render_section_file($defaultPath, $data, $section, $sectionId);
        }
    }

    // 3. Plugin-provided template (via frontendTemplatePath())
    $reg = \App\Sections\SectionRegistry::get($type);
    if ($reg) {
        $pluginPath = $reg->frontendTemplatePath();
        if ($pluginPath !== null) {
            $pluginFile = $basePath . '/' . $pluginPath . '/' . $type . '.php';
            if (file_exists($pluginFile)) {
                return render_section_file($pluginFile, $data, $section, $sectionId);
            }
        }
    }

    // No template found for this section type
    return '';
}

/**
 * Render a section template file with the given data.
 */
function render_section_file(string $file, array $data, array $section, string $sectionId): string
{
    ob_start();
    // Forward controller view data (e.g. $latest_posts, $upcoming_events, $gallery_preview_albums)
    // so section templates can access them just like the old inline home.php did.
    $viewData = app()->service('view')->currentRenderData();
    extract($viewData, EXTR_SKIP);
    extract(['data' => $data, 'section' => $section, 'sectionId' => $sectionId], EXTR_SKIP);
    include $file;
    return ob_get_clean();
}

/**
 * Render a reusable "section text color" dropdown for admin section forms.
 */
function render_section_text_color_field(array $data): void
{
    $val = $data['section_text_color'] ?? '';
    ?>
    <div class="form-group" style="margin:0;">
        <label><?= __('admin.section.text_color') ?></label>
        <select name="section_text_color">
            <option value=""><?= __('admin.section.scheme_default') ?></option>
            <option value="black" <?= $val === 'black' ? 'selected' : '' ?>><?= __('admin.section.color_black') ?></option>
            <option value="white" <?= $val === 'white' ? 'selected' : '' ?>><?= __('admin.section.color_white') ?></option>
            <option value="theme" <?= $val === 'theme' ? 'selected' : '' ?>><?= __('admin.section.color_theme') ?></option>
        </select>
    </div>
    <?php
}

/**
 * Collect and render CSS/JS assets for active sections (deduplicated).
 *
 * @param array  $sections Array of page_sections rows
 * @param string $position 'head' for CSS, 'footer' for JS
 */
function render_section_assets(array $sections, string $position = 'head'): string
{
    $loaded  = [];
    $html    = '';
    $version = cms_version();

    foreach ($sections as $section) {
        if (empty($section['is_active'])) {
            continue;
        }
        $reg = \App\Sections\SectionRegistry::get($section['section_type']);
        if (!$reg) {
            continue;
        }

        $assets = ($position === 'head') ? $reg->styles() : $reg->scripts();
        foreach ($assets as $url) {
            if (in_array($url, $loaded, true)) {
                continue;
            }
            $loaded[] = $url;
            if ($position === 'head') {
                $html .= '<link rel="stylesheet" href="' . e($url) . '?v=' . e($version) . '">' . "\n";
            } else {
                $html .= '<script src="' . e($url) . '?v=' . e($version) . '"></script>' . "\n";
            }
        }
    }

    return $html;
}

/**
 * Read a plugin/section setting from iw_plugin_settings.
 */
function plugin_setting(string $pluginSlug, string $key, string $default = ''): string
{
    try {
        $row = app()->db()->fetch(
            "SELECT setting_value FROM iw_plugin_settings WHERE plugin_slug = :slug AND setting_key = :key LIMIT 1",
            ['slug' => $pluginSlug, 'key' => $key]
        );
        return $row ? ($row['setting_value'] ?? $default) : $default;
    } catch (\Throwable $e) {
        return $default;
    }
}


// =============================================================================
// LUCIDE ICON SYSTEM
// =============================================================================
// Self-hosted SVG icons from Lucide (https://lucide.dev). MIT licensed.
// Inline SVG, no external requests, no icon font. Uses currentColor.

/**
 * Render a Lucide icon as inline SVG.
 *
 * @param string $name  Icon name (e.g. 'layout-dashboard', 'file-text')
 * @param int    $size  Icon size in px (default 18)
 * @param string $class Optional CSS class
 * @return string       SVG markup
 */
function icon(string $name, int $size = 18, string $class = ''): string
{
    static $icons = [
        // Navigation
        'layout-dashboard'  => '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
        'mail'              => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
        'server'            => '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
        'send'              => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
        'key'               => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
        'arrow-left'        => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
        'save'              => '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
        'check'             => '<path d="M20 6 9 17l-5-5"/>',
        'file-text'         => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
        'pen-line'          => '<path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/>',
        'image'             => '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
        'calendar'          => '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
        'download'          => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
        'folder-open'       => '<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2"/>',
        'menu'              => '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
        'layout-template'   => '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/>',
        'sun'               => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
        'puzzle'            => '<path d="M15.39 4.39a1 1 0 0 0 .61.93A3 3 0 0 1 18 8a3 3 0 0 1-2 2.68 1 1 0 0 0-.61.93V14a1 1 0 0 1-1 1h-2.39a1 1 0 0 0-.93.61A3 3 0 0 1 8 18a3 3 0 0 1-2.68-2 1 1 0 0 0-.93-.61H2a1 1 0 0 1-1-1v-2.39a1 1 0 0 1 .61-.93A3 3 0 0 0 4 8a3 3 0 0 0-2-2.68A1 1 0 0 1 1.39 4.39V2A1 1 0 0 1 2.39 1h2.22a1 1 0 0 1 .93.61A3 3 0 0 0 8 4a3 3 0 0 0 2.68-2A1 1 0 0 1 11.61 1h2.78a1 1 0 0 1 1 1z"/>',
        'users'             => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
        'settings'          => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
        'check-circle'      => '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
        'shield-check'      => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
        'wrench'            => '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
        'database'          => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
        'hard-drive'        => '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
        'monitor'           => '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
        'upload'            => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
        'plus'              => '<path d="M5 12h14"/><path d="M12 5v14"/>',
        'bell'              => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
        'sliders'           => '<path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
        'minus'             => '<path d="M5 12h14"/>',
        'refresh-cw'        => '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',

        // Page Builder
        'page-builder'      => '<rect x="1" y="1" width="14" height="4" rx="1"/><rect x="1" y="7" width="6" height="8" rx="1"/><rect x="9" y="7" width="6" height="8" rx="1"/>',

        // Status
        'circle-dot'        => '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>',
        'megaphone'         => '<path d="m3 11 18-5v12L3 13v-2z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',

        // UI actions
        'search'            => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
        'inbox'             => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
        'grip-vertical'     => '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
        'external-link'     => '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
        'package'           => '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
        'palette'           => '<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
        'copy'              => '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
        'eye'               => '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
        'trash'             => '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',

        // Web Tools
        'maximize'          => '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>',
        'qr-code'           => '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
        'paintbrush'        => '<path d="M10 2v2"/><path d="M14 2v4"/><path d="M17 2a1 1 0 0 1 1 1v9H6V3a1 1 0 0 1 1-1z"/><path d="M6 12a1 1 0 0 0-1 1v1a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v2.9a2 2 0 1 0 4 0V17a1 1 0 0 1 1-1h2a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1"/>',
        'code'              => '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
        'git-compare'       => '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>',

        // Contact Form Builder + common admin actions
        'pencil'            => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
        'trash-2'           => '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
        'plus-circle'       => '<circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/>',
        'info'              => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
        'activity'          => '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
        'history'           => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
        'arrow-right-left'  => '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',

        // Security / 2FA (B1)
        'shield'            => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',

        // Accessibility (B3)
        'accessibility'     => '<circle cx="16" cy="4" r="1"/><path d="m18 19 1-7-6 1"/><path d="m5 8 3-3 5.5 3-2.36 3.5"/><path d="M4.24 14.5a5 5 0 0 0 6.88 6"/><path d="M13.76 17.5a5 5 0 0 0-6.88-6"/>',
    ];

    $path = $icons[$name] ?? null;
    if (!$path) {
        return '<span style="width:' . $size . 'px;display:inline-block;"></span>';
    }

    $cls = $class ? ' class="' . e($class) . '"' : '';
    return '<svg' . $cls . ' width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' . $path . '</svg>';
}


// =============================================================================
// ADMIN COMPONENT HELPERS
// =============================================================================
// Unified rendering functions for admin UI components.
// All section forms and admin views should use these instead of inline HTML.

/**
 * Render an admin form field (label + input + optional help text).
 *
 * @param string $type    'text'|'textarea'|'select'|'checkbox'|'number'|'url'|'email'
 * @param string $name    Input name attribute
 * @param string $label   Label text
 * @param mixed  $value   Current value
 * @param array  $opts    Options: placeholder, hint, rows, required, options(for select), class, attrs, monospace
 */
function admin_field(string $type, string $name, string $label, $value = '', array $opts = []): void
{
    $hint        = $opts['hint'] ?? '';
    $placeholder = $opts['placeholder'] ?? '';
    $required    = !empty($opts['required']) ? ' required' : '';
    $rows        = (int) ($opts['rows'] ?? 4);
    $class       = $opts['class'] ?? '';
    $monospace    = !empty($opts['monospace']) ? ' style="font-family:var(--font-family-mono);font-size:var(--font-size-small);"' : '';
    $extraAttrs  = $opts['attrs'] ?? '';
    $wrapStyle   = $opts['wrap_style'] ?? '';
    $wrapClass   = 'form-group' . ($wrapStyle ? '' : '');

    echo '<div class="' . $wrapClass . '"' . ($wrapStyle ? ' style="' . $wrapStyle . '"' : '') . '>';
    echo '<label class="form-label">' . $label . '</label>';

    switch ($type) {
        case 'textarea':
            echo '<textarea name="' . e($name) . '" class="form-control ' . e($class) . '" rows="' . $rows . '" placeholder="' . e($placeholder) . '"' . $required . $monospace . '>' . e((string) $value) . '</textarea>';
            break;

        case 'select':
            echo '<select name="' . e($name) . '" class="form-control ' . e($class) . '"' . $required . '>';
            foreach (($opts['options'] ?? []) as $optVal => $optLabel) {
                $sel = ((string) $value === (string) $optVal) ? ' selected' : '';
                echo '<option value="' . e((string) $optVal) . '"' . $sel . '>' . $optLabel . '</option>';
            }
            echo '</select>';
            break;

        case 'checkbox':
            echo '<label class="form-check-label">';
            echo '<input type="hidden" name="' . e($name) . '" value="0">';
            $chk = $value ? ' checked' : '';
            echo '<input type="checkbox" name="' . e($name) . '" value="1"' . $chk . '> ';
            echo ($opts['checkbox_label'] ?? $label);
            echo '</label>';
            break;

        default: // text, number, url, email
            echo '<input type="' . e($type) . '" name="' . e($name) . '" class="form-control ' . e($class) . '" value="' . e((string) $value) . '" placeholder="' . e($placeholder) . '"' . $required . ' ' . $extraAttrs . '>';
    }

    if ($hint !== '') {
        echo '<small class="form-hint">' . $hint . '</small>';
    }

    echo '</div>';
}

/**
 * Render a section presentation config header with separator.
 */
function admin_section_separator(string $title = '', string $hint = ''): void
{
    if ($title === '') {
        $title = __('admin.section.presentation');
    }
    if ($hint === '') {
        $hint = __('admin.section.presentation_hint');
    }
    echo '<hr class="admin-section-hr">';
    echo '<h5 class="admin-section-heading">' . $title;
    if ($hint) {
        echo ' <small class="admin-section-heading-hint">' . $hint . '</small>';
    }
    echo '</h5>';
}

/**
 * Open a presentation-style grid for section config dropdowns.
 *
 * @param int $cols Number of columns (2, 3, 4, or 5)
 */
function admin_field_grid_open(int $cols = 3): void
{
    echo '<div class="admin-field-grid admin-field-grid--' . $cols . 'col">';
}

/**
 * Close a field grid.
 */
function admin_field_grid_close(): void
{
    echo '</div>';
}

/**
 * Render a media picker field (unified, single pattern).
 *
 * @param string $name        Input name
 * @param string $value       Current file path
 * @param string $label       Label text
 * @param string $type        'image' or 'video'
 * @param string $placeholder Placeholder text
 */
function admin_media_picker(string $name, string $value = '', string $label = '', string $type = 'image', string $placeholder = ''): void
{
    if ($placeholder === '') {
        $placeholder = $type === 'video'
            ? __('admin.section.hero_ph_video')
            : __('admin.section.hero_ph_image');
    }
    $dataType = $type === 'video' ? 'video' : 'image';
    ?>
    <div class="form-group">
        <?php if ($label): ?>
        <label class="form-label"><?= $label ?></label>
        <?php endif; ?>
        <div class="section-media-field-wrap" data-type="<?= $dataType ?>">
            <input type="text" name="<?= e($name) ?>" class="form-control section-media-input" value="<?= e($value) ?>" placeholder="<?= e($placeholder) ?>">
            <button type="button" class="btn btn-secondary btn-sm section-media-browse" data-media-type="<?= $dataType ?>"><?= __('admin.media.choose') ?></button>
            <button type="button" class="btn btn-xs section-media-remove" title="<?= __('admin.media.choose') ?>">&times;</button>
            <div class="section-media-preview" style="<?= ($type === 'image' && $value) ? '' : 'display:none;' ?>">
                <img src="<?= e($value) ?>" alt="" loading="lazy">
            </div>
        </div>
    </div>
    <?php
}

/**
 * Render a repeatable item card wrapper (for features, cards, team members etc.).
 *
 * @param int    $index       Item index (0-based)
 * @param string $labelFormat Label with :num placeholder
 * @param string $removeLabel Remove button text
 */
function admin_item_card_open(int $index, string $labelFormat = '', string $removeLabel = ''): void
{
    if ($removeLabel === '') {
        $removeLabel = __('admin.section.remove_item');
    }
    $num = $index + 1;
    $label = $labelFormat ? str_replace(':num', (string) $num, $labelFormat) : '#' . $num;
    ?>
    <div class="admin-item-card section-item-sortable">
        <div class="admin-item-card-header">
            <div class="item-move-buttons">
                <button type="button" class="btn btn-xs item-move-up">&#9650;</button>
                <button type="button" class="btn btn-xs item-move-down">&#9660;</button>
            </div>
            <strong class="admin-item-card-label"><?= $label ?></strong>
            <button type="button" class="btn btn-xs btn-danger admin-item-remove">&#10005; <?= $removeLabel ?></button>
        </div>
        <div class="admin-item-card-body">
    <?php
}

/**
 * Close an item card.
 */
function admin_item_card_close(): void
{
    echo '</div></div>';
}

/**
 * Render a standard color scheme dropdown.
 *
 * @param string $value     Current value
 * @param bool   $withNone  Include 'none' option
 */
function admin_color_scheme_field(string $value = '', bool $withNone = false): void
{
    $options = [
        ''        => __('admin.section.scheme_default'),
        'alt'     => __('admin.section.scheme_alt'),
        'primary' => __('admin.section.scheme_primary'),
        'dark'    => __('admin.section.scheme_dark'),
    ];
    if ($withNone) {
        $options['none'] = __('admin.section.scheme_none');
    }
    admin_field('select', 'color_scheme', __('admin.section.color_scheme'), $value, [
        'options'    => $options,
        'wrap_style' => 'margin:0;',
    ]);
}

/**
 * Render a standardized admin page content header.
 *
 * @param string $title     Page title
 * @param string $icon      Lucide icon name (e.g. 'file-text')
 * @param array  $actions   Array of action buttons: [['url'=>..., 'label'=>..., 'class'=>'btn-primary'], ...]
 * @param string $subtitle  Optional subtitle text
 */
function admin_content_header(string $title, string $iconName = '', array $actions = [], string $subtitle = ''): void
{
    echo '<div class="content-header">';
    echo '<div class="content-header-left">';
    if ($iconName !== '') {
        echo '<div class="content-header-icon">' . icon($iconName, 20) . '</div>';
    }
    echo '<div class="content-header-title-group">';
    echo '<h2>' . $title . '</h2>';
    if ($subtitle !== '') {
        echo '<div class="content-header-subtitle">' . e($subtitle) . '</div>';
    }
    echo '</div></div>';
    if (!empty($actions)) {
        echo '<div class="header-actions">';
        foreach ($actions as $action) {
            $class = $action['class'] ?? 'btn-secondary';
            $attrs = $action['attrs'] ?? '';
            if (!empty($action['url'])) {
                echo '<a href="' . e($action['url']) . '" class="btn ' . e($class) . '"' . ($attrs ? ' ' . $attrs : '') . '>' . ($action['label'] ?? '') . '</a>';
            } else {
                // Span-based button (e.g. disabled)
                echo '<span class="btn ' . e($class) . '"' . ($attrs ? ' ' . $attrs : '') . '>' . ($action['label'] ?? '') . '</span>';
            }
        }
        echo '</div>';
    }
    echo '</div>';
}

/**
 * Render a standardized empty state block (for use inside tables or standalone).
 *
 * @param string $message   Main message text
 * @param string $icon      Lucide icon name
 * @param string $ctaUrl    Optional CTA link URL
 * @param string $ctaLabel  Optional CTA link label
 * @param bool   $tableRow  If true, wraps in <tr><td> for table usage
 * @param int    $colspan   Colspan for table row
 */
function admin_empty_state(string $message, string $iconName = 'inbox', string $ctaUrl = '', string $ctaLabel = '', bool $tableRow = false, int $colspan = 1): void
{
    $html = '<div class="admin-empty-state">';
    $html .= '<div class="admin-empty-state-icon">' . icon($iconName, 28) . '</div>';
    $html .= '<div class="admin-empty-state-title">' . e($message) . '</div>';
    if ($ctaUrl !== '' && $ctaLabel !== '') {
        $html .= '<div class="admin-empty-state-text"><a href="' . e($ctaUrl) . '">' . e($ctaLabel) . '</a></div>';
    }
    $html .= '</div>';

    if ($tableRow) {
        echo '<tr><td colspan="' . $colspan . '">' . $html . '</td></tr>';
    } else {
        echo $html;
    }
}


// =============================================================================
// NAVIGATION RENDERING
// =============================================================================

/**
 * Render navigation items as nested HTML list.
 * Supports up to 3 levels (parent -> child -> grandchild).
 *
 * @param array  $items      Tree-structured menu items (from Menu::getItemsTree())
 * @param string $currentUri Current page URI for active state detection
 * @param int    $depth      Current nesting depth (0 = top level)
 * @return string HTML output
 */
function render_nav_items(array $items, string $currentUri = '', int $depth = 0): string
{
    // Max depth: 3 levels (0, 1, 2)
    if ($depth > 2) {
        return '';
    }

    $html = '';

    foreach ($items as $item) {
        $label       = e($item['label'] ?? '');
        $url         = e($item['url'] ?? '#');
        $target      = !empty($item['target']) ? ' target="' . e($item['target']) . '"' : '';
        $hasChildren = !empty($item['children']);
        $isActive    = ($currentUri !== '' && $currentUri === ($item['url'] ?? ''));

        // Build CSS classes for <li>
        $liClasses = ['nav-item'];
        if ($depth === 1) {
            $liClasses[] = 'nav-item--sub';
        } elseif ($depth === 2) {
            $liClasses[] = 'nav-item--sub-2';
        }
        if ($hasChildren) {
            $liClasses[] = 'has-children';
        }

        // Build CSS classes for <a>
        $aClasses = $isActive ? ' class="active"' : '';

        // Build ARIA attributes for <a>
        $aAria = '';
        if ($hasChildren) {
            $aAria = ' aria-haspopup="true" aria-expanded="false"';
        }

        // Determine chevron type: top-level uses nav-chevron, level 2+ uses nav-flyout-chevron
        $chevronHtml = '';
        if ($hasChildren) {
            if ($depth === 0) {
                $chevronHtml = ' <span class="nav-chevron"></span>';
            } else {
                $chevronHtml = ' <span class="nav-flyout-chevron"></span>';
            }
        }

        $html .= '<li class="' . implode(' ', $liClasses) . '" role="none">' . "\n";
        $html .= '    <a href="' . $url . '" role="menuitem"' . $target . $aAria . $aClasses . '>' . $label . $chevronHtml . '</a>' . "\n";

        // Render children submenu
        if ($hasChildren) {
            $subClasses = 'sub-menu';
            if ($depth + 1 === 2) {
                $subClasses .= ' sub-menu--flyout';
            }

            $html .= '    <ul class="' . $subClasses . '" role="menu" aria-label="' . $label . '">' . "\n";
            $html .= render_nav_items($item['children'], $currentUri, $depth + 1);
            $html .= '    </ul>' . "\n";
        }

        $html .= '</li>' . "\n";
    }

    return $html;
}

// ─────────────────────────────────────────────────────────────────────────────
// Inline SVG icon helper (Lucide icon subset)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Render an icon value as HTML. Supports Lucide icon names (rendered as inline
 * SVG), emoji characters, and arbitrary text (rendered as-is).
 */
function render_icon(string $icon, int $size = 24, string $class = ''): string
{
    $icon = trim($icon);
    if ($icon === '') return '';

    // 110 Lucide icons (v1.7.0) – sourced from unpkg.com/lucide-static
    // Categories: General, Navigation, Communication, Business, People, Media,
    //             Technology, Location, Time, Nature, Social, Data, Transport
    static $lucide = [
        // — General / UI —
        'home'           => '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
        'menu'           => '<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>',
        'search'         => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
        'settings'       => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
        'sliders'        => '<path d="M10 8h4"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M17 16h4"/><path d="M19 12V3"/><path d="M19 21v-5"/><path d="M3 14h4"/><path d="M5 10V3"/><path d="M5 21v-7"/>',
        'x'              => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
        'plus'           => '<path d="M5 12h14"/><path d="M12 5v14"/>',
        'minus'          => '<path d="M5 12h14"/>',
        'check'          => '<path d="M20 6 9 17l-5-5"/>',
        'check-circle'   => '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
        'x-circle'       => '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
        'info'           => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
        'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
        'help-circle'    => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
        'eye'            => '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
        'pencil'         => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
        'trash-2'        => '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
        'list'           => '<path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/>',
        'download'       => '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>',
        'upload'         => '<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>',
        'save'           => '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
        'printer'        => '<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
        'clipboard'      => '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
        'archive'        => '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
        'share-2'        => '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
        'external-link'  => '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
        'link'           => '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
        'layers'         => '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/>',
        'scissors'       => '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>',
        'ruler'          => '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>',
        // — Navigation / Arrows —
        'chevron-down'   => '<path d="m6 9 6 6 6-6"/>',
        'chevron-right'  => '<path d="m9 18 6-6-6-6"/>',
        'arrow-up'       => '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
        'arrow-right'    => '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
        'arrow-right-left' => '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
        'trending-up'    => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
        'trending-down'  => '<path d="M16 17h6v-6"/><path d="m22 17-8.5-8.5-5 5L2 7"/>',
        'compass'        => '<circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/>',
        'navigation'     => '<polygon points="3 11 22 2 13 21 11 13 3 11"/>',
        // — Communication —
        'mail'           => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
        'phone'          => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
        'bell'           => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
        'send'           => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
        'at-sign'        => '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>',
        'message-circle' => '<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>',
        'message-square' => '<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>',
        'megaphone'      => '<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/><path d="M8 6v8"/>',
        // — Business / Commerce —
        'briefcase'      => '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>',
        'building-2'     => '<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
        'store'          => '<path d="M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5"/><path d="M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244"/><path d="M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05"/>',
        'shopping-cart'  => '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
        'credit-card'    => '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
        'dollar-sign'    => '<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
        'tag'            => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
        'percent'        => '<line x1="19" x2="5" y1="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>',
        'badge-check'    => '<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/>',
        // — People —
        'user'           => '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
        'users'          => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
        // — Media / Content —
        'image'          => '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
        'camera'         => '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>',
        'video'          => '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
        'music'          => '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
        'film'           => '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>',
        'play'           => '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>',
        'pause'          => '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>',
        'volume-2'       => '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/><path d="M16 9a5 5 0 0 1 0 6"/><path d="M19.364 18.364a9 9 0 0 0 0-12.728"/>',
        'mic'            => '<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>',
        'headphones'     => '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
        'book-open'      => '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
        'newspaper'      => '<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>',
        'file-text'      => '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
        'folder'         => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
        'type'           => '<path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/>',
        'pen-tool'       => '<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z"/><path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18"/><path d="m2.3 2.3 7.286 7.286"/><circle cx="11" cy="11" r="2"/>',
        // — Technology —
        'code'           => '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
        'terminal'       => '<path d="M12 19h8"/><path d="m4 17 6-6-6-6"/>',
        'monitor'        => '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
        'laptop'         => '<path d="M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z"/><path d="M20.054 15.987H3.946"/>',
        'tablet'         => '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>',
        'smartphone'     => '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
        'server'         => '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
        'database'       => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
        'hard-drive'     => '<path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/>',
        'cpu'            => '<path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/>',
        'wifi'           => '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>',
        'cloud'          => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
        'globe'          => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
        'lock'           => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
        'key'            => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
        'shield'         => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
        // — Location —
        'map-pin'        => '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
        'map'            => '<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"/><path d="M15 5.764v15"/><path d="M9 3.236v15"/>',
        // — Time —
        'clock'          => '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
        'history'        => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
        'activity'       => '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
        'calendar'       => '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
        'timer'          => '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>',
        // — Nature / Weather —
        'sun'            => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
        'moon'           => '<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>',
        'leaf'           => '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/>',
        'flame'          => '<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>',
        // — Social / Feedback —
        'heart'          => '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
        'star'           => '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
        'thumbs-up'      => '<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/>',
        'thumbs-down'    => '<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/><path d="M17 14V2"/>',
        'bookmark'       => '<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"/>',
        'flag'           => '<path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"/>',
        // — Data / Charts —
        'bar-chart'      => '<path d="M5 21v-6"/><path d="M12 21V9"/><path d="M19 21V3"/>',
        'pie-chart'      => '<path d="M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z"/><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/>',
        'activity'       => '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
        'hash'           => '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
        // — Objects / Misc —
        'lightbulb'      => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
        'target'         => '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
        'zap'            => '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
        'rocket'         => '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
        'award'          => '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
        'trophy'         => '<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/><path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/><path d="M18 9h1.5a1 1 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/><path d="M6 9H4.5a1 1 0 0 1 0-5H6"/>',
        'gift'           => '<path d="M12 7v14"/><path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8"/><path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5"/><rect x="3" y="7" width="18" height="4" rx="1"/>',
        'sparkles'       => '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>',
        'wand-2'         => '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>',
        'puzzle'         => '<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/>',
        'wrench'         => '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>',
        'hammer'         => '<path d="m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9"/><path d="m18 15 4-4"/><path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5"/>',
        'palette'        => '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
        'coffee'         => '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>',
        'cake'           => '<path d="M20 21v-8a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8"/><path d="M4 16s.5-1 2-1 2.5 2 4 2 2.5-2 4-2 2.5 2 4 2 2-1 2-1"/><path d="M2 21h20"/><path d="M7 8v3"/><path d="M12 8v3"/><path d="M17 8v3"/><path d="M7 4h.01"/><path d="M12 4h.01"/><path d="M17 4h.01"/>',
        'box'            => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
        'package'        => '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/>',
        'layout'         => '<rect width="18" height="18" x="3" y="3" rx="2"/><line x1="3" x2="21" y1="9" y2="9"/><line x1="9" x2="9" y1="9" y2="21"/>',
        'utensils'       => '<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>',
        // — Transport —
        'car'            => '<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/>',
        'truck'          => '<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
        'plane'          => '<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
        // — Education / Health —
        'graduation-cap' => '<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>',
        'hospital'       => '<path d="M12 7v4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M14 9h-4"/><path d="M18 11h2a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h2"/><path d="M18 21V5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16"/>',
    ];

    if (isset($lucide[$icon])) {
        $cls = $class ? ' class="' . e($class) . '"' : '';
        return '<svg xmlns="http://www.w3.org/2000/svg" width="' . $size . '" height="' . $size . '"'
             . $cls . ' viewBox="0 0 24 24" fill="none" stroke="currentColor"'
             . ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
             . $lucide[$icon] . '</svg>';
    }

    if (mb_strlen($icon) <= 3) {
        return '<span class="icon-emoji">' . $icon . '</span>';
    }

    return e($icon);
}

/**
 * Return the Lucide icon catalogue with category grouping.
 * Used by the admin icon picker to render the grid.
 */
function get_lucide_icon_catalogue(): array
{
    $categories = [
        'Allgemein'      => ['home','menu','search','settings','sliders','x','plus','minus','check','check-circle','x-circle','info','alert-triangle','help-circle','eye','pencil','trash-2','list','download','upload','save','printer','clipboard','archive','share-2','external-link','link','layers','scissors','ruler'],
        'Navigation'     => ['chevron-down','chevron-right','arrow-up','arrow-right','arrow-right-left','trending-up','trending-down','compass','navigation'],
        'Kommunikation'  => ['mail','phone','bell','send','at-sign','message-circle','message-square','megaphone'],
        'Business'       => ['briefcase','building-2','store','shopping-cart','credit-card','dollar-sign','tag','percent','badge-check'],
        'Personen'       => ['user','users'],
        'Medien'         => ['image','camera','video','music','film','play','pause','volume-2','mic','headphones','book-open','newspaper','file-text','folder','type','pen-tool'],
        'Technologie'    => ['code','terminal','monitor','laptop','tablet','smartphone','server','database','hard-drive','cpu','wifi','cloud','globe','lock','key','shield'],
        'Standort'       => ['map-pin','map'],
        'Zeit'           => ['clock','calendar','timer'],
        'Natur'          => ['sun','moon','leaf','flame'],
        'Feedback'       => ['heart','star','thumbs-up','thumbs-down','bookmark','flag'],
        'Daten'          => ['bar-chart','pie-chart','activity','hash'],
        'Objekte'        => ['lightbulb','target','zap','rocket','award','trophy','gift','sparkles','wand-2','puzzle','wrench','hammer','palette','coffee','cake','box','package','layout','utensils'],
        'Transport'      => ['car','truck','plane'],
        'Bildung'        => ['graduation-cap','hospital'],
    ];

    // Build catalogue with SVG data from render_icon
    $catalogue = [];
    foreach ($categories as $cat => $names) {
        $items = [];
        foreach ($names as $name) {
            $svg = render_icon($name, 24);
            if ($svg && str_starts_with($svg, '<svg')) {
                $items[] = ['name' => $name, 'svg' => $svg];
            }
        }
        if ($items) {
            $catalogue[] = ['category' => $cat, 'icons' => $items];
        }
    }
    return $catalogue;
}

// ─────────────────────────────────────────────────────────────────────────────
// Search helpers
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Highlight search terms in already-escaped text.
 * Input MUST be pre-escaped with e() to prevent XSS.
 * Returns HTML with <mark> tags wrapping matched terms.
 */
function highlight_search(string $escapedText, string $query): string
{
    $query = trim($query);
    if ($query === '' || $escapedText === '') {
        return $escapedText;
    }
    // Escape the query for use in regex, then wrap matches in <mark>
    $pattern = '/' . preg_quote(e($query), '/') . '/iu';
    return preg_replace($pattern, '<mark>$0</mark>', $escapedText) ?? $escapedText;
}

// ─────────────────────────────────────────────────────────────────────────────
// Contact Form Builder helpers (F5 v3.3.0)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Default contact form fields used when no config is saved.
 *
 * @return array<int,array<string,mixed>>
 */
function contact_form_default_fields(): array
{
    return [
        ['id' => 'field_name',    'type' => 'text',     'label' => 'Name',      'placeholder' => '',  'required' => true,  'width' => 'half'],
        ['id' => 'field_email',   'type' => 'email',    'label' => 'E-Mail',    'placeholder' => '',  'required' => true,  'width' => 'half'],
        ['id' => 'field_subject', 'type' => 'text',     'label' => 'Betreff',   'placeholder' => '',  'required' => true,  'width' => 'full'],
        ['id' => 'field_message', 'type' => 'textarea', 'label' => 'Nachricht', 'placeholder' => '…', 'required' => true,  'width' => 'full'],
    ];
}

/**
 * Load contact form field config from settings.
 * Falls back to default 4 fields if not configured.
 *
 * @return array<int,array<string,mixed>>
 */
function contact_form_fields(): array
{
    $raw = function_exists('setting') ? setting('contact_form_fields', '') : '';
    if ($raw === '') {
        return contact_form_default_fields();
    }
    $fields = json_decode($raw, true);
    return (is_array($fields) && !empty($fields)) ? $fields : contact_form_default_fields();
}

/**
 * Render HTML for all contact form fields.
 *
 * Supports all 7 themes via a flexible $classMap override.
 * Half-width fields are automatically grouped in pairs inside a row div.
 *
 * @param array<int,array<string,mixed>> $fields    Field config (from contact_form_fields())
 * @param array<string,string>           $errors    Field-level errors keyed by field ID
 * @param array<string,string>           $old       Previously submitted values keyed by field ID
 * @param array<string,string>           $classMap  CSS class overrides (see defaults below)
 *
 * Default classMap keys:
 *   group          → outer div per field
 *   row            → wrapper div for paired half-width fields
 *   label          → label element class (empty = no class attribute)
 *   input          → text/email/tel input class
 *   textarea       → textarea class
 *   select         → select class
 *   checkbox_wrap  → label wrapper class for checkbox
 *   error_class    → error message paragraph/span class
 *   error_tag      → tag for error message ('p' or 'span')
 *   input_error    → extra class appended to input/textarea/select on error
 *   required_mark  → character/HTML for required indicator (default '*')
 */
function render_contact_form_fields(
    array $fields,
    array $errors   = [],
    array $old      = [],
    array $classMap = []
): string {
    if (empty($fields)) {
        return '';
    }

    $cls = array_merge([
        'group'         => 'contact-form-group',
        'row'           => 'contact-form-row',
        'label'         => 'contact-form-label',
        'input'         => 'contact-form-input',
        'textarea'      => 'contact-form-textarea',
        'select'        => 'contact-form-select',
        'checkbox_wrap' => 'contact-form-checkbox-wrap',
        'error_class'   => 'contact-form-error',
        'error_tag'     => 'p',
        'input_error'   => 'is-error',
        'required_mark' => '*',
    ], $classMap);

    /**
     * Render a single field block.
     *
     * @param array<string,mixed> $f
     */
    $renderField = static function (array $f) use ($cls, $errors, $old): string {
        $id       = $f['id']          ?? '';
        $type     = $f['type']        ?? 'text';
        $label    = $f['label']       ?? '';
        $ph       = $f['placeholder'] ?? '';
        $required = !empty($f['required']);
        $fieldId  = 'cf_field_' . $id;
        $name     = 'cf_' . $id;
        $value    = $old[$id] ?? '';
        $hasError = isset($errors[$id]);
        $errMod   = $hasError ? ' ' . $cls['input_error'] : '';

        $groupClass = $cls['group'];
        $out = '<div class="' . $groupClass . '">';

        // Label (skipped for hidden and checkbox)
        if ($type !== 'hidden' && $type !== 'checkbox') {
            $labelClass = $cls['label'] !== '' ? ' class="' . $cls['label'] . '"' : '';
            $reqMark    = $required
                ? ' <span aria-hidden="true">' . $cls['required_mark'] . '</span>'
                : '';
            $out .= '<label' . $labelClass . ' for="' . $fieldId . '">'
                  . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . $reqMark . '</label>';
        }

        switch ($type) {
            case 'textarea':
                $out .= '<textarea'
                      . ' id="' . $fieldId . '"'
                      . ' name="' . $name . '"'
                      . ' placeholder="' . htmlspecialchars($ph, ENT_QUOTES, 'UTF-8') . '"'
                      . ' class="' . $cls['textarea'] . $errMod . '"'
                      . ($required ? ' required' : '')
                      . '>'
                      . htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
                      . '</textarea>';
                break;

            case 'select':
                $options = array_filter(array_map('trim', explode(',', $f['options'] ?? '')));
                $out .= '<select'
                      . ' id="' . $fieldId . '"'
                      . ' name="' . $name . '"'
                      . ' class="' . $cls['select'] . $errMod . '"'
                      . ($required ? ' required' : '')
                      . '>';
                $phText = $ph ?: '– Bitte wählen –';
                $out .= '<option value="">' . htmlspecialchars($phText, ENT_QUOTES, 'UTF-8') . '</option>';
                foreach ($options as $opt) {
                    $sel  = ($value === $opt) ? ' selected' : '';
                    $out .= '<option value="' . htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') . '"' . $sel . '>'
                          . htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') . '</option>';
                }
                $out .= '</select>';
                break;

            case 'checkbox':
                $checked    = !empty($old[$id]) ? ' checked' : '';
                $wrapClass  = $cls['checkbox_wrap'] !== '' ? ' class="' . $cls['checkbox_wrap'] . '"' : '';
                $reqMark    = $required
                    ? ' <span aria-hidden="true">' . $cls['required_mark'] . '</span>'
                    : '';
                $out .= '<label' . $wrapClass . '>'
                      . '<input type="checkbox"'
                      . ' id="' . $fieldId . '"'
                      . ' name="' . $name . '"'
                      . ' value="1"' . $checked . '>'
                      . ' ' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . $reqMark
                      . '</label>';
                break;

            case 'hidden':
                $out .= '<input type="hidden"'
                      . ' id="' . $fieldId . '"'
                      . ' name="' . $name . '"'
                      . ' value="' . htmlspecialchars($f['value'] ?? '', ENT_QUOTES, 'UTF-8') . '">';
                $out .= '</div>';
                return $out; // no error display for hidden fields

            default: // text, email, tel
                $out .= '<input'
                      . ' type="' . htmlspecialchars($type, ENT_QUOTES, 'UTF-8') . '"'
                      . ' id="' . $fieldId . '"'
                      . ' name="' . $name . '"'
                      . ' value="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '"'
                      . ' placeholder="' . htmlspecialchars($ph, ENT_QUOTES, 'UTF-8') . '"'
                      . ' class="' . $cls['input'] . $errMod . '"'
                      . ($required ? ' required' : '')
                      . '>';
                break;
        }

        // Error message
        if ($hasError) {
            $tag = in_array($cls['error_tag'], ['span', 'p'], true) ? $cls['error_tag'] : 'p';
            $out .= '<' . $tag . ' class="' . $cls['error_class'] . '">'
                  . htmlspecialchars($errors[$id], ENT_QUOTES, 'UTF-8')
                  . '</' . $tag . '>';
        }

        $out .= '</div>';
        return $out;
    };

    $html  = '';
    $count = count($fields);
    $i     = 0;

    while ($i < $count) {
        $field = $fields[$i];
        $width = $field['width'] ?? 'full';

        // Pair consecutive half-width fields into a row
        if ($width === 'half'
            && isset($fields[$i + 1])
            && ($fields[$i + 1]['width'] ?? 'full') === 'half'
        ) {
            $html .= '<div class="' . $cls['row'] . '">';
            $html .= $renderField($field);
            $html .= $renderField($fields[$i + 1]);
            $html .= '</div>';
            $i += 2;
        } else {
            $html .= $renderField($field);
            $i++;
        }
    }

    return $html;
}
