<?php
declare(strict_types=1);

namespace App\Widgets;

use App\Ads\AdPlacementService;
use App\Settings\SettingsRepository;
use App\Security\HtmlSanitizer;
use App\Support\Translator;
use PDO;
use Throwable;

final class PublicWidgetRenderer
{
    private ?bool $widgetTranslationsAvailable = null;

    /**
     * @param (callable(string): string)|null $renderAd Resolves an advertisement
     *        placement. Injected rather than built here so the widget shares the
     *        request's one ad service -- constructing a second one lost the
     *        creatives repository, the user agent, the locale and the signed-in
     *        state, which silently disabled the homepage placement entirely and
     *        broke the one-advertisement-per-placement guarantee.
     */
    public function __construct(
        private readonly PDO $pdo,
        private readonly HtmlSanitizer $sanitizer = new HtmlSanitizer(),
        private readonly mixed $renderAd = null,
    ) {
    }

    /**
     * Render every enabled widget in one section of the published layout.
     *
     * Returns '' when the section holds nothing, which is what lets the layout
     * fall back to its built-in markup: a region only becomes widget-driven
     * once an administrator actually puts a widget in it.
     *
     * @param callable(string,array<string,mixed>):string $url
     */
    public function renderSection(string $sectionKey, Translator $translator, callable $url): string
    {
        $sectionKey = trim($sectionKey);
        if ($sectionKey === '') {
            return '';
        }

        try {
            // A request renders one active layout; this is a singleton lookup,
            // not a user-visible list ceiling.
            $layout = $this->pdo->query(
                "SELECT id FROM layouts WHERE status='published' ORDER BY updated_at DESC,id DESC LIMIT 1"
            )->fetch(PDO::FETCH_ASSOC);

            if (!is_array($layout)) {
                return '';
            }

            $localized = $this->hasWidgetTranslations();
            $statement = $this->pdo->prepare(
                "SELECT w.id,w.type,w.title,w.config_json,ls.section_key,w.sort_order,"
                . ($localized ? "wt.title AS translated_title,wt.content_json AS translated_content_json " : "NULL AS translated_title,NULL AS translated_content_json ")
                . "FROM layout_sections ls INNER JOIN widgets w ON w.layout_section_id=ls.id "
                . ($localized ? "LEFT JOIN widget_translations wt ON wt.widget_id=w.id AND wt.locale=:widget_locale " : "")
                . "WHERE ls.layout_id=:layout_id AND ls.section_key=:section_key AND w.is_enabled=1 "
                . "ORDER BY w.sort_order ASC,w.id ASC"
            );
            $params = ['layout_id' => (int) $layout['id'], 'section_key' => $sectionKey];
            if ($localized) $params['widget_locale'] = $translator->locale();
            $statement->execute($params);
            $rows = $statement->fetchAll(PDO::FETCH_ASSOC);

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

            $html = '';
            foreach ($rows as $row) {
                $html .= $this->renderWidgetSafely($row, $translator, $url);
            }

            return $html;
        } catch (Throwable $throwable) {
            // A broken widget must never take a page down; the region simply
            // falls back to its built-in markup.
            error_log('[Quaeriva widgets] ' . $throwable::class . ': ' . $throwable->getMessage());
            return '';
        }
    }

    /** @param callable(string,array<string,mixed>):string $url */
    public function renderHomepage(Translator $translator, callable $url): string
    {
        try {
            // A request renders one active layout; this is a singleton lookup,
            // not a user-visible list ceiling.
            $layout = $this->pdo->query(
                "SELECT id FROM layouts WHERE status='published' ORDER BY updated_at DESC,id DESC LIMIT 1"
            )->fetch(PDO::FETCH_ASSOC);
            if (is_array($layout)) {
                $localized = $this->hasWidgetTranslations();
                $statement = $this->pdo->prepare(
                    "SELECT w.id,w.type,w.title,w.config_json,ls.name AS section_name,ls.section_key,ls.sort_order AS section_order,w.sort_order,"
                    . ($localized ? "wt.title AS translated_title,wt.content_json AS translated_content_json " : "NULL AS translated_title,NULL AS translated_content_json ")
                    . "FROM layout_sections ls INNER JOIN widgets w ON w.layout_section_id=ls.id "
                    . ($localized ? "LEFT JOIN widget_translations wt ON wt.widget_id=w.id AND wt.locale=:widget_locale " : "")
                    . "WHERE ls.layout_id=:layout_id AND ls.section_key='homepage_main' AND w.is_enabled=1 "
                    . "ORDER BY ls.sort_order ASC,ls.id ASC,w.sort_order ASC,w.id ASC"
                );
                $params = ['layout_id' => (int) $layout['id']];
                if ($localized) $params['widget_locale'] = $translator->locale();
                $statement->execute($params);
                $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
            } else {
                // Fresh installs get a useful lightweight community overview without
                // requiring an administrator to learn the widget manager first.
                $rows = [
                    ['type' => 'top_contributors', 'title' => '', 'config_json' => '{"limit":5}'],
                    ['type' => 'most_viewed', 'title' => '', 'config_json' => '{"limit":5}'],
                    ['type' => 'unanswered', 'title' => '', 'config_json' => '{"limit":5}'],
                    ['type' => 'active_tags', 'title' => '', 'config_json' => '{"limit":10}'],
                ];
            }
            if ($rows === []) {
                return '';
            }

            $html = '<section class="content-section public-widgets"><div class="container public-widget-stack">';
            foreach ($rows as $row) {
                $html .= $this->renderWidgetSafely($row, $translator, $url);
            }

            return $html . '</div></section>';
        } catch (Throwable $throwable) {
            error_log('[Quaeriva widgets] ' . $throwable::class . ': ' . $throwable->getMessage());
            return '';
        }
    }

    /** @param array<string,mixed> $row @param callable(string,array<string,mixed>):string $url */
    private function renderWidgetSafely(array $row, Translator $translator, callable $url): string
    {
        try {
            return $this->renderWidget($row, $translator, $url);
        } catch (Throwable $throwable) {
            $widgetId = (int) ($row['id'] ?? 0);
            $widgetType = trim((string) ($row['type'] ?? 'unknown')) ?: 'unknown';
            error_log('[Quaeriva widget] id=' . $widgetId . ' type=' . $widgetType . ' ' . $throwable::class . ': ' . $throwable->getMessage());
            return '';
        }
    }

    /** @param array<string,mixed> $row @param callable(string,array<string,mixed>):string $url */
    private function renderWidget(array $row, Translator $translator, callable $url): string
    {
        $type = (string) ($row['type'] ?? '');
        $title = trim((string) ($row['translated_title'] ?? $row['title'] ?? ''));
        $config = json_decode((string) ($row['config_json'] ?? '{}'), true);
        if (!is_array($config)) {
            $config = [];
        }
        $translatedConfig = json_decode((string) ($row['translated_content_json'] ?? '{}'), true);
        if (is_array($translatedConfig)) {
            $config = (new WidgetRegistry($this->sanitizer))->applyTranslation($type, $config, $translatedConfig);
        }
        $heading = $title !== '' ? '<h2 dir="auto">' . $this->e($title) . '</h2>' : '';

        if ($type === 'ad_slot') {
            $placement = trim((string) ($config['placement'] ?? 'homepage'));
            if ($placement !== 'homepage') return '';
            // The widget is pinned to the homepage placement, so placing it in
            // another region would duplicate the homepage advertisement there.
            if (($row['section_key'] ?? 'homepage_main') !== 'homepage_main') return '';
            if (!is_callable($this->renderAd)) {
                return '';
            }
            try {
                return (string) ($this->renderAd)('homepage');
            } catch (Throwable) {
                return '';
            }
        }

        if ($type === 'html_block') {
            try {
                $body = $this->sanitizer->sanitize((string) ($config['content_html'] ?? ''));
            } catch (Throwable) {
                return '';
            }
            if (trim($body) === '') {
                return '';
            }
            return '<article class="public-widget public-widget-html">' . $heading . '<div class="public-widget-copy" dir="auto">' . $body . '</div></article>';
        }

        if ($type === 'latest_questions') {
            $limit = $this->limit($config);
            $categoryId = max(0, (int) ($config['category_id'] ?? 0));
            $where = "q.status='published'";
            $params = [];
            if ($categoryId > 0) {
                $where .= ' AND q.category_id=:category_id';
                $params['category_id'] = $categoryId;
            }
            $statement = $this->pdo->prepare(
                "SELECT q.slug,q.title,q.answers_count,q.votes_count,q.views_count "
                . "FROM questions q WHERE {$where} ORDER BY q.created_at DESC,q.id DESC LIMIT {$limit}"
            );
            $statement->execute($params);
            $items = $statement->fetchAll(PDO::FETCH_ASSOC);
            return $this->renderQuestionListWidget($type, $heading, $items, $translator, $url, 'questions.latest');
        }

        if ($type === 'latest_topics') {
            $limit = $this->limit($config);
            $statement = $this->pdo->query(
                "SELECT t.slug,t.title,t.kind,t.comments_count,t.views_count
                 FROM topics t WHERE t.status='published'
                 ORDER BY t.last_activity_at DESC,t.id DESC LIMIT {$limit}"
            );
            $items = $statement->fetchAll(PDO::FETCH_ASSOC);
            if ($items === []) return '';
            if ($heading === '') $heading = '<h2>' . $this->e($translator->get('widgets.latest_topics')) . '</h2>';
            $body = '<div class="public-widget-question-list">';
            foreach ($items as $item) {
                $slug = trim((string) ($item['slug'] ?? ''));
                if ($slug === '') continue;
                $badge = ($item['kind'] ?? '') === 'announcement' ? ' <span class="admin-badge">' . $this->e($translator->get('topics.announcement_badge')) . '</span>' : '';
                $body .= '<a class="public-widget-question" href="' . $this->e($url('d/' . rawurlencode($slug), [])) . '"><span dir="auto">' . $this->e($item['title'] ?? '') . $badge . '</span><small><bdi>' . (int) ($item['comments_count'] ?? 0) . '</bdi> ' . $this->e($translator->get('topics.comments')) . ' · <bdi>' . (int) ($item['views_count'] ?? 0) . '</bdi> ' . $this->e($translator->get('common.views')) . '</small></a>';
            }
            $body .= '</div>';
            return '<section class="public-widget public-widget-latest-topics">' . $heading . $body . '</section>';
        }

        if ($type === 'categories') {
            $limit = max(1, min(200, (int) ($config['limit'] ?? 50)));
            $rows = $this->pdo->query(
                "SELECT c.name,c.slug,COUNT(q.id) AS question_count "
                . "FROM categories c LEFT JOIN questions q ON q.category_id=c.id AND q.status='published' "
                . "GROUP BY c.id,c.name,c.slug,c.sort_order ORDER BY c.sort_order ASC,c.name ASC LIMIT {$limit}"
            )->fetchAll(PDO::FETCH_ASSOC);
            if ($rows === []) {
                return '';
            }
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('taxonomy.categories')) . '</h2>';
            }
            $body = '<div class="public-widget-categories">';
            foreach ($rows as $category) {
                $body .= '<a href="' . $this->e($url('c/' . rawurlencode((string) $category['slug']), [])) . '">'
                    . '<span dir="auto">' . $this->e($category['name']) . '</span>'
                    . '<bdi>' . (int) $category['question_count'] . '</bdi></a>';
            }
            $body .= '</div>';
            return '<section class="public-widget public-widget-categories-wrap">' . $heading . $body . '</section>';
        }

        if (in_array($type, ['most_viewed', 'most_useful', 'most_answered', 'unanswered', 'trending'], true)) {
            $limit = $this->limit($config);
            [$where, $order] = match ($type) {
                'most_viewed' => ["q.status='published'", 'q.views_count DESC,q.created_at DESC,q.id DESC'],
                'most_useful' => ["q.status='published'", 'q.votes_count DESC,q.views_count DESC,q.created_at DESC,q.id DESC'],
                'most_answered' => ["q.status='published'", 'q.answers_count DESC,q.views_count DESC,q.created_at DESC,q.id DESC'],
                'unanswered' => ["q.status='published' AND q.answers_count=0", 'q.created_at DESC,q.id DESC'],
                'trending' => ["q.status='published'", '(q.votes_count*8 + q.answers_count*5 + LEAST(q.views_count,1000)/20) DESC,q.created_at DESC,q.id DESC'],
            };
            $items = $this->pdo->query(
                "SELECT q.slug,q.title,q.answers_count,q.votes_count,q.views_count "
                . "FROM questions q WHERE {$where} ORDER BY {$order} LIMIT {$limit}"
            )->fetchAll(PDO::FETCH_ASSOC);
            return $this->renderQuestionListWidget($type, $heading, $items, $translator, $url, 'widgets.' . $type);
        }

        if ($type === 'active_tags') {
            $limit = $this->limit($config);
            $rows = $this->pdo->query(
                "SELECT t.id,t.name,t.slug,t.content_locale,COUNT(DISTINCT q.id) AS question_count "
                . "FROM tags t INNER JOIN question_tags qt ON qt.tag_id=t.id "
                . "INNER JOIN questions q ON q.id=qt.question_id AND q.status='published' "
                . "GROUP BY t.id,t.name,t.slug,t.content_locale "
                . "ORDER BY question_count DESC,t.name ASC LIMIT {$limit}"
            )->fetchAll(PDO::FETCH_ASSOC);
            if ($rows === []) {
                return '';
            }
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.active_tags')) . '</h2>';
            }
            $body = '<div class="public-widget-tags">';
            foreach ($rows as $tag) {
                $body .= '<a class="tag-chip" href="' . $this->e($url('t/' . rawurlencode((string) $tag['slug']), [])) . '">'
                    . '<span dir="auto">' . $this->e($tag['name']) . '</span>'
                    . '<bdi>' . (int) $tag['question_count'] . '</bdi></a>';
            }
            $body .= '</div>';
            return '<section class="public-widget public-widget-active-tags">' . $heading . $body . '</section>';
        }

        if ($type === 'top_contributors') {
            $limit = $this->limit($config);
            $rows = $this->pdo->query(
                "SELECT u.id,u.name,u.username,u.avatar_url,u.points,"
                . "COALESCE(a.answers_count,0) AS answers_count,COALESCE(a.accepted_count,0) AS accepted_count,"
                . "COALESCE(a.answer_votes,0) AS answer_votes,COALESCE(q.questions_count,0) AS questions_count "
                . "FROM users u "
                . "LEFT JOIN (SELECT user_id,COUNT(*) AS answers_count,SUM(CASE WHEN is_best=1 THEN 1 ELSE 0 END) AS accepted_count,COALESCE(SUM(votes_count),0) AS answer_votes FROM answers WHERE user_id IS NOT NULL GROUP BY user_id) a ON a.user_id=u.id "
                . "LEFT JOIN (SELECT user_id,COUNT(*) AS questions_count FROM questions WHERE status='published' AND user_id IS NOT NULL GROUP BY user_id) q ON q.user_id=u.id "
                . "WHERE u.status='active' AND u.username IS NOT NULL AND u.username<>'' "
                . "AND (COALESCE(a.answers_count,0)>0 OR COALESCE(q.questions_count,0)>0) "
                . "ORDER BY accepted_count DESC,answer_votes DESC,answers_count DESC,u.points DESC,u.id ASC LIMIT {$limit}"
            )->fetchAll(PDO::FETCH_ASSOC);
            if ($rows === []) {
                return '';
            }
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.top_contributors')) . '</h2>';
            }
            $body = '<div class="public-widget-contributors">';
            foreach ($rows as $member) {
                $userId = (int) ($member['id'] ?? 0);
                $avatar = trim((string) ($member['avatar_url'] ?? ''));
                if ($avatar === '') {
                    $avatar = $url('assets/media/avatars/avatar-' . (($userId % 8) + 1) . '.svg', []);
                }
                $body .= '<a class="public-widget-contributor" href="' . $this->e($url('u/' . rawurlencode((string) $member['username']), [])) . '">'
                    . '<img src="' . $this->e($avatar) . '" alt="">'
                    . '<span><strong dir="auto">' . $this->e($member['name'] ?: $member['username']) . '</strong>'
                    . '<small><bdi>' . (int) $member['accepted_count'] . '</bdi> ' . $this->e($translator->get('profile.accepted_answers'))
                    . ' · <bdi>' . (int) $member['answers_count'] . '</bdi> ' . $this->e($translator->get('common.answers')) . '</small></span>'
                    . '</a>';
            }
            $body .= '</div>';
            return '<section class="public-widget public-widget-top-contributors">' . $heading . $body . '</section>';
        }

        if ($type === 'category_questions') {
            $limit = $this->limit($config);
            $categoryId = max(0, (int) ($config['category_id'] ?? 0));
            $where = "q.status='published'";
            $params = [];
            if ($categoryId > 0) {
                $where .= ' AND q.category_id=:category_id';
                $params['category_id'] = $categoryId;
            }
            $statement = $this->pdo->prepare(
                "SELECT q.slug,q.title,q.answers_count,q.votes_count,q.views_count "
                . "FROM questions q WHERE {$where} ORDER BY q.created_at DESC,q.id DESC LIMIT {$limit}"
            );
            $statement->execute($params);
            $items = $statement->fetchAll(PDO::FETCH_ASSOC);
            return $this->renderQuestionListWidget($type, $heading, $items, $translator, $url, 'widgets.types.category_questions');
        }

        if ($type === 'link_list') {
            $rawLinks = (array) ($config['links'] ?? []);
            $limit = $this->limit($config);
            if ($rawLinks === []) return '';
            $items = array_slice($rawLinks, 0, $limit);
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.types.link_list')) . '</h2>';
            }
            $body = '<ul class="public-widget-links">';
            foreach ($items as $item) {
                $l = trim((string) ($item['label'] ?? ''));
                $u = trim((string) ($item['url'] ?? ''));
                if ($l === '' || $u === '') continue;
                $body .= '<li><a href="' . $this->e($u) . '" rel="noopener"><bdi dir="auto">' . $this->e($l) . '</bdi></a></li>';
            }
            $body .= '</ul>';
            return '<section class="public-widget public-widget-link-list">' . $heading . $body . '</section>';
        }

        if ($type === 'about_box') {
            $desc = trim((string) ($config['description'] ?? ''));
            if ($desc === '') return '';
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.types.about_box')) . '</h2>';
            }
            $btnText = trim((string) ($config['button_text'] ?? ''));
            $btnUrl = trim((string) ($config['button_url'] ?? ''));
            $buttonHtml = ($btnText !== '' && $btnUrl !== '')
                ? '<div class="public-widget-action"><a class="button button-small" href="' . $this->e($btnUrl) . '">' . $this->e($btnText) . '</a></div>'
                : '';
            return '<article class="public-widget public-widget-about">' . $heading . '<div class="public-widget-copy" dir="auto">' . nl2br($this->e($desc)) . '</div>' . $buttonHtml . '</article>';
        }

        if ($type === 'social_links') {
            $networks = ['twitter' => 'Twitter/X', 'facebook' => 'Facebook', 'github' => 'GitHub', 'linkedin' => 'LinkedIn', 'telegram' => 'Telegram', 'youtube' => 'YouTube', 'discord' => 'Discord'];
            $linksHtml = '';
            foreach ($networks as $key => $name) {
                $u = trim((string) ($config[$key] ?? ''));
                if ($u === '') continue;
                $linksHtml .= '<a class="social-link social-link-' . $this->e($key) . '" href="' . $this->e($u) . '" target="_blank" rel="noopener noreferrer"><span>' . $this->e($name) . '</span></a>';
            }
            if ($linksHtml === '') return '';
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.types.social_links')) . '</h2>';
            }
            return '<section class="public-widget public-widget-social">' . $heading . '<div class="public-widget-social-links">' . $linksHtml . '</div></section>';
        }

        if ($type === 'cta_button') {
            $ctaHeading = trim((string) ($config['heading'] ?? ''));
            $ctaSub = trim((string) ($config['subheading'] ?? ''));
            $btnText = trim((string) ($config['button_text'] ?? ''));
            $btnUrl = trim((string) ($config['button_url'] ?? ''));
            if ($ctaHeading === '' || $btnText === '' || $btnUrl === '') return '';
            $subHtml = $ctaSub !== '' ? '<p dir="auto">' . $this->e($ctaSub) . '</p>' : '';
            return '<article class="public-widget public-widget-cta"><h2 dir="auto">' . $this->e($ctaHeading) . '</h2>' . $subHtml . '<a class="button button-primary" href="' . $this->e($btnUrl) . '">' . $this->e($btnText) . '</a></article>';
        }

        if ($type === 'site_stats') {
            $showQ = !empty($config['show_questions']);
            $showA = !empty($config['show_answers']);
            $showU = !empty($config['show_users']);
            $showV = !empty($config['show_views']);
            if (!$showQ && !$showA && !$showU && !$showV) return '';
            if ($heading === '') {
                $heading = '<h2>' . $this->e($translator->get('widgets.types.site_stats')) . '</h2>';
            }
            $statsHtml = '<div class="public-widget-stats-grid">';
            if ($showQ) {
                $qCount = (int) $this->pdo->query("SELECT COUNT(*) FROM questions WHERE status='published'")->fetchColumn();
                $statsHtml .= '<div class="stat-box"><strong><bdi>' . $qCount . '</bdi></strong><span>' . $this->e($translator->get('common.questions')) . '</span></div>';
            }
            if ($showA) {
                $aCount = (int) $this->pdo->query("SELECT COUNT(*) FROM answers")->fetchColumn();
                $statsHtml .= '<div class="stat-box"><strong><bdi>' . $aCount . '</bdi></strong><span>' . $this->e($translator->get('common.answers')) . '</span></div>';
            }
            if ($showU) {
                $uCount = (int) $this->pdo->query("SELECT COUNT(*) FROM users WHERE status='active'")->fetchColumn();
                $statsHtml .= '<div class="stat-box"><strong><bdi>' . $uCount . '</bdi></strong><span>' . $this->e($translator->get('common.users')) . '</span></div>';
            }
            if ($showV) {
                $vCount = (int) $this->pdo->query("SELECT COALESCE(SUM(views_count), 0) FROM questions WHERE status='published'")->fetchColumn();
                $statsHtml .= '<div class="stat-box"><strong><bdi>' . $vCount . '</bdi></strong><span>' . $this->e($translator->get('common.views')) . '</span></div>';
            }
            $statsHtml .= '</div>';
            return '<section class="public-widget public-widget-stats">' . $heading . $statsHtml . '</section>';
        }

        if ($type === 'search_box') {
            $placeholder = trim((string) ($config['placeholder'] ?? '')) ?: $translator->get('common.search_placeholder');
            $btnText = trim((string) ($config['button_text'] ?? '')) ?: $translator->get('common.search');
            return '<section class="public-widget public-widget-search-wrap">' . $heading . '<form class="public-widget-search-form" method="get" action="' . $this->e($url('search', [])) . '"><input type="search" name="q" placeholder="' . $this->e($placeholder) . '" required><button class="button button-small" type="submit">' . $this->e($btnText) . '</button></form></section>';
        }

        if ($type === 'image_banner') {
            $imgUrl = trim((string) ($config['image_url'] ?? ''));
            $alt = trim((string) ($config['alt_text'] ?? ''));
            $link = trim((string) ($config['link_url'] ?? ''));
            $caption = trim((string) ($config['caption'] ?? ''));
            if ($imgUrl === '') return '';
            $imgTag = '<img src="' . $this->e($imgUrl) . '" alt="' . $this->e($alt) . '" loading="lazy">';
            $body = $link !== '' ? '<a href="' . $this->e($link) . '" target="_blank" rel="noopener">' . $imgTag . '</a>' : $imgTag;
            if ($caption !== '') {
                $body .= '<figcaption dir="auto">' . $this->e($caption) . '</figcaption>';
            }
            return '<figure class="public-widget public-widget-banner">' . $heading . $body . '</figure>';
        }

        if ($type === 'text_block') {
            $text = trim((string) ($config['text'] ?? ''));
            if ($text === '') return '';
            return '<article class="public-widget public-widget-text">' . $heading . '<div class="public-widget-copy" dir="auto">' . nl2br($this->e($text)) . '</div></article>';
        }

        $registry = new WidgetRegistry($this->sanitizer);
        $extension = $registry->extension($type);
        if ($extension !== null && is_callable($extension['render'] ?? null)) {
            try {
                $validated = $registry->validate($type, $config);
                $html = ($extension['render'])($validated, $translator, $url, $row, $heading);
                return is_string($html) ? $html : '';
            } catch (Throwable $throwable) {
                error_log('[Quaeriva plugin widget] type=' . $type . ' ' . $throwable::class . ': ' . $throwable->getMessage());
                return '';
            }
        }

        return '';
    }

    /** @param array<int,array<string,mixed>> $items @param callable(string,array<string,mixed>):string $url */
    private function renderQuestionListWidget(
        string $type,
        string $heading,
        array $items,
        Translator $translator,
        callable $url,
        string $defaultHeadingKey,
    ): string {
        if ($items === []) {
            return '';
        }
        if ($heading === '') {
            $heading = '<h2>' . $this->e($translator->get($defaultHeadingKey)) . '</h2>';
        }
        $body = '<div class="public-widget-question-list">';
        foreach ($items as $item) {
            $body .= '<a class="public-widget-question" href="' . $this->e($url(function_exists('quaeriva_question_path') ? quaeriva_question_path((string) $item['slug'], (int) ($item['id'] ?? 0)) : 'q/' . (string) $item['slug'], [])) . '">'
                . '<span dir="auto">' . $this->e($item['title']) . '</span>'
                . '<small><bdi>' . (int) ($item['answers_count'] ?? 0) . '</bdi> ' . $this->e($translator->get('common.answers'))
                . ' · <bdi>' . (int) ($item['votes_count'] ?? 0) . '</bdi> ' . $this->e($translator->get('common.votes'))
                . ' · <bdi>' . (int) ($item['views_count'] ?? 0) . '</bdi> ' . $this->e($translator->get('common.views')) . '</small>'
                . '</a>';
        }
        $body .= '</div>';
        return '<section class="public-widget public-widget-' . $this->e($type) . '">' . $heading . $body . '</section>';
    }

    /** @param array<string,mixed> $config */
    private function limit(array $config): int
    {
        return max(1, min(20, (int) ($config['limit'] ?? 5)));
    }

    private function hasWidgetTranslations(): bool
    {
        if ($this->widgetTranslationsAvailable === null) {
            // Existence is boolean, so one matching metadata row is sufficient.
            $statement = $this->pdo->prepare(
                'SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table_name LIMIT 1'
            );
            $statement->execute(['table_name' => 'widget_translations']);
            $this->widgetTranslationsAvailable = $statement->fetchColumn() !== false;
        }
        return $this->widgetTranslationsAvailable;
    }

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