<?php
declare(strict_types=1);

require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'bootstrap.php';
app_bootstrap();
require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'helpers.php';

use App\Database\Connection;
use App\Ads\AdPlacementService;
use App\Database\SchemaHealthService;
use App\Audit\AuditLogger;
use App\Auth\AuthService;
use App\Auth\ApiTokenService;
use App\Mailer\NullMailer;
use App\Mailer\PhpMailMailer;
use App\Mailer\SmtpMailer;
use App\Mailer\MailerInterface;
use App\Mailer\MailerFactory;
use App\Support\FileLogger;
use App\Support\TranslatableException;
use App\Support\CoreHooks;
use App\Support\Hooks;
use App\Languages\LanguageRepository;
use App\Questions\AnswerService;
use App\Questions\VoteService;
use App\Security\CsrfService;
use App\Security\ChallengeService;
use App\Security\FormBotGuard;
use App\Security\HtmlSanitizer;
use App\Widgets\PublicWidgetRenderer;
use App\Web\Session;
use App\Questions\QuestionService;
use App\Notifications\NotificationService;
use App\Contact\ContactService;
use App\Community\CommentService;
use App\Community\PostingPolicyService;
use App\Community\SpamRiskService;
use App\Community\SpamAssessment;
use App\Community\ContentFingerprintService;
use App\Community\FollowService;
use App\Community\HomeFeedService;
use App\Questions\DuplicateDetectionService;
use App\Questions\SearchService;
use App\Taxonomy\TaxonomyService;
use App\Taxonomy\TagCreationService;
use App\Users\ProfileService;
use App\Users\TrustService;
use App\Security\RateLimitService;
use App\Seo\SeoBuilder;
use App\Seo\CanonicalPolicy;
use App\Seo\SitemapService;
use App\Seo\SitemapXmlRenderer;
use App\Seo\ProfileIndexPolicy;
use App\Settings\SettingsRepository;
use App\Slug\Slugger;
use App\Support\LocaleContext;
use App\Support\Translator;
use App\Support\CustomCodeService;
use App\Theme\ThemeAppearanceService;
use App\Theme\ThemeManager;
use App\Theme\ThemeTokenService;
use App\Theme\ThemeProfileRepository;
use App\Topics\TopicRepository;
use App\Topics\TopicService;

if (!function_exists('quaeriva_dispatch')) {
    /**
     * @param array<string, mixed>|null $server
     * @param array<string, mixed>|null $query
     * @return array{status: int, headers: array<string, string>, body: string}
     */
    function quaeriva_dispatch(?array $server = null, ?array $query = null, ?array $input = null): array
    {
        $server = $server ?? $_SERVER;
        $query = $query ?? $_GET;
        $input = $input ?? $_POST;
        $site = quaeriva_apply_runtime_settings(app_config('site'));
        $method = strtoupper((string) ($server['REQUEST_METHOD'] ?? 'GET'));
        $requestPath = quaeriva_request_path((string) ($server['REQUEST_URI'] ?? '/'));
        $relativePath = quaeriva_relative_path($requestPath);

        if ($relativePath === null) {
            return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
        }

        $segments = quaeriva_path_segments($relativePath);
        $route = $segments[0] ?? '';

        if ($route === 'robots.txt' && count($segments) === 1) {
            return quaeriva_render_robots($site, $server, $method);
        }

        if ($route === 'sitemap.xml' && count($segments) === 1) {
            if (($site['sitemap_enabled'] ?? true) === false) {
                return ['status' => 404, 'headers' => ['Cache-Control' => 'no-store'], 'body' => ''];
            }

            return quaeriva_render_sitemap($site, $server, $method);
        }

        if ($route === 'sitemaps' && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            if (($site['sitemap_enabled'] ?? true) === false) return ['status' => 404, 'headers' => ['Cache-Control' => 'no-store'], 'body' => ''];
            return quaeriva_render_sitemap_child((string) $segments[1], $site, $server, $method);
        }

        if ($route === 'lang' && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            // Backward-compatible cookie-only switch. The public UI uses the CSRF-protected POST flow below.
            if (($site['single_language'] ?? false) === true) {
                return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
            }
            return quaeriva_locale_redirect($segments[1], $query, $server, $site);
        }

        if (isset($query['lang']) && in_array($method, ['GET', 'HEAD'], true)) {
            $cleanQuery = $query;
            $legacyLocale = (string) $cleanQuery['lang'];
            unset($cleanQuery['lang']);
            $next = $requestPath;
            if ($cleanQuery !== []) {
                $next .= '?' . http_build_query($cleanQuery, '', '&', PHP_QUERY_RFC3986);
            }
            return quaeriva_locale_redirect($legacyLocale, ['next' => $next], $server, $site);
        }

        if ($route === 'install' && count($segments) <= 1) {
            return quaeriva_render_error(405, $site, $server, $query, 'errors/405');
        }

        if ($route === 'questions' && count($segments) === 1 && in_array($method, ['GET', 'HEAD'], true)) {
            return quaeriva_redirect(quaeriva_url() . '#latest-questions', 301);
        }

        if ($route === 'questions' && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            return quaeriva_redirect(quaeriva_question_url($segments[1]), 301);
        }

        $writeRoute = ($route === 'logout' && count($segments) === 1)
            || ($route === 'lang' && in_array(count($segments), [1, 2], true))
            || in_array($route, ['login', 'register', 'forgot-password', 'resend-verification', 'reset-password', 'ask', 'contact'], true)
            || ($route === 'tags' && count($segments) === 2 && ($segments[1] ?? '') === 'create')
            || ($route === 'preview' && count($segments) === 1)
            || ($route === 'content-image' && count($segments) === 1)
            || ($route === 'notifications' && in_array($segments[1] ?? '', ['read', 'preferences'], true))
            || ($route === 'settings' && count($segments) === 2 && in_array($segments[1] ?? '', ['profile', 'api-tokens'], true))
            || ($route === 'd' && count($segments) === 2 && ($segments[1] ?? '') === 'new')
            || ($route === 'd' && count($segments) === 3 && in_array($segments[2] ?? '', ['comments', 'report'], true))
            || ($route === 'd' && count($segments) === 5 && $segments[2] === 'comments' && $segments[4] === 'report')
            || ($route === 'd' && count($segments) === 3 && ($segments[2] ?? '') === 'follow')
            || (in_array($route, ['c', 't'], true) && count($segments) === 3 && ($segments[2] ?? '') === 'follow')
            || (in_array($route, ['q', 'questions'], true) && count($segments) >= 3 && in_array($segments[2] ?? '', ['answers', 'vote', 'report', 'comments', 'follow'], true));

        if (!in_array($method, ['GET', 'HEAD'], true) && !($writeRoute && $method === 'POST')) {
            return quaeriva_render_error(405, $site, $server, $query, 'errors/405');
        }

        Session::start();
        $currentUser = Session::user();
        if ($currentUser !== null) {
            try {
                $currentUser = Session::refresh(quaeriva_auth_pdo());
            } catch (Throwable $throwable) {
                (new FileLogger())->error('Browser session refresh failed.', $throwable);
            }
        }
        $localeContext = quaeriva_locale_context($site, $server, $query, $currentUser);
        $translator = quaeriva_make_translator($localeContext->uiLocale(), (string) ($site['locale'] ?? 'ar'));
        // Advertising targets by locale, and the ad service is built lazily
        // after this point.
        $GLOBALS['quaeriva_content_locale'] = $localeContext->uiLocale();
        $currentPath = quaeriva_current_url($requestPath === '' ? '/' : $requestPath, $query);
        $csrf = new CsrfService();
        $csrfToken = $csrf->token();

        $maintenanceBypass = in_array($route, ['login', 'logout', 'lang', 'verify-email', 'forgot-password', 'resend-verification', 'reset-password'], true);
        $maintenancePrivileged = quaeriva_user_has_role($currentUser, 'admin', 'moderator');
        if (($site['maintenance_enabled'] ?? false) === true && !$maintenanceBypass && !$maintenancePrivileged) {
            return quaeriva_render_message_page(
                $translator, $localeContext, $site, $server, $currentPath,
                $translator->get('maintenance.title'),
                trim((string) ($site['maintenance_message'] ?? '')) !== '' ? (string) $site['maintenance_message'] : $translator->get('maintenance.message'),
                503, $currentUser, $csrfToken
            );
        }

        // Tag creation is intentionally schema-compatible with the pre-019 tags table: a brand-new
        // display name can be inserted before the locale-scoped index migration is applied. Keeping
        // this JSON endpoint out of the global HTML schema guard also prevents fetch() from turning
        // a pending migration page into the misleading generic "tag create failed" message.
        $tagCreateCompatibilityRoute = $route === 'tags' && count($segments) === 2 && ($segments[1] ?? '') === 'create';
        $schemaGuardedWrite = $writeRoute
            && $method === 'POST'
            && !$tagCreateCompatibilityRoute
            // Preview touches no table at all, so the schema guard would only
            // replace its JSON with an HTML upgrade page.
            && $route !== 'preview'
            // The image upload writes a file, not a row, so the schema guard
            // would only replace its JSON with an HTML upgrade page.
            && $route !== 'content-image'
            && !in_array($route, ['lang', 'logout'], true)
            && ($currentUser !== null || in_array($route, ['login', 'register', 'forgot-password', 'resend-verification', 'reset-password', 'contact'], true));
        if ($schemaGuardedWrite) {
            try {
                $schemaPdo = Connection::connect(app_config('database'));
                $schemaHealth = (new SchemaHealthService($schemaPdo, app_root_path('database', 'migrations')))->inspect();
                if (!($schemaHealth['runtime_compatible'] ?? false)) {
                    return quaeriva_render_message_page(
                        $translator, $localeContext, $site, $server, $currentPath,
                        $translator->get('system.database_upgrade_title'),
                        $translator->get('system.database_upgrade_message'),
                        503, $currentUser, $csrfToken
                    );
                }
            } catch (Throwable $throwable) {
                error_log('[Quaeriva schema guard] ' . $throwable::class . ': ' . $throwable->getMessage());
                return quaeriva_render_message_page(
                    $translator, $localeContext, $site, $server, $currentPath,
                    $translator->get('auth.error'),
                    $translator->get('auth.service_error'),
                    503, $currentUser, $csrfToken
                );
            }
        }

        if ($route === 'lang' && in_array(count($segments), [1, 2], true) && $method === 'POST') {
            if (($site['single_language'] ?? false) === true) {
                return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
            }
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                return quaeriva_render_message_page(
                    $translator,
                    $localeContext,
                    $site,
                    $server,
                    $currentPath,
                    $translator->get('auth.error'),
                    $translator->get('auth.csrf_error'),
                    403,
                    $currentUser,
                    $csrfToken
                );
            }

            $requestedLocale = strtolower(str_replace('_', '-', trim((string) ($segments[1] ?? $input['locale'] ?? ''))));
            $supportedLocales = array_values(array_map('strval', $site['supported_locales'] ?? ['ar', 'en']));

            if (!in_array($requestedLocale, $supportedLocales, true)) {
                return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
            }

            if (is_array($currentUser) && isset($currentUser['id'])) {
                try {
                    (new AuthService(quaeriva_auth_pdo(), new NullMailer(), true, null, '', trim((string) ($site['name'] ?? ''))))->updatePreferredLocale((int) $currentUser['id'], $requestedLocale);
                } catch (Throwable) {
                    // The first-party cookie still makes the UI switch immediately if account persistence is temporarily unavailable.
                }
                Session::setPreferredLocale($requestedLocale);
            }

            return quaeriva_locale_redirect(
                $requestedLocale,
                ['next' => (string) ($input['next'] ?? $query['next'] ?? quaeriva_url())],
                $server,
                $site,
                303
            );
        }

        if ($route === 'logout' && count($segments) === 1) {
            if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                return quaeriva_render_error(405, $site, $server, $query, 'errors/405');
            }

            Session::logout();
            return quaeriva_redirect(quaeriva_url(), 303);
        }

        if ($route === 'profile' && count($segments) === 1) {
            return quaeriva_redirect(quaeriva_url('settings/profile'), 302);
        }

        if ($route === 'settings' && count($segments) === 2 && $segments[1] === 'profile') {
            return quaeriva_profile_settings_route($method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf, $csrfToken);
        }

        if ($route === 'settings' && count($segments) === 2 && $segments[1] === 'api-tokens') {
            return quaeriva_api_tokens_route($method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf, $csrfToken);
        }

        if ($route === 'u' && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            return quaeriva_public_profile_route($segments[1], $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
        }

        if (in_array($route, ['login', 'register', 'forgot-password', 'resend-verification', 'reset-password', 'verify-email'], true)) {
            return quaeriva_auth_route($route, $method, $input, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf, $csrfToken);
        }

        if ($segments === []) {
            // Legacy home ?q= searches are normalized to the dedicated, noindex search screen.
            $searchQuery = trim((string) ($query['q'] ?? ''));
            if ($searchQuery !== '') {
                return quaeriva_redirect(quaeriva_url('search', ['q' => $searchQuery]), 302);
            }
            $feedFilter = strtolower(trim((string) ($query['feed'] ?? 'all')));
            $communityFeed = [];
            $communityFeedAvailable = true;
            try {
                $communityFeed = (new HomeFeedService(quaeriva_auth_pdo()))->feed($feedFilter, 24);
            } catch (Throwable $throwable) {
                (new FileLogger())->error('Homepage community feed query failed.', $throwable);
                $communityFeed = ['items' => [], 'filter' => 'all'];
                $communityFeedAvailable = false;
            }
            $widgetsHtml = '';
            try {
                $widgetPdo = Connection::connect(app_config('database'));
                $widgetsHtml = (new PublicWidgetRenderer($widgetPdo, new HtmlSanitizer(), static fn (string $placement): string => quaeriva_render_ad($placement)))->renderHomepage(
                    $translator,
                    static fn (string $path, array $params = []): string => quaeriva_url($path, $params)
                );
            } catch (Throwable $throwable) {
                (new FileLogger())->error('Homepage widget rendering failed.', $throwable);
                $widgetsHtml = '';
            }
            // ponytail: unified community feed already carries questions and
            // topics, so the old separate question/topic queries are gone.
            $content = quaeriva_render_template('home', [
                'translator' => $translator,
                'renderSection' => static fn (string $section): string => quaeriva_render_widget_section($section, $translator),
                'localeContext' => $localeContext,
                'communityFeed' => $communityFeed['items'] ?? [],
                'feedFilter' => $communityFeed['filter'] ?? 'all',
                'currentUser' => $currentUser,
                'dbAvailable' => $communityFeedAvailable,
                'searchQuery' => $searchQuery,
                'submittedQuestionId' => max(0, (int) ($query['submitted'] ?? 0)),
                'widgetsHtml' => $widgetsHtml,
            ]);

            return quaeriva_render_layout(
                $content,
                $translator,
                $localeContext,
                $site,
                $currentPath,
                $translator->get('home.title'),
                $translator->get('home.intro'),
                'home',
                $currentUser,
                $csrfToken
            );
        }

        if ($route === 'search' && count($segments) === 1 && in_array($method, ['GET', 'HEAD'], true)) {
            return quaeriva_search_route($query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
        }

        // Every permalink shape resolves, whichever one the operator configured
        // and whichever one the link was minted under: /q/slug, /q/12/slug,
        // /q/12 and /q/12-slug. A request that is not the canonical shape is
        // answered with a 301 to it, so an archived Google result or an external
        // link keeps working and consolidates onto one URL.
        $questionRequest = quaeriva_match_question_request($route, $segments);

        if ($questionRequest !== null) {
            $slug = $questionRequest['slug'];
            $redirectSlug = quaeriva_find_duplicate_redirect_slug($slug);
            if ($redirectSlug !== null) {
                return quaeriva_redirect(quaeriva_question_url($redirectSlug), 301);
            }

            $canonicalPath = quaeriva_question_path($slug, $questionRequest['id']);
            if (trim(implode('/', $segments), '/') !== $canonicalPath) {
                return quaeriva_redirect(quaeriva_url($canonicalPath, $query), 301);
            }

            $questionData = quaeriva_load_question($site, $slug, $currentUser, $query);
            $question = $questionData['question'];
            $structuredData = null;
            if (is_array($question)) {
                $seoBuilder = new SeoBuilder(quaeriva_seo_base_url($site, $server));
                $qaStructuredData = $seoBuilder->jsonLdForQuestion($question, $questionData['answers'], (int) ($questionData['answers_meta']['total'] ?? count($questionData['answers'])));
                $breadcrumbStructuredData = $seoBuilder->breadcrumbForQuestion($question, $translator->get('common.home'));
                if (is_array($qaStructuredData) && is_array($breadcrumbStructuredData)) {
                    $structuredData = [$qaStructuredData, $breadcrumbStructuredData];
                } elseif (is_array($qaStructuredData)) {
                    $structuredData = $qaStructuredData;
                } elseif (is_array($breadcrumbStructuredData)) {
                    $structuredData = $breadcrumbStructuredData;
                }
            }
            $questionSeoIndex = is_array($question) ? (string) ($question['seo_index'] ?? 'inherit') : 'noindex';
            $title = is_array($question) && trim((string) ($question['meta_title'] ?? '')) !== ''
                ? (string) $question['meta_title']
                : (is_array($question) ? (string) ($question['title'] ?? $translator->get('questions.not_found')) : $translator->get('questions.not_found'));
            $description = is_array($question) && trim((string) ($question['meta_description'] ?? '')) !== ''
                ? (string) $question['meta_description']
                : (is_array($question)
                    ? quaeriva_meta_excerpt((string) ($question['body'] ?? ''), 170, (string) ($site['description'] ?? $translator->get('questions.not_found_hint')))
                    : $translator->get('questions.not_found_hint'));
            $questionCanonical = trim((string) ($question['canonical_url'] ?? ''));
            $canonicalOverride = $questionCanonical === ''
                ? null
                : (str_starts_with($questionCanonical, '/')
                    ? (new SeoBuilder(quaeriva_seo_base_url($site, $server)))->canonicalUrl($questionCanonical)
                    : $questionCanonical);
            $breadcrumbItems = [['label' => $translator->get('common.home'), 'url' => quaeriva_url()]];
            $questionCategoryName = trim((string) ($question['category_name'] ?? ''));
            $questionCategorySlug = trim((string) ($question['category_slug'] ?? ''));
            if ($questionCategoryName !== '') {
                $breadcrumbItems[] = [
                    'label' => $questionCategoryName,
                    'url' => $questionCategorySlug !== '' ? quaeriva_url('c/' . rawurlencode($questionCategorySlug)) : null,
                ];
            }
            $breadcrumbItems[] = ['label' => $title, 'url' => null];
            $content = quaeriva_render_template('question', [
                'translator' => $translator,
                'renderSection' => static fn (string $section): string => quaeriva_render_widget_section($section, $translator),
                'localeContext' => $localeContext,
                'question' => $question,
                'answers' => $questionData['answers'],
                'answersMeta' => $questionData['answers_meta'] ?? ['page' => 1, 'per_page' => 50, 'total' => 0, 'total_pages' => 1],
                'questionTags' => $questionData['tags'] ?? [],
                'comments' => $questionData['comments'] ?? ['question' => [], 'answers' => []],
                'isFollowing' => (bool) ($questionData['is_following'] ?? false),
                'reportedItems' => $questionData['reported_items'] ?? ['question' => [], 'answer' => [], 'comment' => []],
                'slug' => $slug,
                'currentUser' => $currentUser,
                'csrfToken' => $csrfToken,
                'expandQuestionComments' => (($query['commented'] ?? '') === 'question' || (int) ($query['show_comment'] ?? 0) > 0),
                'notice' => ($query['answer'] ?? '') === 'success'
                    ? $translator->get('questions.answer_success')
                    : (($query['voted'] ?? '') === '1' || ($query['answer_voted'] ?? '') === '1'
                        ? $translator->get('questions.vote_success')
                        : (($query['voted'] ?? '') === 'removed' || ($query['answer_voted'] ?? '') === 'removed'
                            ? $translator->get('questions.vote_removed')
                            : (($query['reported'] ?? '') === 'duplicate'
                                ? $translator->get('questions.report_duplicate')
                                : (($query['reported'] ?? '') === '1'
                                    ? $translator->get('questions.report_success')
                                    : (($query['commented'] ?? '') === '1'
                                        ? $translator->get('questions.comment_success')
                                        : (isset($query['followed'])
                                            ? $translator->get(((string) $query['followed']) === '1' ? 'questions.follow_success' : 'questions.unfollow_success')
                                            : (($query['accepted'] ?? '') === '1' ? $translator->get('questions.accept_success') : ''))))))),
                'site' => $site,
                'postingRules' => quaeriva_posting_rules(),
                'currentPath' => $currentPath,
        'renderAd' => static fn (string $placement): string => quaeriva_render_ad($placement),
                'renderBetweenAnswers' => static fn (int $position, int $totalAnswers): string => quaeriva_render_between_answers_ad($position, $totalAnswers),
            ]);
            $response = quaeriva_render_layout(
                $content,
                $translator,
                $localeContext,
                $site,
                $currentPath,
                $title,
                $description,
                'question',
                $currentUser,
                $csrfToken,
                $structuredData,
                !is_array($question) || $questionSeoIndex === 'noindex',
                $canonicalOverride,
                is_array($question) ? quaeriva_question_path($slug, (int) ($question['id'] ?? 0)) : null,
                $breadcrumbItems
            );

            if ($questionData['available'] && !is_array($question)) {
                $response['status'] = 404;
            }

            return $response;
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 3 && $segments[2] === 'answers') {
            return quaeriva_answer_submit($segments[1], $method, $input, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if ($route === 'd') {
            if (count($segments) === 1 && in_array($method, ['GET', 'HEAD'], true)) {
                return quaeriva_topics_route($query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
            }
            if (count($segments) === 2 && $segments[1] === 'new') {
                return quaeriva_topic_create_route($method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf, $csrfToken);
            }
            if (count($segments) === 3 && $segments[2] === 'comments') {
                return quaeriva_topic_comment_submit($segments[1], $method, $input, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
            if (count($segments) === 3 && $segments[2] === 'report') {
                return quaeriva_topic_report_submit($segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
            if (count($segments) === 5 && $segments[2] === 'comments' && $segments[4] === 'report') {
                return quaeriva_topic_comment_report_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
            if (count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
                $topic = (new TopicRepository(quaeriva_auth_pdo()))->findBySlug($segments[1]);
                // A redirect immediately after a write can reach a separate
                // database connection before a shared-host read replica has
                // caught up. Retry with a fresh PDO handle so a just-created
                // published topic never presents a misleading 404.
                if (!is_array($topic) && (($query['created'] ?? '') === '1')) {
                    for ($attempt = 0; $attempt < 2 && !is_array($topic); $attempt++) {
                        usleep(100000);
                        Connection::reset();
                        $topic = (new TopicRepository(quaeriva_auth_pdo()))->findBySlug($segments[1]);
                    }
                }
                if (is_array($topic)) {
                    return quaeriva_topic_route($topic, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
                }
                return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
            }
            if (count($segments) === 3 && $segments[2] === 'follow') {
                return quaeriva_topic_follow_submit($segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
        }

        if ($route === 't' && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            // Tags own /t permanently. Preserve those URLs, but move an old
            // topic URL only when no tag claims the same slug.
            $legacySlug = (string) $segments[1];
            $legacyTopic = (new TopicRepository(quaeriva_auth_pdo()))->findBySlug($legacySlug);
            if (is_array($legacyTopic)) {
                $legacyTag = (new TaxonomyService(quaeriva_auth_pdo()))->findTagBySlug($legacySlug);
                if (!is_array($legacyTag)) return quaeriva_redirect(quaeriva_topic_url($legacySlug), 301);
            }
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 3 && $segments[2] === 'vote') {
            return quaeriva_vote_submit($segments[1], $method, $input, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 3 && $segments[2] === 'report') {
            return quaeriva_report_submit($segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 3 && $segments[2] === 'comments') {
            return quaeriva_comment_submit($segments[1], null, $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 5 && $segments[2] === 'comments' && $segments[4] === 'report') {
            return quaeriva_comment_report_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 3 && $segments[2] === 'follow') {
            return quaeriva_follow_submit($segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 5 && $segments[2] === 'answers' && $segments[4] === 'vote') {
            return quaeriva_answer_vote_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 5 && $segments[2] === 'answers' && $segments[4] === 'comments') {
            return quaeriva_comment_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 5 && $segments[2] === 'answers' && $segments[4] === 'report') {
            return quaeriva_answer_report_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['q', 'questions'], true) && count($segments) === 5 && $segments[2] === 'answers' && $segments[4] === 'accept') {
            return quaeriva_accept_answer_submit($segments[1], max(0, (int) $segments[3]), $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if (in_array($route, ['c', 't'], true) && count($segments) === 2 && in_array($method, ['GET', 'HEAD'], true)) {
            return quaeriva_taxonomy_route($route === 'c' ? 'category' : 'tag', $segments[1], $query, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
        }

        if (in_array($route, ['c', 't'], true) && count($segments) === 3 && $segments[2] === 'follow') {
            return quaeriva_taxonomy_follow_submit($route === 'c' ? 'category' : 'tag', $segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if ($route === 'notifications' && count($segments) === 1) {
            return quaeriva_notifications_route($method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrfToken);
        }

        if ($route === 'notifications' && count($segments) === 2 && in_array($segments[1], ['read', 'preferences'], true)) {
            return quaeriva_notifications_action($segments[1], $method, $input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
        }

        if ($route === 'suggest' && count($segments) === 2 && $segments[1] === 'questions') {
            return quaeriva_duplicate_suggestions($query, $site, $server, $translator);
        }

        if ($route === 'preview' && count($segments) === 1) {
            return quaeriva_rich_text_preview($input, $currentUser, $csrf);
        }

        if ($route === 'content-image' && count($segments) === 1) {
            if ($method !== 'POST') {
                return quaeriva_render_error(405, $site, $server, $query, 'errors/405');
            }
            return quaeriva_content_image_upload($input, $currentUser, $csrf, $server);
        }

        if ($route === 'tags' && count($segments) === 2 && $segments[1] === 'create') {
            return quaeriva_tag_create_submit($method, $input, $site, $server, $translator, $currentUser, $csrf);
        }

        if ($route === 'ask' && count($segments) === 1) {
            if ($method === 'POST') {
                return quaeriva_question_submit($input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
            $taxonomyUnavailable = false;
            $categoryData = quaeriva_filter_ask_taxonomy(quaeriva_load_categories(), $site, $taxonomyUnavailable);
            $tagData = quaeriva_filter_ask_taxonomy(quaeriva_load_tags(), $site, $taxonomyUnavailable);
            $content = quaeriva_render_template('ask', [
                'translator' => $translator,
                'localeContext' => $localeContext,
                'site' => $site,
                'currentUser' => $currentUser,
                'csrfToken' => $csrfToken,
                'categories' => $categoryData,
                'tags' => $tagData,
                'taxonomyUnavailable' => $taxonomyUnavailable,
                'values' => [],
                'postingRules' => quaeriva_posting_rules(),
                'error' => '',
            ]);

            return quaeriva_render_layout(
                $content, $translator, $localeContext, $site, $currentPath,
                $translator->get('questions.ask'), $translator->get('questions.ask_intro'),
                'ask', $currentUser, $csrfToken
            );
        }

        if ($route === 'contact' && count($segments) === 1) {
            if (($site['contact_enabled'] ?? true) !== true) {
                return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
            }
            if ($method === 'POST') {
                return quaeriva_contact_submit($input, $site, $server, $translator, $localeContext, $currentPath, $currentUser, $csrf);
            }
            $content = quaeriva_render_template('contact', [
                'translator' => $translator, 'site' => $site, 'csrfToken' => $csrfToken, 'values' => [], 'error' => '', 'notice' => ''
            ]);
            return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('contact.title'), $translator->get('contact.intro'), 'contact', $currentUser, $csrfToken);
        }

        return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
    }
}

/** @param array<string, mixed> $server */
function quaeriva_request_path(string $requestUri): string
{
    $path = parse_url($requestUri, PHP_URL_PATH);

    if (!is_string($path) || $path === '') {
        return '/';
    }

    return '/' . ltrim($path, '/');
}

/** @param array<string, mixed> $query */
function quaeriva_current_url(string $requestPath, array $query): string
{
    $requestPath = quaeriva_request_path($requestPath);
    unset($query['lang']);

    $clean = [];
    foreach ($query as $key => $value) {
        if (!is_string($key) || $key === '' || (!is_scalar($value) && $value !== null)) {
            continue;
        }
        $clean[$key] = $value;
    }

    if ($clean === []) {
        return $requestPath;
    }

    return $requestPath . '?' . http_build_query($clean, '', '&', PHP_QUERY_RFC3986);
}

/** @param array<string, mixed> $site @return array<string, mixed> */
function quaeriva_apply_runtime_settings(array $site): array
{
    $site['theme'] = ThemeAppearanceService::defaults((array) ($site['theme'] ?? []));
    $site['theme']['active'] = ThemeManager::resolveActiveName('default');
    $GLOBALS['quaeriva_active_theme'] = $site['theme']['active'];

    // Declared outside the try because the language-mode block below runs
    // whether or not the database answered. It used to read $settings there
    // unconditionally, so any connection failure -- the exact case this catch
    // exists to survive -- became a fatal "call to a member function get() on
    // null" instead of the degraded shell it was meant to produce.
    $settings = null;
    $settingsFailure = null;

    try {
        $pdo = Connection::connect(app_config('database'));
        $settings = new SettingsRepository($pdo);
        $activePlugins = (array) $settings->get('plugins.active', []);
        $site['plugins'] = quaeriva_load_plugins($activePlugins);

        // Appearance is cosmetic; identity is not. A single invalid stored theme
        // value used to throw straight past the site name, description, locale,
        // timezone and panel path below, and the outer catch swallowed it: the
        // public pages silently served the shipped defaults while the panel,
        // which reads the settings table directly, showed the operator's real
        // values. The theme now degrades to its defaults on its own.
        try {
        $theme = ThemeAppearanceService::normalize(
            [
                'primary' => $settings->get('theme.primary', $site['theme']['primary']),
                'primary_dark' => $settings->get('theme.primary_dark', $site['theme']['primary_dark']),
                'accent' => $settings->get('theme.accent', $site['theme']['accent']),
                'surface' => $settings->get('theme.surface', $site['theme']['surface']),
                'surface_soft' => $settings->get('theme.surface_soft', $site['theme']['surface_soft']),
                'ink' => $settings->get('theme.ink', $site['theme']['ink']),
                'ink_muted' => $settings->get('theme.ink_muted', $site['theme']['ink_muted']),
                'line' => $settings->get('theme.line', $site['theme']['line']),
                'danger' => $settings->get('theme.danger', $site['theme']['danger']),
                'radius_md' => $settings->get('theme.radius_md', $site['theme']['radius_md']),
                'radius_lg' => $settings->get('theme.radius_lg', $site['theme']['radius_lg']),
                'container_width' => $settings->get('theme.container_width', $site['theme']['container_width']),
                'narrow_width' => $settings->get('theme.narrow_width', $site['theme']['narrow_width']),
                'font_stack' => $settings->get('theme.font_stack', $site['theme']['font_stack']),
                'font_size_base' => $settings->get('theme.font_size_base', $site['theme']['font_size_base']),
                'brand_text' => $settings->get('theme.brand_text', $site['theme']['brand_text']),
                'brand_display_mode' => $settings->get('theme.brand_display_mode', $site['theme']['brand_display_mode']),
                'mobile_bottom_nav' => $settings->get('theme.mobile_bottom_nav', $site['theme']['mobile_bottom_nav']),
                'logo_url' => $settings->get('theme.logo_url', $site['theme']['logo_url']),
                'logo_name' => $settings->get('theme.logo_name', $site['theme']['logo_name']),
                'favicon_url' => $settings->get('theme.favicon_url', $site['theme']['favicon_url']),
                'custom_css' => $settings->get('theme.custom_css', $site['theme']['custom_css']),
            ],
            $site['theme']
        );
        $requestedTheme = (string) $settings->get('theme.active', 'default');
        $theme['active'] = $requestedTheme;
        $site['theme'] = $theme;
        try {
            $legacyTokens = ThemeTokenService::fromLegacyAppearance($theme);
            $storedTokens = $settings->get('theme.tokens', []);
            $site['theme_tokens'] = ThemeTokenService::normalize(is_array($storedTokens) ? $storedTokens : [], $legacyTokens);
        } catch (Throwable $tokenFailure) {
            error_log('Quaeriva: stored Theme System 2.0 tokens are invalid, using legacy appearance values: ' . $tokenFailure->getMessage());
            $site['theme_tokens'] = ThemeTokenService::fromLegacyAppearance($theme);
        }
        } catch (Throwable $themeFailure) {
            error_log('Quaeriva: stored theme values are invalid, falling back to the defaults: ' . $themeFailure->getMessage());
        }

        try {
            $languageRepository = new LanguageRepository($pdo);
            $languages = $languageRepository->enabled();
            if ($languages !== []) {
                $site['languages'] = $languages;
                $site['supported_locales'] = array_values(array_map(static fn (array $row): string => (string) $row['code'], $languages));
                $site['locale_directions'] = [];
                foreach ($languages as $language) {
                    $site['locale_directions'][(string) $language['code']] = (string) ($language['direction'] ?? 'ltr');
                }
            }
        } catch (Throwable) {
            // 0.6.x databases without the language-management migration keep the shipped ar/en catalog.
        }

        $requiredThemeDirections = array_values(array_unique(array_filter(array_map(
            static fn (mixed $direction): string => strtolower(trim((string) $direction)),
            array_values((array) ($site['locale_directions'] ?? []))
        ), static fn (string $direction): bool => in_array($direction, ['rtl', 'ltr'], true))));
        $site['theme']['active'] = ThemeManager::resolveActiveName($requestedTheme ?? 'default', $requiredThemeDirections);
        $GLOBALS['quaeriva_active_theme'] = $site['theme']['active'];

        $locale = strtolower(trim((string) $settings->get('site.default_locale', $site['locale'] ?? 'ar')));
        if (in_array($locale, $site['supported_locales'] ?? ['ar', 'en'], true)) {
            $site['locale'] = $locale;
        } elseif (($site['supported_locales'] ?? []) !== []) {
            $site['locale'] = (string) $site['supported_locales'][0];
        }
        $site['language_switcher_enabled'] = (bool) $settings->get('site.language_switcher_enabled', true);
        $site['browser_locale_detection'] = (bool) $settings->get('site.browser_locale_detection', false);

        $name = trim((string) $settings->get('site.name', $site['name'] ?? 'Quaeriva'));
        if ($name !== '') {
            $site['name'] = $name;
        }

        $adminPath = trim((string) $settings->get('site.admin_path', ''));
        if ($adminPath !== '') {
            $site['admin_path'] = $adminPath;
            $GLOBALS['quaeriva_admin_path_override'] = $adminPath;
        }

        $timezone = trim((string) $settings->get('site.timezone', $site['timezone'] ?? 'UTC'));
        if (in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
            $site['timezone'] = $timezone;
            date_default_timezone_set($timezone);
        } else {
            $site['timezone'] = 'UTC';
            date_default_timezone_set('UTC');
        }

        // Slug style is a per-request default rather than a constructor argument
        // because three unrelated services mint slugs and none of them should
        // have to be handed a display preference.
        Slugger::useUnicodeByDefault((string) $settings->get('seo.slug_style', 'unicode') !== 'ascii');
        $GLOBALS['quaeriva_question_permalink'] = (string) $settings->get('seo.question_permalink', 'slug');
        $GLOBALS['quaeriva_question_prefix'] = (string) $settings->get('seo.question_prefix', 'q');
        $site['strict_csp'] = (bool) $settings->get('security.strict_csp', false);

        $description = trim((string) $settings->get('site.description', ''));
        if ($description !== '') {
            $site['description'] = $description;
        }
        $site['seo_default_title'] = trim((string) $settings->get('seo.default_title', ''));

        $robotsMode = (string) $settings->get('seo.robots_mode', 'index,follow');
        $site['robots_mode'] = in_array($robotsMode, ['index,follow', 'noindex,follow', 'index,nofollow', 'noindex,nofollow'], true)
            ? $robotsMode
            : 'index,follow';
        $site['sitemap_enabled'] = (bool) $settings->get('seo.sitemap_enabled', true);
        $site['og_image'] = trim((string) $settings->get('seo.og_image', ''));
        $site['twitter_site'] = trim((string) $settings->get('seo.twitter_site', ''));
        $site['google_verification'] = trim((string) $settings->get('seo.google_verification', ''));
        $site['bing_verification'] = trim((string) $settings->get('seo.bing_verification', ''));
        $site['registration_enabled'] = (bool) $settings->get('membership.registration_enabled', true);
        $site['require_email_verification'] = (bool) $settings->get('membership.require_email_verification', false);
        $site['maintenance_enabled'] = (bool) $settings->get('maintenance.enabled', false);
        $site['maintenance_message'] = trim((string) $settings->get('maintenance.message', ''));
        $site['contact_enabled'] = (bool) $settings->get('contact.enabled', true);
        $site['mail_enabled'] = (bool) $settings->get('mail.enabled', false);
        $site['comments_enabled'] = (bool) $settings->get('community.comments_enabled', true);
        $site['follows_enabled'] = (bool) $settings->get('community.follows_enabled', true);
        $site['duplicate_suggestions_enabled'] = (bool) $settings->get('community.duplicate_suggestions_enabled', true);
        $site['turnstile_enabled'] = (bool) $settings->get('security.turnstile_enabled', false);
        $site['turnstile_site_key'] = trim((string) $settings->get('security.turnstile_site_key', ''));
    } catch (Throwable $exception) {
        // The public shell must remain installable before the settings table exists.
        $settingsFailure = $exception;

        // Before this, a site whose database had become unreachable rendered a
        // shell with no content and no explanation anywhere. The reason goes to
        // the server's PHP error log -- never to the page, which is public.
        if (is_file(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.quaeriva-installed.lock')
            || is_file(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . '.quaeriva-installed.lock')) {
            // Connection::connect deliberately reports a generic message so
            // nothing leaks to a visitor; the driver's real error is the
            // previous exception, and the log is the right place for it.
            $cause = $exception->getPrevious();
            error_log('Quaeriva: settings unavailable, the site is running without its database: '
                . $exception->getMessage()
                . ($cause !== null ? ' | cause: ' . $cause->getMessage() : ''));
        }
    }

    $site['settings_available'] = $settings !== null;
    $site['settings_error'] = $settingsFailure?->getMessage();

    if (!isset($site['languages'])) {
        $site['languages'] = [
            ['code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية', 'direction' => 'rtl', 'is_enabled' => 1, 'sort_order' => 10],
            ['code' => 'en', 'name' => 'English', 'native_name' => 'English', 'direction' => 'ltr', 'is_enabled' => 1, 'sort_order' => 20],
        ];
        $site['locale_directions'] = ['ar' => 'rtl', 'en' => 'ltr'];
    }

    // Single-language mode. When exactly one language is enabled there is
    // nothing to switch between, so the switcher, browser-locale detection and
    // the ?lang override are all pointless -- and worse, they can strand a
    // visitor on a locale the site no longer serves. Rather than asking the
    // administrator to keep several settings consistent by hand, the whole
    // site collapses onto that one language here, and every consumer of $site
    // ('single_language' included) follows from this one decision.
    $enabledCodes = [];
    foreach ((array) ($site['languages'] ?? []) as $language) {
        $code = strtolower(trim((string) ($language['code'] ?? '')));
        if ($code !== '') {
            $enabledCodes[] = $code;
        }
    }
    $enabledCodes = array_values(array_unique($enabledCodes));

    $languageModeDefault = count($enabledCodes) === 1 ? 'single' : 'multilingual';
    $configuredMode = strtolower(trim((string) (
        $settings?->get('site.language_mode', $languageModeDefault) ?? $languageModeDefault
    )));
    $mode = in_array($configuredMode, ['single', 'multilingual'], true)
        ? $configuredMode
        : (count($enabledCodes) === 1 ? 'single' : 'multilingual');
    $site['language_mode'] = $mode;
    $site['single_language'] = $mode === 'single' || count($enabledCodes) === 1;

    if ($site['single_language']) {
        $configuredDefault = strtolower(str_replace('_', '-', trim((string) ($site['locale'] ?? ''))));
        $only = in_array($configuredDefault, $enabledCodes, true) ? $configuredDefault : ($enabledCodes[0] ?? (string) ($site['locale'] ?? 'ar'));
        $site['locale'] = $only;
        $site['supported_locales'] = [$only];
        $site['language_switcher_enabled'] = false;
        $site['browser_locale_detection'] = false;
    }

    return $site;
}

function quaeriva_make_translator(string $locale, string $fallbackLocale): Translator
{
    try {
        $repository = new LanguageRepository(Connection::connect(app_config('database')));
        $resolvedFallback = $repository->fallbackLocaleFor($locale, $fallbackLocale);
        // Theme strings come first so database overrides still win: an
        // administrator can always correct a theme's wording.
        $activeTheme = (string) ($GLOBALS['quaeriva_active_theme'] ?? 'default');
        return new Translator(
            $locale,
            $resolvedFallback,
            null,
            array_merge(\App\Theme\ThemeManager::translations($activeTheme, $locale), $repository->overrides($locale)),
            array_merge(\App\Theme\ThemeManager::translations($activeTheme, $resolvedFallback), $repository->overrides($resolvedFallback)),
        );
    } catch (Throwable) {
        return new Translator($locale, $fallbackLocale);
    }
}

/**
 * Resolve a submitted content locale against the languages the site actually
 * has enabled.
 *
 * The Ask form hides the language selector when only one language is enabled,
 * but the form is not a trust boundary: a posted value that is absent, unknown
 * or disabled must still land on a locale the site supports.
 *
 * @param array<string, mixed> $site
 */
function quaeriva_resolve_content_locale(mixed $value, array $site, Translator $translator): string
{
    $default = (string) ($site['locale'] ?? $translator->locale());
    $supported = [];

    foreach ((array) ($site['supported_locales'] ?? []) as $code) {
        if (is_scalar($code) && (string) $code !== '') {
            $supported[] = strtolower(str_replace('_', '-', trim((string) $code)));
        }
    }

    if ($supported === []) {
        $supported = [$default];
    }

    $candidate = is_scalar($value) ? strtolower(str_replace('_', '-', trim((string) $value))) : '';

    if ($candidate !== '' && in_array($candidate, $supported, true)) {
        return $candidate;
    }

    $fallback = strtolower(str_replace('_', '-', $default));

    return in_array($fallback, $supported, true) ? $fallback : $supported[0];
}

function quaeriva_relative_path(string $path): ?string
{
    $base = app_base_path();

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

        if (!str_starts_with($path, $base . '/')) {
            return null;
        }

        $path = substr($path, strlen($base));
    }

    return '/' . trim($path, '/');
}

/** @return array<int, string> */
function quaeriva_path_segments(string $path): array
{
    $segments = [];

    foreach (explode('/', trim($path, '/')) as $segment) {
        if ($segment === '') {
            continue;
        }

        $segments[] = rawurldecode($segment);
    }

    return $segments;
}

/** @param array<string, mixed> $site @param array<string, mixed> $server @param array<string, mixed> $query */
function quaeriva_locale_context(array $site, array $server, array $query, ?array $currentUser = null): LocaleContext
{
    $supported = array_values(array_map('strval', $site['supported_locales'] ?? ['ar', 'en']));
    $switcherEnabled = (bool) ($site['language_switcher_enabled'] ?? true);

    if (!$switcherEnabled) {
        return new LocaleContext($supported, (string) ($site['locale'] ?? 'ar'), null, null, null, null, (array) ($site['locale_directions'] ?? []));
    }

    $explicit = isset($query['lang']) ? (string) $query['lang'] : null;
    $cookie = isset($_COOKIE['quaeriva_locale']) ? (string) $_COOKIE['quaeriva_locale'] : null;
    $preferred = is_array($currentUser) ? (string) ($currentUser['preferred_locale'] ?? '') : null;
    $browser = null;

    if ((bool) ($site['browser_locale_detection'] ?? false)) {
        $acceptLanguage = (string) ($server['HTTP_ACCEPT_LANGUAGE'] ?? '');
        foreach (explode(',', $acceptLanguage) as $part) {
            $candidate = strtolower(str_replace('_', '-', trim((string) preg_replace('/;.*$/', '', $part))));
            if ($candidate === '') {
                continue;
            }

            if (in_array($candidate, $supported, true)) {
                $browser = $candidate;
                break;
            }

            $baseCandidate = explode('-', $candidate)[0];
            if (in_array($baseCandidate, $supported, true)) {
                $browser = $baseCandidate;
                break;
            }
        }
    }

    return new LocaleContext(
        $supported,
        (string) ($site['locale'] ?? 'ar'),
        $explicit,
        $cookie,
        $preferred !== '' ? $preferred : null,
        $browser,
        (array) ($site['locale_directions'] ?? [])
    );
}

/** @param array<string, mixed> $query @param array<string, mixed> $server @param array<string, mixed> $site */
function quaeriva_locale_redirect(string $locale, array $query, array $server, array $site, int $status = 302): array
{
    $locale = strtolower(trim($locale));
    $supported = array_values(array_map('strval', $site['supported_locales'] ?? ['ar', 'en']));

    if (!in_array($locale, $supported, true)) {
        return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
    }

    $next = quaeriva_safe_next((string) ($query['next'] ?? ''));
    $cookiePath = app_base_path() === '' ? '/' : app_base_path() . '/';
    $secure = (($server['HTTPS'] ?? '') !== '' && (string) $server['HTTPS'] !== 'off') ? '; Secure' : '';
    $cookie = 'quaeriva_locale=' . rawurlencode($locale) . '; Path=' . $cookiePath . '; Max-Age=31536000; SameSite=Lax; HttpOnly' . $secure;
    $destination = $next === '' ? quaeriva_url() : $next;

    return [
        'status' => $status,
        'headers' => [
            'Location' => $destination,
            'Set-Cookie' => $cookie,
            'Cache-Control' => 'no-store',
        ],
        'body' => '',
    ];
}

function quaeriva_safe_next(string $next): string
{
    $next = trim($next);

    if ($next === '' || !str_starts_with($next, '/') || str_starts_with($next, '//')) {
        return '';
    }

    if (preg_match('/[\x00-\x20]/', $next) || str_contains($next, '\\') || str_contains($next, '://')) {
        return '';
    }

    $base = app_base_path();

    if ($base !== '' && !($next === $base || str_starts_with($next, $base . '/'))) {
        return '';
    }

    return $next;
}

/** @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_seo_base_url(array $site, array $server): string
{
    $configured = trim((string) app_env('APP_URL', ''));

    if (preg_match('~^https?://[^/]+$~i', $configured) !== 1) {
        $host = trim((string) ($server['HTTP_HOST'] ?? ''));
        $scheme = !empty($server['HTTPS']) && strtolower((string) $server['HTTPS']) !== 'off' ? 'https' : 'http';
        $configured = preg_match('/^[A-Za-z0-9.-]+(?::\d{1,5})?$/', $host) === 1
            ? $scheme . '://' . $host
            : '';
    }

    return rtrim($configured, '/') . app_base_path((string) ($site['base_url'] ?? ''));
}

/** Trusted origin for links carrying account verification or recovery bearer tokens. */
function quaeriva_trusted_external_base_url(array $site): string
{
    $configured = rtrim(trim((string) app_env('APP_URL', '')), '/');
    if (preg_match('~^https?://[A-Za-z0-9.-]+(?::\d{1,5})?$~i', $configured) !== 1) {
        return '';
    }

    return $configured . app_base_path((string) ($site['base_url'] ?? ''));
}

/** Build an absolute application URL for links that leave the current browser context (email/Telegram). */
function quaeriva_absolute_app_url(array $site, array $server, string $path = '', array $query = []): string
{
    $base = rtrim(quaeriva_seo_base_url($site, $server), '/');
    $path = trim($path, "\\/");
    $url = $base . ($path === '' ? '/' : '/' . $path);
    if ($query !== []) {
        $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
    }
    return $url;
}

/** @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_render_robots(array $site, array $server, string $method): array
{
    $sitemapUrl = rtrim(quaeriva_seo_base_url($site, $server), '/') . '/sitemap.xml';
    $sitemapLine = ($site['sitemap_enabled'] ?? true) === false ? '' : "\nSitemap: " . $sitemapUrl;
    $adminPath = quaeriva_url(quaeriva_admin_path());
    $installPath = quaeriva_url('install');
    $apiPath = quaeriva_url('api');
    $searchPath = quaeriva_url('search');
    $suggestPath = quaeriva_url('suggest');

    $body = "User-agent: *\n"
        . "Allow: /\n"
        . "Disallow: " . $adminPath . "\n"
        . "Disallow: " . $installPath . "\n"
        . "Disallow: " . $apiPath . "\n"
        . "Disallow: " . $searchPath . "\n"
        . "Disallow: " . $suggestPath . "\n\n"
        . "User-agent: GPTBot\n"
        . "Allow: /\n"
        . "Disallow: " . $adminPath . "\n"
        . "Disallow: " . $installPath . "\n"
        . "Disallow: " . $apiPath . "\n\n"
        . "User-agent: ClaudeBot\n"
        . "Allow: /\n"
        . "Disallow: " . $adminPath . "\n"
        . "Disallow: " . $installPath . "\n"
        . "Disallow: " . $apiPath . "\n\n"
        . "User-agent: PerplexityBot\n"
        . "Allow: /\n"
        . "Disallow: " . $adminPath . "\n"
        . "Disallow: " . $installPath . "\n"
        . "Disallow: " . $apiPath . "\n"
        . $sitemapLine . "\n";

    return [
        'status' => 200,
        'headers' => [
            'Content-Type' => 'text/plain; charset=UTF-8',
            'Cache-Control' => 'public, max-age=3600',
        ],
        'body' => $method === 'HEAD' ? '' : $body,
    ];
}

/** @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_render_sitemap(array $site, array $server, string $method): array
{
    try {
        $pdo = Connection::connect(app_config('database'));
        $seo = new SeoBuilder(quaeriva_seo_base_url($site, $server));
        $entries = (new SitemapService($pdo, $seo))->indexEntries();
        $xml = SitemapXmlRenderer::index($entries);
        return ['status'=>200,'headers'=>['Content-Type'=>'application/xml; charset=UTF-8','Cache-Control'=>'public, max-age=300'],'body'=>$method==='HEAD'?'':$xml];
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Sitemap index generation failed.', $throwable);
        return ['status'=>503,'headers'=>['Content-Type'=>'application/xml; charset=UTF-8','Cache-Control'=>'no-store'],'body'=>$method==='HEAD'?'':SitemapXmlRenderer::index([])];
    }
}

/** @param array<string,mixed> $site @param array<string,mixed> $server */
function quaeriva_render_sitemap_child(string $file, array $site, array $server, string $method): array
{
    if (preg_match('/^[a-z0-9-]+\.xml$/', $file) !== 1) return ['status'=>404,'headers'=>['Cache-Control'=>'no-store'],'body'=>''];
    $name = substr($file, 0, -4);
    try {
        $pdo = Connection::connect(app_config('database'));
        $seo = new SeoBuilder(quaeriva_seo_base_url($site, $server));
        $entries = (new SitemapService($pdo, $seo))->childEntries($name);
        if ($entries === []) return ['status'=>404,'headers'=>['Content-Type'=>'application/xml; charset=UTF-8','Cache-Control'=>'no-store'],'body'=>$method==='HEAD'?'':SitemapXmlRenderer::urlSet([])];
        return ['status'=>200,'headers'=>['Content-Type'=>'application/xml; charset=UTF-8','Cache-Control'=>'public, max-age=300'],'body'=>$method==='HEAD'?'':SitemapXmlRenderer::urlSet($entries)];
    } catch (InvalidArgumentException) {
        return ['status'=>404,'headers'=>['Cache-Control'=>'no-store'],'body'=>''];
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Sitemap child generation failed.', $throwable, ['file'=>$file]);
        return ['status'=>503,'headers'=>['Content-Type'=>'application/xml; charset=UTF-8','Cache-Control'=>'no-store'],'body'=>$method==='HEAD'?'':SitemapXmlRenderer::urlSet([])];
    }
}

/** @param array<string, mixed> $site @param array<string, mixed> $server @param array<string, mixed> $query */
function quaeriva_render_error(int $status, array $site, array $server, array $query, string $template): array
{
    $localeContext = quaeriva_locale_context($site, $server, $query);
    $translator = quaeriva_make_translator($localeContext->uiLocale(), (string) ($site['locale'] ?? 'ar'));
    $title = $status === 405 ? $translator->get('common.error') : $translator->get('common.not_found');
    $content = quaeriva_render_template($template, [
        'translator' => $translator,
        'localeContext' => $localeContext,
    ]);

    $response = quaeriva_render_layout(
        $content,
        $translator,
        $localeContext,
        $site,
        quaeriva_request_path((string) ($server['REQUEST_URI'] ?? '/')),
        $title,
        $translator->get('common.not_found_hint'),
        'error',
        null,
        '',
        null,
        true
    );
    $response['status'] = $status;

    if ($status === 405) {
        $response['headers']['Allow'] = 'GET, HEAD';
    }

    return $response;
}

function quaeriva_redirect(string $location, int $status = 302): array
{
    return ['status' => $status, 'headers' => ['Location' => $location, 'Cache-Control' => 'no-store'], 'body' => ''];
}

/** @param array<string,mixed> $input @param array<string,mixed> $query */
function quaeriva_preserve_answers_page(array $input, array $query = []): array
{
    $page = max(1, min(1000000, (int) ($input['answers_page'] ?? 1)));
    if ($page > 1) $query['answers_page'] = $page;
    return $query;
}

function quaeriva_answer_page(PDO $pdo, int $questionId, int $answerId, int $perPage = 50): int
{
    $target = $pdo->prepare('SELECT is_best,created_at,id FROM answers WHERE id=:id AND question_id=:question_id LIMIT 1');
    $target->execute(['id' => $answerId, 'question_id' => $questionId]);
    $answer = $target->fetch(PDO::FETCH_ASSOC);
    if (!is_array($answer)) return 1;
    $before = $pdo->prepare(
        "SELECT COUNT(*) FROM answers WHERE question_id=:question_id AND status='published' AND (
            is_best > :is_best OR (is_best=:same_best AND (created_at < :created_at OR (created_at=:same_created_at AND id < :id)))
        )"
    );
    $before->execute(['question_id' => $questionId, 'is_best' => (int) $answer['is_best'], 'same_best' => (int) $answer['is_best'], 'created_at' => (string) $answer['created_at'], 'same_created_at' => (string) $answer['created_at'], 'id' => $answerId]);
    return max(1, (int) ceil(((int) $before->fetchColumn() + 1) / max(1, $perPage)));
}

/** @param array<string, mixed> $input @param array<string, mixed> $query @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_api_tokens_route(
    string $method,array $input,array $site,array $server,Translator $translator,LocaleContext $localeContext,string $currentPath,?array $currentUser,CsrfService $csrf,string $csrfToken
): array {
    if($currentUser===null)return quaeriva_redirect(quaeriva_url('login',['redirect'=>$currentPath]),303);
    $error='';$notice='';$rawToken='';$tokens=[];
    try{
        $pdo=quaeriva_auth_pdo();
        $service=new ApiTokenService($pdo);
        if($method==='POST'){
            if(!$csrf->validate((string)($input['_csrf']??'')))throw new InvalidArgumentException($translator->get('auth.csrf_error'));
            $action=(string)($input['token_action']??'');
            if($action==='create'){
                $created=$service->createToken((int)$currentUser['id'],(string)($input['label']??''),is_array($input['scopes']??null)?$input['scopes']:[],trim((string)($input['expires_at']??''))?:null);
                $rawToken=$created['token'];$notice=$translator->get('api_tokens.created');
                try { (new AuditLogger($pdo))->log((int)$currentUser['id'],'api_token.created','api_token',(int)($created['id']??0),null,['label'=>(string)($input['label']??''),'scopes'=>is_array($input['scopes']??null)?array_values($input['scopes']):[]],(string)($server['REMOTE_ADDR']??'')); } catch(Throwable) {}
            }elseif($action==='revoke'){
                $tokenId=(int)($input['token_id']??0);
                $service->revoke((int)$currentUser['id'],$tokenId);$notice=$translator->get('api_tokens.revoked_notice');
                try { (new AuditLogger($pdo))->log((int)$currentUser['id'],'api_token.revoked','api_token',$tokenId,null,['revoked'=>true],(string)($server['REMOTE_ADDR']??'')); } catch(Throwable) {}
            }
        }
        $tokens=$service->listForUser((int)$currentUser['id']);
    }catch(InvalidArgumentException){$error=$translator->get('api_tokens.error');}catch(Throwable){$error=$translator->get('api_tokens.error');}
    $content=quaeriva_render_template('auth/api-tokens',['translator'=>$translator,'tokens'=>$tokens,'scopes'=>ApiTokenService::SCOPES,'csrfToken'=>$csrfToken,'rawToken'=>$rawToken,'notice'=>$notice,'error'=>$error]);
    $response=quaeriva_render_layout($content,$translator,$localeContext,$site,$currentPath,$translator->get('api_tokens.title'),$translator->get('api_tokens.intro'),'api-tokens',$currentUser,$csrfToken,null,true);
    if($error!=='')$response['status']=422;return $response;
}

function quaeriva_profile_settings_route(
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf,
    string $csrfToken
): array {
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    $error='';$notice='';
    try {
        $pdo=quaeriva_auth_pdo();$service=new ProfileService($pdo);
        $user=$service->findForSettings((int)$currentUser['id']) ?? $currentUser;
        if($method==='POST'){
            if(!$csrf->validate((string)($input['_csrf']??''))) throw new InvalidArgumentException($translator->get('auth.csrf_error'));
            $user=$service->updateProfile((int)$currentUser['id'],(string)($input['name']??''),(string)($input['username']??''),(string)($input['bio']??''),(string)($input['avatar_url']??''));
            Session::replaceUser($user);$currentUser=$user;$notice=$translator->get('profile.saved');
        }
    } catch (InvalidArgumentException) { $error=$translator->get('profile.save_error'); $user=array_merge($currentUser,['name'=>(string)($input['name']??$currentUser['name']??''),'username'=>(string)($input['username']??$currentUser['username']??''),'bio'=>(string)($input['bio']??$currentUser['bio']??''),'avatar_url'=>(string)($input['avatar_url']??$currentUser['avatar_url']??'')]); }
      catch (Throwable) { $error=$translator->get('profile.save_error'); $user=$currentUser; }
    $content=quaeriva_render_template('auth/profile',['translator'=>$translator,'user'=>$user,'csrfToken'=>$csrfToken,'notice'=>$notice,'error'=>$error,'site'=>$site]);
    $response=quaeriva_render_layout($content,$translator,$localeContext,$site,$currentPath,$translator->get('profile.settings_title'),$translator->get('profile.settings_intro'),'profile',$currentUser,$csrfToken,null,true);
    if($error!=='')$response['status']=422;
    return $response;
}

function quaeriva_public_profile_route(
    string $username,array $site,array $server,Translator $translator,LocaleContext $localeContext,string $currentPath,?array $currentUser,string $csrfToken
): array {
    try {
        $service=new ProfileService(quaeriva_auth_pdo());$user=$service->findPublicByUsername($username);
        if(!is_array($user)) return quaeriva_render_error(404,$site,$server,[],'errors/404');
        $siteName = trim((string) ($site['name'] ?? ''));
        if ($siteName === '') {
            $siteName = trim($translator->get('app.name'));
        }
        $activity=$service->activity((int)$user['id']);$summary=$service->summary((int)$user['id']);
        $content=quaeriva_render_template('user-profile',['translator'=>$translator,'user'=>$user,'activity'=>$activity,'summary'=>$summary]);
        $profileUrl=(new SeoBuilder(quaeriva_seo_base_url($site,$server)))->canonicalUrl('/u/'.rawurlencode((string)$user['username']));
        $person=['@type'=>'Person','name'=>(string)$user['name'],'identifier'=>(string)$user['id'],'url'=>$profileUrl];
        if(trim((string)($user['username']??''))!=='')$person['alternateName']='@'.(string)$user['username'];
        if(trim((string)($user['bio']??''))!=='')$person['description']=trim((string)$user['bio']);
        if(trim((string)($user['avatar_url']??''))!=='')$person['image']=trim((string)$user['avatar_url']);
        $profileIndexable = ProfileIndexPolicy::isIndexable($user, $summary);
        $structuredData = $profileIndexable ? [
            '@context' => 'https://schema.org',
            '@type' => 'ProfilePage',
            'dateCreated' => (string) ($user['created_at'] ?? ''),
            'dateModified' => (string) ($user['updated_at'] ?? $user['created_at'] ?? ''),
            'mainEntity' => $person,
        ] : null;
        return quaeriva_render_layout($content,$translator,$localeContext,$site,$currentPath,(string)$user['name'],$translator->get('profile.public_description', ['name' => (string)$user['name'], 'site' => $siteName]),'profile',$currentUser,$csrfToken,$structuredData,!$profileIndexable);
    } catch(Throwable $throwable){error_log('[Quaeriva profile] '.$throwable::class.': '.$throwable->getMessage());return quaeriva_render_error(404,$site,$server,[],'errors/404');}
}

function quaeriva_auth_route(
    string $route,
    string $method,
    array $input,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf,
    string $csrfToken
): array {
    $siteName = trim((string) ($site['name'] ?? ''));
    if ($siteName === '') {
        $siteName = trim($translator->get('app.name'));
    }

    if ($route === 'profile') {
        if ($currentUser === null) {
            return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
        }

        $profileIntro = $translator->get('auth.profile_intro', ['site' => $siteName]);

        $content = quaeriva_render_template('auth/profile', [
            'translator' => $translator,
            'user' => $currentUser,
            'csrfToken' => $csrfToken,
            'site' => $site,
        ]);

        return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('auth.profile'), $profileIntro, 'profile', $currentUser, $csrfToken, null, true);
    }

    if ($route === 'verify-email') {
        $verified = false;

        if ($method === 'GET') {
            try {
                $verified = (new AuthService(quaeriva_auth_pdo(), new NullMailer(), true, null, '', $siteName))->verifyEmail((string) ($query['token'] ?? ''));
            } catch (Throwable) {
                $verified = false;
            }
        }

        return quaeriva_render_message_page(
            $translator,
            $localeContext,
            $site,
            $server,
            $currentPath,
            $verified ? $translator->get('auth.verify_success') : $translator->get('auth.verify_failure'),
            $verified ? $translator->get('auth.verify_success_hint') : $translator->get('auth.verify_failure_hint'),
            $verified ? 200 : 422,
            $currentUser,
            $csrfToken
        );
    }

    if ($route === 'resend-verification') {
        $error = '';
        $notice = '';
        $email = trim((string) ($input['email'] ?? $query['email'] ?? ''));
        if ($method === 'POST') {
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                $error = $translator->get('auth.csrf_error');
            } else {
                try {
                    $authPdo = quaeriva_auth_pdo();
                    if (quaeriva_auth_rate_allowed($authPdo, $server, 'auth.resend-verification', $email, 3, 3600)) {
                        (new AuthService($authPdo, quaeriva_runtime_mailer($authPdo), true, $translator, quaeriva_trusted_external_base_url($site), $siteName))->resendVerification($email, $translator->locale());
                    }
                } catch (Throwable $throwable) {
                    (new FileLogger())->error('Verification resend failed.', $throwable);
                }
                $notice = $translator->get('auth.verification_resend_requested');
            }
        }
        $content = quaeriva_render_template('auth/resend', compact('translator', 'csrfToken', 'email', 'error', 'notice'));
        return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('auth.resend_title'), $translator->get('auth.resend_intro'), 'resend-verification', $currentUser, $csrfToken, null, true);
    }

    if ($route === 'register' && ($site['registration_enabled'] ?? true) !== true) {
        return quaeriva_render_message_page(
            $translator, $localeContext, $site, $server, $currentPath,
            $translator->get('auth.registration_closed'),
            $translator->get('auth.registration_closed_hint'),
            403, $currentUser, $csrfToken
        );
    }

    $values = [
        'name' => (string) ($input['name'] ?? ''),
        'email' => (string) ($input['email'] ?? $query['email'] ?? ''),
        'remember' => !empty($input['remember']),
    ];
    $redirect = quaeriva_safe_next((string) ($input['redirect'] ?? $query['redirect'] ?? ''));
    $error = '';
    $notice = '';

    if ($route === 'login') {
        $noticeKey = (string) ($query['notice'] ?? '');
        $notice = match ($noticeKey) {
            'registered' => $translator->get('auth.registration_success'),
            'registered-ready' => $translator->get('auth.registration_success_ready'),
            'registered-mail-failed' => $translator->get('auth.registration_mail_failed'),
            'reset' => $translator->get('auth.reset_success'),
            default => '',
        };

        if ($method === 'POST') {
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                $error = $translator->get('auth.csrf_error');
            } else {
                try {
                    $authPdo = quaeriva_auth_pdo();
                    if (!quaeriva_auth_rate_allowed($authPdo, $server, 'auth.login', $values['email'], 10, 900)) {
                        $error = $translator->get('auth.rate_limited');
                    } else {
                    $authService = new AuthService($authPdo, new NullMailer(), true, null, '', $siteName);
                    $user = $authService->authenticate($values['email'], (string) ($input['password'] ?? ''));
                    if ($user === null) {
                        $error = $translator->get('auth.invalid_credentials');
                    } else {
                        $cookieLocale = isset($_COOKIE['quaeriva_locale']) ? strtolower(trim((string) $_COOKIE['quaeriva_locale'])) : '';
                        if (in_array($cookieLocale, array_values(array_map('strval', $site['supported_locales'] ?? ['ar', 'en'])), true) && $cookieLocale !== (string) ($user['preferred_locale'] ?? '')) {
                            try {
                                $authService->updatePreferredLocale((int) $user['id'], $cookieLocale);
                                $user['preferred_locale'] = $cookieLocale;
                            } catch (Throwable) {
                                // A login must not fail only because the optional preference sync failed.
                            }
                        }

                        $remember = !empty($input['remember']);
                        Session::login($user, $remember);
                        return quaeriva_redirect($redirect !== '' ? $redirect : quaeriva_url(), 303);
                    }
                    }
                } catch (Throwable $exception) {
                    quaeriva_log_failure('login', $exception);
                    $error = $translator->get('auth.service_error');
                }
            }
        }

        $template = 'auth/login';
        $titleKey = 'auth.login_title';
    } elseif ($route === 'register') {
        if ($method === 'POST') {
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                $error = $translator->get('auth.csrf_error');
            } else {
                try {
                    $authPdo = quaeriva_auth_pdo();
                    $formGuard = FormBotGuard::sessionGuard()->inspect($input);
                    if (!($formGuard['valid_signature'] ?? false) || ($formGuard['honeypot'] ?? false) || ($formGuard['too_fast'] ?? false)) {
                        $error = $translator->get('auth.invalid_registration');
                    } elseif (!quaeriva_verify_turnstile($authPdo, $input, $server)) {
                        $error = $translator->get('auth.challenge_failed');
                    } elseif (!quaeriva_auth_rate_allowed($authPdo, $server, 'auth.register', '', 5, 3600)) {
                        $error = $translator->get('auth.rate_limited');
                    } else {
                    $verificationRequired = (bool) ($site['require_email_verification'] ?? false);
                    $authService = new AuthService($authPdo, quaeriva_runtime_mailer($authPdo), $verificationRequired, $translator, quaeriva_trusted_external_base_url($site), $siteName);
                    $authService->register($values['name'], $values['email'], (string) ($input['password'] ?? ''), $translator->locale());
                    return quaeriva_redirect(quaeriva_url('login', ['notice' => $authService->mailDeliveryFailed() ? 'registered-mail-failed' : ($verificationRequired ? 'registered' : 'registered-ready')]), 303);
                    }
                } catch (InvalidArgumentException $exception) {
                    $error = $translator->get('auth.invalid_registration');
                } catch (Throwable $exception) {
                    quaeriva_log_failure('register', $exception);
                    $error = $translator->get('auth.registration_failed');
                }
            }
        }

        $template = 'auth/register';
        $titleKey = 'auth.register_title';
    } elseif ($route === 'forgot-password') {
        if ($method === 'POST') {
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                $error = $translator->get('auth.csrf_error');
            } else {
                try {
                    $authPdo = quaeriva_auth_pdo();
                    if (quaeriva_auth_rate_allowed($authPdo, $server, 'auth.forgot', $values['email'], 5, 3600)) {
                        (new AuthService($authPdo, quaeriva_runtime_mailer($authPdo), true, $translator, quaeriva_trusted_external_base_url($site), $siteName))->requestPasswordReset($values['email']);
                    }
                } catch (Throwable) {
                    // Keep the response generic so account existence and rate-limit state are never disclosed.
                }
                $notice = $translator->get('auth.reset_requested');
            }
        }

        $template = 'auth/forgot';
        $titleKey = 'auth.forgot_title';
    } else {
        $token = (string) ($input['token'] ?? $query['token'] ?? '');
        if ($method === 'POST') {
            if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
                $error = $translator->get('auth.csrf_error');
            } else {
                try {
                    $authPdo = quaeriva_auth_pdo();
                    if (!quaeriva_auth_rate_allowed($authPdo, $server, 'auth.reset', '', 10, 3600)) {
                        $error = $translator->get('auth.rate_limited');
                    } elseif ((new AuthService($authPdo, new NullMailer(), true, null, '', $siteName))->resetPassword($token, (string) ($input['password'] ?? ''))) {
                        return quaeriva_redirect(quaeriva_url('login', ['notice' => 'reset']), 303);
                    }
                } catch (Throwable) {
                    // Use the same message for invalid or expired tokens.
                }
                if ($error === '') $error = $translator->get('auth.reset_invalid');
            }
        }

        $template = 'auth/reset';
        $titleKey = 'auth.reset_title';
    }

    $content = quaeriva_render_template($template, [
        'translator' => $translator,
        'csrfToken' => $csrfToken,
        'values' => $values,
        'redirect' => $redirect,
        'error' => $error,
        'notice' => $notice,
        'token' => (string) ($input['token'] ?? $query['token'] ?? ''),
        'site' => $site,
    ]);

    $titleReplacements = $titleKey === 'auth.register_title' ? ['site' => $siteName] : [];
    $title = $translator->get($titleKey, $titleReplacements);

    // Authentication screens carry no indexable content and must never compete
    // with real pages in search results.
    return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $title, $title, $route, $currentUser, $csrfToken, null, true);
}

function quaeriva_auth_pdo(): PDO
{
    return Connection::connect(app_config('database'));
}

/** Verify the optional Cloudflare Turnstile challenge without exposing the secret key to templates. */
function quaeriva_verify_turnstile(PDO $pdo, array $input, array $server): bool
{
    $settings = new SettingsRepository($pdo);
    if (!(bool) $settings->get('security.turnstile_enabled', false)) return true;
    $siteKey = trim((string) $settings->get('security.turnstile_site_key', ''));
    $secretKey = trim((string) $settings->get('security.turnstile_secret_key', ''));
    $challenge = new ChallengeService($siteKey, $secretKey);
    if (!$challenge->enabled()) return false;
    return $challenge->verify((string) ($input['cf-turnstile-response'] ?? ''), trim((string) ($server['REMOTE_ADDR'] ?? '')));
}

/**
 * Record a failure the user is shown a generic message for.
 *
 * "Service temporarily unavailable" is the right thing to show a visitor and
 * the worst possible thing to give an operator: several unrelated faults all
 * render identically, so there is nothing to act on. The real reason goes to
 * the server's PHP error log, never to the response.
 */
function quaeriva_log_failure(string $context, Throwable $exception): void
{
    $cause = $exception->getPrevious();

    error_log(sprintf(
        'Quaeriva [%s]: %s: %s in %s:%d%s',
        $context,
        $exception::class,
        $exception->getMessage(),
        $exception->getFile(),
        $exception->getLine(),
        $cause !== null ? ' | cause: ' . $cause::class . ': ' . $cause->getMessage() : ''
    ));

    try {
        (new FileLogger())->error('Quaeriva failure [' . $context . ']', $exception);
    } catch (Throwable) {
        // Logging must never crash the request.
    }
}

function quaeriva_auth_rate_allowed(PDO $pdo, array $server, string $action, string $email, int $limit, int $windowSeconds): bool
{
    $ip = trim((string)($server['REMOTE_ADDR'] ?? 'unknown'));
    $identity = 'ip:' . ($ip !== '' ? $ip : 'unknown');
    $email = strtolower(trim($email));
    if ($email !== '') $identity .= ':email:' . hash('sha256', $email);
    return (new RateLimitService($pdo))->consume($identity, $action, $limit, $windowSeconds);
}

function quaeriva_runtime_mailer(PDO $pdo): MailerInterface
{
    try {
        $site=app_config('site');
        return (new MailerFactory(new SettingsRepository($pdo), trim((string)($site['name']??'Quaeriva')) ?: 'Quaeriva'))->make();
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Unable to create the configured mailer.', $throwable);
        return new NullMailer();
    }
}

function quaeriva_ad_service(): ?AdPlacementService
{
    static $resolved = false;
    static $service = null;
    if ($resolved) return $service;
    $resolved = true;
    try {
        $adPdo = quaeriva_auth_pdo();
        $adUser = \App\Web\Session::user();
        $service = new AdPlacementService(
            new SettingsRepository($adPdo),
            isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : null,
            new \App\Ads\AdCreativeRepository($adPdo),
            strtolower((string) ($GLOBALS['quaeriva_content_locale'] ?? '')),
            is_array($adUser) && (int) ($adUser['id'] ?? 0) > 0
        );
    } catch (Throwable $throwable) {
        error_log('[Quaeriva ads] ' . $throwable::class . ': ' . $throwable->getMessage());
        $service = null;
    }
    return $service;
}

function quaeriva_render_ad(string $placement): string
{
    try {
        return quaeriva_ad_service()?->render($placement) ?? '';
    } catch (Throwable $throwable) {
        error_log('[Quaeriva ad render] ' . $throwable::class . ': ' . $throwable->getMessage());
        return '';
    }
}

function quaeriva_custom_code_service(): ?CustomCodeService
{
    static $resolved = false;
    static $service = null;
    if ($resolved) return $service;
    $resolved = true;
    try {
        $service = new CustomCodeService(quaeriva_auth_pdo());
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Unable to create the custom code service.', $throwable);
        $service = null;
    }
    return $service;
}

/**
 * @param array<string, mixed>|null $currentUser
 */
function quaeriva_render_custom_code(string $position, string $pageType = '', string $locale = '', ?array $currentUser = null): string
{
    try {
        return quaeriva_custom_code_service()?->render($position, $pageType, $locale, $currentUser) ?? '';
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Custom code render failed.', $throwable, ['position' => $position]);
        return '';
    }
}

/**
 * Render the widgets an administrator placed in a theme section.
 *
 * Returns '' when the section is empty, which is what allows the layout to keep
 * its built-in markup for that region until someone configures it.
 */
function quaeriva_render_widget_section(string $section, Translator $translator): string
{
    static $renderer = null;

    try {
        if ($renderer === null) {
            $renderer = new PublicWidgetRenderer(Connection::connect(app_config('database')), new HtmlSanitizer(), static fn (string $placement): string => quaeriva_render_ad($placement));
        }

        return $renderer->renderSection(
            $section,
            $translator,
            static fn (string $path = '', array $query = []): string => quaeriva_url($path, $query)
        );
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Widget section render failed.', $throwable, ['section' => $section]);
        return '';
    }
}

/**
 * Visible items of a managed navigation menu.
 *
 * An empty result means the layout falls back to its built-in links, which is
 * also what happens before migration 026 has been applied.
 *
 * @param array<string, mixed>|null $currentUser
 * @return array<int, array<string, mixed>>
 */
function quaeriva_menu_items(string $menuKey, ?array $currentUser, string $locale): array
{
    try {
        $items = (new \App\Theme\MenuRepository(Connection::connect(app_config('database'))))
            ->visible($menuKey, $currentUser, $locale);
        $filtered = Hooks::filter(CoreHooks::MENU_ITEMS, $items, [
            'menu_key' => $menuKey,
            'locale' => $locale,
            'current_user' => $currentUser,
        ]);
        return is_array($filtered) ? array_values(array_filter($filtered, 'is_array')) : $items;
    } catch (Throwable) {
        return [];
    }
}

function quaeriva_render_between_answers_ad(int $position, ?int $totalAnswers = null): string
{
    try {
        return quaeriva_ad_service()?->renderBetweenAnswers($position, $totalAnswers) ?? '';
    } catch (Throwable $throwable) {
        error_log('[Quaeriva ad render] ' . $throwable::class . ': ' . $throwable->getMessage());
        return '';
    }
}

function quaeriva_topic_path(string $slug): string
{
    return 'd/' . rawurlencode(trim($slug));
}

function quaeriva_topic_url(string $slug, array $query = []): string
{
    return quaeriva_url(quaeriva_topic_path($slug), $query);
}

/** @param array<string,mixed> $input @param array<string,mixed> $query */
function quaeriva_preserve_topic_comments_page(array $input, array $query = []): array
{
    $page = max(1, min(1000000, (int) ($input['comments_page'] ?? 1)));
    if ($page > 1) $query['comments_page'] = $page;
    return $query;
}

/** @param array<string,mixed> $query */
function quaeriva_topics_route(
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    string $csrfToken
): array {
    try {
        $pdo = quaeriva_auth_pdo();
        $categories = quaeriva_load_categories();
        $categoryFilter = trim((string) ($query['category'] ?? ''));
        $categoryId = max(0, (int) ($query['category_id'] ?? 0));
        if ($categoryId <= 0 && $categoryFilter !== '') {
            foreach ($categories as $category) {
                if ((string) ($category['slug'] ?? '') === $categoryFilter || (string) ($category['id'] ?? '') === $categoryFilter) {
                    $categoryId = (int) ($category['id'] ?? 0);
                    break;
                }
            }
        }
        $filters = [
            'status' => 'published',
            'category_id' => $categoryId,
            'locale' => trim((string) ($query['locale'] ?? '')),
            'kind' => trim((string) ($query['kind'] ?? '')),
            'search' => trim((string) ($query['search'] ?? '')),
        ];
        $listing = (new TopicRepository($pdo))->listing($filters, max(1, (int) ($query['page'] ?? 1)), 20);
        $content = quaeriva_render_template('topics', [
            'translator' => $translator, 'localeContext' => $localeContext, 'site' => $site,
            'currentUser' => $currentUser, 'csrfToken' => $csrfToken, 'currentPath' => $currentPath,
            'topics' => $listing['items'], 'topicsMeta' => $listing, 'categories' => $categories,
            'languages' => (array) ($site['languages'] ?? []), 'filters' => $filters, 'mode' => 'listing',
            'error' => '', 'values' => [],
        ]);
        $description = $translator->get('topics.intro');
        return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('topics.title'), $description, 'topics', $currentUser, $csrfToken, null, false);
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Topic listing failed.', $throwable);
        return quaeriva_render_error(503, $site, $server, $query, 'errors/404');
    }
}

function quaeriva_topic_create_route(
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf,
    string $csrfToken
): array {
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    $values = [
        'title' => trim((string) ($input['title'] ?? '')),
        'body' => trim((string) ($input['body'] ?? '')),
        'content_locale' => quaeriva_resolve_content_locale($input['content_locale'] ?? null, $site, $translator),
        'category_id' => max(0, (int) ($input['category_id'] ?? 0)),
        'kind' => trim((string) ($input['kind'] ?? 'discussion')),
    ];
    $error = '';
    if ($method === 'POST') {
        if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
            $error = $translator->get('auth.csrf_error');
        } else {
            try {
                $pdo = quaeriva_auth_pdo();
                $policy = PostingPolicyService::fromSettings(new SettingsRepository($pdo));
                $rate = new RateLimitService($pdo);
                if (!$rate->consume('user:' . (int) $currentUser['id'], 'topic.create', max(1, $policy->int('question_rate_hour')), 3600)) throw new TranslatableException('topics.rate_limited');
                $nearDuplicate = quaeriva_recent_near_duplicate_post($pdo, 'topic', (int) $currentUser['id'], 0, $values['title'] . "
" . $values['body'], $policy->int('duplicate_content_minutes'));
                $ip = trim((string) ($server['REMOTE_ADDR'] ?? 'unknown'));
                $ipBurst = !$rate->consume('ip:' . $ip, 'topic.risk', max(10, $policy->int('question_rate_hour') * 4), 3600);
                $assessment = quaeriva_spam_assessment(new SettingsRepository($pdo), $currentUser, $input, $values['title'] . "
" . $values['body'], ['duplicate'=>$nearDuplicate,'ip_burst'=>$ipBurst]);
                $topicStatus = quaeriva_enforce_spam_disposition($assessment);
                $topicId = (new TopicService($pdo, $policy))->create(
                    (int) $currentUser['id'], $values['title'], $values['body'],
                    $values['category_id'] > 0 ? $values['category_id'] : null,
                    $values['content_locale'], $values['kind'], $topicStatus
                );
                if ($topicStatus === 'pending') return quaeriva_redirect(quaeriva_url('d', ['submitted'=>$topicId]), 303);
                $topic = (new TopicService($pdo))->find($topicId);
                return quaeriva_redirect(quaeriva_topic_url((string) ($topic['slug'] ?? ''), ['created' => 1]), 303);
            } catch (Throwable $throwable) {
                $error = $throwable instanceof TranslatableException ? $translator->get($throwable->translationKey, $throwable->replace) : $translator->get('topics.save_error');
            }
        }
    } elseif ($method !== 'GET' && $method !== 'HEAD') {
        return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    }
    $content = quaeriva_render_template('topics', [
        'translator' => $translator, 'localeContext' => $localeContext, 'site' => $site,
        'currentUser' => $currentUser, 'csrfToken' => $csrfToken, 'currentPath' => $currentPath,
        'topics' => [], 'topicsMeta' => [], 'categories' => quaeriva_load_categories(),
        'languages' => (array) ($site['languages'] ?? []), 'filters' => [], 'mode' => 'new',
        'error' => $error, 'values' => $values,
    ]);
    $response = quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('topics.start'), $translator->get('topics.start_intro'), 'topics', $currentUser, $csrfToken);
    if ($error !== '') $response['status'] = 422;
    return $response;
}

function quaeriva_topic_route(
    array $topic,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    string $csrfToken
): array {
    try {
        $pdo = quaeriva_auth_pdo();
        $topicId = (int) ($topic['id'] ?? 0);
        if (!isset($_SESSION['quaeriva_viewed_topics'])) $_SESSION['quaeriva_viewed_topics'] = [];
        if ($topicId > 0 && !isset($_SESSION['quaeriva_viewed_topics'][$topicId])) {
            $_SESSION['quaeriva_viewed_topics'][$topicId] = time();
            try { $pdo->prepare('UPDATE topics SET views_count=views_count+1 WHERE id=:id')->execute(['id' => $topicId]); $topic['views_count'] = (int) ($topic['views_count'] ?? 0) + 1; } catch (Throwable) {}
        }
        $commentsPage = max(1, (int) ($query['comments_page'] ?? 1));
        $comments = ($site['comments_enabled'] ?? true) === true ? (new CommentService($pdo))->forTopic($topicId, $commentsPage, 50) : ['comments' => [], 'total' => 0, 'page' => 1, 'per_page' => 50, 'total_pages' => 1];
        $reported = ['topic' => false, 'comment' => []];
        $isFollowing = false;
        if (is_array($currentUser) && (int) ($currentUser['id'] ?? 0) > 0) {
            $uid = (int) $currentUser['id'];
            if (($site['follows_enabled'] ?? true) === true) {
                try { $isFollowing = (new FollowService($pdo))->isFollowing($uid, 'topic', $topicId); } catch (Throwable) {}
            }
            // PDO native prepares do not permit reusing a named placeholder.
            // Keep the topic ids separate even though they carry the same value.
            $statement = $pdo->prepare("SELECT reportable_type,reportable_id FROM reports WHERE user_id=:user_id AND status IN ('open','reviewing') AND ((reportable_type='topic' AND reportable_id=:topic_id) OR (reportable_type='comment' AND reportable_id IN (SELECT id FROM comments WHERE commentable_type='topic' AND commentable_id=:comment_topic_id)))");
            $statement->execute(['user_id' => $uid, 'topic_id' => $topicId, 'comment_topic_id' => $topicId]);
            foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $report) {
                if (($report['reportable_type'] ?? '') === 'topic') $reported['topic'] = true;
                if (($report['reportable_type'] ?? '') === 'comment') $reported['comment'][] = (int) $report['reportable_id'];
            }
        }
        $seo = new SeoBuilder(quaeriva_seo_base_url($site, $server));
        $structured = $seo->jsonLdForTopic($topic);
        $content = quaeriva_render_template('topic', [
            'translator' => $translator, 'localeContext' => $localeContext, 'site' => $site,
            'currentUser' => $currentUser, 'csrfToken' => $csrfToken, 'currentPath' => $currentPath,
            'topic' => $topic, 'comments' => $comments, 'reportedItems' => $reported, 'isFollowing' => $isFollowing,
            'slug' => (string) ($topic['slug'] ?? ''), 'notice' => ($query['created'] ?? '') === '1' ? $translator->get('topics.created') : (($query['commented'] ?? '') === '1' ? $translator->get('topics.comment_success') : (($query['reported'] ?? '') === '1' ? $translator->get('topics.report_success') : (isset($query['followed']) ? $translator->get(((string) $query['followed'] === '1') ? 'topics.follow_success' : 'topics.unfollow_success') : ''))),
            'query' => $query,
            'postingRules' => quaeriva_posting_rules(),
        ]);
        $title = (string) ($topic['title'] ?? $translator->get('topics.not_found'));
        $description = quaeriva_meta_excerpt((string) ($topic['body'] ?? ''), 170, $translator->get('topics.intro'));
        $topicPath = quaeriva_topic_path((string) ($topic['slug'] ?? ''));
        $breadcrumbItems = [['label' => $translator->get('common.home'), 'url' => quaeriva_url()]];
        $topicCategoryName = trim((string) ($topic['category_name'] ?? ''));
        $topicCategorySlug = trim((string) ($topic['category_slug'] ?? ''));
        if ($topicCategoryName !== '') {
            $breadcrumbItems[] = [
                'label' => $topicCategoryName,
                'url' => $topicCategorySlug !== '' ? quaeriva_url('c/' . rawurlencode($topicCategorySlug)) : null,
            ];
        }
        $breadcrumbItems[] = ['label' => $title, 'url' => null];
        $response = quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $title, $description, 'topic', $currentUser, $csrfToken, $structured, false, $seo->canonicalUrl(CanonicalPolicy::forPage('topic', '/' . $topicPath, $query)), $topicPath, $breadcrumbItems);
        return $response;
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Topic page failed.', $throwable);
        return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
    }
}

function quaeriva_topic_follow_submit(
    string $slug, string $method, array $input, array $site, array $server,
    Translator $translator, LocaleContext $localeContext, string $currentPath, ?array $currentUser, CsrfService $csrf
): array {
    $returnUrl = quaeriva_topic_url($slug);
    if (($site['follows_enabled'] ?? true) !== true) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $returnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $topic = (new TopicRepository($pdo))->findBySlug($slug);
        if (!is_array($topic)) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
        $following = (new FollowService($pdo))->toggle((int) $currentUser['id'], 'topic', (int) $topic['id']);
        return quaeriva_redirect(quaeriva_topic_url($slug, ['followed' => $following ? 1 : 0]), 303);
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Topic follow state change failed.', $throwable, ['slug' => $slug]);
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $returnUrl, $translator->get('auth.error'), $translator->get('topics.follow_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_topic_comment_submit(
    string $slug, string $method, array $input, array $query, array $site, array $server,
    Translator $translator, LocaleContext $localeContext, string $currentPath, ?array $currentUser, CsrfService $csrf
): array {
    $returnUrl = quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) return quaeriva_render_message_page($translator, $localeContext, $site, $server, $returnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    try {
        $pdo = quaeriva_auth_pdo();
        $topic = (new TopicRepository($pdo))->findBySlug($slug);
        if (!is_array($topic)) throw new TranslatableException('topics.not_found');
        $body = trim((string) ($input['body'] ?? ''));
        $policy = PostingPolicyService::fromSettings(new SettingsRepository($pdo));
        $policy->validateComment($body);
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'comment.create', max(1, $policy->int('comment_rate_minute')), 60)) throw new TranslatableException('topics.rate_limited');
        $nearDuplicate = quaeriva_recent_near_duplicate_post($pdo, 'comment', (int) $currentUser['id'], (int) $topic['id'], $body, $policy->int('duplicate_content_minutes'));
        $assessment = quaeriva_spam_assessment(new SettingsRepository($pdo), $currentUser, $input, $body, ['duplicate'=>$nearDuplicate]);
        $commentStatus = quaeriva_enforce_spam_disposition($assessment);
        (new CommentService($pdo))->create((int) $currentUser['id'], 'topic', (int) $topic['id'], (new HtmlSanitizer())->sanitize($body), $policy->int('duplicate_content_minutes'), $commentStatus);
        if ($commentStatus === 'published') try {
            $followers = (new FollowService($pdo))->followerUserIds('topic', (int) $topic['id']);
            (new NotificationService($pdo, new SettingsRepository($pdo)))->notifyUsers(
                $followers,
                'new_comment',
                $translator->get('notifications.new_comment_title'),
                (string) ($topic['title'] ?? ''),
                quaeriva_absolute_app_url($site, $server, quaeriva_topic_path($slug)),
                [(int) $currentUser['id']]
            );
        } catch (Throwable $notifyError) {
            (new FileLogger())->error('Topic follower notification failed.', $notifyError, ['topic_id' => (int) $topic['id']]);
        }
        return quaeriva_redirect(quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input, ['commented' => 1])), 303);
    } catch (Throwable $throwable) {
        $message = $throwable instanceof TranslatableException ? $translator->get($throwable->translationKey, $throwable->replace) : $translator->get('topics.comment_error');
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $returnUrl, $translator->get('auth.error'), $message, 422, $currentUser, $csrf->token());
    }
}

/** @return list<array<string, mixed>> */
function quaeriva_taxonomy_route(
    string $type,
    string $slug,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    string $csrfToken
): array {
    try {
        $pdo = quaeriva_auth_pdo();
        $service = new TaxonomyService($pdo);
        $taxonomy = $type === 'tag' ? $service->findTagBySlug($slug) : $service->findCategoryBySlug($slug);
        if (!is_array($taxonomy)) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
        $isFollowing = false;
        if (($site['follows_enabled'] ?? true) === true && is_array($currentUser)) {
            try { $isFollowing = (new FollowService($pdo))->isFollowing((int) $currentUser['id'], $type, (int) $taxonomy['id']); } catch (Throwable) {}
        }
        $questions = $service->questionsFor($type, (int) $taxonomy['id']);
        $topics = $type === 'category' ? $service->topicsFor((int) $taxonomy['id'], max(1, (int) ($query['page'] ?? 1)), 20) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20, 'total_pages' => 1];
        $topicItems = is_array($topics['items'] ?? null) ? $topics['items'] : [];
        $content = quaeriva_render_template('taxonomy', [
            'translator' => $translator, 'site' => $site, 'currentUser' => $currentUser,
            'csrfToken' => $csrfToken, 'currentPath' => $currentPath, 'type' => $type,
            'taxonomy' => $taxonomy, 'questions' => $questions, 'topics' => $topics,
            'isFollowing' => $isFollowing,
        ]);
        $description = trim((string) ($taxonomy['seo_description'] ?? $taxonomy['description'] ?? ''));
        if ($description === '') $description = $translator->get($type === 'tag' ? 'taxonomy.tag_description' : 'taxonomy.category_description', ['name' => (string) ($taxonomy['name'] ?? '')]);
        $title = trim((string) ($taxonomy['seo_title'] ?? '')) ?: (string) ($taxonomy['name'] ?? '');
        $canonicalValue = trim((string) ($taxonomy['canonical_url'] ?? ''));
        $canonicalOverride = $canonicalValue === '' ? null : (str_starts_with($canonicalValue, '/') ? (new SeoBuilder(quaeriva_seo_base_url($site, $server)))->canonicalUrl($canonicalValue) : $canonicalValue);
        $noIndex = strtolower(trim((string) ($taxonomy['seo_index'] ?? 'inherit'))) === 'noindex' || ($questions === [] && $topicItems === []);
        $breadcrumbItems = [
            ['label' => $translator->get('common.home'), 'url' => quaeriva_url()],
            ['label' => $translator->get($type === 'tag' ? 'taxonomy.tag' : 'taxonomy.category'), 'url' => null],
            ['label' => $title, 'url' => null],
        ];
        return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $title, $description, $type === 'tag' ? 'tag' : 'category', $currentUser, $csrfToken, null, $noIndex, $canonicalOverride, null, $breadcrumbItems);
    } catch (Throwable) {
        return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    }
}

function quaeriva_taxonomy_follow_submit(
    string $type,
    string $slug,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $prefix = $type === 'tag' ? 't/' : 'c/';
    $taxonomyReturnUrl = quaeriva_url($prefix . rawurlencode($slug));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if (($site['follows_enabled'] ?? true) !== true) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) return quaeriva_render_message_page($translator,$localeContext,$site,$server,$taxonomyReturnUrl,$translator->get('auth.error'),$translator->get('auth.csrf_error'),403,$currentUser,$csrf->token());
    try {
        $pdo = quaeriva_auth_pdo();
        $service = new TaxonomyService($pdo);
        $taxonomy = $type === 'tag' ? $service->findTagBySlug($slug) : $service->findCategoryBySlug($slug);
        if (!is_array($taxonomy)) throw new InvalidArgumentException('Taxonomy not found.');
        (new FollowService($pdo))->toggle((int) $currentUser['id'], $type, (int) $taxonomy['id']);
        return quaeriva_redirect($taxonomyReturnUrl, 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator,$localeContext,$site,$server,$taxonomyReturnUrl,$translator->get('auth.error'),$translator->get('questions.follow_error'),422,$currentUser,$csrf->token());
    }
}

function quaeriva_load_categories(): array
{
    try {
        $pdo = quaeriva_auth_pdo();
        $statement = $pdo->query('SELECT id, name, parent_id, content_locale, sort_order FROM categories ORDER BY sort_order ASC, name ASC, id ASC');
        $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
        if ($rows === []) return [];

        $children = [];
        foreach ($rows as $row) {
            $parentId = isset($row['parent_id']) && $row['parent_id'] !== null ? (int) $row['parent_id'] : 0;
            $children[$parentId][] = $row;
        }
        $ordered = [];
        $seen = [];
        $walk = static function (int $parentId, int $depth) use (&$walk, &$children, &$ordered, &$seen): void {
            foreach ($children[$parentId] ?? [] as $row) {
                $id = (int) ($row['id'] ?? 0);
                if ($id <= 0 || isset($seen[$id])) continue;
                $seen[$id] = true;
                $row['depth'] = min(8, max(0, $depth));
                $ordered[] = $row;
                $walk($id, $depth + 1);
            }
        };
        $walk(0, 0);
        foreach ($rows as $row) {
            $id = (int) ($row['id'] ?? 0);
            if ($id <= 0 || isset($seen[$id])) continue;
            $row['depth'] = 0;
            $ordered[] = $row;
        }
        return $ordered;
    } catch (Throwable $throwable) {
        error_log('[Quaeriva categories] ' . $throwable::class . ': ' . $throwable->getMessage());
        return [];
    }
}

/** @param list<array<string,mixed>> $rows @param array<string,mixed> $site @param-out bool $hidden */
function quaeriva_filter_ask_taxonomy(array $rows, array $site, ?bool &$hidden = null): array
{
    $supported = array_values(array_unique(array_map(
        static fn (mixed $value): string => strtolower(str_replace('_', '-', trim((string) $value))),
        (array) ($site['supported_locales'] ?? [])
    )));
    $filtered = array_values(array_filter($rows, static function (array $row) use ($supported): bool {
        $locale = strtolower(str_replace('_', '-', trim((string) ($row['content_locale'] ?? ''))));
        return $locale !== '' && in_array($locale, $supported, true);
    }));
    $hidden = (bool) $hidden || count($filtered) < count($rows);
    return $filtered;
}

function quaeriva_load_tags(): array
{
    try {
        return (new TaxonomyService(quaeriva_auth_pdo()))->tags();
    } catch (Throwable) {
        return [];
    }
}

function quaeriva_question_submit(
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    if ($currentUser === null) {
        return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    }
    $csrfToken = $csrf->token();
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $currentPath, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrfToken);
    }

    $values = [
        'title' => trim((string) ($input['title'] ?? '')),
        'body' => trim((string) ($input['body'] ?? '')),
        'content_locale' => quaeriva_resolve_content_locale($input['content_locale'] ?? null, $site, $translator),
        'category_id' => max(0, (int) ($input['category_id'] ?? 0)),
        'tag_ids' => is_array($input['tag_ids'] ?? null) ? array_slice(array_values(array_unique(array_filter(array_map('intval', $input['tag_ids']), static fn (int $id): bool => $id > 0))), 0, 5) : [],
        'is_anonymous' => !empty($input['is_anonymous']),
    ];

    try {
        $pdo = quaeriva_auth_pdo();
        $settings = new SettingsRepository($pdo);
        $policy = PostingPolicyService::fromSettings($settings);
        $policy->validateQuestion($values['title'], $values['body'], count($values['tag_ids']));
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . (int) $currentUser['id'], 'question.create', max(1, $policy->int('question_rate_hour')), 3600)) {
            throw new TranslatableException('questions.rate_limited');
        }
        if (quaeriva_recent_duplicate_post($pdo, 'question', (int) $currentUser['id'], 0, $values['title'], $values['body'], $policy->int('duplicate_content_minutes'))) {
            throw new TranslatableException('questions.duplicate_post');
        }

        $nearDuplicate = quaeriva_recent_near_duplicate_post($pdo, 'question', (int) $currentUser['id'], 0, $values['title'] . "
" . $values['body'], $policy->int('duplicate_content_minutes'));
        $ip = trim((string) ($server['REMOTE_ADDR'] ?? 'unknown'));
        $ipBurst = !$rate->consume('ip:' . $ip, 'question.risk', max(10, $policy->int('question_rate_hour') * 4), 3600);
        $assessment = quaeriva_spam_assessment($settings, $currentUser, $input, $values['title'] . "
" . $values['body'], ['duplicate'=>$nearDuplicate,'ip_burst'=>$ipBurst]);
        $riskStatus = quaeriva_enforce_spam_disposition($assessment);
        $modMode = (string) $settings->get('community.question_moderation', 'all');
        $questionService = new QuestionService($pdo);
        $hasPublished = $questionService->hasPublishedBefore((int) $currentUser['id']);
        $initialStatus = $questionService->decideStatus($currentUser, $modMode, $hasPublished);
        if ($riskStatus === 'pending') $initialStatus = 'pending';

        $questionId = $questionService->create(
            (int) $currentUser['id'],
            $values['title'],
            $values['body'],
            $values['category_id'] > 0 ? $values['category_id'] : null,
            $values['content_locale'],
            $values['is_anonymous'],
            $values['tag_ids'],
            $initialStatus
        );
        try {
            (new NotificationService($pdo, $settings))->notifyAdmins(
                'new_question',
                $translator->get('notifications.new_question_title'),
                $values['title'],
                quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), [
                    'page' => 'questions',
                    'status' => $initialStatus === 'pending' ? 'pending' : 'published',
                    'review_question' => $questionId
                ])
            );
        } catch (Throwable) {
            // The question is authoritative; notification channels are best-effort.
        }

        if ($initialStatus === 'published') {
            $createdQuestion = $questionService->find($questionId);
            $targetSlug = is_array($createdQuestion) && !empty($createdQuestion['slug']) ? (string) $createdQuestion['slug'] : (string) $questionId;
            return quaeriva_redirect(quaeriva_url(function_exists('quaeriva_question_path') ? quaeriva_question_path($targetSlug, $questionId) : 'q/' . $targetSlug), 303);
        }

        return quaeriva_redirect(quaeriva_url('', ['submitted' => $questionId]), 303);
    } catch (TranslatableException $exception) {
        $error = $exception->translate($translator);
    } catch (InvalidArgumentException) {
        $error = $translator->get('questions.submit_error');
    } catch (Throwable) {
        $error = $translator->get('questions.submit_error');
    }

    $taxonomyUnavailable = false;
    $categoryData = quaeriva_filter_ask_taxonomy(quaeriva_load_categories(), $site, $taxonomyUnavailable);
    $tagData = quaeriva_filter_ask_taxonomy(quaeriva_load_tags(), $site, $taxonomyUnavailable);
    $content = quaeriva_render_template('ask', [
        'translator' => $translator,
        'localeContext' => $localeContext,
        'site' => $site,
        'currentUser' => $currentUser,
        'csrfToken' => $csrfToken,
        'categories' => $categoryData,
        'tags' => $tagData,
        'taxonomyUnavailable' => $taxonomyUnavailable,
        'values' => $values,
        'postingRules' => quaeriva_posting_rules(),
        'error' => $error,
    ]);
    $response = quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('questions.ask'), $translator->get('questions.ask_intro'), 'ask', $currentUser, $csrfToken);
    $response['status'] = 422;
    return $response;
}

function quaeriva_report_submit(
    string $slug,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);
        if (!is_array($question)) throw new InvalidArgumentException('Question not found.');
        $reason = strtolower(trim((string) ($input['reason'] ?? 'other')));
        if (!in_array($reason, ['spam', 'abuse', 'duplicate', 'misleading', 'other'], true)) $reason = 'other';
        $notes = trim((string) ($input['notes'] ?? ''));
        if ((function_exists('mb_strlen') ? mb_strlen($notes, 'UTF-8') : strlen($notes)) > 1000) throw new InvalidArgumentException('Report notes are too long.');
        $userId = (int) $currentUser['id'];
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . $userId, 'report.create', 10, 3600)) throw new InvalidArgumentException('Rate limited.');
        $existing = $pdo->prepare("SELECT id FROM reports WHERE reportable_type='question' AND reportable_id=:id AND user_id=:user_id AND status IN ('open','reviewing') LIMIT 1");
        $existing->execute(['id'=>(int)$question['id'],'user_id'=>$userId]);
        if ($existing->fetchColumn() !== false) {
            return quaeriva_redirect(quaeriva_question_url($slug, 0, ['reported' => 'duplicate']), 303);
        }
        $statement = $pdo->prepare('INSERT INTO reports (reportable_type, reportable_id, user_id, reason, status, notes, created_at, updated_at) VALUES (:type, :id, :user_id, :reason, :status, :notes, NOW(), NOW())');
        $statement->execute(['type' => 'question', 'id' => (int) $question['id'], 'user_id' => $userId, 'reason' => $reason, 'status' => 'open', 'notes' => $notes !== '' ? $notes : null]);
        $createdReportId = (int) $pdo->lastInsertId();
        if ($createdReportId > 0) {
            try {
                $settings = new SettingsRepository($pdo);
                (new NotificationService($pdo, $settings))->notifyAdmins('new_report', $translator->get('notifications.new_report_title'), (string) $question['title'], quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), ['page' => 'reports', 'view_report' => $createdReportId]));
            } catch (Throwable) {}
        }
        return quaeriva_redirect(quaeriva_question_url($slug, 0, ['reported' => 1]), 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.report_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_topic_report_submit(
    string $slug,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $topicReturnUrl = quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $topicReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $statement = $pdo->prepare("SELECT id,title,slug FROM topics WHERE slug=:slug AND status='published' LIMIT 1");
        $statement->execute(['slug' => $slug]);
        $topic = $statement->fetch(PDO::FETCH_ASSOC);
        if (!is_array($topic)) throw new InvalidArgumentException('Topic not found.');
        $reason = strtolower(trim((string) ($input['reason'] ?? 'other')));
        if (!in_array($reason, ['spam', 'abuse', 'duplicate', 'misleading', 'other'], true)) $reason = 'other';
        $notes = trim((string) ($input['notes'] ?? ''));
        if ((function_exists('mb_strlen') ? mb_strlen($notes, 'UTF-8') : strlen($notes)) > 1000) throw new InvalidArgumentException('Report notes are too long.');
        $userId = (int) $currentUser['id'];
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . $userId, 'report.create', 10, 3600)) throw new InvalidArgumentException('Rate limited.');
        $existing = $pdo->prepare("SELECT id FROM reports WHERE reportable_type='topic' AND reportable_id=:id AND user_id=:user_id AND status IN ('open','reviewing') LIMIT 1");
        $existing->execute(['id' => (int) $topic['id'], 'user_id' => $userId]);
        if ($existing->fetchColumn() !== false) return quaeriva_redirect(quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input, ['reported' => 'duplicate'])), 303);
        $insert = $pdo->prepare('INSERT INTO reports (reportable_type, reportable_id, user_id, reason, status, notes, created_at, updated_at) VALUES (:type, :id, :user_id, :reason, :status, :notes, NOW(), NOW())');
        $insert->execute(['type' => 'topic', 'id' => (int) $topic['id'], 'user_id' => $userId, 'reason' => $reason, 'status' => 'open', 'notes' => $notes !== '' ? $notes : null]);
        $reportId = (int) $pdo->lastInsertId();
        if ($reportId > 0) {
            try {
                $settings = new SettingsRepository($pdo);
                (new NotificationService($pdo, $settings))->notifyAdmins('new_report', $translator->get('notifications.new_report_title'), (string) $topic['title'], quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), ['page' => 'reports', 'view_report' => $reportId]));
            } catch (Throwable) {}
        }
        return quaeriva_redirect(quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input, ['reported' => 1])), 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $topicReturnUrl, $translator->get('auth.error'), $translator->get('topics.report_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_topic_comment_report_submit(
    string $slug,
    int $commentId,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $topicReturnUrl = quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($commentId <= 0 || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $topicReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $target = $pdo->prepare(
            "SELECT c.id,t.title,t.slug FROM comments c INNER JOIN topics t ON t.id=c.commentable_id
             WHERE c.id=:comment_id AND c.commentable_type='topic' AND c.status='published' AND t.slug=:slug AND t.status='published' LIMIT 1"
        );
        $target->execute(['comment_id' => $commentId, 'slug' => $slug]);
        $comment = $target->fetch(PDO::FETCH_ASSOC);
        if (!is_array($comment)) throw new InvalidArgumentException('Comment not found.');
        $reason = strtolower(trim((string) ($input['reason'] ?? 'other')));
        if (!in_array($reason, ['spam', 'abuse', 'duplicate', 'misleading', 'other'], true)) $reason = 'other';
        $notes = trim((string) ($input['notes'] ?? ''));
        if ((function_exists('mb_strlen') ? mb_strlen($notes, 'UTF-8') : strlen($notes)) > 1000) throw new InvalidArgumentException('Report notes are too long.');
        $userId = (int) $currentUser['id'];
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . $userId, 'report.create', 10, 3600)) throw new InvalidArgumentException('Rate limited.');
        $existing = $pdo->prepare("SELECT id FROM reports WHERE reportable_type='comment' AND reportable_id=:id AND user_id=:user_id AND status IN ('open','reviewing') LIMIT 1");
        $existing->execute(['id' => $commentId, 'user_id' => $userId]);
        if ($existing->fetchColumn() !== false) return quaeriva_redirect(quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input, ['reported' => 'duplicate'])) . '#comment-' . $commentId, 303);
        $insert = $pdo->prepare("INSERT INTO reports (reportable_type,reportable_id,user_id,reason,status,notes,created_at,updated_at) VALUES ('comment',:id,:user_id,:reason,'open',:notes,NOW(),NOW())");
        $insert->execute(['id' => $commentId, 'user_id' => $userId, 'reason' => $reason, 'notes' => $notes !== '' ? $notes : null]);
        return quaeriva_redirect(quaeriva_topic_url($slug, quaeriva_preserve_topic_comments_page($input, ['reported' => 1])) . '#comment-' . $commentId, 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $topicReturnUrl, $translator->get('auth.error'), $translator->get('topics.report_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_answer_report_submit(
    string $slug,
    int $answerId,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input)) . '#answer-' . $answerId;
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($answerId <= 0) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $target = $pdo->prepare(
            "SELECT a.id, a.question_id, q.title, q.slug
             FROM answers a
             INNER JOIN questions q ON q.id = a.question_id
             WHERE a.id = :answer_id AND q.slug = :slug AND q.status = 'published'
             LIMIT 1"
        );
        $target->execute(['answer_id' => $answerId, 'slug' => $slug]);
        $answer = $target->fetch(PDO::FETCH_ASSOC);
        if (!is_array($answer)) throw new InvalidArgumentException('Answer not found.');
        $reason = (string) ($input['reason'] ?? 'other');
        if (!in_array($reason, ['spam', 'abuse', 'duplicate', 'misleading', 'other'], true)) $reason = 'other';
        $notes = trim((string) ($input['notes'] ?? ''));
        if ((function_exists('mb_strlen') ? mb_strlen($notes, 'UTF-8') : strlen($notes)) > 1000) {
            throw new InvalidArgumentException('Report notes are too long.');
        }
        $userId = (int) $currentUser['id'];
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . $userId, 'report.create', 10, 3600)) throw new InvalidArgumentException('Rate limited.');
        $existing = $pdo->prepare("SELECT id FROM reports WHERE reportable_type='answer' AND reportable_id=:id AND user_id=:user_id AND status IN ('open','reviewing') LIMIT 1");
        $existing->execute(['id'=>$answerId,'user_id'=>$userId]);
        if ($existing->fetchColumn() !== false) {
            return quaeriva_redirect(quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['reported' => 'duplicate'])) . '#answer-' . $answerId, 303);
        }
        $statement = $pdo->prepare('INSERT INTO reports (reportable_type, reportable_id, user_id, reason, status, notes, created_at, updated_at) VALUES (:type, :id, :user_id, :reason, :status, :notes, NOW(), NOW())');
        $statement->execute(['type' => 'answer', 'id' => $answerId, 'user_id' => $userId, 'reason' => $reason, 'status' => 'open', 'notes' => $notes !== '' ? $notes : null]);
        $createdReportId = (int) $pdo->lastInsertId();
        if ($createdReportId > 0) {
            try {
                $settings = new SettingsRepository($pdo);
                (new NotificationService($pdo, $settings))->notifyAdmins('new_report', $translator->get('notifications.new_report_title'), (string) $answer['title'], quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), ['page' => 'reports', 'view_report' => $createdReportId]));
            } catch (Throwable) {}
        }
        return quaeriva_redirect(quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['reported' => 1])) . '#answer-' . $answerId, 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.report_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_contact_submit(
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $csrfToken = $csrf->token();
    $values = [
        'name' => trim((string) ($input['name'] ?? '')),
        'email' => trim((string) ($input['email'] ?? '')),
        'subject' => trim((string) ($input['subject'] ?? '')),
        'message' => trim((string) ($input['message'] ?? '')),
    ];
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        $error = $translator->get('auth.csrf_error');
    } else {
        try {
            $pdo = quaeriva_auth_pdo();
            $formGuard = FormBotGuard::sessionGuard()->inspect($input);
            if (!($formGuard['valid_signature'] ?? false) || ($formGuard['honeypot'] ?? false) || ($formGuard['too_fast'] ?? false)) throw new InvalidArgumentException($translator->get('contact.error'));
            if (!quaeriva_verify_turnstile($pdo, $input, $server)) throw new InvalidArgumentException($translator->get('auth.challenge_failed'));
            $ip = trim((string) ($server['REMOTE_ADDR'] ?? 'unknown'));
            $rate = new RateLimitService($pdo);
            if (!$rate->consume('contact:' . $ip, 'contact.create', 5, 3600)) throw new InvalidArgumentException($translator->get('contact.rate_limited'));
            $id = (new ContactService($pdo))->create($values['name'], $values['email'], $values['subject'], $values['message'], $translator->locale(), $ip);
            try {
                $settings = new SettingsRepository($pdo);
                (new NotificationService($pdo, $settings))->notifyAdmins('contact_message', $translator->get('notifications.contact_message_title'), $values['subject'] !== '' ? $values['subject'] : $values['message'], quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), ['page' => 'messages', 'edit_message' => $id]));
            } catch (Throwable) {}
            return quaeriva_redirect(quaeriva_url('contact', ['sent' => 1]), 303);
        } catch (InvalidArgumentException) {
            $error = $translator->get('contact.error');
        } catch (Throwable) {
            $error = $translator->get('contact.error');
        }
    }
    $content = quaeriva_render_template('contact', ['translator' => $translator, 'site' => $site, 'csrfToken' => $csrfToken, 'values' => $values, 'error' => $error, 'notice' => '']);
    $response = quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('contact.title'), $translator->get('contact.intro'), 'contact', $currentUser, $csrfToken);
    $response['status'] = 422;
    return $response;
}

/** @return list<int> */
function quaeriva_question_notification_recipients(PDO $pdo, int $questionId, ?int $extraUserId = null): array
{
    $ids = [];
    $owner = $pdo->prepare('SELECT user_id FROM questions WHERE id=:id LIMIT 1');
    $owner->execute(['id' => $questionId]);
    $ownerId = (int) $owner->fetchColumn();
    if ($ownerId > 0) $ids[] = $ownerId;
    if ($extraUserId !== null && $extraUserId > 0) $ids[] = $extraUserId;
    try {
        $ids = array_merge($ids, (new FollowService($pdo))->followerUserIds('question', $questionId));
    } catch (Throwable) {}
    return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}

function quaeriva_comment_submit(
    string $slug,
    ?int $answerId,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input));
    if (($site['comments_enabled'] ?? true) !== true) {
        return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    }
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $questionReturnUrl]), 303);
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);
        if (!is_array($question)) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
        $userId = (int) $currentUser['id'];
        $settings = new SettingsRepository($pdo);
        $policy = PostingPolicyService::fromSettings($settings);
        $commentBody = (string) ($input['body'] ?? '');
        $policy->validateComment($commentBody);
        $rateLimit = new RateLimitService($pdo);
        $ip = trim((string) ($server['REMOTE_ADDR'] ?? 'unknown'));
        if (!$rateLimit->consume('user:' . $userId, 'comment.create.burst', max(1, $policy->int('comment_rate_minute')), 60)
            || !$rateLimit->consume('user:' . $userId, 'comment.create', max(1, $policy->int('comment_rate_hour')), 3600)
            || !$rateLimit->consume('ip:' . $ip, 'comment.create', max(10, $policy->int('comment_rate_hour') * 2), 3600)) {
            throw new TranslatableException('questions.comment_rate_limited');
        }
        $commentableType = $answerId === null ? 'question' : 'answer';
        $commentableId = $answerId === null ? (int) $question['id'] : $answerId;
        $nearDuplicate = quaeriva_recent_near_duplicate_post($pdo, 'comment', $userId, $commentableId, $commentBody, $policy->int('duplicate_content_minutes'));
        $assessment = quaeriva_spam_assessment($settings, $currentUser, $input, $commentBody, ['duplicate'=>$nearDuplicate]);
        $commentStatus = quaeriva_enforce_spam_disposition($assessment);
        (new CommentService($pdo))->create($userId, $commentableType, $commentableId, $commentBody, $policy->int('duplicate_content_minutes'), $commentStatus);

        $extraUserId = null;
        if ($answerId !== null) {
            $owner = $pdo->prepare('SELECT user_id FROM answers WHERE id=:id AND question_id=:question_id LIMIT 1');
            $owner->execute(['id' => $answerId, 'question_id' => (int) $question['id']]);
            $extraUserId = (int) $owner->fetchColumn() ?: null;
        } else {
            $extraUserId = (int) ($question['user_id'] ?? 0) ?: null;
        }
        try {
            $settings = new SettingsRepository($pdo);
            if ($commentStatus === 'published' && (bool) $settings->get('notifications.new_comment', true)) {
                (new NotificationService($pdo, $settings))->notifyUsers(
                    quaeriva_question_notification_recipients($pdo, (int) $question['id'], $extraUserId),
                    'new_comment',
                    $translator->get('notifications.new_comment_title'),
                    (string) $question['title'],
                    quaeriva_absolute_app_url($site, $server, quaeriva_question_path($slug)),
                    [$userId]
                );
            }
        } catch (Throwable) {}
        $fragment = $answerId === null ? '#question-comments' : '#answer-' . $answerId . '-comments';
        $target = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['commented' => $commentStatus === 'published' ? ($answerId === null ? 'question' : 'answer') : 'pending'])) . $fragment;
        return quaeriva_redirect($target, 303);
    } catch (TranslatableException $exception) {
        $message = $exception->translate($translator);
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $message, 422, $currentUser, $csrf->token());
    } catch (InvalidArgumentException) {
        $message = $translator->get('questions.comment_error');
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $message, 422, $currentUser, $csrf->token());
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.comment_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_comment_report_submit(
    string $slug,
    int $commentId,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input));
    if ($method !== 'POST') return quaeriva_render_error(405, $site, $server, [], 'errors/405');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($commentId <= 0) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $target = $pdo->prepare(
            "SELECT c.id,c.commentable_type,c.commentable_id,c.body,
                    COALESCE(q_direct.id,q_answer.id) AS question_id,
                    COALESCE(q_direct.title,q_answer.title) AS question_title,
                    COALESCE(q_direct.slug,q_answer.slug) AS question_slug,
                    CASE WHEN c.commentable_type='answer' THEN c.commentable_id ELSE NULL END AS answer_id
             FROM comments c
             LEFT JOIN questions q_direct ON c.commentable_type='question' AND q_direct.id=c.commentable_id
             LEFT JOIN answers a ON c.commentable_type='answer' AND a.id=c.commentable_id
             LEFT JOIN questions q_answer ON q_answer.id=a.question_id
             WHERE c.id=:comment_id
               AND COALESCE(q_direct.slug,q_answer.slug)=:slug
               AND COALESCE(q_direct.status,q_answer.status)='published'
               AND c.status='published'
             LIMIT 1"
        );
        $target->execute(['comment_id' => $commentId, 'slug' => $slug]);
        $comment = $target->fetch(PDO::FETCH_ASSOC);
        if (!is_array($comment)) throw new InvalidArgumentException('Comment not found.');
        $reason = strtolower(trim((string) ($input['reason'] ?? 'other')));
        if (!in_array($reason, ['spam','abuse','duplicate','misleading','other'], true)) $reason = 'other';
        $notes = trim((string) ($input['notes'] ?? ''));
        if ((function_exists('mb_strlen') ? mb_strlen($notes, 'UTF-8') : strlen($notes)) > 1000) throw new InvalidArgumentException('Report notes are too long.');
        $userId = (int) $currentUser['id'];
        $rate = new RateLimitService($pdo);
        if (!$rate->consume('user:' . $userId, 'report.create', 10, 3600)) throw new InvalidArgumentException('Rate limited.');
        $existing = $pdo->prepare("SELECT id FROM reports WHERE reportable_type='comment' AND reportable_id=:id AND user_id=:user_id AND status IN ('open','reviewing') LIMIT 1");
        $existing->execute(['id'=>$commentId,'user_id'=>$userId]);
        if ($existing->fetchColumn() !== false) {
            return quaeriva_redirect(quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['reported' => 'duplicate'])) . '#comment-' . $commentId, 303);
        }
        $statement = $pdo->prepare("INSERT INTO reports (reportable_type,reportable_id,user_id,reason,status,notes,created_at,updated_at) VALUES ('comment',:id,:user_id,:reason,'open',:notes,NOW(),NOW())");
        $statement->execute(['id'=>$commentId,'user_id'=>$userId,'reason'=>$reason,'notes'=>$notes !== '' ? $notes : null]);
        $reportId = (int) $pdo->lastInsertId();
        if ($reportId > 0) {
            try {
                $settings = new SettingsRepository($pdo);
                (new NotificationService($pdo, $settings))->notifyAdmins('new_report', $translator->get('notifications.new_report_title'), (string) ($comment['question_title'] ?? ''), quaeriva_absolute_app_url($site, $server, quaeriva_admin_path(), ['page' => 'reports', 'view_report' => $reportId]));
            } catch (Throwable) {}
        }
        return quaeriva_redirect(quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['reported' => 1])) . '#comment-' . $commentId, 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.report_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_follow_submit(
    string $slug,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug);
    if (($site['follows_enabled'] ?? true) !== true) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);
        if (!is_array($question)) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
        $following = (new FollowService($pdo))->toggle((int) $currentUser['id'], 'question', (int) $question['id']);
        return quaeriva_redirect(quaeriva_question_url($slug, 0, ['followed' => $following ? 1 : 0]), 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.follow_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_notifications_route(
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    string $csrfToken
): array {
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    try {
        $pdo = quaeriva_auth_pdo();
        $service = new NotificationService($pdo, new SettingsRepository($pdo));
        $notifications = $service->inbox((int) $currentUser['id']);
        $preferences = $service->preferences((int) $currentUser['id']);
    } catch (Throwable) {
        $notifications = [];
        $preferences = ['new_answer'=>['in_app'=>true,'email'=>false],'new_comment'=>['in_app'=>true,'email'=>false],'answer_accepted'=>['in_app'=>true,'email'=>false],'followed_question'=>['in_app'=>true,'email'=>false]];
    }
    $content = quaeriva_render_template('notifications', [
        'translator' => $translator,
        'notifications' => $notifications,
        'preferences' => $preferences,
        'csrfToken' => $csrfToken,
        'site' => $site,
    ]);
    return quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $translator->get('notifications.title'), $translator->get('notifications.intro'), 'notifications', $currentUser, $csrfToken, null, true);
}

function quaeriva_notifications_action(
    string $action,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $notificationsReturnUrl = quaeriva_url('notifications');
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => quaeriva_url('notifications')]), 303);
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $notificationsReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $service = new NotificationService($pdo, new SettingsRepository($pdo));
        if ($action === 'read') {
            $service->markRead((int) $currentUser['id'], max(0, (int) ($input['notification_id'] ?? 0)));
        } else {
            foreach (['new_answer', 'new_comment', 'answer_accepted', 'followed_question'] as $event) {
                $service->savePreference(
                    (int) $currentUser['id'],
                    $event,
                    !empty($input[$event . '_in_app']),
                    !empty($input[$event . '_email'])
                );
            }
        }
        return quaeriva_redirect(quaeriva_url('notifications', ['saved' => 1]), 303);
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $notificationsReturnUrl, $translator->get('auth.error'), $translator->get('auth.service_error'), 500, $currentUser, $csrf->token());
    }
}

/**
 * Render a draft through the same formatter that renders published content.
 *
 * The editor keeps its plain textarea, so the preview is what makes the
 * formatting visible. Rendering it here rather than in JavaScript means there
 * is exactly one Markdown implementation: the preview cannot drift from the
 * published post, and no sanitiser has to be duplicated client-side.
 *
 * @param array<string, mixed> $input
 * @param array<string, mixed>|null $currentUser
 * @return array<string, mixed>
 */
function quaeriva_rich_text_preview(array $input, ?array $currentUser, CsrfService $csrf): array
{
    $json = static function (int $status, array $payload): array {
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return [
            'status' => $status,
            'headers' => [
                'Content-Type' => 'application/json; charset=UTF-8',
                'Cache-Control' => 'private, no-store',
                'X-Content-Type-Options' => 'nosniff',
            ],
            'body' => is_string($body) ? $body : '{"ok":false}',
        ];
    };

    if ($currentUser === null || (int) ($currentUser['id'] ?? 0) <= 0) {
        return $json(401, ['ok' => false, 'error' => 'authentication_required']);
    }

    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return $json(403, ['ok' => false, 'error' => 'csrf']);
    }

    $mode = ((string) ($input['mode'] ?? 'full')) === 'comment' ? 'comment' : 'full';
    $body = (string) ($input['body'] ?? '');
    $limit = $mode === 'comment' ? 600 : 20000;

    if (function_exists('mb_substr') && mb_strlen($body, 'UTF-8') > $limit) {
        $body = mb_substr($body, 0, $limit, 'UTF-8');
    } elseif (strlen($body) > $limit * 4) {
        $body = substr($body, 0, $limit * 4);
    }

    return $json(200, ['ok' => true, 'html' => quaeriva_rich_text($body, $mode)]);
}

/**
 * Content image upload, restricted to moderators and administrators.
 *
 * Ordinary members cannot reach this. Widening it would mean per-account
 * quotas, orphan cleanup and abuse review, none of which exist yet -- the
 * limited audience is the reason this stays a small feature rather than a
 * storage subsystem.
 */
function quaeriva_content_image_upload(array $input, ?array $currentUser, CsrfService $csrf, array $server): array
{
    $json = static function (int $status, array $payload): array {
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return [
            'status' => $status,
            'headers' => [
                'Content-Type' => 'application/json; charset=UTF-8',
                'Cache-Control' => 'private, no-store',
                'X-Content-Type-Options' => 'nosniff',
            ],
            'body' => is_string($body) ? $body : '{"ok":false}',
        ];
    };

    if ($currentUser === null || (int) ($currentUser['id'] ?? 0) <= 0) {
        return $json(401, ['ok' => false, 'error' => 'authentication_required']);
    }
    if (!quaeriva_user_has_role($currentUser, 'admin', 'moderator')) {
        return $json(403, ['ok' => false, 'error' => 'forbidden']);
    }
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return $json(403, ['ok' => false, 'error' => 'csrf']);
    }

    try {
        $pdo = quaeriva_auth_pdo();
        $rate = new RateLimitService($pdo);
        // A moderator pasting screenshots is normal; a script emptying the disk
        // is not, so the ceiling is generous but present.
        if (!$rate->consume('user:' . (int) $currentUser['id'], 'content.image', 60, 3600)) {
            return $json(429, ['ok' => false, 'error' => 'rate_limited']);
        }
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Content image rate limit check failed.', $throwable);
    }

    if (!isset($_FILES['image']) || !is_array($_FILES['image'])) {
        return $json(422, ['ok' => false, 'error' => 'no_file']);
    }

    try {
        $url = (new ImageUploadService('content'))->store($_FILES['image']);
    } catch (TranslatableException $exception) {
        return $json(422, ['ok' => false, 'error' => $exception->translationKey]);
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Content image upload failed.', $throwable);
        return $json(500, ['ok' => false, 'error' => 'upload_failed']);
    }

    if ($url === '') {
        return $json(422, ['ok' => false, 'error' => 'no_file']);
    }

    // The renderer only embeds what it recognises as ours, so the same check
    // decides here whether the upload is usable before it is offered back.
    if (!\App\Content\RichTextFormatter::isEmbeddableImage($url)) {
        (new FileLogger())->error('Stored image is not embeddable.', new RuntimeException($url));
        return $json(500, ['ok' => false, 'error' => 'upload_failed']);
    }

    return $json(200, ['ok' => true, 'url' => $url]);
}

function quaeriva_tag_create_submit(
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $json = static function (int $status, array $payload): array {
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return [
            'status' => $status,
            'headers' => ['Content-Type' => 'application/json; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'],
            'body' => is_string($body) ? $body : '{"ok":false}',
        ];
    };
    if ($method !== 'POST') return $json(405, ['ok' => false, 'error' => 'method_not_allowed']);
    if ($currentUser === null || (int) ($currentUser['id'] ?? 0) <= 0) return $json(401, ['ok' => false, 'error' => 'authentication_required']);
    if (!$csrf->validate((string) ($input['_csrf'] ?? ''))) return $json(403, ['ok' => false, 'error' => 'csrf', 'message' => $translator->get('auth.csrf_error')]);

    try {
        $pdo = quaeriva_auth_pdo();
        $settings = new SettingsRepository($pdo);
        $policy = PostingPolicyService::fromSettings($settings);
        $policy->validateTagName((string) ($input['name'] ?? ''));
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'tag.create', max(1, $policy->int('tag_create_rate_hour')), 3600)) {
            return $json(429, ['ok' => false, 'error' => 'rate_limited', 'message' => $translator->get('questions.tags_create_rate_limited')]);
        }
        $tag = (new TagCreationService($pdo))->createOrFind(
            (int) $currentUser['id'],
            (string) ($input['name'] ?? ''),
            quaeriva_resolve_content_locale($input['content_locale'] ?? null, $site, $translator)
        );
        return $json(($tag['created'] ?? false) ? 201 : 200, ['ok' => true, 'tag' => $tag]);
    } catch (TranslatableException $exception) {
        return $json(422, ['ok' => false, 'error' => 'invalid_tag', 'message' => $exception->translate($translator)]);
    } catch (InvalidArgumentException) {
        return $json(422, ['ok' => false, 'error' => 'invalid_tag', 'message' => $translator->get('questions.tags_create_failed')]);
    } catch (Throwable $throwable) {
        error_log('[Quaeriva tag create] ' . $throwable::class . ': ' . $throwable->getMessage());
        return $json(500, ['ok' => false, 'error' => 'tag_create_failed', 'message' => $translator->get('questions.tags_create_failed')]);
    }
}

function quaeriva_duplicate_suggestions(array $query, array $site, array $server, Translator $translator): array
{
    if (($site['duplicate_suggestions_enabled'] ?? true) !== true) {
        return ['status' => 404, 'headers' => ['Content-Type' => 'application/json; charset=UTF-8', 'Cache-Control' => 'no-store'], 'body' => json_encode(['questions' => []], JSON_UNESCAPED_UNICODE) ?: '{"questions":[]}'];
    }
    $title = trim((string) ($query['q'] ?? ''));
    $items = [];
    if ($title !== '') {
        try {
            $pdo = quaeriva_auth_pdo();
            $ip = trim((string) ($server['REMOTE_ADDR'] ?? 'unknown'));
            if ((new RateLimitService($pdo))->consume('suggest:' . $ip, 'question.suggest', 60, 300)) {
                $contentLocale = is_scalar($query['content_locale'] ?? null) ? (string) $query['content_locale'] : null;
                $categoryId = max(0, (int) ($query['category_id'] ?? 0));
                $tagIds = is_array($query['tag_ids'] ?? null) ? array_values(array_map('intval', $query['tag_ids'])) : [];
                foreach ((new DuplicateDetectionService($pdo))->findSimilar($title, 5, $contentLocale, $categoryId > 0 ? $categoryId : null, $tagIds) as $row) {
                    $items[] = [
                        'title' => (string) ($row['title'] ?? ''),
                        'url' => quaeriva_question_url((string) ($row['slug'] ?? ''), (int) ($row['id'] ?? 0)),
                        'answers_count' => (int) ($row['answers_count'] ?? 0),
                    ];
                }
            }
        } catch (Throwable) {}
    }
    $body = json_encode(['questions' => $items], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    return [
        'status' => 200,
        'headers' => ['Content-Type' => 'application/json; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'],
        'body' => is_string($body) ? $body : '{"questions":[]}',
    ];
}

/** @param array<string, mixed> $input @param array<string, mixed> $query @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_answer_submit(
    string $slug,
    string $method,
    array $input,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug);
    if ($currentUser === null) {
        return quaeriva_redirect(quaeriva_url('login', ['redirect' => $questionReturnUrl]), 303);
    }

    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }

    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);

        if (!is_array($question)) {
            return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
        }
        $settings = new SettingsRepository($pdo);
        $policy = PostingPolicyService::fromSettings($settings);
        $answerBody = (string) ($input['body'] ?? '');
        $policy->validateAnswer($answerBody);
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'answer.create', max(1, $policy->int('answer_rate_hour')), 3600)) {
            throw new InvalidArgumentException($translator->get('questions.rate_limited'));
        }
        if (quaeriva_recent_duplicate_post($pdo, 'answer', (int) $currentUser['id'], (int) $question['id'], '', $answerBody, $policy->int('duplicate_content_minutes'))) {
            throw new InvalidArgumentException($translator->get('questions.duplicate_post'));
        }
        $nearDuplicate = quaeriva_recent_near_duplicate_post($pdo, 'answer', (int) $currentUser['id'], (int) $question['id'], $answerBody, $policy->int('duplicate_content_minutes'));
        $assessment = quaeriva_spam_assessment($settings, $currentUser, $input, $answerBody, ['duplicate'=>$nearDuplicate]);
        $answerStatus = quaeriva_enforce_spam_disposition($assessment);
        $answerId = (new AnswerService($pdo))->create((int) $currentUser['id'], (int) $question['id'], $answerBody, $answerStatus);
        $answerPage = $answerStatus === 'published' ? quaeriva_answer_page($pdo, (int) $question['id'], $answerId) : 1;
        if ($answerStatus === 'published') try {
            $settings = new SettingsRepository($pdo);
            if ((bool) $settings->get('notifications.new_answer', true)) {
                (new NotificationService($pdo, $settings))->notifyUsers(
                    quaeriva_question_notification_recipients($pdo, (int) $question['id']),
                    'new_answer',
                    $translator->get('notifications.new_answer_title'),
                    (string) $question['title'],
                    quaeriva_absolute_app_url($site, $server, quaeriva_question_path($slug), ['answers_page' => $answerPage]) . '#answer-' . $answerId,
                    [(int) $currentUser['id']]
                );
            }
        } catch (Throwable) {}

        return $answerStatus === 'published'
            ? quaeriva_redirect(quaeriva_question_url($slug, 0, ['answer' => 'success', 'answers_page' => $answerPage]) . '#answer-' . $answerId, 303)
            : quaeriva_redirect(quaeriva_question_url($slug, 0, ['answer' => 'pending']), 303);
    } catch (InvalidArgumentException) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.answer_error'), 422, $currentUser, $csrf->token());
    } catch (Throwable) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.service_error'), 500, $currentUser, $csrf->token());
    }
}

function quaeriva_accept_answer_submit(
    string $slug,
    int $answerId,
    string $method,
    array $input,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug) . '#answer-' . $answerId;
    if ($currentUser === null) return quaeriva_redirect(quaeriva_url('login', ['redirect' => $currentPath]), 303);
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'answer.accept', 30, 3600)) {
            throw new InvalidArgumentException($translator->get('questions.rate_limited'));
        }
        $question = (new QuestionService($pdo))->findBySlug($slug);
        if (!is_array($question)) return quaeriva_render_error(404, $site, $server, [], 'errors/404');
        $answerStatement = $pdo->prepare('SELECT id,user_id FROM answers WHERE id=:id AND question_id=:question_id LIMIT 1');
        $answerStatement->execute(['id'=>$answerId,'question_id'=>(int)$question['id']]);
        $answer = $answerStatement->fetch(PDO::FETCH_ASSOC);
        if (!is_array($answer)) throw new InvalidArgumentException($translator->get('questions.accept_error'));
        (new AnswerService($pdo))->accept((int) $currentUser['id'], $answerId);
        $answerOwnerId = (int) ($answer['user_id'] ?? 0);
        if ($answerOwnerId > 0 && $answerOwnerId !== (int) $currentUser['id']) {
            try {
                (new NotificationService($pdo, new SettingsRepository($pdo)))->notifyUsers(
                    [$answerOwnerId],
                    'answer_accepted',
                    $translator->get('notifications.answer_accepted_title'),
                    (string) ($question['title'] ?? ''),
                    quaeriva_absolute_app_url($site, $server, quaeriva_question_path($slug)) . '#answer-' . $answerId
                );
            } catch (Throwable) {}
        }
        return quaeriva_redirect(quaeriva_question_url($slug, 0, ['accepted'=>'1', 'answers_page' => 1]) . '#answer-' . $answerId, 303);
    } catch (Throwable $throwable) {
        $message = $translator->get('questions.accept_error');
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $message, 422, $currentUser, $csrf->token());
    }
}

/** @param array<string, mixed> $input @param array<string, mixed> $query @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_vote_submit(
    string $slug,
    string $method,
    array $input,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug);
    if ($currentUser === null) {
        return quaeriva_redirect(quaeriva_url('login', ['redirect' => $questionReturnUrl]), 303);
    }

    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }

    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);

        if (!is_array($question)) {
            return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
        }
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'vote.cast', 120, 3600)) {
            throw new InvalidArgumentException($translator->get('questions.rate_limited'));
        }

        $result = (new VoteService($pdo))->cast((int) $currentUser['id'], 'question', (int) $question['id'], (int) ($input['value'] ?? 0));

        return quaeriva_redirect(quaeriva_question_url($slug, 0, ['voted' => $result === 0 ? 'removed' : '1']), 303);
    } catch (Throwable $throwable) {
        error_log('[Quaeriva vote] ' . $throwable::class . ': ' . $throwable->getMessage());
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.vote_error'), 422, $currentUser, $csrf->token());
    }
}

function quaeriva_answer_vote_submit(
    string $slug,
    int $answerId,
    string $method,
    array $input,
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    CsrfService $csrf
): array {
    $questionReturnUrl = quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input)) . '#answer-' . $answerId;
    if ($currentUser === null) {
        return quaeriva_redirect(quaeriva_url('login', ['redirect' => $questionReturnUrl]), 303);
    }
    if ($answerId <= 0) return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
    if ($method !== 'POST' || !$csrf->validate((string) ($input['_csrf'] ?? ''))) {
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('auth.csrf_error'), 403, $currentUser, $csrf->token());
    }
    try {
        $pdo = quaeriva_auth_pdo();
        $question = (new QuestionService($pdo))->findBySlug($slug);
        if (!is_array($question)) return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
        $answer = $pdo->prepare('SELECT id FROM answers WHERE id=:id AND question_id=:question_id LIMIT 1');
        $answer->execute(['id' => $answerId, 'question_id' => (int) $question['id']]);
        if ($answer->fetchColumn() === false) return quaeriva_render_error(404, $site, $server, $query, 'errors/404');
        if (!(new RateLimitService($pdo))->consume('user:' . (int) $currentUser['id'], 'answer.vote.cast', 120, 3600)) {
            throw new InvalidArgumentException($translator->get('questions.rate_limited'));
        }
        $result = (new VoteService($pdo))->cast((int) $currentUser['id'], 'answer', $answerId, (int) ($input['value'] ?? 0));
        return quaeriva_redirect(quaeriva_question_url($slug, 0, quaeriva_preserve_answers_page($input, ['answer_voted' => $result === 0 ? 'removed' : '1'])) . '#answer-' . $answerId, 303);
    } catch (Throwable $throwable) {
        error_log('[Quaeriva answer vote] ' . $throwable::class . ': ' . $throwable->getMessage());
        return quaeriva_render_message_page($translator, $localeContext, $site, $server, $questionReturnUrl, $translator->get('auth.error'), $translator->get('questions.vote_error'), 422, $currentUser, $csrf->token());
    }
}

/** @param array<string,mixed> $query @param array<string,mixed> $site @param array<string,mixed> $server */
function quaeriva_search_route(
    array $query,
    array $site,
    array $server,
    Translator $translator,
    LocaleContext $localeContext,
    string $currentPath,
    ?array $currentUser,
    string $csrfToken
): array {
    $results = ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20, 'filters' => []];
    $options = ['categories' => [], 'tags' => []];
    $available = false;
    try {
        $service = new SearchService(Connection::connect(app_config('database')));
        $results = $service->search($query, max(1, (int) ($query['page'] ?? 1)), 20);
        $options = $service->options();
        $available = true;
    } catch (Throwable) {
        $results['filters'] = [
            'q' => trim((string) ($query['q'] ?? '')),
            'category' => trim((string) ($query['category'] ?? '')),
            'tag' => trim((string) ($query['tag'] ?? '')),
            'answered' => trim((string) ($query['answered'] ?? '')),
            'date' => trim((string) ($query['date'] ?? '')),
            'member' => trim((string) ($query['member'] ?? '')),
            'locale' => trim((string) ($query['locale'] ?? '')),
        ];
    }
    $content = quaeriva_render_template('search', [
        'translator' => $translator,
        'localeContext' => $localeContext,
        'site' => $site,
        'results' => $results,
        'options' => $options,
        'dbAvailable' => $available,
    ]);
    return quaeriva_render_layout(
        $content, $translator, $localeContext, $site, $currentPath,
        $translator->get('search.title'), $translator->get('search.description'),
        'search', $currentUser, $csrfToken, null, true
    );
}

/** @param array<string, mixed> $site @param array<string, mixed> $server */
function quaeriva_render_message_page(
    Translator $translator,
    LocaleContext $localeContext,
    array $site,
    array $server,
    string $currentPath,
    string $title,
    string $message,
    int $status = 200,
    ?array $currentUser = null,
    string $csrfToken = ''
): array {
    $content = quaeriva_render_template('auth/message', [
        'translator' => $translator,
        'title' => $title,
        'message' => $message,
        'currentUser' => $currentUser,
        'returnUrl' => $currentPath !== '' ? $currentPath : quaeriva_url(),
    ]);
    // Transient status pages (verification results, action outcomes) are not
    // content and must stay out of the index.
    $response = quaeriva_render_layout($content, $translator, $localeContext, $site, $currentPath, $title, $message, 'message', $currentUser, $csrfToken, null, true);
    $response['status'] = $status;

    return $response;
}

/** @return array<string,int|string> */
function quaeriva_posting_rules(): array
{
    $defaults = PostingPolicyService::defaults();
    try {
        return PostingPolicyService::fromSettings(new SettingsRepository(quaeriva_auth_pdo()))->rules();
    } catch (Throwable) {
        return $defaults;
    }
}

/** @param array<string,mixed> $currentUser @param array<string,mixed> $input @param array<string,mixed> $signals */
function quaeriva_spam_assessment(SettingsRepository $settings, array $currentUser, array $input, string $content, array $signals = []): SpamAssessment
{
    $guard = FormBotGuard::sessionGuard()->inspect($input);
    $context = array_merge(TrustService::context($currentUser), $signals, [
        'content' => $content,
        'honeypot' => $guard['honeypot'],
        'form_age_ms' => $guard['age_ms'],
        'invalid_form_signature' => !$guard['valid_signature'],
    ]);
    $moderate = max(10, min(100, (int) $settings->get('community.spam_moderate_score', 35)));
    $reject = max($moderate + 1, min(200, (int) $settings->get('community.spam_reject_score', 80)));
    return (new SpamRiskService($moderate, $reject))->assess($context);
}

function quaeriva_recent_near_duplicate_post(PDO $pdo, string $type, int $userId, int $targetId, string $content, int $minutes): bool
{
    $minutes = max(0, min(1440, $minutes));
    if ($minutes === 0 || $userId <= 0 || trim($content) === '') return false;
    $sql = match ($type) {
        'question' => "SELECT CONCAT(title, '\n', body) AS content FROM questions WHERE user_id=:user_id AND created_at>=DATE_SUB(NOW(), INTERVAL {$minutes} MINUTE) ORDER BY id DESC LIMIT 20",
        'topic' => "SELECT CONCAT(title, '\n', body) AS content FROM topics WHERE user_id=:user_id AND created_at>=DATE_SUB(NOW(), INTERVAL {$minutes} MINUTE) ORDER BY id DESC LIMIT 20",
        'answer' => "SELECT body AS content FROM answers WHERE user_id=:user_id AND question_id=:target_id AND created_at>=DATE_SUB(NOW(), INTERVAL {$minutes} MINUTE) ORDER BY id DESC LIMIT 20",
        'comment' => "SELECT body AS content FROM comments WHERE user_id=:user_id AND commentable_id=:target_id AND created_at>=DATE_SUB(NOW(), INTERVAL {$minutes} MINUTE) ORDER BY id DESC LIMIT 20",
        default => '',
    };
    if ($sql === '') return false;
    $stmt = $pdo->prepare($sql);
    $params = ['user_id'=>$userId];
    if (in_array($type, ['answer','comment'], true)) $params['target_id'] = $targetId;
    $stmt->execute($params);
    $fingerprints = new ContentFingerprintService();
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $existing) {
        if ($fingerprints->equivalent((string) $existing, $content)) return true;
    }
    return false;
}

function quaeriva_enforce_spam_disposition(SpamAssessment $assessment): string
{
    if ($assessment->disposition === 'reject') throw new \App\Support\TranslatableException('content.spam_rejected');
    return $assessment->disposition === 'moderate' ? 'pending' : 'published';
}

function quaeriva_recent_duplicate_post(PDO $pdo, string $type, int $userId, int $questionId, string $title, string $body, int $minutes): bool
{
    $minutes = max(0, min(1440, $minutes));
    if ($minutes === 0 || $userId <= 0) {
        return false;
    }

    $since = date('Y-m-d H:i:s', time() - ($minutes * 60));

    if ($type === 'question') {
        $statement = $pdo->prepare(
            'SELECT 1 FROM questions WHERE user_id=:user_id AND title=:title AND body=:body AND created_at >= :since LIMIT 1'
        );
        $statement->execute(['user_id' => $userId, 'title' => trim($title), 'body' => trim($body), 'since' => $since]);
        return $statement->fetchColumn() !== false;
    }

    if ($type === 'answer' && $questionId > 0) {
        $statement = $pdo->prepare(
            'SELECT 1 FROM answers WHERE user_id=:user_id AND question_id=:question_id AND body=:body AND created_at >= :since LIMIT 1'
        );
        $statement->execute(['user_id' => $userId, 'question_id' => $questionId, 'body' => trim($body), 'since' => $since]);
        return $statement->fetchColumn() !== false;
    }

    return false;
}

function quaeriva_meta_excerpt(string $text, int $maxLength = 170, string $fallback = ''): string
{
    $text = trim((string) preg_replace('/\s+/u', ' ', strip_tags($text)));
    if ($text === '') {
        return trim($fallback);
    }

    $maxLength = max(80, min(300, $maxLength));
    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
        return mb_strlen($text, 'UTF-8') <= $maxLength
            ? $text
            : rtrim(mb_substr($text, 0, $maxLength - 1, 'UTF-8')) . '…';
    }

    if (preg_match_all('/./us', $text, $matches) !== false && isset($matches[0]) && count($matches[0]) > $maxLength) {
        return rtrim(implode('', array_slice($matches[0], 0, $maxLength - 1))) . '…';
    }

    return strlen($text) <= $maxLength ? $text : rtrim(substr($text, 0, $maxLength - 1)) . '…';
}

/** @param array<string, mixed> $site */
function quaeriva_render_layout(
    string $content,
    Translator $translator,
    LocaleContext $localeContext,
    array $site,
    string $currentPath,
    string $title,
    string $metaDescription,
    string $activeRoute,
    ?array $currentUser = null,
    string $csrfToken = '',
    ?array $structuredData = null,
    bool $noIndex = false,
    ?string $canonicalOverride = null,
    ?string $alternatePath = null,
    ?array $breadcrumbItems = null
): array {
    if ($currentUser === null) {
        $currentUser = Session::user();
    }

    if ($csrfToken === '') {
        $csrfToken = (new CsrfService())->token();
    }

    $themePreviewId = max(0, (int) ($_GET['theme_preview'] ?? 0));
    if ($themePreviewId > 0 && quaeriva_user_has_role($currentUser, 'admin')) {
        try {
            $previewProfile = (new ThemeProfileRepository(Connection::connect(app_config('database'))))->find($themePreviewId);
            if (is_array($previewProfile) && is_array($previewProfile['tokens'] ?? null)) {
                $site['theme_tokens'] = ThemeTokenService::normalize($previewProfile['tokens']);
                $noIndex = true;
            }
        } catch (Throwable) {
            // Preview is optional. A missing migration or invalid profile must not break the public page.
        }
    }

    if ($breadcrumbItems === null && $activeRoute !== 'home') {
        $breadcrumbItems = [
            ['label' => $translator->get('common.home'), 'url' => quaeriva_url()],
            ['label' => $title, 'url' => null],
        ];
    }

    if ($activeRoute === 'home' && trim((string) ($site['seo_default_title'] ?? '')) !== '') {
        $title = (string) $site['seo_default_title'];
    }

    if ($activeRoute === 'home' && trim((string) ($site['description'] ?? '')) !== '') {
        $metaDescription = (string) $site['description'];
    }

    $canonicalRequestPath = quaeriva_request_path($currentPath);
    $relativePath = quaeriva_relative_path($canonicalRequestPath);
    $canonicalPath = $relativePath === null || $relativePath === '/' ? '/' : $relativePath;
    $canonicalQuery = [];
    $queryString = parse_url($currentPath, PHP_URL_QUERY);
    if (is_string($queryString) && $queryString !== '') parse_str($queryString, $canonicalQuery);
    $canonicalRelative = CanonicalPolicy::forPage($activeRoute, $canonicalPath, $canonicalQuery);
    $canonical = $canonicalOverride ?? (new SeoBuilder(quaeriva_seo_base_url($site, $_SERVER)))->canonicalUrl($canonicalRelative);

    $meta = Hooks::filter(CoreHooks::SEO_META, [
        'title' => $title,
        'description' => $metaDescription,
        'canonical' => $canonical,
        'noindex' => $noIndex,
    ], [
        'page_type' => $activeRoute,
        'locale' => $translator->locale(),
        'path' => $canonicalPath,
    ]);
    if (is_array($meta)) {
        $title = trim((string) ($meta['title'] ?? $title)) ?: $title;
        $metaDescription = trim((string) ($meta['description'] ?? $metaDescription));
        $filteredCanonical = trim((string) ($meta['canonical'] ?? $canonical));
        if ($filteredCanonical !== '') $canonical = $filteredCanonical;
        if (array_key_exists('noindex', $meta)) $noIndex = (bool) $meta['noindex'];
    }

    $filteredStructuredData = Hooks::filter(CoreHooks::SEO_STRUCTURED_DATA, $structuredData, [
        'page_type' => $activeRoute,
        'locale' => $translator->locale(),
        'path' => $canonicalPath,
    ]);
    if ($filteredStructuredData === null || is_array($filteredStructuredData)) {
        $structuredData = $filteredStructuredData;
    }

    $body = quaeriva_render_template('layouts/app', [
        'content' => $content,
        'translator' => $translator,
        'localeContext' => $localeContext,
        'site' => $site,
        'currentPath' => $currentPath,
        'title' => $title,
        'metaDescription' => $metaDescription,
        'canonical' => $canonical,
        'alternatePath' => $alternatePath,
        'breadcrumbItems' => $breadcrumbItems,
        'activeRoute' => $activeRoute,
        'currentUser' => $currentUser,
        'csrfToken' => $csrfToken,
        'structuredData' => $structuredData,
        'noIndex' => $noIndex || str_starts_with((string) ($site['robots_mode'] ?? 'index,follow'), 'noindex'),
        'robotsMode' => $noIndex ? 'noindex,follow' : (string) ($site['robots_mode'] ?? 'index,follow'),
        'ogImage' => (string) ($site['og_image'] ?? ''),
        'twitterSite' => (string) ($site['twitter_site'] ?? ''),
        'googleVerification' => (string) ($site['google_verification'] ?? ''),
        'bingVerification' => (string) ($site['bing_verification'] ?? ''),
        '_theme' => (string) ($site['theme']['active'] ?? 'default'),
        'renderAd' => static fn (string $placement): string => quaeriva_render_ad($placement),
        'customCodeHead' => quaeriva_render_custom_code('head', $activeRoute, $translator->locale(), $currentUser),
        'customCodeBodyStart' => quaeriva_render_custom_code('body_start', $activeRoute, $translator->locale(), $currentUser),
        'customCodeBodyEnd' => quaeriva_render_custom_code('body_end', $activeRoute, $translator->locale(), $currentUser),
        'renderSection' => static fn (string $section): string => quaeriva_render_widget_section($section, $translator),
        'menuItems' => quaeriva_menu_items('primary', $currentUser, $translator->locale()),
    ]);

    $headers = [
        'Content-Type' => 'text/html; charset=UTF-8',
        'Content-Language' => $translator->locale(),
        'Cache-Control' => 'private, no-cache',
        // Device-restricted advertising makes the response depend on the agent,
        // so a shared cache must not serve one device's variant to another.
        'Vary' => quaeriva_ad_service()?->hasDeviceTargeting() === true
            ? 'Accept-Language, User-Agent'
            : 'Accept-Language',
        'X-Content-Type-Options' => 'nosniff',
        'X-Frame-Options' => 'SAMEORIGIN',
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
        'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
        'Content-Security-Policy' => quaeriva_content_security_policy($site),
    ];
    $https = strtolower((string)($_SERVER['HTTPS'] ?? '')) === 'on' || (string)($_SERVER['SERVER_PORT'] ?? '') === '443';
    if ($https) {
        $headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
    }
    return [
        'status' => 200,
        'headers' => $headers,
        'body' => $body,
    ];
}

/** @param array<string, mixed> $site @return array{available: bool, question: array<string, mixed>|null, answers: array<int, array<string, mixed>>} */
/**
 * The Content-Security-Policy for a public page.
 *
 * The four directives sent unconditionally are the ones that close real attack
 * paths without restricting where content may come from: no plugins, no
 * injected <base> rewriting every relative URL, no framing by another origin,
 * and no form posting to somebody else's endpoint. None of them can break the
 * custom-code and ad features, which exist precisely to carry third-party
 * <script> and <ins> tags (app/Support/CustomCodeService.php:138).
 *
 * Restricting script-src is the directive worth having and the one that breaks
 * those features, so it is opt-in: an operator running no external analytics or
 * ad network turns it on and gets a policy that stops injected script outright.
 *
 * @param array<string, mixed> $site
 */
function quaeriva_content_security_policy(array $site): string
{
    $policy = [
        "object-src 'none'",
        "base-uri 'self'",
        "frame-ancestors 'self'",
        "form-action 'self'",
    ];

    if (($site['strict_csp'] ?? false) === true) {
        $policy[] = "default-src 'self'";
        if (($site['turnstile_enabled'] ?? false) !== true) $policy[] = "script-src 'self'";
        // The theme writes its palette into an inline <style>, and the operator's
        // custom CSS goes to a second one; a nonce cannot cover the operator's
        // own snippets, so inline style stays allowed. Inline style cannot
        // execute script.
        $policy[] = "style-src 'self' 'unsafe-inline'";
        // Logos, favicons, OG images and ad creatives are operator-supplied URLs
        // that legitimately live elsewhere.
        $policy[] = 'img-src \'self\' data: https:';
        $policy[] = "font-src 'self'";
        if (($site['turnstile_enabled'] ?? false) === true) {
            $policy[] = "script-src 'self' https://challenges.cloudflare.com";
            $policy[] = "frame-src https://challenges.cloudflare.com";
            $policy[] = "connect-src 'self' https://challenges.cloudflare.com";
        } else {
            $policy[] = "connect-src 'self'";
        }
    }

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

/**
 * Recognise a question view request under any supported permalink shape.
 *
 * Returns null when the path is not a question view -- including the action
 * endpoints, which keep the slug form and are handled further down.
 *
 * @param list<string> $segments
 * @return array{slug: string, id: int}|null
 */
function quaeriva_match_question_request(string $route, array $segments): ?array
{
    $prefix = quaeriva_permalink_settings()['prefix'];

    // The legacy prefixes stay routable for good: every link ever published,
    // indexed or pasted elsewhere used one of them.
    if (!in_array($route, array_unique([$prefix, 'q', 'questions']), true)) {
        return null;
    }

    $actions = ['answers', 'vote', 'report', 'comments', 'follow'];
    $count = count($segments);

    if ($count === 3 && ctype_digit($segments[1]) && !in_array($segments[2], $actions, true)) {
        return quaeriva_resolve_question_reference($segments[2], (int) $segments[1]);
    }

    if ($count !== 2) {
        return null;
    }

    return quaeriva_resolve_question_reference($segments[1], 0);
}

/**
 * Resolve whatever the URL carried into the question's real slug and id.
 *
 * The slug is tried first and the numeric forms only afterwards: a title like
 * "10 best sites" produces the slug "10-best-sites", which would otherwise be
 * read as question 10 and redirect the visitor to a different question.
 *
 * @return array{slug: string, id: int}
 */
function quaeriva_resolve_question_reference(string $value, int $id): array
{
    $value = trim($value);

    try {
        $pdo = Connection::connect(app_config('database'));

        if ($value !== '') {
            $statement = $pdo->prepare('SELECT id, slug FROM questions WHERE slug = :slug LIMIT 1');
            $statement->execute(['slug' => $value]);
            $row = $statement->fetch();
            if (is_array($row)) {
                return ['slug' => (string) $row['slug'], 'id' => (int) $row['id']];
            }
        }

        if ($id <= 0 && ctype_digit($value)) {
            $id = (int) $value;
        }

        if ($id <= 0
            && quaeriva_permalink_settings()['structure'] === 'id-dash-slug'
            && preg_match('/^(\d+)-(.+)$/', $value, $matches) === 1) {
            $id = (int) $matches[1];
        }

        if ($id > 0) {
            $statement = $pdo->prepare('SELECT id, slug FROM questions WHERE id = :id LIMIT 1');
            $statement->execute(['id' => $id]);
            $row = $statement->fetch();
            if (is_array($row)) {
                return ['slug' => (string) $row['slug'], 'id' => (int) $row['id']];
            }
        }
    } catch (Throwable $throwable) {
        // No database, or the query failed: fall through to the not-found page
        // rather than turning a missing question into a 500.
        (new FileLogger())->error('Question permalink lookup failed.', $throwable);
    }

    // Unknown: hand back what the URL said so the page renders "not found"
    // instead of redirecting somewhere else.
    return ['slug' => $value, 'id' => 0];
}

function quaeriva_load_question(array $site, string $slug, ?array $currentUser = null, array $query = []): array
{
    try {
        $pdo = Connection::connect(app_config('database'));
        $question = (new QuestionService($pdo))->findBySlug($slug);

        if (!is_array($question)) {
            return ['available' => true, 'question' => null, 'answers' => []];
        }

        $details = $pdo->prepare(
            "SELECT q.*, COALESCE(c.name, '') AS category_name, COALESCE(c.slug, '') AS category_slug,
                    CASE WHEN q.is_anonymous = 1 OR u.id IS NULL THEN '' ELSE COALESCE(u.name, '') END AS author_name,
                    CASE WHEN q.is_anonymous = 1 OR u.id IS NULL THEN '' ELSE COALESCE(u.username, '') END AS author_username,
                    CASE WHEN q.is_anonymous = 1 OR u.id IS NULL THEN '' ELSE COALESCE(u.avatar_url, '') END AS author_avatar_url
             FROM questions q
             LEFT JOIN categories c ON c.id = q.category_id
             LEFT JOIN users u ON u.id = q.user_id
             WHERE q.id = :id
             LIMIT 1"
        );
        $details->execute(['id' => $question['id']]);
        $question = $details->fetch(PDO::FETCH_ASSOC) ?: $question;

        if (isset($question['id']) && (string) ($question['status'] ?? '') === 'published') {
            $qId = (int) $question['id'];
            if (!isset($_SESSION['quaeriva_viewed_questions'])) {
                $_SESSION['quaeriva_viewed_questions'] = [];
            }
            if (!isset($_SESSION['quaeriva_viewed_questions'][$qId])) {
                $_SESSION['quaeriva_viewed_questions'][$qId] = time();
                try {
                    $pdo->prepare("UPDATE questions SET views_count = views_count + 1 WHERE id = :id")->execute(['id' => $qId]);
                    $question['views_count'] = (int) ($question['views_count'] ?? 0) + 1;
                } catch (Throwable) {}
            }
        }

        $answersPage = max(1, min(1000000, (int) ($query['answers_page'] ?? 1)));
        $answersPerPage = 50;
        $answersTotalStatement = $pdo->prepare("SELECT COUNT(*) FROM answers WHERE question_id=:question_id AND status='published'");
        $answersTotalStatement->execute(['question_id' => $question['id']]);
        $answersTotal = (int) $answersTotalStatement->fetchColumn();
        $answersPages = max(1, (int) ceil($answersTotal / $answersPerPage));
        $answersPage = min($answersPage, $answersPages);
        $answersOffset = ($answersPage - 1) * $answersPerPage;
        $answersStatement = $pdo->prepare(
            "SELECT a.*, CASE WHEN u.id IS NULL THEN '' ELSE COALESCE(u.name, '') END AS author_name,
                    CASE WHEN u.id IS NULL THEN '' ELSE COALESCE(u.username, '') END AS author_username,
                    CASE WHEN u.id IS NULL THEN '' ELSE COALESCE(u.avatar_url, '') END AS author_avatar_url
             FROM answers a
             LEFT JOIN users u ON u.id = a.user_id
             WHERE a.question_id = :question_id AND a.status='published'
             ORDER BY a.is_best DESC, a.created_at ASC, a.id ASC
             LIMIT {$answersPerPage} OFFSET {$answersOffset}"
        );
        $answersStatement->execute(['question_id' => $question['id']]);
        $answers = $answersStatement->fetchAll(PDO::FETCH_ASSOC);
        $tagStatement = $pdo->prepare(
            'SELECT t.id,t.name,t.slug,t.content_locale FROM question_tags qt INNER JOIN tags t ON t.id=qt.tag_id WHERE qt.question_id=:question_id ORDER BY t.name ASC,t.id ASC'
        );
        $tagStatement->execute(['question_id' => $question['id']]);
        $tags = $tagStatement->fetchAll(PDO::FETCH_ASSOC);
        $comments = ['question' => [], 'answers' => []];
        $isFollowing = false;
        if (($site['comments_enabled'] ?? true) === true) {
            try {
                $visibleAnswerIds = array_values(array_filter(array_map(static fn (array $answer): int => (int) ($answer['id'] ?? 0), $answers)));
                $comments = (new CommentService($pdo))->forQuestion((int) $question['id'], $visibleAnswerIds);
            } catch (Throwable $throwable) {
                (new FileLogger())->error('Question comments loading failed.', $throwable, ['question_id' => (int) $question['id']]);
            }
        }
        if (($site['follows_enabled'] ?? true) === true && is_array($currentUser) && (int) ($currentUser['id'] ?? 0) > 0) {
            try { $isFollowing = (new FollowService($pdo))->isFollowing((int) $currentUser['id'], 'question', (int) $question['id']); } catch (Throwable $throwable) { (new FileLogger())->error('Question follow state loading failed.', $throwable, ['question_id' => (int) $question['id']]); }
        }

        $reportedItems = ['question' => [], 'answer' => [], 'comment' => []];
        if (is_array($currentUser) && (int) ($currentUser['id'] ?? 0) > 0) {
            try {
                $userId = (int) $currentUser['id'];
                $qId = (int) $question['id'];
                $answerIds = array_map(static fn($a) => (int) $a['id'], $answers);
                $allComments = array_merge((array) ($comments['question'] ?? []), ...array_values((array) ($comments['answers'] ?? [])));
                $commentIds = array_filter(array_map(static fn($c) => (int) ($c['id'] ?? 0), $allComments));

                $clauses = ['(reportable_type = \'question\' AND reportable_id = :qid)'];
                $params = ['uid' => $userId, 'qid' => $qId];
                if ($answerIds !== []) {
                    $inA = [];
                    foreach ($answerIds as $i => $aid) {
                        $inA[] = ':a' . $i;
                        $params['a' . $i] = $aid;
                    }
                    $clauses[] = '(reportable_type = \'answer\' AND reportable_id IN (' . implode(',', $inA) . '))';
                }
                if ($commentIds !== []) {
                    $inC = [];
                    foreach ($commentIds as $i => $cid) {
                        $inC[] = ':c' . $i;
                        $params['c' . $i] = $cid;
                    }
                    $clauses[] = '(reportable_type = \'comment\' AND reportable_id IN (' . implode(',', $inC) . '))';
                }
                $rStmt = $pdo->prepare(
                    'SELECT reportable_type, reportable_id FROM reports WHERE user_id = :uid AND status IN (\'open\', \'reviewing\') AND (' . implode(' OR ', $clauses) . ')'
                );
                $rStmt->execute($params);
                foreach ($rStmt->fetchAll(PDO::FETCH_ASSOC) as $rRow) {
                    $rType = (string) ($rRow['reportable_type'] ?? '');
                    $rId = (int) ($rRow['reportable_id'] ?? 0);
                    if (isset($reportedItems[$rType])) {
                        $reportedItems[$rType][] = $rId;
                    }
                }
            } catch (Throwable) {}
        }

        return [
            'available' => true,
            'question' => $question,
            'answers' => $answers,
            'answers_meta' => ['page' => $answersPage, 'per_page' => $answersPerPage, 'total' => $answersTotal, 'total_pages' => $answersPages],
            'tags' => $tags,
            'comments' => $comments,
            'is_following' => $isFollowing,
            'reported_items' => $reportedItems,
        ];
    } catch (Throwable $throwable) {
        (new FileLogger())->error('Question loading failed.', $throwable, ['slug' => $slug]);
        return ['available' => false, 'question' => null, 'answers' => []];
    }
}

function quaeriva_find_duplicate_redirect_slug(string $slug): ?string
{
    try {
        return (new QuestionService(Connection::connect(app_config('database'))))->duplicateRedirectSlug($slug);
    } catch (Throwable) {
        return null;
    }
}

function quaeriva_cli_static_file(string $requestUri): ?string
{
    $requestPath = quaeriva_request_path($requestUri);
    $relativePath = quaeriva_relative_path($requestPath);
    if ($relativePath === null) {
        return null;
    }

    $staticPath = rawurldecode(ltrim($relativePath, '/'));
    $staticExtension = strtolower(pathinfo($staticPath, PATHINFO_EXTENSION));
    $staticExtensions = ['css', 'gif', 'ico', 'jpeg', 'jpg', 'js', 'map', 'png', 'svg', 'ttf', 'webp', 'woff', 'woff2'];
    $publicRoot = realpath(__DIR__);
    $staticFile = $staticPath !== '' && !str_contains($staticPath, '..') && in_array($staticExtension, $staticExtensions, true) && $publicRoot !== false
        ? realpath($publicRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $staticPath))
        : false;

    if ($publicRoot === false || $staticFile === false || !str_starts_with($staticFile, $publicRoot . DIRECTORY_SEPARATOR) || !is_file($staticFile)) {
        return null;
    }

    return $staticFile;
}

if (PHP_SAPI === 'cli-server' && quaeriva_cli_static_file((string) ($_SERVER['REQUEST_URI'] ?? '/')) !== null) {
    return false;
}

if (realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === realpath(__FILE__)) {
    $requestPath = quaeriva_request_path((string) ($_SERVER['REQUEST_URI'] ?? '/'));

    // The panel path and the timezone live in the settings table, but routing
    // happens before quaeriva_dispatch() reads them. Resolve both here, from
    // the pooled connection, so a renamed panel answers and the API path -- which
    // returns before dispatch -- gets the operator's timezone too.
    try {
        $bootSettings = new SettingsRepository(Connection::connect(app_config('database')));
        $bootAdminPath = trim((string) $bootSettings->get('site.admin_path', ''));
        if ($bootAdminPath !== '') {
            $GLOBALS['quaeriva_admin_path_override'] = $bootAdminPath;
        }
        $bootTimezone = trim((string) $bootSettings->get('site.timezone', ''));
        if ($bootTimezone !== '' && in_array($bootTimezone, DateTimeZone::listIdentifiers(), true)) {
            date_default_timezone_set($bootTimezone);
        }
    } catch (Throwable) {
        // No database: fall back to config/site.php and .env, as before.
    }

    $apiRequest = $requestPath === '/api/v1' || str_starts_with($requestPath, '/api/v1/')
        || (app_base_path() !== '' && ($requestPath === app_base_path() . '/api/v1' || str_starts_with($requestPath, app_base_path() . '/api/v1/')));

    if ($apiRequest) {
        require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'v1' . DIRECTORY_SEPARATOR . 'index.php';
        quaeriva_api_response()->send();
        exit;
    }

    $relativeRequestPath = quaeriva_relative_path($requestPath);

    if ($relativeRequestPath !== null && in_array(trim($relativeRequestPath, '/'), ['install'], true)) {
        require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'install' . DIRECTORY_SEPARATOR . 'index.php';
        install_handle_request();
        exit;
    }

    if ($relativeRequestPath !== null && trim($relativeRequestPath, '/') === quaeriva_admin_path()) {
        // Tells admin/index.php the request came through the router, so a
        // renamed panel cannot still be reached at the literal /admin path
        // that Apache serves straight from the directory.
        define('QUAERIVA_ADMIN_ROUTED', true);
        require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'index.php';
        exit;
    }

    $response = quaeriva_dispatch();
    http_response_code($response['status']);

    foreach ($response['headers'] as $name => $value) {
        header($name . ': ' . $value);
    }

    if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'HEAD') {
        echo $response['body'];
    }
}
