<?php


require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/includes/debug-log.php';

if (!headers_sent()) {
    header('Content-Type: text/html; charset=UTF-8');
}
ini_set('default_charset', 'UTF-8');
if (function_exists('mb_internal_encoding')) {
    mb_internal_encoding('UTF-8');
}




if (session_status() === PHP_SESSION_NONE) {
    
    @ini_set('session.use_strict_mode', '1');
    @ini_set('session.cookie_httponly', '1');

    $isHttps = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off')
        || (!empty($_SERVER['REQUEST_SCHEME']) && strtolower((string)$_SERVER['REQUEST_SCHEME']) === 'https')
        || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');

    
    if (PHP_VERSION_ID >= 70300) {
        @session_set_cookie_params([
            'lifetime' => 0,
            'path'     => '/',
            'secure'   => $isHttps,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    } else {
        $path = '/; samesite=Lax';
        @session_set_cookie_params(0, $path, '', $isHttps, true);
    }

    session_start();
}




function csrf_token(): string
{
    if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(16));
    }
    return $_SESSION['csrf_token'];
}




function csrf_check(?string $token): bool
{
    if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
        return false;
    }
    if (!is_string($token) || $token === '') {
        return false;
    }
    return hash_equals($_SESSION['csrf_token'], $token);
}




function session_harden_after_login(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        @session_regenerate_id(true);
    }
}




function set_cookie_safe(string $name, string $value, int $expires): void
{
    $isHttps = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off')
        || (!empty($_SERVER['REQUEST_SCHEME']) && strtolower((string)$_SERVER['REQUEST_SCHEME']) === 'https')
        || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');

    if (PHP_VERSION_ID >= 70300) {
        @setcookie($name, $value, [
            'expires'  => $expires,
            'path'     => '/',
            'secure'   => $isHttps,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    } else {
        
        $path = '/; samesite=Lax';
        @setcookie($name, $value, $expires, $path, '', $isHttps, true);
    }
}





function ensure_category_icon_column(PDO $pdo): void
{
    try {
        $hasIcon = false;
        $stmt    = $pdo->query("PRAGMA table_info(categories)");
        if ($stmt) {
            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
                if (isset($row['name']) && $row['name'] === 'icon_path') {
                    $hasIcon = true;
                    break;
                }
            }
        }
        if (!$hasIcon) {
            $pdo->exec("ALTER TABLE categories ADD COLUMN icon_path TEXT");
        }
    } catch (Throwable $e) {
        
    }
}




function ensure_new_sites_style_key_column(PDO $pdo): void
{
    try {
        $hasStyleKey = false;
        $stmt = $pdo->query("PRAGMA table_info(new_sites)");
        if ($stmt) {
            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
                if (isset($row['name']) && $row['name'] === 'style_key') {
                    $hasStyleKey = true;
                    break;
                }
            }
        }
        if (!$hasStyleKey) {
            $pdo->exec("ALTER TABLE new_sites ADD COLUMN style_key TEXT NOT NULL DEFAULT 'auto'");
        }
    } catch (Throwable $e) {
        
    }
}






function get_new_site_style_options(): array
{
    return [
        'auto'    => '自动',
        'neon'    => '霓虹',
        'glass'   => '琉璃',
        'metal'   => '金属',
        'candy'   => '糖果',
        'warning' => '警示',
        'tech'    => '科技',
        'aurora'  => '极光',
    ];
}




function normalize_new_site_style_key(string $styleKey): string
{
    $styleKey = trim(function_exists('mb_strtolower') ? mb_strtolower($styleKey, 'UTF-8') : strtolower($styleKey));
    $aliases = [
        'auto' => 'auto',
        '自动' => 'auto',
        'neon' => 'neon',
        '霓虹' => 'neon',
        'glass' => 'glass',
        '琉璃' => 'glass',
        'metal' => 'metal',
        '金属' => 'metal',
        'candy' => 'candy',
        '糖果' => 'candy',
        'warning' => 'warning',
        '警示' => 'warning',
        'tech' => 'tech',
        '科技' => 'tech',
        'aurora' => 'aurora',
        '极光' => 'aurora',
    ];

    $resolved = $aliases[$styleKey] ?? '';
    if ($resolved === '') {
        return 'auto';
    }
    return $resolved;
}




function render_new_site_anchor(array $site): string
{
    $name = trim((string)($site['name'] ?? ''));
    $url  = trim((string)($site['url'] ?? ''));
    if ($name === '' || $url === '') {
        return '';
    }

    $styleKey = normalize_new_site_style_key((string)($site['style_key'] ?? 'auto'));

    
    $themeKeys = ['neon', 'glass', 'metal', 'candy', 'warning', 'tech', 'aurora'];
    $resolvedTheme = $styleKey;
    if ($styleKey === 'auto') {
        $hash = crc32($url . '|' . $name);
        $idx = (int)($hash % count($themeKeys));
        if ($idx < 0) {
            $idx = -$idx;
        }
        $resolvedTheme = $themeKeys[$idx];
    }

    $baseStyle = implode('', [
        'display:block;',
        'padding:7px 10px;',
        'border-radius:10px;',
        'font-size:12px;',
        'font-weight:600;',
        'line-height:1.35;',
        'text-decoration:none;',
        'border:1px solid transparent;',
        'margin-bottom:6px;',
    ]);

    $themeStyles = [
        'neon' => 'color:#ecfeff;background:linear-gradient(135deg,#0f766e,#164e63);border-color:#14b8a6;',
        'glass' => 'color:#1e3a8a;background:linear-gradient(135deg,#eff6ff,#e0e7ff);border-color:#93c5fd;',
        'metal' => 'color:#111827;background:linear-gradient(135deg,#f3f4f6,#d1d5db);border-color:#9ca3af;',
        'candy' => 'color:#831843;background:linear-gradient(135deg,#fce7f3,#fae8ff);border-color:#f9a8d4;',
        'warning' => 'color:#7c2d12;background:linear-gradient(135deg,#fff7ed,#fef3c7);border-color:#fdba74;',
        'tech' => 'color:#dbeafe;background:linear-gradient(135deg,#1e293b,#0f172a);border-color:#3b82f6;',
        'aurora' => 'color:#052e2b;background:linear-gradient(135deg,#dcfce7,#e0f2fe);border-color:#67e8f9;',
    ];
    $themeStyle = $themeStyles[$resolvedTheme] ?? $themeStyles['tech'];
    $style = $baseStyle . $themeStyle;

    return '<a href="' . h($url) . '" target="_blank" rel="nofollow noopener" style="' . h($style) . '" data-style-key="' . h($styleKey) . '">' . h($name) . '</a>';
}





function send_email_smtp(string $toEmail, string $toName, string $subject, string $htmlBody, string $textBody = ''): bool
{
    $enabledSetting = get_setting('smtp.enabled', '0');

    $host     = get_setting('smtp.host', '');
    $portStr  = get_setting('smtp.port', '0');
    $secure   = get_setting('smtp.secure', 'ssl'); 
    $username = get_setting('smtp.username', '');
    $password = get_setting('smtp.password', '');
    $fromEmail= get_setting('smtp.from_email', '');
    $fromName = get_setting('smtp.from_name', '');

    $port = (int)$portStr;

    
    $enabled = $enabledSetting === '1';
    if (!$enabled) {
        if ($host === '' || $port <= 0 || $username === '' || $password === '' || $fromEmail === '') {
            return false;
        }
        
        $enabled = true;
    }

    if (!$enabled || $host === '' || $port <= 0 || $username === '' || $password === '' || $fromEmail === '') {
        
        return false;
    }

    if ($textBody === '') {
        $textBody = strip_tags($htmlBody);
    }

    if ($toName === '') {
        $toName = $toEmail;
    }

    $transportHost = $host;
    if ($secure === 'ssl') {
        $transportHost = 'ssl://' . $host;
    } elseif ($secure === 'tls') {
        $transportHost = $host; 
    }

    $errno  = 0;
    $errstr = '';

    $fp = @fsockopen($secure === 'ssl' ? $transportHost : $host, $port, $errno, $errstr, 10.0);
    if (!$fp) {
        return false;
    }

    stream_set_timeout($fp, 10);

    $readResponse = function (int $expectCode = 0) use ($fp): bool {
        $data = '';
        while (($line = fgets($fp, 515)) !== false) {
            $data .= $line;
            if (strlen($line) < 4) {
                break;
            }
            
            if (isset($line[3]) && $line[3] === ' ') {
                break;
            }
        }
        if ($expectCode === 0) {
            return true;
        }
        if (substr($data, 0, 3) !== (string)$expectCode) {
            return false;
        }
        return true;
    };

    $write = function (string $cmd) use ($fp): void {
        fwrite($fp, $cmd . "\r\n");
    };

    try {
        if (!$readResponse(220)) {
            fclose($fp);
            return false;
        }

        $hostName = 'localhost';
        $write('EHLO ' . $hostName);
        if (!$readResponse(250)) {
            
            $write('HELO ' . $hostName);
            if (!$readResponse(250)) {
                fclose($fp);
                return false;
            }
        }

        
        if ($secure === 'tls') {
            $write('STARTTLS');
            if (!$readResponse(220)) {
                fclose($fp);
                return false;
            }
            if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                fclose($fp);
                return false;
            }
            
            $write('EHLO ' . $hostName);
            if (!$readResponse(250)) {
                fclose($fp);
                return false;
            }
        }

        
        $write('AUTH LOGIN');
        if (!$readResponse(334)) {
            fclose($fp);
            return false;
        }
        $write(base64_encode($username));
        if (!$readResponse(334)) {
            fclose($fp);
            return false;
        }
        $write(base64_encode($password));
        if (!$readResponse(235)) {
            fclose($fp);
            return false;
        }

        
        if (function_exists('mb_encode_mimeheader')) {
            $subjectEncoded = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
            $fromNameEncoded = $fromName !== '' ? mb_encode_mimeheader($fromName, 'UTF-8', 'B', "\r\n") : '';
            $toNameEncoded   = $toName !== '' ? mb_encode_mimeheader($toName, 'UTF-8', 'B', "\r\n") : '';
        } else {
            $subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
            $fromNameEncoded = $fromName !== '' ? '=?UTF-8?B?' . base64_encode($fromName) . '?=' : '';
            $toNameEncoded   = $toName !== '' ? '=?UTF-8?B?' . base64_encode($toName) . '?=' : '';
        }

        $fromHeader = $fromNameEncoded !== '' ? ($fromNameEncoded . ' <' . $fromEmail . '>') : $fromEmail;
        $toHeader   = $toNameEncoded !== '' ? ($toNameEncoded . ' <' . $toEmail . '>') : $toEmail;

        $boundary = 'b_' . bin2hex(random_bytes(8));

        $headers  = [];
        $headers[] = 'From: ' . $fromHeader;
        $headers[] = 'To: ' . $toHeader;
        $headers[] = 'Subject: ' . $subjectEncoded;
        $headers[] = 'MIME-Version: 1.0';
        $headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';

        $body  = '--' . $boundary . "\r\n";
        $body .= "Content-Type: text/plain; charset=UTF-8\r\n";
        $body .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
        $body .= $textBody . "\r\n\r\n";
        $body .= '--' . $boundary . "\r\n";
        $body .= "Content-Type: text/html; charset=UTF-8\r\n";
        $body .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
        $body .= $htmlBody . "\r\n\r\n";
        $body .= '--' . $boundary . "--\r\n";

        $write('MAIL FROM: <' . $fromEmail . '>');
        if (!$readResponse(250)) {
            fclose($fp);
            return false;
        }

        $write('RCPT TO: <' . $toEmail . '>');
        if (!$readResponse(250)) {
            fclose($fp);
            return false;
        }

        $write('DATA');
        if (!$readResponse(354)) {
            fclose($fp);
            return false;
        }

        $data = implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.";
        $write($data);
        if (!$readResponse(250)) {
            fclose($fp);
            return false;
        }

        $write('QUIT');
        $readResponse(221);
        fclose($fp);
        return true;
    } catch (Throwable $e) {
        if (is_resource($fp)) {
            fclose($fp);
        }
        return false;
    }
}





function cleanup_visit_log(): void
{
    try {
        
        $today = date('Y-m-d');
        $markerDir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'logs';
        if (!is_dir($markerDir)) {
            @mkdir($markerDir, 0777, true);
        }
        $markerFile = $markerDir . DIRECTORY_SEPARATOR . '.visit_log_cleanup_day';
        if (is_file($markerFile)) {
            $lastDay = trim((string)@file_get_contents($markerFile));
            if ($lastDay === $today) {
                return;
            }
        }

        $pdo = db();
        $threshold = time() - 7 * 86400;
        $stmt = $pdo->prepare("DELETE FROM visit_log WHERE visit_time < :threshold");
        $stmt->execute([':threshold' => $threshold]);

        
        @file_put_contents($markerFile, $today);

        
        cleanup_ratelimit_cache_files(7);
    } catch (Throwable $e) {
        
    }
}









function cleanup_ratelimit_cache_files(int $keepDays = 7): void
{
    if ($keepDays <= 0) {
        $keepDays = 1;
    }

    $logsDir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'logs';
    if (!is_dir($logsDir)) {
        @mkdir($logsDir, 0777, true);
    }
    $today = date('Y-m-d');
    $markerFile = $logsDir . DIRECTORY_SEPARATOR . '.ratelimit_cleanup_day';
    if (is_file($markerFile)) {
        $lastDay = trim((string)@file_get_contents($markerFile));
        if ($lastDay === $today) {
            return;
        }
    }

    $dir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'ratelimit';
    if (!is_dir($dir)) {
        @file_put_contents($markerFile, $today);
        return;
    }

    $threshold = time() - $keepDays * 86400;
    try {
        $it = new DirectoryIterator($dir);
        foreach ($it as $fileinfo) {
            if ($fileinfo->isDot() || !$fileinfo->isFile()) {
                continue;
            }
            $mtime = (int)$fileinfo->getMTime();
            if ($mtime > 0 && $mtime < $threshold) {
                @unlink($fileinfo->getPathname());
            }
        }
    } catch (Throwable $e) {
        
    }

    @file_put_contents($markerFile, $today);
}

function client_ip(): string
{
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    return is_string($ip) ? $ip : '';
}





function rate_limit_cooldown(string $action, int $cooldownSeconds, ?string $ip = null): bool
{
    if ($cooldownSeconds <= 0) {
        return true;
    }

    $ip = $ip ?? client_ip();
    if (!is_string($ip)) {
        $ip = '';
    }
    $ip = trim($ip);
    if ($ip === '') {
        
        return true;
    }

    $baseDir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'ratelimit';
    if (!is_dir($baseDir)) {
        @mkdir($baseDir, 0777, true);
    }

    $key  = sha1($action . '|' . $ip);
    $path = $baseDir . DIRECTORY_SEPARATOR . $key . '.txt';
    $now  = time();

    $last = 0;
    if (is_file($path)) {
        $last = (int)trim((string)@file_get_contents($path));
    }

    if ($last > 0 && ($now - $last) < $cooldownSeconds) {
        return false;
    }

    @file_put_contents($path, (string)$now, LOCK_EX);
    return true;
}




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








function get_setting(string $key, string $default = ''): string
{
    static $cache = [];
    if (array_key_exists($key, $cache)) {
        return $cache[$key];
    }

    try {
        $stmt = db()->prepare('SELECT value FROM settings WHERE key = :key LIMIT 1');
        $stmt->execute([':key' => $key]);
        $row = $stmt->fetch();
        if ($row && isset($row['value'])) {
            $cache[$key] = (string) $row['value'];
            return $cache[$key];
        }
    } catch (Throwable $e) {
        
    }

    $cache[$key] = $default;
    return $default;
}





function get_home_category_name(): string
{
    $name = trim(get_setting('site.home_category_name', '主页'));
    return $name !== '' ? $name : '主页';
}





function render_home_nav_link(string $href = 'index.php'): string
{
    $href = trim($href);
    if ($href === '') {
        $href = 'index.php';
    }
    return '<a href="' . h($href) . '"><span aria-hidden="true" style="display:inline-flex;width:16px;justify-content:center;line-height:1;vertical-align:-2px;margin-right:6px;">&#x1F3E0;</span>' . h(get_home_category_name()) . '</a>';
}




function get_article_list_icon_value(): string
{
    $icon = trim(get_setting('article.list_icon', '📝'));
    if ($icon === '' || preg_match('/^\\?+$/', $icon)) {
        $icon = '📝';
    }
    return $icon;
}




function is_safe_icon_url(string $url): bool
{
    $url = trim($url);
    if ($url === '') {
        return false;
    }

    $lower = strtolower($url);
    $isHttp = (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0);
    $isRoot = (strpos($url, '/') === 0);
    $isRelUpload = (strpos($lower, 'uploads/') === 0 || strpos($lower, './uploads/') === 0);
    if (!($isHttp || $isRoot || $isRelUpload)) {
        return false;
    }

    $path = $url;
    if (function_exists('parse_url')) {
        $parsedPath = parse_url($url, PHP_URL_PATH);
        if (is_string($parsedPath) && $parsedPath !== '') {
            $path = $parsedPath;
        }
    }

    if (!preg_match('/\.(png|jpe?g|gif|svg|webp)$/i', $path)) {
        return false;
    }

    return true;
}




function render_article_list_icon_html(string $icon, string $class = 'article-list-icon'): string
{
    $icon = trim($icon);
    if ($icon === '') {
        return '';
    }

    if (is_safe_icon_url($icon)) {
        return '<img class="' . h($class) . '" src="' . h($icon) . '" alt="" loading="lazy">';
    }

    return '<span class="' . h($class) . '" aria-hidden="true">' . h($icon) . '</span>';
}




function attachment_name_path_map(): array
{
    static $cache = null;
    if ($cache !== null) {
        return $cache;
    }
    $cache = [];
    try {
        $stmt = db()->query("
            SELECT original_name, path
            FROM media_files
            WHERE type = 'attachment' AND original_name IS NOT NULL AND original_name <> ''
            ORDER BY id DESC
        ");
        $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
        foreach ($rows as $row) {
            $name = trim((string)($row['original_name'] ?? ''));
            $path = trim((string)($row['path'] ?? ''));
            if ($name === '' || $path === '') {
                continue;
            }
            if (!isset($cache[$name])) {
                $cache[$name] = $path;
            }
        }
    } catch (Throwable $e) {
        $cache = [];
    }
    return $cache;
}




function linkify_attachment_names(string $html): string
{
    $html = (string)$html;
    if ($html === '') {
        return $html;
    }
    $map = attachment_name_path_map();
    if (!$map) {
        return $html;
    }
    if (!class_exists('DOMDocument')) {
        return $html;
    }

    $old = libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $flags = 0;
    if (defined('LIBXML_HTML_NOIMPLIED')) {
        $flags |= LIBXML_HTML_NOIMPLIED;
    }
    if (defined('LIBXML_HTML_NODEFDTD')) {
        $flags |= LIBXML_HTML_NODEFDTD;
    }
    $dom->loadHTML('<?xml encoding="UTF-8"><div>' . $html . '</div>', $flags);
    libxml_clear_errors();
    libxml_use_internal_errors($old);

    $root = $dom->getElementsByTagName('div')->item(0);
    if (!$root) {
        return $html;
    }

    $walker = function (DOMNode $node) use (&$walker, $dom, $map): void {
        if ($node->nodeType === XML_ELEMENT_NODE) {
            $tag = strtolower($node->nodeName);
            if ($tag === 'a') {
                return;
            }
            if (in_array($tag, ['code', 'pre'], true)) {
                return;
            }
        }

        if ($node->nodeType === XML_TEXT_NODE) {
            $text = (string)$node->textContent;
            $trim = trim($text);
            if ($trim === '' || !isset($map[$trim])) {
                return;
            }
            if (!preg_match('/^(\\s*)([\\s\\S]+?)(\\s*)$/', $text, $m)) {
                return;
            }
            if (trim($m[2]) !== $trim) {
                return;
            }
            $parent = $node->parentNode;
            if (!$parent || ($parent->nodeType === XML_ELEMENT_NODE && strtolower($parent->nodeName) === 'a')) {
                return;
            }

            $href = $map[$trim];
            $frag = $dom->createDocumentFragment();
            if ($m[1] !== '') {
                $frag->appendChild($dom->createTextNode($m[1]));
            }
            $a = $dom->createElement('a');
            $a->setAttribute('href', $href);
            $a->setAttribute('target', '_blank');
            $a->setAttribute('rel', 'nofollow noopener');
            $a->setAttribute('download', basename($href));
            $a->appendChild($dom->createTextNode($trim));
            $frag->appendChild($a);
            if ($m[3] !== '') {
                $frag->appendChild($dom->createTextNode($m[3]));
            }
            if ($parent) {
                $parent->replaceChild($frag, $node);
            }
            return;
        }

        $children = [];
        foreach ($node->childNodes as $c) {
            $children[] = $c;
        }
        foreach ($children as $c) {
            $walker($c);
        }
    };

    $walker($root);

    $out = '';
    foreach ($root->childNodes as $c) {
        $out .= $dom->saveHTML($c);
    }
    return $out;
}




function record_media_file(string $type, string $path, string $originalName, int $size, string $mime, ?int $uploaderId): void
{
    $type = trim($type);
    $path = trim($path);
    if ($type === '' || $path === '') {
        return;
    }
    try {
        $stmt = db()->prepare("
            INSERT INTO media_files (type, path, original_name, size, mime, uploader_id, created_at)
            VALUES (:type, :path, :original_name, :size, :mime, :uploader_id, :created_at)
        ");
        $stmt->execute([
            ':type' => $type,
            ':path' => $path,
            ':original_name' => $originalName,
            ':size' => $size,
            ':mime' => $mime,
            ':uploader_id' => $uploaderId,
            ':created_at' => time(),
        ]);
    } catch (Throwable $e) {
        
    }
}





function current_user_id(): ?int
{
    if (!empty($_SESSION['user_id']) && is_int($_SESSION['user_id'])) {
        return $_SESSION['user_id'];
    }
    return null;
}




function current_user(): ?array
{
    static $cache = null;
    if ($cache !== null) {
        return $cache;
    }

    $uid = current_user_id();
    if (!$uid) {
        $cache = null;
        return null;
    }

    try {
        $stmt = db()->prepare("SELECT * FROM users WHERE id = :id LIMIT 1");
        $stmt->execute([':id' => $uid]);
        $row = $stmt->fetch();
        $cache = $row ?: null;
        return $cache;
    } catch (Throwable $e) {
        $cache = null;
        return null;
    }
}






function load_member_activity(?array $user): array
{
    $myArticles = [];
    $myReplies  = [];

    if (!$user || empty($user['id'])) {
        return ['myArticles' => [], 'myReplies' => []];
    }

    $uid = (int)$user['id'];

    try {
        $pdo = db();

        
        $stmt = $pdo->prepare("
            SELECT id, title, status, published_at
            FROM articles
            WHERE author_id = :uid
            ORDER BY created_at DESC, id DESC
            LIMIT 5
        ");
        $stmt->execute([':uid' => $uid]);
        $myArticles = $stmt->fetchAll() ?: [];
    } catch (Throwable $e) {
        $myArticles = [];
    }

    try {
        $pdo = db();

        
        $stmt = $pdo->prepare("
            SELECT c.id,
                   c.article_id,
                   c.content,
                   c.created_at,
                   a.title AS article_title
            FROM comments c
            LEFT JOIN articles a ON a.id = c.article_id
            WHERE c.user_id = :uid
            ORDER BY c.created_at DESC, c.id DESC
            LIMIT 20
        ");
        $stmt->execute([':uid' => $uid]);
        $myReplies = $stmt->fetchAll() ?: [];
    } catch (Throwable $e) {
        $myReplies = [];
    }

    return [
        'myArticles' => $myArticles,
        'myReplies'  => $myReplies,
    ];
}




function is_mobile_ua(string $ua): bool
{
    $ua = strtolower($ua);
    return (strpos($ua, 'mobile') !== false)
        || (strpos($ua, 'android') !== false)
        || (strpos($ua, 'iphone') !== false)
        || (strpos($ua, 'ipad') !== false);
}




function ensure_visitor_id(): string
{
    $cookieName = 'lzblog_vid';
    if (!empty($_COOKIE[$cookieName]) && is_string($_COOKIE[$cookieName])) {
        return $_COOKIE[$cookieName];
    }

    
    $id = bin2hex(random_bytes(8));
    
    set_cookie_safe($cookieName, $id, time() + 365 * 86400);
    $_COOKIE[$cookieName] = $id;
    return $id;
}





function sanitize_member_html(string $html): string
{
    $html = trim($html);
    if ($html === '') {
        return '';
    }

    if (!class_exists('DOMDocument')) {
        
        return strip_tags($html, '<p><br><strong><em><b><i><u><blockquote><code><pre><ul><ol><li><h1><h2><h3><hr><a><img><video><source><span>');
    }

    
    $html = preg_replace('/javascript\\s*:/i', '', $html);
    $html = preg_replace('/data\\s*:/i', '', $html);

    $allowedTags = [
        'p' => true, 'br' => true,
        'strong' => true, 'em' => true, 'b' => true, 'i' => true, 'u' => true,
        'blockquote' => true, 'code' => true, 'pre' => true,
        'ul' => true, 'ol' => true, 'li' => true,
        'h1' => true, 'h2' => true, 'h3' => true,
        'hr' => true,
        'a' => true,
        'img' => true,
        'video' => true,
        'source' => true,
        'span' => true,
    ];

    $dropTags = [
        'script' => true, 'style' => true, 'iframe' => true, 'object' => true, 'embed' => true,
        'link' => true, 'meta' => true, 'base' => true, 'form' => true, 'input' => true, 'button' => true,
        'textarea' => true, 'select' => true, 'option' => true,
    ];

    $wrap = '<div>' . $html . '</div>';

    $old = libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    
    $flags = 0;
    if (defined('LIBXML_HTML_NOIMPLIED')) {
        $flags |= LIBXML_HTML_NOIMPLIED;
    }
    if (defined('LIBXML_HTML_NODEFDTD')) {
        $flags |= LIBXML_HTML_NODEFDTD;
    }
    $dom->loadHTML('<?xml encoding="UTF-8">' . $wrap, $flags);
    libxml_clear_errors();
    libxml_use_internal_errors($old);

    $root = $dom->getElementsByTagName('div')->item(0);
    if (!$root) {
        return strip_tags($html);
    }

    $walker = function (DOMNode $node) use (&$walker, $allowedTags, $dropTags): void {
        if ($node->nodeType === XML_ELEMENT_NODE) {
            $tag = strtolower($node->nodeName);

            
            if (isset($dropTags[$tag])) {
                if ($node->parentNode) {
                    $node->parentNode->removeChild($node);
                }
                return;
            }

            
            if (!isset($allowedTags[$tag])) {
                $parent = $node->parentNode;
                if ($parent) {
                    while ($node->firstChild) {
                        $parent->insertBefore($node->firstChild, $node);
                    }
                    $parent->removeChild($node);
                }
                return;
            }

            
            $keep = [];
            if ($tag === 'a') {
                $href  = $node->getAttribute('href');
                $title = $node->getAttribute('title');

                $href = trim((string)$href);
                if ($href !== '') {
                    $lower = strtolower($href);
                    $ok = false;

                    
                    if (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0) {
                        $ok = true;
                    }
                    
                    if (strpos($href, '/') === 0 || strpos($href, '#') === 0) {
                        $ok = true;
                    }

                    if ($ok) {
                        $keep['href'] = $href;
                    }
                }

                if ($title !== '') {
                    $keep['title'] = $title;
                }

                
                $keep['rel'] = 'nofollow noopener';
            }

            if ($tag === 'blockquote') {
                $dataHidden = (string)$node->getAttribute('data-hidden-block');
                if ($dataHidden === 'comment-only') {
                    $keep['data-hidden-block'] = 'comment-only';
                }
                $cls = (string)$node->getAttribute('class');
                if ($cls !== '' && preg_match('/\\bhidden-comment-block\\b/u', $cls)) {
                    $keep['class'] = 'hidden-comment-block';
                }
            }

            if ($tag === 'span') {
                $marker = (string)$node->getAttribute('data-hidden-marker');
                if (in_array($marker, ['comment-only', 'comment-only-start', 'comment-only-end'], true)) {
                    $keep['data-hidden-marker'] = $marker;
                }
            }

            if ($tag === 'img') {
                $srcRaw = trim((string)$node->getAttribute('src'));
                $dataMceSrc = trim((string)$node->getAttribute('data-mce-src'));
                $dataSrc = trim((string)$node->getAttribute('data-src'));
                $dataOriginal = trim((string)$node->getAttribute('data-original'));
                $alt = (string)$node->getAttribute('alt');
                $title = (string)$node->getAttribute('title');
                $width = (string)$node->getAttribute('width');
                $height = (string)$node->getAttribute('height');

                
                $normalizeSrc = function (string $s): string {
                    $s = html_entity_decode($s, ENT_QUOTES, 'UTF-8');
                    $s = str_replace(["\xEF\xBB\xBF", "\xE2\x80\x8B", "\xE2\x81\xA3", "\xE2\x81\xA4"], '', $s);
                    $s = trim($s);
                    
                    $s = preg_replace('/^[\\\\\\"\\\']+/u', '', $s);
                    $s = preg_replace('/[\\\\\\"\\\']+$/u', '', $s);
                    
                    $s = str_replace('\\/', '/', $s);
                    
                    $s = str_replace('\\', '/', $s);
                    return trim((string)$s);
                };

                $srcCandidates = [];
                foreach ([$srcRaw, $dataMceSrc, $dataSrc, $dataOriginal] as $cand) {
                    if (!is_string($cand)) {
                        continue;
                    }
                    $cand = trim($cand);
                    if ($cand === '') {
                        continue;
                    }
                    if (!in_array($cand, $srcCandidates, true)) {
                        $srcCandidates[] = $cand;
                    }
                }

                $src = '';
                foreach ($srcCandidates as $cand) {
                    $candNorm = $normalizeSrc((string)$cand);
                    if ($candNorm === '') {
                        continue;
                    }
                    $lower = strtolower($candNorm);
                    
                    if (strpos($lower, 'blob:') === 0) {
                        continue;
                    }

                    $ok = false;
                    if (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0) {
                        $ok = true;
                    }
                    if (strpos($lower, '//') === 0) {
                        $ok = true;
                    }
                    if (strpos($candNorm, '/') === 0 || strpos($candNorm, 'uploads/') === 0) {
                        $ok = true;
                    }
                    if (strpos($candNorm, './') === 0 || strpos($candNorm, '../') === 0) {
                        $ok = true;
                    }

                    if ($ok) {
                        $src = $candNorm;
                        break;
                    }
                }

                if ($src === '') {
                    
                    if (function_exists('blog_debug_log')) {
                        blog_debug_log('sanitize_html.log', [
                            'event' => 'drop_img',
                            'uid' => (int)(function_exists('current_user_id') ? (current_user_id() ?: 0) : 0),
                            'src_raw' => blog_debug_preview((string)$srcRaw),
                            'data_mce_src' => blog_debug_preview((string)$dataMceSrc),
                            'data_src' => blog_debug_preview((string)$dataSrc),
                            'data_original' => blog_debug_preview((string)$dataOriginal),
                        ]);
                    }
                    if ($node->parentNode) {
                        $node->parentNode->removeChild($node);
                    }
                    return;
                }

                if ($srcRaw === '' || $normalizeSrc((string)$srcRaw) !== $src) {
                    if (function_exists('blog_debug_log')) {
                        blog_debug_log('sanitize_html.log', [
                            'event' => 'fix_img_src',
                            'uid' => (int)(function_exists('current_user_id') ? (current_user_id() ?: 0) : 0),
                            'src_raw' => blog_debug_preview((string)$srcRaw),
                            'src_fixed' => blog_debug_preview((string)$src),
                            'data_mce_src' => blog_debug_preview((string)$dataMceSrc),
                        ]);
                    }
                }

                $keep['src'] = $src;
                if ($alt !== '') {
                    $keep['alt'] = $alt;
                }
                if ($title !== '') {
                    $keep['title'] = $title;
                }
                if ($width !== '' && preg_match('/^\\d{1,4}$/', $width)) {
                    $keep['width'] = $width;
                }
                if ($height !== '' && preg_match('/^\\d{1,4}$/', $height)) {
                    $keep['height'] = $height;
                }
            }

            if ($tag === 'video') {
                $src = trim((string)$node->getAttribute('src'));
                $poster = trim((string)$node->getAttribute('poster'));
                $width = (string)$node->getAttribute('width');
                $height = (string)$node->getAttribute('height');
                $preload = (string)$node->getAttribute('preload');
                $hasControls = $node->hasAttribute('controls');
                $playsInline = $node->hasAttribute('playsinline');

                if ($src !== '') {
                    $lower = strtolower($src);
                    $ok = false;
                    if (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0) {
                        $ok = true;
                    }
                    if (strpos($lower, '//') === 0) {
                        $ok = true;
                    }
                    if (strpos($src, '/') === 0 || strpos($src, 'uploads/') === 0) {
                        $ok = true;
                    }
                    if (strpos($src, './') === 0 || strpos($src, '../') === 0) {
                        $ok = true;
                    }
                    if ($ok) {
                        $keep['src'] = $src;
                    }
                }

                if ($poster !== '') {
                    $lowerPoster = strtolower($poster);
                    $okPoster = false;
                    if (strpos($lowerPoster, 'http://') === 0 || strpos($lowerPoster, 'https://') === 0) {
                        $okPoster = true;
                    }
                    if (strpos($lowerPoster, '//') === 0) {
                        $okPoster = true;
                    }
                    if (strpos($poster, '/') === 0 || strpos($poster, 'uploads/') === 0) {
                        $okPoster = true;
                    }
                    if (strpos($poster, './') === 0 || strpos($poster, '../') === 0) {
                        $okPoster = true;
                    }
                    if ($okPoster) {
                        $keep['poster'] = $poster;
                    }
                }

                if ($width !== '' && preg_match('/^\\d{1,4}$/', $width)) {
                    $keep['width'] = $width;
                }
                if ($height !== '' && preg_match('/^\\d{1,4}$/', $height)) {
                    $keep['height'] = $height;
                }
                if ($preload !== '' && in_array($preload, ['auto', 'metadata', 'none'], true)) {
                    $keep['preload'] = $preload;
                }
                if ($hasControls) {
                    $keep['controls'] = 'controls';
                }
                if ($playsInline) {
                    $keep['playsinline'] = 'playsinline';
                }
            }

            if ($tag === 'source') {
                $src = trim((string)$node->getAttribute('src'));
                $type = (string)$node->getAttribute('type');
                if ($src !== '') {
                    $lower = strtolower($src);
                    $ok = false;
                    if (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0) {
                        $ok = true;
                    }
                    if (strpos($lower, '//') === 0) {
                        $ok = true;
                    }
                    if (strpos($src, '/') === 0 || strpos($src, 'uploads/') === 0) {
                        $ok = true;
                    }
                    if (strpos($src, './') === 0 || strpos($src, '../') === 0) {
                        $ok = true;
                    }
                    if ($ok) {
                        $keep['src'] = $src;
                    }
                }
                if ($type !== '') {
                    $keep['type'] = $type;
                }
            }

            
            if ($node->hasAttributes()) {
                $attrs = [];
                foreach ($node->attributes as $attr) {
                    $attrs[] = $attr->nodeName;
                }
                foreach ($attrs as $an) {
                    $node->removeAttribute($an);
                }
            }
            foreach ($keep as $k => $v) {
                $node->setAttribute($k, $v);
            }
        }

        
        $children = [];
        foreach ($node->childNodes as $c) {
            $children[] = $c;
        }
        foreach ($children as $c) {
            $walker($c);
        }
    };

    
    
    
    $rootChildren = [];
    foreach ($root->childNodes as $child) {
        $rootChildren[] = $child;
    }
    foreach ($rootChildren as $child) {
        $walker($child);
    }

    $out = '';
    foreach ($root->childNodes as $c) {
        $out .= $dom->saveHTML($c);
    }
    return $out;
}





function normalize_html_newlines(string $html): string
{
    $html = (string)$html;
    if ($html === '' || (strpos($html, "\n") === false && strpos($html, "\r") === false)) {
        return $html;
    }

    if (!class_exists('DOMDocument')) {
        return preg_replace('/\r\n|\r|\n/', '<br>', $html);
    }

    $old = libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $flags = 0;
    if (defined('LIBXML_HTML_NOIMPLIED')) {
        $flags |= LIBXML_HTML_NOIMPLIED;
    }
    if (defined('LIBXML_HTML_NODEFDTD')) {
        $flags |= LIBXML_HTML_NODEFDTD;
    }
    $dom->loadHTML('<?xml encoding="UTF-8"><div>' . $html . '</div>', $flags);
    libxml_clear_errors();
    libxml_use_internal_errors($old);

    $root = $dom->getElementsByTagName('div')->item(0);
    if (!$root) {
        return $html;
    }

    $xpath = new DOMXPath($dom);
    $textNodes = $xpath->query('.//text()', $root);
    if ($textNodes instanceof DOMNodeList) {
        foreach ($textNodes as $textNode) {
            $text = (string)$textNode->nodeValue;
            $onlyWhitespace = trim(str_replace("\xC2\xA0", ' ', $text)) === '';
            if ($onlyWhitespace) {
                if ($textNode->parentNode) {
                    $textNode->parentNode->removeChild($textNode);
                }
                continue;
            }
            if (strpos($text, "\n") === false && strpos($text, "\r") === false) {
                continue;
            }

            $skip = false;
            $p = $textNode->parentNode;
            while ($p && $p->nodeType === XML_ELEMENT_NODE) {
                $tag = strtolower($p->nodeName);
                if ($tag === 'pre' || $tag === 'code') {
                    $skip = true;
                    break;
                }
                $p = $p->parentNode;
            }
            if ($skip) {
                continue;
            }

            $parts = preg_split('/\r\n|\r|\n/', $text);
            if (!$parts) {
                continue;
            }
            $frag = $dom->createDocumentFragment();
            $first = true;
            foreach ($parts as $part) {
                if (!$first) {
                    $frag->appendChild($dom->createElement('br'));
                }
                if ($part !== '') {
                    $frag->appendChild($dom->createTextNode($part));
                }
                $first = false;
            }
            if ($textNode->parentNode) {
                $textNode->parentNode->replaceChild($frag, $textNode);
            }
        }
    }

    $out = '';
    foreach ($root->childNodes as $c) {
        $out .= $dom->saveHTML($c);
    }
    return $out;
}







function is_ip_blacklisted(?string $ip = null): bool
{
    $ip = $ip ?? client_ip();
    if ($ip === '') {
        return false;
    }

    try {
        $pdo = db();
        $stmt = $pdo->prepare("
            SELECT 1
            FROM ip_blacklist
            WHERE ip = :ip
              AND (expires_at IS NULL OR expires_at > :now)
            LIMIT 1
        ");
        $stmt->execute([
            ':ip'  => $ip,
            ':now' => time(),
        ]);
        return (bool)$stmt->fetchColumn();
    } catch (Throwable $e) {
        
        return false;
    }
}




function deny_if_ip_blacklisted(): void
{
    $ip = client_ip();
    if ($ip === '' || !is_ip_blacklisted($ip)) {
        return;
    }

    http_response_code(403);
    echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>访问受限</title></head><body style="margin:0;padding:0;">';
    echo '<p>当前 IP 暂时被限制访问本站，如有疑问请联系站长。</p>';
    echo '</body></html>';
    exit;
}






function track_visit(?int $articleId = null): void
{
    try {
        $pdo = db();

        $url       = $_SERVER['REQUEST_URI'] ?? ($_SERVER['PHP_SELF'] ?? '');
        $ip        = client_ip();
        $ua        = $_SERVER['HTTP_USER_AGENT'] ?? '';
        $referrer  = $_SERVER['HTTP_REFERER'] ?? '';
        $isMobile  = is_mobile_ua($ua) ? 1 : 0;
        $visitorId = ensure_visitor_id();
        $userId    = current_user_id();
        $now       = time();

        
        $dateKey = date('Y-m-d', $now);
        $todayStart = strtotime($dateKey . ' 00:00:00');
        $tomorrow   = $todayStart + 86400;

        
        try {
            $stmtInit = $pdo->prepare("
                INSERT INTO visit_daily (date, pv, uv, ip_count)
                VALUES (:date, 0, 0, 0)
                ON CONFLICT(date) DO NOTHING
            ");
            $stmtInit->execute([':date' => $dateKey]);
        } catch (Throwable $e) {
            try {
                $stmtInit = $pdo->prepare("INSERT OR IGNORE INTO visit_daily (date, pv, uv, ip_count) VALUES (:date, 0, 0, 0)");
                $stmtInit->execute([':date' => $dateKey]);
            } catch (Throwable $e) {
                
            }
        }

        
        $uvInc = 0;
        $ipInc = 0;

        try {
            if (is_string($visitorId) && $visitorId !== '') {
                $stmtUv = $pdo->prepare("
                    SELECT 1
                    FROM visit_log
                    WHERE visit_time >= :start AND visit_time < :end
                      AND visitor_id = :vid
                    LIMIT 1
                ");
                $stmtUv->execute([
                    ':start' => $todayStart,
                    ':end'   => $tomorrow,
                    ':vid'   => $visitorId,
                ]);
                if (!$stmtUv->fetchColumn()) {
                    $uvInc = 1;
                }
            }
        } catch (Throwable $e) {
            $uvInc = 0;
        }

        try {
            if (is_string($ip) && $ip !== '') {
                $stmtIp = $pdo->prepare("
                    SELECT 1
                    FROM visit_log
                    WHERE visit_time >= :start AND visit_time < :end
                      AND ip = :ip
                    LIMIT 1
                ");
                $stmtIp->execute([
                    ':start' => $todayStart,
                    ':end'   => $tomorrow,
                    ':ip'    => $ip,
                ]);
                if (!$stmtIp->fetchColumn()) {
                    $ipInc = 1;
                }
            }
        } catch (Throwable $e) {
            $ipInc = 0;
        }

        
        try {
            $stmt = $pdo->prepare("
                INSERT INTO visit_log
                    (visit_time, url, article_id, user_id, visitor_id, ip, user_agent, referrer, is_mobile)
                VALUES
                    (:visit_time, :url, :article_id, :user_id, :visitor_id, :ip, :user_agent, :referrer, :is_mobile)
            ");

            $stmt->execute([
                ':visit_time' => $now,
                ':url'        => $url,
                ':article_id' => $articleId,
                ':user_id'    => $userId,
                ':visitor_id' => $visitorId,
                ':ip'         => $ip,
                ':user_agent' => $ua,
                ':referrer'   => $referrer,
                ':is_mobile'  => $isMobile,
            ]);
        } catch (Throwable $e) {
            
        }

        
        try {
            $stmtUp = $pdo->prepare("
                UPDATE visit_daily
                SET pv = pv + 1,
                    uv = uv + :uv_inc,
                    ip_count = ip_count + :ip_inc
                WHERE date = :date
            ");
            $stmtUp->execute([
                ':date'   => $dateKey,
                ':uv_inc' => $uvInc,
                ':ip_inc' => $ipInc,
            ]);
        } catch (Throwable $e) {
            
        }

        
        if ($articleId !== null) {
            try {
                $stmt2 = $pdo->prepare("
                    UPDATE articles
                    SET view_count = view_count + 1
                    WHERE id = :id
                ");
                $stmt2->execute([':id' => $articleId]);
            } catch (Throwable $e) {
                
            }
        }
    } catch (Throwable $e) {
        
    }
}








function generate_captcha(string $key): string
{
    if (!isset($_SESSION['captcha'])) {
        $_SESSION['captcha'] = [];
    }

    $a = random_int(0, 9);
    $b = random_int(0, 9);
    $_SESSION['captcha'][$key] = $a + $b;

    return $a . ' + ' . $b . ' = ?';
}








function verify_captcha(string $key, $answer): bool
{
    if (!isset($_SESSION['captcha'][$key])) {
        return false;
    }

    $expected = (int)$_SESSION['captcha'][$key];
    unset($_SESSION['captcha'][$key]); 

    return ((int)$answer === $expected);
}










function save_article_tags(PDO $pdo, int $articleId, string $tagsText): void
{
    $tagsText = trim($tagsText);
    if ($articleId <= 0 || $tagsText === '') {
        return;
    }

    
    $raw = preg_split('/[,\s]+/u', $tagsText);
    $names = [];
    foreach ($raw as $name) {
        $name = trim($name);
        if ($name === '') {
            continue;
        }
        if (mb_strlen($name, 'UTF-8') > 20) {
            $name = mb_substr($name, 0, 20, 'UTF-8');
        }
        $names[$name] = true;
    }

    if (!$names) {
        return;
    }

    try {
        $pdo->beginTransaction();

        
        $stmtDel = $pdo->prepare("DELETE FROM article_tag WHERE article_id = :aid");
        $stmtDel->execute([':aid' => $articleId]);

        $stmtSelect = $pdo->prepare("SELECT id FROM tags WHERE name = :name LIMIT 1");
        $stmtInsert = $pdo->prepare("
            INSERT INTO tags (name, slug, created_at)
            VALUES (:name, :slug, :created_at)
        ");
        $stmtAttach = $pdo->prepare("
            INSERT OR IGNORE INTO article_tag (article_id, tag_id)
            VALUES (:article_id, :tag_id)
        ");

        foreach (array_keys($names) as $name) {
            
            $stmtSelect->execute([':name' => $name]);
            $row = $stmtSelect->fetch();
            if ($row) {
                $tagId = (int)$row['id'];
            } else {
                $slug = null; 
                $stmtInsert->execute([
                    ':name'       => $name,
                    ':slug'       => $slug,
                    ':created_at' => time(),
                ]);
                $tagId = (int)$pdo->lastInsertId();
            }

            
            $stmtAttach->execute([
                ':article_id' => $articleId,
                ':tag_id'     => $tagId,
            ]);
        }

        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        
    }
}












function article_is_unlocked_for_current_visitor(PDO $pdo, int $articleId): bool
{
    if ($articleId <= 0) {
        return false;
    }

    $userId = current_user_id();
    $ip     = client_ip();
    $ua     = isset($_SERVER['HTTP_USER_AGENT']) && is_string($_SERVER['HTTP_USER_AGENT'])
        ? $_SERVER['HTTP_USER_AGENT']
        : '';

    try {
        if ($userId) {
            $stmt = $pdo->prepare("
                SELECT 1
                FROM comments
                WHERE article_id = :aid
                  AND user_id = :uid
                  AND status = 'approved'
                LIMIT 1
            ");
            $stmt->execute([
                ':aid' => $articleId,
                ':uid' => $userId,
            ]);
            if ($stmt->fetchColumn()) {
                return true;
            }
        }

        if ($ip !== '' && $ua !== '') {
            $stmt = $pdo->prepare("
                SELECT 1
                FROM comments
                WHERE article_id = :aid
                  AND user_id IS NULL
                  AND ip = :ip
                  AND user_agent = :ua
                  AND status = 'approved'
                LIMIT 1
            ");
            $stmt->execute([
                ':aid' => $articleId,
                ':ip'  => $ip,
                ':ua'  => $ua,
            ]);
            if ($stmt->fetchColumn()) {
                return true;
            }
        }
    } catch (Throwable $e) {
        return false;
    }

    return false;
}













function convert_label_paragraphs_to_hidden_markers(string $html, string $label, string $placeholder, string $startTag, string $endTag): string
{
    if ($html === '' || $label === '') {
        return $html;
    }
    if (strpos($html, $label) === false) {
        return $html;
    }
    if (!class_exists('DOMDocument')) {
        return $html;
    }

    $old = libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $flags = 0;
    if (defined('LIBXML_HTML_NOIMPLIED')) {
        $flags |= LIBXML_HTML_NOIMPLIED;
    }
    if (defined('LIBXML_HTML_NODEFDTD')) {
        $flags |= LIBXML_HTML_NODEFDTD;
    }
    $dom->loadHTML('<?xml encoding="UTF-8"><div>' . $html . '</div>', $flags);
    libxml_clear_errors();
    libxml_use_internal_errors($old);

    $root = $dom->getElementsByTagName('div')->item(0);
    if (!$root) {
        return $html;
    }

    $paras = [];
    foreach ($root->getElementsByTagName('p') as $p) {
        $paras[] = $p;
    }

    $changed = false;
    $mbPos = function (string $haystack, string $needle) {
        if (function_exists('mb_strpos')) {
            return mb_strpos($haystack, $needle);
        }
        return strpos($haystack, $needle);
    };
    $mbSub = function (string $str, int $start, ?int $len = null) {
        if (function_exists('mb_substr')) {
            return $len === null ? mb_substr($str, $start) : mb_substr($str, $start, $len);
        }
        return $len === null ? substr($str, $start) : substr($str, $start, $len);
    };
    $mbLen = function (string $str) {
        if (function_exists('mb_strlen')) {
            return mb_strlen($str);
        }
        return strlen($str);
    };

    foreach ($paras as $p) {
        $text = (string)$p->textContent;
        if ($text === '' || $mbPos($text, $label) === false) {
            continue;
        }

        $beforeNodes = [];
        $afterNodes = [];
        $found = false;

        foreach ($p->childNodes as $child) {
            if (!$found) {
                if ($child->nodeType === XML_TEXT_NODE) {
                    $raw = (string)$child->nodeValue;
                    $pos = $mbPos($raw, $label);
                    if ($pos !== false) {
                        $beforeText = $mbSub($raw, 0, $pos);
                        $afterText = $mbSub($raw, $pos + $mbLen($label));
                        if ($beforeText !== '') {
                            $beforeNodes[] = $dom->createTextNode($beforeText);
                        }
                        if ($afterText !== '') {
                            $afterNodes[] = $dom->createTextNode($afterText);
                        }
                        $found = true;
                        continue;
                    }
                } elseif ($child->nodeType === XML_ELEMENT_NODE) {
                    $childText = (string)$child->textContent;
                    if ($childText !== '' && $mbPos($childText, $label) !== false) {
                        $normChild = trim((string)preg_replace('/\\s+/u', ' ', $childText));
                        $normLabel = trim((string)preg_replace('/\\s+/u', ' ', $label));
                        if ($normChild !== $normLabel) {
                            $afterText = trim(str_replace($label, '', $childText));
                            if ($afterText !== '') {
                                $afterNodes[] = $dom->createTextNode($afterText);
                            }
                        }
                        $found = true;
                        continue;
                    }
                }

                $beforeNodes[] = $child->cloneNode(true);
            } else {
                $afterNodes[] = $child->cloneNode(true);
            }
        }

        if (!$found) {
            continue;
        }

        $hiddenText = '';
        foreach ($afterNodes as $node) {
            if ($node->nodeType === XML_TEXT_NODE) {
                $hiddenText .= (string)$node->nodeValue;
            } elseif ($node->nodeType === XML_ELEMENT_NODE) {
                $hiddenText .= (string)$node->textContent;
            }
        }
        $hiddenText = str_replace(["\xEF\xBB\xBF", "\xE2\x80\x8B", "\xE2\x80\x8C", "\xE2\x80\x8D"], '', $hiddenText);
        $hiddenText = trim((string)preg_replace('/\\s+/u', ' ', $hiddenText));
        if ($placeholder !== '' && $hiddenText === $placeholder) {
            $afterNodes = [];
        }

        $frag = $dom->createDocumentFragment();
        foreach ($beforeNodes as $node) {
            $frag->appendChild($node);
        }
        $frag->appendChild($dom->createComment('hidden:comment-only:start'));
        if ($afterNodes) {
            foreach ($afterNodes as $node) {
                $frag->appendChild($node);
            }
        } else {
            $nextEl = $p->nextSibling;
            while ($nextEl && $nextEl->nodeType !== XML_ELEMENT_NODE) {
                $nextEl = $nextEl->nextSibling;
            }
            if ($nextEl instanceof DOMElement) {
                $frag->appendChild($nextEl);
            } else {
                $inner = $dom->createElement('p');
                $frag->appendChild($inner);
            }
        }
        $frag->appendChild($dom->createComment('hidden:comment-only:end'));

        if ($p->parentNode) {
            $p->parentNode->replaceChild($frag, $p);
            $changed = true;
        }
    }

    if (!$changed) {
        return $html;
    }

    $out = '';
    foreach ($root->childNodes as $c) {
        $out .= $dom->saveHTML($c);
    }
    return $out;
}

function render_article_with_hidden_blocks(string $html, bool $unlocked): string
{
    
    $html = linkify_attachment_names($html);
    $html = normalize_html_newlines($html);

    $hiddenLabel = '隐藏内容（评论可见）';
    $hiddenPlaceholder = '这里是隐藏内容块';
    $startTag = '<!--hidden:comment-only:start-->';
    $endTag   = '<!--hidden:comment-only:end-->';

    $normalizeText = function (string $text): string {
        $text = preg_replace('/\\s+/u', ' ', $text);
        return trim((string)$text);
    };

    
    $hiddenSentinel = "\xE2\x81\xA3";
    $hiddenSentinelEnd = "\xE2\x81\xA4";
    $hiddenZws = "\xE2\x80\x8B";
    $hiddenSentinelStartSeq = $hiddenSentinel . $hiddenSentinel . $hiddenSentinel;
    $hiddenSentinelEndSeq = $hiddenSentinelEnd . $hiddenSentinelEnd . $hiddenSentinelEnd;
    $convertBlockquotes = function (string $input) use ($hiddenLabel, $normalizeText, $hiddenSentinel): string {
        if (stripos($input, '<blockquote') === false) {
            return $input;
        }
        if (!class_exists('DOMDocument')) {
            return $input;
        }

        $old = libxml_use_internal_errors(true);
        $dom = new DOMDocument();
        $flags = 0;
        if (defined('LIBXML_HTML_NOIMPLIED')) {
            $flags |= LIBXML_HTML_NOIMPLIED;
        }
        if (defined('LIBXML_HTML_NODEFDTD')) {
            $flags |= LIBXML_HTML_NODEFDTD;
        }
        $dom->loadHTML('<?xml encoding="UTF-8"><div>' . $input . '</div>', $flags);
        libxml_clear_errors();
        libxml_use_internal_errors($old);

        $root = $dom->getElementsByTagName('div')->item(0);
        if (!$root) {
            return $input;
        }

        $blockquotes = [];
        foreach ($root->getElementsByTagName('blockquote') as $bq) {
            $blockquotes[] = $bq;
        }

        $converted = false;
        foreach ($blockquotes as $bq) {
            $hidden = false;

            if ($bq->hasAttribute('data-hidden-block') && $bq->getAttribute('data-hidden-block') === 'comment-only') {
                $hidden = true;
            }
            $cls = $bq->getAttribute('class');
            if (!$hidden && $cls && preg_match('/\\bhidden-comment-block\\b/u', $cls)) {
                $hidden = true;
            }

            if (!$hidden) {
                $firstEl = null;
                foreach ($bq->childNodes as $child) {
                    if ($child->nodeType === XML_ELEMENT_NODE) {
                        $firstEl = $child;
                        break;
                    }
                }
                if ($firstEl && $normalizeText($firstEl->textContent) === $hiddenLabel) {
                    $hidden = true;
                }
            }

            if (!$hidden && $hiddenLabel !== '' && strpos((string)$bq->textContent, $hiddenLabel) !== false) {
                $hidden = true;
            }

            if (!$hidden && $hiddenSentinel !== '' && strpos((string)$bq->textContent, $hiddenSentinel) !== false) {
                $hidden = true;
            }
            if (!$hidden && $hiddenSentinelEnd !== '' && strpos((string)$bq->textContent, $hiddenSentinelEnd) !== false) {
                $hidden = true;
            }
            if (!$hidden && $hiddenZws !== '' && strpos((string)$bq->textContent, $hiddenZws) !== false) {
                $hidden = true;
            }

            if (!$hidden) {
                foreach ($bq->getElementsByTagName('*') as $child) {
                    if ($child->hasAttribute('data-hidden-marker')) {
                        $hidden = true;
                        break;
                    }
                }
            }

            if (!$hidden) {
                continue;
            }

            
            $removeNodes = [];
            foreach ($bq->getElementsByTagName('*') as $child) {
                if ($child->hasAttribute('data-hidden-marker')) {
                    $removeNodes[] = $child;
                    continue;
                }
                $ccls = $child->getAttribute('class');
                if ($ccls && preg_match('/\\bhidden-(?:label|marker)\\b/u', $ccls)) {
                    $removeNodes[] = $child;
                    continue;
                }
            }
            foreach ($removeNodes as $node) {
                if ($node->parentNode) {
                    $node->parentNode->removeChild($node);
                }
            }

            $firstEl = null;
            foreach ($bq->childNodes as $child) {
                if ($child->nodeType === XML_ELEMENT_NODE) {
                    $firstEl = $child;
                    break;
                }
            }
            if ($firstEl && $normalizeText($firstEl->textContent) === $hiddenLabel) {
                if ($firstEl->parentNode) {
                    $firstEl->parentNode->removeChild($firstEl);
                }
            }

            $children = [];
            foreach ($bq->childNodes as $child) {
                $children[] = $child;
            }

            $frag = $dom->createDocumentFragment();
            $frag->appendChild($dom->createComment('hidden:comment-only:start'));
            foreach ($children as $child) {
                $frag->appendChild($child);
            }
            $frag->appendChild($dom->createComment('hidden:comment-only:end'));

            if ($bq->parentNode) {
                $bq->parentNode->replaceChild($frag, $bq);
                $converted = true;
            }
        }

        if (!$converted) {
            return $input;
        }

        $out = '';
        foreach ($root->childNodes as $c) {
            $out .= $dom->saveHTML($c);
        }
        return $out;
    };

    $html = $convertBlockquotes($html);

    
    if (strpos($html, $hiddenSentinelStartSeq) !== false && strpos($html, $hiddenSentinelEndSeq) !== false) {
        $pattern = '/' . preg_quote($hiddenSentinelStartSeq, '/') . '([\\s\\S]*?)' . preg_quote($hiddenSentinelEndSeq, '/') . '/u';
        $html = preg_replace($pattern, $startTag . '$1' . $endTag, $html);
    }

    
    $hasBlockquote = (stripos($html, '<blockquote') !== false);
    $hasMarkers = (strpos($html, $startTag) !== false || strpos($html, $endTag) !== false);
    if (!$hasBlockquote && !$hasMarkers) {
        
        $html = convert_label_paragraphs_to_hidden_markers($html, $hiddenLabel, $hiddenPlaceholder, $startTag, $endTag);
        $hasMarkers = (strpos($html, $startTag) !== false || strpos($html, $endTag) !== false);
    }
    if (!$hasBlockquote && !$hasMarkers && strpos($html, $hiddenLabel) !== false && stripos($html, '<p') === false) {
        $labelPattern = preg_quote($hiddenLabel, '/');
        $placeholderPattern = preg_quote($hiddenPlaceholder, '/');
        $pattern = '/(^|<br\\s*\\/?>\\s*|\\r?\\n\\s*)'
            . $labelPattern
            . '\\s*(?:' . $placeholderPattern . ')?\\s*([^\\r\\n<]*)/u';
        $html = preg_replace_callback($pattern, function ($m) use ($startTag, $endTag) {
            $prefix = $m[1] ?? '';
            $content = $m[2] ?? '';
            return $prefix . $startTag . $content . $endTag;
        }, $html);
    }

    $result = '';
    $offset = 0;

    while (true) {
        $posStart = strpos($html, $startTag, $offset);
        if ($posStart === false) {
            // 没有更多隐藏块，追加剩余部分
            $result .= substr($html, $offset);
            break;
        }

        // 追加 start 之前的普通内容
        $result .= substr($html, $offset, $posStart - $offset);

        $posStartContent = $posStart + strlen($startTag);
        $posEnd = strpos($html, $endTag, $posStartContent);
        if ($posEnd === false) {
            // 找不到结束标记，防御性处理：把剩余内容都当作普通内容输出
            $result .= substr($html, $posStart);
            break;
        }

        $hiddenContent = substr($html, $posStartContent, $posEnd - $posStartContent);
        // 清理编辑器占位文字，避免混入隐藏内容
        $hiddenContent = str_replace(["\xEF\xBB\xBF", "在这里输入内容...", "在这里输入内容…", "\xE2\x81\xA3", "\xE2\x81\xA4", "\xE2\x80\x8B"], '', $hiddenContent);

        if ($unlocked) {
            // 已解锁：直接输出原始隐藏内容，不再额外包裹样式块
            $result .= $hiddenContent;
        } else {
            // 未解锁：仅显示提示，占位不展示真实内容
            $result .= '<div class="hidden-block-placeholder">'
                . '此处为评论可见隐藏内容。本篇文章发表评论并通过审核后刷新页面查看内容。'
                . '</div>';
        }

        // 继续向后处理
        $offset = $posEnd + strlen($endTag);
    }

    if (strpos($result, "\xE2\x81\xA3") !== false || strpos($result, "\xE2\x81\xA4") !== false || strpos($result, "\xE2\x80\x8B") !== false) {
        $result = str_replace(["\xE2\x81\xA3", "\xE2\x81\xA4", "\xE2\x80\x8B"], '', $result);
    }
    return $result;
}

/**
 * 保存前修复隐藏内容标记：
 * - 兼容不可见起止标记（U+2063/U+2064）
 * - 兼容“隐藏内容（评论可见）”行
 * - 兼容 blockquote 隐藏块
 */
function normalize_hidden_blocks_before_save(string $html): string
{
    $html = (string)$html;
    if ($html === '') {
        return '';
    }
    $originalHtml = $html;

    $hiddenLabel = '隐藏内容（评论可见）';
    $hiddenPlaceholder = '这里是隐藏内容块';
    $startTag = '';
    $endTag   = '';
    $hiddenSentinelStartSeq = "\xE2\x81\xA3\xE2\x81\xA3\xE2\x81\xA3";
    $hiddenSentinelEndSeq   = "\xE2\x81\xA4\xE2\x81\xA4\xE2\x81\xA4";
    $hiddenSentinel = "\xE2\x81\xA3";
    $hiddenSentinelEnd = "\xE2\x81\xA4";
    $hiddenZws = "\xE2\x80\x8B";

    $normalizeText = function (string $text): string {
        $text = preg_replace('/\\s+/u', ' ', $text);
        return trim((string)$text);
    };

    // A) 将 blockquote 隐藏块转为注释标记
    $convertBlockquotes = function (string $input) use (
        $hiddenLabel,
        $hiddenSentinel,
        $hiddenSentinelEnd,
        $hiddenZws,
        $normalizeText,
        $startTag,
        $endTag
    ): string {
        if (stripos($input, '<blockquote') === false) {
            return $input;
        }
        if (!class_exists('DOMDocument')) {
            return $input;
        }

        $old = libxml_use_internal_errors(true);
        $dom = new DOMDocument();
        $flags = 0;
        if (defined('LIBXML_HTML_NOIMPLIED')) {
            $flags |= LIBXML_HTML_NOIMPLIED;
        }
        if (defined('LIBXML_HTML_NODEFDTD')) {
            $flags |= LIBXML_HTML_NODEFDTD;
        }
        $dom->loadHTML('<?xml encoding="UTF-8"><div>' . $input . '</div>', $flags);
        libxml_clear_errors();
        libxml_use_internal_errors($old);

        $root = $dom->getElementsByTagName('div')->item(0);
        if (!$root) {
            return $input;
        }

        $blockquotes = [];
        foreach ($root->getElementsByTagName('blockquote') as $bq) {
            $blockquotes[] = $bq;
        }

        $converted = false;
        foreach ($blockquotes as $bq) {
            $hidden = false;

            if ($bq->hasAttribute('data-hidden-block') && $bq->getAttribute('data-hidden-block') === 'comment-only') {
                $hidden = true;
            }
            $cls = $bq->getAttribute('class');
            if (!$hidden && $cls && preg_match('/\\bhidden-comment-block\\b/u', $cls)) {
                $hidden = true;
            }

            if (!$hidden) {
                $firstEl = null;
                foreach ($bq->childNodes as $child) {
                    if ($child->nodeType === XML_ELEMENT_NODE) {
                        $firstEl = $child;
                        break;
                    }
                }
                if ($firstEl && $normalizeText($firstEl->textContent) === $hiddenLabel) {
                    $hidden = true;
                }
            }

            $text = (string)$bq->textContent;
            if (!$hidden && $hiddenLabel !== '' && strpos($text, $hiddenLabel) !== false) {
                $hidden = true;
            }
            if (!$hidden && $hiddenSentinel !== '' && strpos($text, $hiddenSentinel) !== false) {
                $hidden = true;
            }
            if (!$hidden && $hiddenSentinelEnd !== '' && strpos($text, $hiddenSentinelEnd) !== false) {
                $hidden = true;
            }
            if (!$hidden && $hiddenZws !== '' && strpos($text, $hiddenZws) !== false) {
                $hidden = true;
            }
            if (!$hidden) {
                foreach ($bq->getElementsByTagName('*') as $child) {
                    if ($child->hasAttribute('data-hidden-marker')) {
                        $hidden = true;
                        break;
                    }
                }
            }

            if (!$hidden) {
                continue;
            }

            // 清理标记节点（隐藏标签 / 隐藏标记）
            $removeNodes = [];
            foreach ($bq->getElementsByTagName('*') as $child) {
                if ($child->hasAttribute('data-hidden-marker')) {
                    $removeNodes[] = $child;
                    continue;
                }
                $ccls = $child->getAttribute('class');
                if ($ccls && preg_match('/\\bhidden-(?:label|marker)\\b/u', $ccls)) {
                    $removeNodes[] = $child;
                    continue;
                }
            }
            foreach ($removeNodes as $node) {
                if ($node->parentNode) {
                    $node->parentNode->removeChild($node);
                }
            }

            $firstEl = null;
            foreach ($bq->childNodes as $child) {
                if ($child->nodeType === XML_ELEMENT_NODE) {
                    $firstEl = $child;
                    break;
                }
            }
            if ($firstEl && $normalizeText($firstEl->textContent) === $hiddenLabel) {
                if ($firstEl->parentNode) {
                    $firstEl->parentNode->removeChild($firstEl);
                }
            }

            $children = [];
            foreach ($bq->childNodes as $child) {
                $children[] = $child;
            }

            $frag = $dom->createDocumentFragment();
            $frag->appendChild($dom->createComment('hidden:comment-only:start'));
            foreach ($children as $child) {
                $frag->appendChild($child);
            }
            $frag->appendChild($dom->createComment('hidden:comment-only:end'));

            if ($bq->parentNode) {
                $bq->parentNode->replaceChild($frag, $bq);
                $converted = true;
            }
        }

        if (!$converted) {
            return $input;
        }

        $out = '';
        foreach ($root->childNodes as $c) {
            $out .= $dom->saveHTML($c);
        }
        return $out;
    };

    $html = $convertBlockquotes($html);

    // 1) 将不可见起止标记转为注释标记
    if (strpos($html, $hiddenSentinelStartSeq) !== false && strpos($html, $hiddenSentinelEndSeq) !== false) {
        $pattern = '/' . preg_quote($hiddenSentinelStartSeq, '/') . '([\\s\\S]*?)' . preg_quote($hiddenSentinelEndSeq, '/') . '/u';
        $html = preg_replace($pattern, $startTag . '$1' . $endTag, $html);
    }

    $hasBlockquote = (stripos($html, '<blockquote') !== false);
    $hasMarkers = (strpos($html, $startTag) !== false || strpos($html, $endTag) !== false);

    // 2) 先用 DOM 方式处理 <p> 内的“隐藏内容”行，避免正则误伤 <br> 后的正文
    if (!$hasBlockquote && !$hasMarkers) {
        $html = convert_label_paragraphs_to_hidden_markers($html, $hiddenLabel, $hiddenPlaceholder, $startTag, $endTag);
        $hasMarkers = (strpos($html, $startTag) !== false || strpos($html, $endTag) !== false);
    }

    // 3) 兼容纯文本（无 <p>）的旧格式
    if (!$hasBlockquote && !$hasMarkers && strpos($html, $hiddenLabel) !== false && stripos($html, '<p') === false) {
        $labelPattern = preg_quote($hiddenLabel, '/');
        $placeholderPattern = preg_quote($hiddenPlaceholder, '/');
        $pattern = '/(^|<br\\s*\\/?>\\s*|\\r?\\n\\s*)'
            . $labelPattern
            . '\\s*(?:' . $placeholderPattern . ')?\\s*([^\\r\\n<]*)/u';
        $html = preg_replace_callback($pattern, function ($m) use ($startTag, $endTag) {
            $prefix = $m[1] ?? '';
            $content = $m[2] ?? '';
            return $prefix . $startTag . $content . $endTag;
        }, $html);
    }

    // debug: 如果包含隐藏提示但仍未生成注释标记，记录到日志
    if ($originalHtml !== '' && strpos($originalHtml, $hiddenLabel) !== false && strpos($html, $startTag) === false) {
        $logPath = __DIR__ . '/data/hidden_debug.log';
        $line = '[' . date('Y-m-d H:i:s') . '] '
            . 'url=' . ($_SERVER['REQUEST_URI'] ?? '') . ' '
            . 'len=' . strlen($originalHtml) . ' '
            . 'html=' . str_replace(["\r", "\n"], ['\\r', '\\n'], $originalHtml)
            . PHP_EOL;
        @file_put_contents($logPath, $line, FILE_APPEND);
    }

    return $html;
}

// Migrate legacy category icon folder to unified icons folder (uploads/article_icons).
function migrate_legacy_category_icons(): void
{
    static $done = false;
    if ($done) {
        return;
    }
    $done = true;

    $markerDir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'logs';
    if (!is_dir($markerDir)) {
        @mkdir($markerDir, 0777, true);
    }
    $markerFile = $markerDir . DIRECTORY_SEPARATOR . '.icon_dir_migrated_v1';
    if (is_file($markerFile)) {
        return;
    }

    $src = BASE_PATH . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'category_icons';
    $dst = BASE_PATH . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'article_icons';
    if (!is_dir($src)) {
        @file_put_contents($markerFile, date('c'));
        return;
    }
    if (!is_dir($dst)) {
        @mkdir($dst, 0777, true);
    }

    $mapping = [];
    try {
        $it = new DirectoryIterator($src);
        foreach ($it as $fileinfo) {
            if ($fileinfo->isDot() || !$fileinfo->isFile()) {
                continue;
            }
            $name = $fileinfo->getFilename();
            if ($name === '') {
                continue;
            }
            $targetName = $name;
            $targetPath = $dst . DIRECTORY_SEPARATOR . $targetName;
            if (is_file($targetPath)) {
                $ext = pathinfo($name, PATHINFO_EXTENSION);
                $base = pathinfo($name, PATHINFO_FILENAME);
                $targetName = $base . '_' . bin2hex(random_bytes(3)) . ($ext ? ('.' . $ext) : '');
                $targetPath = $dst . DIRECTORY_SEPARATOR . $targetName;
            }
            $moved = @rename($fileinfo->getPathname(), $targetPath);
            if (!$moved) {
                if (@copy($fileinfo->getPathname(), $targetPath)) {
                    @unlink($fileinfo->getPathname());
                    $moved = true;
                }
            }
            if ($moved) {
                $oldRel = 'uploads/category_icons/' . $name;
                $newRel = 'uploads/article_icons/' . $targetName;
                $mapping[$oldRel] = $newRel;
                $mapping['/' . $oldRel] = '/' . $newRel;
            }
        }
    } catch (Throwable $e) {
        // ignore migration errors
    }

    if ($mapping) {
        try {
            $pdo = db();
            $stmtCat = $pdo->prepare("UPDATE categories SET icon_path = :new WHERE icon_path = :old");
            foreach ($mapping as $old => $new) {
                try {
                    $stmtCat->execute([':new' => $new, ':old' => $old]);
                } catch (Throwable $e) {
                    // ignore
                }
            }

            // Update media_files paths if table exists.
            try {
                $hasMedia = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='media_files' LIMIT 1")->fetchColumn();
            } catch (Throwable $e) {
                $hasMedia = null;
            }
            if ($hasMedia) {
                $stmtMedia = $pdo->prepare("UPDATE media_files SET path = :new WHERE path = :old");
                foreach ($mapping as $old => $new) {
                    try {
                        $stmtMedia->execute([':new' => $new, ':old' => $old]);
                    } catch (Throwable $e) {
                        // ignore
                    }
                }
            }

            // Update ignored table if exists.
            try {
                $hasIgnored = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='media_files_ignored' LIMIT 1")->fetchColumn();
            } catch (Throwable $e) {
                $hasIgnored = null;
            }
            if ($hasIgnored) {
                $stmtIgnored = $pdo->prepare("UPDATE media_files_ignored SET path = :new WHERE path = :old");
                foreach ($mapping as $old => $new) {
                    try {
                        $stmtIgnored->execute([':new' => $new, ':old' => $old]);
                    } catch (Throwable $e) {
                        // ignore
                    }
                }
            }

            // Update article list icon custom if pointing to legacy path.
            try {
                $stmtSet = $pdo->prepare("UPDATE settings SET value = :new WHERE key = 'article.list_icon_custom' AND value = :old");
                foreach ($mapping as $old => $new) {
                    try {
                        $stmtSet->execute([':new' => $new, ':old' => $old]);
                    } catch (Throwable $e) {
                        // ignore
                    }
                }
            } catch (Throwable $e) {
                // ignore
            }
        } catch (Throwable $e) {
            // ignore
        }
    }

    // Clear sidebar cache so new icon paths take effect.
    $cacheDir = BASE_PATH . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'sidebar';
    if (is_dir($cacheDir)) {
        try {
            $it = new DirectoryIterator($cacheDir);
            foreach ($it as $fileinfo) {
                if ($fileinfo->isDot() || !$fileinfo->isFile()) {
                    continue;
                }
                @unlink($fileinfo->getPathname());
            }
        } catch (Throwable $e) {
            // ignore
        }
    }

    // Remove old directory if empty
    try {
        if (is_dir($src)) {
            $it = new FilesystemIterator($src, FilesystemIterator::SKIP_DOTS);
            if (!iterator_count($it)) {
                @rmdir($src);
            }
        }
    } catch (Throwable $e) {
        // ignore
    }

    @file_put_contents($markerFile, date('c'));
}

/**
 * 统一状态中文化显示（后台/前台通用）。
 */
function status_label_cn(string $status, string $context = ''): string
{
    $status = trim($status);
    if ($status === '') {
        return '';
    }

    $maps = [
        'article' => [
            'draft'     => '草稿',
            'pending'   => '待审核',
            'published' => '已发布',
            'rejected'  => '已驳回',
        ],
        'comment' => [
            'pending'  => '待审核',
            'approved' => '已通过',
            'spam'     => '垃圾',
            'rejected' => '已驳回',
        ],
        'link' => [
            'pending'  => '待审核',
            'approved' => '已通过',
            'rejected' => '已驳回',
        ],
        'user' => [
            'active'        => '正常',
            'disabled'      => '已禁用',
            'pending_email' => '邮箱未验证',
        ],
    ];

    if ($context !== '' && isset($maps[$context][$status])) {
        return $maps[$context][$status];
    }

    $generic = [
        'draft'         => '草稿',
        'pending'       => '待审核',
        'published'     => '已发布',
        'rejected'      => '已驳回',
        'approved'      => '已通过',
        'spam'          => '垃圾',
        'active'        => '正常',
        'disabled'      => '已禁用',
        'pending_email' => '邮箱未验证',
    ];

    return $generic[$status] ?? $status;
}

// Run legacy icon migration once per install.
migrate_legacy_category_icons();


