<?php

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

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

/**
 * 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 URL to a theme asset.
 */
function theme_asset(string $path): string
{
    $view = app()->service('view');
    return $view->themeAsset($path);
}

/**
 * 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.
 */
function format_date(string $date, string $format = 'd.m.Y'): string
{
    return date($format, strtotime($date));
}

/**
 * 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;
}

/**
 * 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 before the legacy 'logo' setting
    if ($placement === 'footer') {
        $header = setting('logo_header');
        if ($header !== '') {
            return '/uploads/' . ltrim($header, '/');
        }
    }

    $legacy = setting('logo');
    if ($legacy !== '') {
        return '/uploads/' . ltrim($legacy, '/');
    }

    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 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 the WebP srcset: use the WebP of the thumbnail when showing thumbs,
    // or the WebP of the original otherwise.
    if ($useThumb) {
        $thumbRel = \App\Services\UploadService::thumbPath($relativePath);
        $webpSrc  = $thumbRel !== null ? webp_url($thumbRel) : webp_url($relativePath);
    } else {
        $webpSrc = webp_url($relativePath);
    }

    // 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 . '>';

    if ($webpSrc !== null) {
        return '<picture>'
             . '<source srcset="' . e($webpSrc) . '" type="image/webp">'
             . $imgTag
             . '</picture>';
    }

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

// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
