<?php
if (!defined('APP_ROOT')) exit;

function user_review_install(array $plugin): void
{
    user_review_schema();
}

function user_review_uninstall(array $plugin): void
{
    app_db_drop_table('plugin_user_review_password_resets');
    app_db_drop_table('plugin_user_review_ip_logs');
    app_db_drop_table('plugin_user_review_users');
    app_db_drop_table('plugin_user_review_stats');
    app_db_drop_table('plugin_user_review_registrations');
    app_db_drop_table('plugin_user_review_topics');
    app_db_drop_table('plugin_user_review_email_codes');
}

function user_review_schema(): void
{
    static $ready = false;
    if ($ready) return;
    $t = app_db_types();
    app_db_create_table('plugin_user_review_registrations', "id {$t['id']},username {$t['string']} NOT NULL,password {$t['string']} NOT NULL,email {$t['string']} NOT NULL DEFAULT '',bio {$t['text']} NOT NULL,avatar_style {$t['string']} NOT NULL DEFAULT '',avatar_seed {$t['string']} NOT NULL DEFAULT '',group_id {$t['uint']} NOT NULL DEFAULT 2,points {$t['uint']} NOT NULL DEFAULT 0,status {$t['key']} NOT NULL DEFAULT 'pending',user_id {$t['uint']} NOT NULL DEFAULT 0,created_at {$t['uint']} NOT NULL,reviewed_at {$t['uint']} NOT NULL DEFAULT 0,reviewer_id {$t['uint']} NOT NULL DEFAULT 0");
    app_db_create_index('idx_plugin_user_review_registrations_status', 'plugin_user_review_registrations(status,created_at DESC)');
    app_db_create_index('idx_plugin_user_review_registrations_username', 'plugin_user_review_registrations(username,status)');
    app_db_create_table('plugin_user_review_topics', "id {$t['id']},user_id {$t['uint']} NOT NULL,forum_id {$t['uint']} NOT NULL,title {$t['string']} NOT NULL,body {$t['text']} NOT NULL,status {$t['key']} NOT NULL DEFAULT 'pending',content_kind {$t['key']} NOT NULL DEFAULT 'topic',reply_topic_id {$t['uint']} NOT NULL DEFAULT 0,published_id {$t['uint']} NOT NULL DEFAULT 0,created_at {$t['uint']} NOT NULL,reviewed_at {$t['uint']} NOT NULL DEFAULT 0,reviewer_id {$t['uint']} NOT NULL DEFAULT 0");
    app_db_ensure_columns('plugin_user_review_topics', ['content_kind' => "{$t['key']} NOT NULL DEFAULT 'topic'"]);
    $topic_columns = app_db_columns(db(), db_driver(), 'plugin_user_review_topics');
    if (isset($topic_columns['content_type'])) q("UPDATE plugin_user_review_topics SET content_kind='reply' WHERE content_type='reply' AND content_kind<>'reply'");
    app_db_create_index('idx_plugin_user_review_topics_status', 'plugin_user_review_topics(status,created_at DESC)');
    app_db_create_index('idx_plugin_user_review_topics_user', 'plugin_user_review_topics(user_id,status)');
    app_db_create_index('idx_plugin_user_review_topics_published', 'plugin_user_review_topics(content_kind,published_id)');
    app_db_create_table('plugin_user_review_email_codes', "id {$t['id']},email {$t['string']} NOT NULL,code_hash {$t['string']} NOT NULL,ip {$t['string']} NOT NULL DEFAULT '',used_at {$t['uint']} NOT NULL DEFAULT 0,expires_at {$t['uint']} NOT NULL,created_at {$t['uint']} NOT NULL");
    app_db_create_index('idx_plugin_user_review_email_codes_email', 'plugin_user_review_email_codes(email,created_at DESC)');
    app_db_create_index('idx_plugin_user_review_email_codes_ip', 'plugin_user_review_email_codes(ip,created_at DESC)');
    app_db_create_table('plugin_user_review_password_resets', "id {$t['id']},user_id {$t['uint']} NOT NULL,token_hash {$t['key']} NOT NULL UNIQUE,expires_at {$t['uint']} NOT NULL,used_at {$t['uint']} NOT NULL DEFAULT 0,created_at {$t['uint']} NOT NULL");
    app_db_create_index('idx_plugin_user_review_password_resets_user', 'plugin_user_review_password_resets(user_id,created_at DESC)');
    app_db_create_table('plugin_user_review_ip_logs', "ip {$t['key']} PRIMARY KEY,register_count {$t['uint']} NOT NULL DEFAULT 0,register_at {$t['uint']} NOT NULL DEFAULT 0,login_fail_count {$t['uint']} NOT NULL DEFAULT 0,login_fail_at {$t['uint']} NOT NULL DEFAULT 0,reset_fail_count {$t['uint']} NOT NULL DEFAULT 0,reset_fail_at {$t['uint']} NOT NULL DEFAULT 0,created_at {$t['uint']} NOT NULL,updated_at {$t['uint']} NOT NULL");
    app_db_create_index('idx_plugin_user_review_ip_logs_updated', 'plugin_user_review_ip_logs(updated_at DESC)');
    user_review_stats_schema();
    $ready = true;
}

function user_review_stats_schema(): void
{
    static $ready = false;
    if ($ready) return;
    $exists = app_db_table_exists(db(), db_driver(), 'plugin_user_review_stats');
    $t = app_db_types();
    app_db_create_table('plugin_user_review_stats', "id {$t['uint']} PRIMARY KEY,registrations {$t['uint']} NOT NULL DEFAULT 0,contents {$t['uint']} NOT NULL DEFAULT 0,registration_latest_at {$t['uint']} NOT NULL DEFAULT 0,content_latest_at {$t['uint']} NOT NULL DEFAULT 0,updated_at {$t['uint']} NOT NULL DEFAULT 0");
    $rebuilt = !$exists || val('SELECT id FROM plugin_user_review_stats WHERE id=1') === false;
    if ($rebuilt) user_review_stats_rebuild();
    $ready = true;
}

function user_review_stats_rebuild(): void
{
    $row = one("SELECT
        (SELECT COUNT(*) FROM plugin_user_review_registrations WHERE status='pending') registrations,
        (SELECT COUNT(*) FROM plugin_user_review_topics WHERE status='pending') contents,
        COALESCE((SELECT MAX(created_at) FROM plugin_user_review_registrations WHERE status='pending'),0) registration_latest_at,
        COALESCE((SELECT MAX(created_at) FROM plugin_user_review_topics WHERE status='pending'),0) content_latest_at") ?: [];
    app_db_upsert('plugin_user_review_stats', ['id' => 1, 'registrations' => (int)($row['registrations'] ?? 0), 'contents' => (int)($row['contents'] ?? 0), 'registration_latest_at' => (int)($row['registration_latest_at'] ?? 0), 'content_latest_at' => (int)($row['content_latest_at'] ?? 0), 'updated_at' => now()], ['id']);
}

function user_review_stats_add(string $type, int $created_at): void
{
    [$count, $latest] = $type === 'registrations' ? ['registrations', 'registration_latest_at'] : ['contents', 'content_latest_at'];
    q("UPDATE plugin_user_review_stats SET $count=$count+1,$latest=" . app_db_greatest($latest, '?') . ',updated_at=? WHERE id=1', [$created_at, now()]);
    unset($GLOBALS['__user_review_pending_stats']);
}

function user_review_stats_refresh(string $type): void
{
    [$table, $count, $latest] = $type === 'registrations'
        ? ['plugin_user_review_registrations', 'registrations', 'registration_latest_at']
        : ['plugin_user_review_topics', 'contents', 'content_latest_at'];
    $row = one("SELECT COUNT(*) total,COALESCE(MAX(created_at),0) latest_at FROM $table WHERE status='pending'") ?: [];
    q("UPDATE plugin_user_review_stats SET $count=?,$latest=?,updated_at=? WHERE id=1", [(int)($row['total'] ?? 0), (int)($row['latest_at'] ?? 0), now()]);
    unset($GLOBALS['__user_review_pending_stats']);
}

function user_review_config(): array
{
    return plugin_config('user_review', [
        'email_suffixes' => '',
        'review_mode' => 'off',
        'registration_review_enabled' => '',
        'publish_review_mode' => '',
        'active_from' => '',
        'active_to' => '',
        'publish_review_flow' => 'before_publish',
        'pending_keywords' => '',
        'reject_keywords' => '',
        'email_verify_enabled' => '0',
        'email_change_enabled' => '0',
        'email_code_ip_interval_minutes' => '5',
        'turnstile_enabled' => '0',
        'publish_turnstile_enabled' => '0',
        'auth_turnstile_enabled' => '0',
        'turnstile_site_key' => '',
        'turnstile_secret_key' => '',
        'smtp_host' => '',
        'smtp_port' => '465',
        'smtp_secure' => 'ssl',
        'smtp_username' => '',
        'smtp_password' => '',
        'smtp_from' => '',
        'smtp_from_name' => '',
        'virtual_send' => '0',
        'review_publish_count' => '1',
        'reserved_usernames' => 'admin,administrator,root,system',
        'register_per_hour' => '1',
        'login_fail_per_hour' => '5',
        'reset_fail_per_hour' => '5',
    ]);
}

function user_review_rate_bucket_config(string $bucket): ?array
{
    $configs = [
        'register' => ['count' => 'register_count', 'time' => 'register_at', 'setting' => 'register_per_hour', 'default' => 1],
        'login_fail' => ['count' => 'login_fail_count', 'time' => 'login_fail_at', 'setting' => 'login_fail_per_hour', 'default' => 5],
        'reset_fail' => ['count' => 'reset_fail_count', 'time' => 'reset_fail_at', 'setting' => 'reset_fail_per_hour', 'default' => 5],
    ];
    return $configs[$bucket] ?? null;
}

function user_review_rate_allow($allowed, array $ctx): bool
{
    if ($allowed === false) return false;
    $config = user_review_rate_bucket_config((string)($ctx['bucket'] ?? ''));
    $ip = clean_ip((string)($ctx['ip'] ?? ''));
    if (!$config || $ip === '') return (bool)$allowed;
    $count = (string)$config['count'];
    $time = (string)$config['time'];
    $row = one("SELECT $count count_value,$time time_value FROM plugin_user_review_ip_logs WHERE ip=?", [$ip]);
    $hits = $row && (int)$row['time_value'] >= time() - 3600 ? (int)$row['count_value'] : 0;
    $limit = max(1, min(100, (int)(user_review_config()[(string)$config['setting']] ?? $config['default'])));
    return $hits < $limit;
}

function user_review_rate_hit($value, array $ctx): mixed
{
    $config = user_review_rate_bucket_config((string)($ctx['bucket'] ?? ''));
    $ip = clean_ip((string)($ctx['ip'] ?? ''));
    if (!$config || $ip === '') return $value;
    $count = (string)$config['count'];
    $time = (string)$config['time'];
    $ts = time();
    $expired = $ts - 3600;
    $sql = "INSERT INTO plugin_user_review_ip_logs(ip,$count,$time,created_at,updated_at) VALUES(?,1,?,?,?)";
    if (db_driver() === 'mysql') {
        $sql .= " ON DUPLICATE KEY UPDATE $count=IF($time<?,1,$count+1),$time=?,updated_at=?";
        q($sql, [$ip, $ts, $ts, $ts, $expired, $ts, $ts]);
        return $value;
    }
    $sql .= " ON CONFLICT(ip) DO UPDATE SET $count=CASE WHEN $time<? THEN 1 ELSE plugin_user_review_ip_logs.$count+1 END,$time=excluded.$time,updated_at=excluded.updated_at";
    q($sql, [$ip, $ts, $ts, $ts, $expired]);
    return $value;
}

function user_review_username_reserved($reserved, array $ctx): bool
{
    if ($reserved === true) return true;
    $username = trim((string)($ctx['username'] ?? ''));
    if ($username === '') return false;
    $username = function_exists('mb_strtolower') ? mb_strtolower($username, 'UTF-8') : strtolower($username);
    $items = preg_split('/[\s,，]+/u', (string)(user_review_config()['reserved_usernames'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
    foreach ($items as $item) {
        $item = function_exists('mb_strtolower') ? mb_strtolower($item, 'UTF-8') : strtolower($item);
        if ($item === $username) return true;
    }
    return false;
}

function user_review_email_verify_enabled(): bool
{
    return user_review_active() && (string)(user_review_config()['email_verify_enabled'] ?? '0') === '1';
}

function user_review_email_change_enabled(): bool
{
    return user_review_active() && (string)(user_review_config()['email_change_enabled'] ?? '0') === '1';
}

function user_review_email_code_ip_interval_minutes(): int
{
    return max(1, min(1440, (int)(user_review_config()['email_code_ip_interval_minutes'] ?? 5)));
}

function user_review_turnstile_enabled(): bool
{
    return (string)(user_review_config()['turnstile_enabled'] ?? '0') === '1';
}

function user_review_publish_turnstile_enabled(): bool
{
    return (string)(user_review_config()['publish_turnstile_enabled'] ?? '0') === '1';
}

function user_review_auth_turnstile_enabled(): bool
{
    return (string)(user_review_config()['auth_turnstile_enabled'] ?? '0') === '1';
}

function user_review_verify_turnstile(): void
{
    $config = user_review_config();
    $secret = trim((string)($config['turnstile_secret_key'] ?? ''));
    $token = trim((string)($_POST['cf-turnstile-response'] ?? ''));
    if ($secret === '' || $token === '') err('请完成人机验证');
    $response = \app\optional\Plugin::remote_http_request('https://challenges.cloudflare.com/turnstile/v0/siteverify', 8, ['Content-Type: application/x-www-form-urlencoded'], [
        'secret' => $secret,
        'response' => $token,
        'remoteip' => ip_addr(),
    ]);
    $data = json_decode((string)$response['body'], true);
    if (!empty($data['success'])) return;
    $codes = is_array($data['error-codes'] ?? null) ? $data['error-codes'] : [];
    $codes = array_values(array_filter(array_map(fn($code) => preg_replace('/[^a-zA-Z0-9._-]/', '', (string)$code), $codes)));
    if (!$codes) {
        $status = (int)($response['status'] ?? 0);
        $codes[] = !$response['ok'] ? ($status > 0 ? 'http-' . $status : 'request-failed') : 'invalid-response';
    }
    err('人机验证失败，错误代码：' . implode(',', $codes));
}

function user_review_check_turnstile(): void
{
    if (user_review_turnstile_enabled()) user_review_verify_turnstile();
}

function user_review_smtp_configured(): bool
{
    $config = user_review_config();
    return trim((string)($config['smtp_host'] ?? '')) !== '';
}

function user_review_smtp_read($fp): string
{
    $response = '';
    while (is_resource($fp) && !feof($fp)) {
        $line = fgets($fp, 512);
        if ($line === false) break;
        $response .= $line;
        if (isset($line[3]) && $line[3] === ' ') break;
    }
    return $response;
}

function user_review_smtp_cmd($fp, string $cmd, array $codes): string
{
    fwrite($fp, $cmd . "\r\n");
    $response = user_review_smtp_read($fp);
    $code = (int)substr($response, 0, 3);
    if (!in_array($code, $codes, true)) throw new RuntimeException('SMTP错误：' . trim($response));
    return $response;
}

function user_review_mail_header_value(string $value): string
{
    return '=?UTF-8?B?' . base64_encode(str_replace(["\r", "\n"], '', $value)) . '?=';
}

function user_review_virtual_send_enabled(): bool
{
    return (string)(user_review_config()['virtual_send'] ?? '0') === '1';
}

function user_review_virtual_mail_html(array $mail): string
{
    return '<div class="user-review-mail-preview"><div class="note warn">虚拟发送已开启，邮件未实际发出。</div><dl><div><dt>收件人</dt><dd>' . h((string)($mail['to'] ?? '')) . '</dd></div><div><dt>主题</dt><dd>' . h((string)($mail['subject'] ?? '')) . '</dd></div></dl><pre>' . h((string)($mail['body'] ?? '')) . '</pre><div class="confirm-actions"><button type="button" data-modal-close>关闭</button></div></div>';
}

function user_review_virtual_mail_take(): ?array
{
    $mail = $GLOBALS['__user_review_virtual_mail'] ?? null;
    unset($GLOBALS['__user_review_virtual_mail']);
    return is_array($mail) ? $mail : null;
}

function user_review_send_mail_text(string $to, string $subject, string $body): bool
{
    if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return false;
    $site = trim(setting('site_name')) ?: 'FORUM';
    $from = trim((string)(user_review_config()['smtp_from'] ?? ''));
    if ($from === '' || !filter_var($from, FILTER_VALIDATE_EMAIL)) $from = 'no-reply@' . preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? 'localhost'));
    $encoded_site = '=?UTF-8?B?' . base64_encode($site) . '?=';
    $headers = [
        'MIME-Version: 1.0',
        'Content-Type: text/plain; charset=UTF-8',
        'From: ' . $encoded_site . ' <' . $from . '>',
    ];
    return mail($to, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers));
}

function user_review_send_mail(string $to, string $subject, string $body): bool
{
    if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return false;
    unset($GLOBALS['__user_review_virtual_mail']);
    if (user_review_virtual_send_enabled()) {
        $GLOBALS['__user_review_virtual_mail'] = ['to' => $to, 'subject' => $subject, 'body' => $body];
        return true;
    }
    $config = user_review_config();
    if (!user_review_smtp_configured()) return user_review_send_mail_text($to, $subject, $body);
    $host = trim((string)($config['smtp_host'] ?? ''));
    $port = max(1, min(65535, (int)($config['smtp_port'] ?? 465)));
    $secure = (string)($config['smtp_secure'] ?? 'ssl');
    $from = trim((string)($config['smtp_from'] ?? ''));
    $from_name = trim((string)($config['smtp_from_name'] ?? '')) ?: (trim(setting('site_name')) ?: 'FORUM');
    $username = trim((string)($config['smtp_username'] ?? ''));
    $password = (string)($config['smtp_password'] ?? '');
    $remote = ($secure === 'ssl' ? 'ssl://' : '') . $host;
    $fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 12, STREAM_CLIENT_CONNECT);
    if (!$fp) return false;
    stream_set_timeout($fp, 12);
    try {
        user_review_smtp_read($fp);
        user_review_smtp_cmd($fp, 'EHLO ' . preg_replace('/[^A-Za-z0-9.\-]/', '', (string)($_SERVER['HTTP_HOST'] ?? 'localhost')), [250]);
        if ($secure === 'tls') {
            user_review_smtp_cmd($fp, 'STARTTLS', [220]);
            if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) throw new RuntimeException('SMTP TLS失败');
            user_review_smtp_cmd($fp, 'EHLO ' . preg_replace('/[^A-Za-z0-9.\-]/', '', (string)($_SERVER['HTTP_HOST'] ?? 'localhost')), [250]);
        }
        if ($username !== '') {
            user_review_smtp_cmd($fp, 'AUTH LOGIN', [334]);
            user_review_smtp_cmd($fp, base64_encode($username), [334]);
            user_review_smtp_cmd($fp, base64_encode($password), [235]);
        }
        user_review_smtp_cmd($fp, 'MAIL FROM:<' . $from . '>', [250]);
        user_review_smtp_cmd($fp, 'RCPT TO:<' . $to . '>', [250, 251]);
        user_review_smtp_cmd($fp, 'DATA', [354]);
        $headers = [
            'MIME-Version: 1.0',
            'Content-Type: text/plain; charset=UTF-8',
            'From: ' . user_review_mail_header_value($from_name) . ' <' . $from . '>',
            'To: <' . $to . '>',
            'Subject: ' . user_review_mail_header_value($subject),
        ];
        $data = implode("\r\n", $headers) . "\r\n\r\n" . str_replace("\n.", "\n..", str_replace(["\r\n", "\r"], "\n", $body)) . "\r\n.";
        user_review_smtp_cmd($fp, $data, [250]);
        user_review_smtp_cmd($fp, 'QUIT', [221]);
        fclose($fp);
        return true;
    } catch (Throwable $e) {
        if (is_resource($fp)) fclose($fp);
        debug_log_write('用户审核SMTP发送失败', $e);
        return false;
    }
}

function user_review_password_recovery_login_link($html, array $ctx): string
{
    return (string)$html . '<p class="auth-extra"><a href="' . h(route_url('password_recovery_forgot')) . '">忘记密码？</a></p>';
}

function user_review_password_recovery_site_closed_allow($allowed, array $ctx): bool
{
    $action = (string)($ctx['action'] ?? '');
    return (bool)$allowed || in_array($action, ['password_recovery_forgot', 'password_recovery_reset'], true);
}

function user_review_password_recovery_notice_sidebar(string $mode): string
{
    $items = $mode === 'reset'
        ? ['重置链接有效期为 1 小时。', '请设置一个新的安全密码。', '重置成功后旧链接会立即失效。']
        : ['邮箱信息不会公开。', '需要用户名和邮箱同时匹配。', '重置邮件可能会进入垃圾邮件箱。'];
    return sidebar_stack_html([sidebar_notice_card_html($mode === 'reset' ? '重置密码说明' : '找回密码说明', $items)]);
}

function user_review_password_recovery_virtual_mail(array $mail): void
{
    $preview = '<template data-user-review-virtual-mail data-title="邮件内容">' . user_review_virtual_mail_html($mail) . '</template>';
    $html = auth_tabs_html('login') . '<div class="form-panel auth-panel"><h2>忘记密码</h2><p class="muted">邮件内容已显示。</p><p class="auth-extra"><a href="' . h(route_url('login')) . '">返回登录</a></p></div>' . $preview;
    page('忘记密码', shell_html($html, user_review_password_recovery_notice_sidebar('forgot')));
}

function user_review_password_recovery_create_reset(array $user): string
{
    q('UPDATE plugin_user_review_password_resets SET used_at=? WHERE user_id=? AND used_at=0', [now(), (int)$user['id']]);
    $token = bin2hex(random_bytes(32));
    q('INSERT INTO plugin_user_review_password_resets(user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?)', [(int)$user['id'], hash('sha256', $token), now() + 3600, now()]);
    return $token;
}

function user_review_password_recovery_forgot_page(array $plugin): void
{
    if (uid()) go(route_url('home'));
    $sent = false;
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $ip = ip_addr();
        if (hook('security.rate_allow', true, ['ip' => $ip, 'bucket' => 'reset_fail']) === false) err('同一IP 1小时内错误次数已达上限');
        hook('forgot_password.before_submit', true, []);
        $username = post('username', 40);
        $email = post('email', 120);
        $user = one('SELECT id,username,email FROM app_users WHERE username=? AND email=?', [$username, $email]);
        if (!$user || !filter_var((string)$user['email'], FILTER_VALIDATE_EMAIL)) {
            fire('security.rate_hit', ['ip' => $ip, 'bucket' => 'reset_fail']);
            err('用户名和邮箱不匹配');
        }
        $token = user_review_password_recovery_create_reset($user);
        $link = base_url() . route_url('password_recovery_reset', ['token' => $token]);
        $subject = '重置密码 - ' . (trim(setting('site_name')) ?: 'FORUM');
        $body = "你好，" . $user['username'] . "\n\n请打开以下链接重置密码：\n" . $link . "\n\n链接有效期为 1 小时。如果不是你本人操作，请忽略本邮件。";
        if (!user_review_send_mail((string)$user['email'], $subject, $body)) err('邮件发送失败，请稍后再试');
        $virtual_mail = user_review_virtual_mail_take();
        if ($virtual_mail !== null) {
            user_review_password_recovery_virtual_mail($virtual_mail);
            return;
        }
        $sent = true;
    }
    $body = '<div class="form-panel auth-panel"><h2>忘记密码</h2>';
    if ($sent) {
        $body .= '<p class="muted">重置密码邮件已经发送，请查收邮箱。</p><p class="auth-extra"><a href="' . h(route_url('login')) . '">返回登录</a></p>';
    } else {
        $form_extra = (string)hook('forgot_password.form_extra', '', []);
        $body .= '<form method="post" data-no-ajax="1">' . form_token() . input('用户名', 'username', '', 'text', true) . input('邮箱', 'email', '', 'email', true) . $form_extra . '<button>发送重置邮件</button></form><p class="auth-extra"><a href="' . h(route_url('login')) . '">返回登录</a></p>';
    }
    page('忘记密码', shell_html(auth_tabs_html('login') . $body . '</div>', user_review_password_recovery_notice_sidebar('forgot')));
}

function user_review_password_recovery_reset_page(array $plugin): void
{
    if (uid()) go(route_url('home'));
    $token = trim((string)($_GET['token'] ?? $_POST['token'] ?? ''));
    if ($token === '') err('重置链接无效');
    $reset = one('SELECT * FROM plugin_user_review_password_resets WHERE token_hash=? AND used_at=0 AND expires_at>=?', [hash('sha256', $token), now()]);
    if (!$reset) err('重置链接无效或已过期');
    user_by_id((int)$reset['user_id']) ?: err('用户不存在');
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $password = (string)($_POST['password'] ?? '');
        $confirm = (string)($_POST['password2'] ?? '');
        if ($password === '') err('密码不能为空');
        require_password_length($password);
        if ($password !== $confirm) err('两次密码不一致');
        tx(function () use ($password, $reset): void {
            q('UPDATE app_users SET password=? WHERE id=?', [password_hash($password, PASSWORD_DEFAULT), (int)$reset['user_id']]);
            q('UPDATE plugin_user_review_password_resets SET used_at=? WHERE id=?', [now(), (int)$reset['id']]);
        });
        if (ajax_request()) go(route_url('login'));
        page('密码已重置', shell_html(auth_tabs_html('login') . '<div class="form-panel auth-panel"><h2>密码已重置</h2><p class="muted">请使用新密码登录。</p><p class="auth-extra"><a href="' . h(route_url('login')) . '">去登录</a></p></div>', user_review_password_recovery_notice_sidebar('reset')));
        return;
    }
    $form = '<div class="form-panel auth-panel"><h2>重置密码</h2><form method="post">' . form_token() . hidden_inputs(['token' => $token]) . input('新密码', 'password', '', 'password', true) . input('确认密码', 'password2', '', 'password', true) . '<button>保存新密码</button></form></div>';
    page('重置密码', shell_html(auth_tabs_html('login') . $form, user_review_password_recovery_notice_sidebar('reset')));
}

function user_review_daily_minutes(string $value): int
{
    $value = trim($value);
    if ($value === '') return -1;
    if (preg_match('/^(\d{1,2}):(\d{2})$/', $value, $m)) {
        $hour = (int)$m[1];
        $minute = (int)$m[2];
        if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) return $hour * 60 + $minute;
    }
    return -1;
}

function user_review_active(): bool
{
    $config = user_review_config();
    $from = user_review_daily_minutes((string)($config['active_from'] ?? ''));
    $to = user_review_daily_minutes((string)($config['active_to'] ?? ''));
    if ($from < 0 && $to < 0) return true;
    $now = (int)date('G') * 60 + (int)date('i');
    if ($from >= 0 && $to < 0) return $now >= $from;
    if ($from < 0 && $to >= 0) return $now < $to;
    if ($from === $to) return true;
    if ($from < $to) return $now >= $from && $now < $to;
    return $now >= $from || $now < $to;
}

function user_review_daily_time_input(string $value): string
{
    $value = trim($value);
    return preg_match('/^\d{1,2}:\d{2}$/', $value) && user_review_daily_minutes($value) >= 0 ? sprintf('%02d:%02d', intdiv(user_review_daily_minutes($value), 60), user_review_daily_minutes($value) % 60) : '';
}

function user_review_suffixes(): array
{
    $raw = (string)user_review_config()['email_suffixes'];
    $items = preg_split('/[\s,，]+/u', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
    $suffixes = [];
    foreach ($items as $item) {
        $item = strtolower(ltrim(trim((string)$item), '@'));
        if ($item !== '') $suffixes[] = $item;
    }
    return array_values(array_unique($suffixes));
}

function user_review_email_allowed(string $email): bool
{
    $suffixes = user_review_suffixes();
    if (!$suffixes) return true;
    if ($email === '' || !str_contains($email, '@')) return false;
    $domain = strtolower(substr(strrchr($email, '@') ?: '', 1));
    foreach ($suffixes as $suffix) {
        if ($domain === $suffix || str_ends_with($domain, '.' . $suffix)) return true;
    }
    return false;
}

function user_review_publish_mode_config(): string
{
    $config = user_review_config();
    $mode = (string)($config['publish_review_mode'] ?? '');
    if ($mode === '') $mode = (string)($config['review_mode'] ?? 'off');
    return in_array($mode, ['off', 'first_posts', 'after_first_posts'], true) ? $mode : 'off';
}

function user_review_mode(): string
{
    return user_review_active() ? user_review_publish_mode_config() : 'off';
}

function user_review_registration_config_enabled(): bool
{
    $config = user_review_config();
    $enabled = (string)($config['registration_review_enabled'] ?? '');
    if ($enabled === '') return (string)($config['review_mode'] ?? 'off') === 'register_review';
    return $enabled === '1';
}

function user_review_registration_enabled(): bool
{
    return user_review_active() && user_review_registration_config_enabled();
}

function user_review_publish_flow(): string
{
    $flow = (string)(user_review_config()['publish_review_flow'] ?? 'before_publish');
    return in_array($flow, ['before_publish', 'after_publish'], true) ? $flow : 'before_publish';
}

function user_review_keyword_rules_configured(): bool
{
    $config = user_review_config();
    return trim((string)($config['pending_keywords'] ?? '')) !== '' || trim((string)($config['reject_keywords'] ?? '')) !== '';
}

function user_review_keyword_matches(string $keywords, string $title, string $body): bool
{
    $keywords = trim($keywords);
    if ($keywords === '') return false;
    $text = $title . "\n" . $body;
    foreach (preg_split('/\|/u', $keywords, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $keyword) {
        $keyword = trim((string)$keyword);
        if ($keyword === '') continue;
        $pattern = str_replace('\\*', '.*', preg_quote($keyword, '/'));
        if (preg_match('/' . $pattern . '/isu', $text) === 1) return true;
    }
    return false;
}

function user_review_keyword_status(string $title, string $body): string
{
    $config = user_review_config();
    if (user_review_keyword_matches((string)($config['reject_keywords'] ?? ''), $title, $body)) return 'rejected';
    if (user_review_keyword_matches((string)($config['pending_keywords'] ?? ''), $title, $body)) return 'pending';
    return '';
}

function user_review_publish_limit(): int
{
    return max(0, min(100, (int)(user_review_config()['review_publish_count'] ?? 1)));
}

function user_review_publish_review_mode(): bool
{
    return in_array(user_review_mode(), ['first_posts', 'after_first_posts'], true);
}

function user_review_send_email_code_page(array $plugin): void
{
    require_post();
    header('Content-Type: application/json; charset=utf-8');
    if (!user_review_email_verify_enabled()) {
        echo json_encode(['ok' => 0, 'message' => '邮箱验证未开启'], JSON_UNESCAPED_UNICODE);
        return;
    }
    $email = post('email', 120);
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        echo json_encode(['ok' => 0, 'message' => '邮箱格式不正确'], JSON_UNESCAPED_UNICODE);
        return;
    }
    if (!user_review_email_allowed($email)) {
        echo json_encode(['ok' => 0, 'message' => '邮箱后缀不允许注册'], JSON_UNESCAPED_UNICODE);
        return;
    }
    $ip = ip_addr();
    $interval_minutes = user_review_email_code_ip_interval_minutes();
    $last = (int)(val("SELECT created_at FROM plugin_user_review_email_codes WHERE ip=? ORDER BY created_at DESC LIMIT 1", [$ip]) ?: 0);
    if ($last > now() - $interval_minutes * 60) {
        echo json_encode(['ok' => 0, 'message' => '每个 IP ' . $interval_minutes . ' 分钟只能发送一次验证码'], JSON_UNESCAPED_UNICODE);
        return;
    }
    $code = (string)random_int(100000, 999999);
    q("UPDATE plugin_user_review_email_codes SET used_at=? WHERE email=? AND used_at=0", [now(), strtolower($email)]);
    q("INSERT INTO plugin_user_review_email_codes(email,code_hash,ip,expires_at,created_at) VALUES(?,?,?,?,?)", [strtolower($email), password_hash($code, PASSWORD_DEFAULT), $ip, now() + 600, now()]);
    $code_id = app_db_last_insert_id('plugin_user_review_email_codes');
    $site = trim(setting('site_name')) ?: 'FORUM';
    $subject = '邮箱验证码 - ' . $site;
    $body = "你的邮箱验证码是：{$code}\n\n验证码 10 分钟内有效。如果不是你本人操作，请忽略本邮件。";
    if (!user_review_send_mail($email, $subject, $body)) {
        q("DELETE FROM plugin_user_review_email_codes WHERE id=?", [$code_id]);
        echo json_encode(['ok' => 0, 'message' => '邮件发送失败'], JSON_UNESCAPED_UNICODE);
        return;
    }
    $virtual_mail = user_review_virtual_mail_take();
    $response = ['ok' => 1, 'message' => $virtual_mail === null ? '验证码已发送' : '邮件内容已显示'];
    if ($virtual_mail !== null) $response['modal'] = ['title' => '邮件内容', 'html' => user_review_virtual_mail_html($virtual_mail)];
    echo json_encode($response, JSON_UNESCAPED_UNICODE);
}

function user_review_check_email_code(string $email, string $code): void
{
    if (!user_review_email_verify_enabled()) return;
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) err('邮箱格式不正确');
    if (!preg_match('/^\d{6}$/', $code)) err('邮箱验证码错误');
    $row = one("SELECT * FROM plugin_user_review_email_codes WHERE email=? AND used_at=0 AND expires_at>=? ORDER BY id DESC LIMIT 1", [strtolower($email), now()]);
    if (!$row || !password_verify($code, (string)$row['code_hash'])) err('邮箱验证码错误或已过期');
    $GLOBALS['__user_review_email_code_id'] = (int)$row['id'];
}

function user_review_email_change_page(array $plugin): void
{
    need_login();
    require_post();
    if (!user_review_email_change_enabled()) err('不允许修改邮箱');
    $email = trim(post('email', 120));
    $current_user = row('app_users', 'id', uid());
    if (!$current_user) err('用户不存在');
    $old_email = trim((string)$current_user['email']);
    if ($email === '' || strcasecmp($email, $old_email) === 0) err('新邮箱不能与当前邮箱相同');
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) err('邮箱格式不正确');
    if (!user_review_email_allowed($email)) err('邮箱后缀不允许使用');
    if (user_review_email_verify_enabled()) {
        user_review_check_email_code($email, post('email_code', 6));
    }
    q("UPDATE app_users SET email=? WHERE id=?", [$email, uid()]);
    user_review_mark_checked_email_code();
    set_flash('邮箱已修改');
    go(route_url('profile'));
}

function user_review_mark_checked_email_code(): void
{
    $id = (int)($GLOBALS['__user_review_email_code_id'] ?? 0);
    if ($id > 0) q("UPDATE plugin_user_review_email_codes SET used_at=? WHERE id=? AND used_at=0", [now(), $id]);
}

function user_review_register_form_extra($html, array $ctx): string
{
    $html = (string)$html;
    if (user_review_email_verify_enabled()) {
        $html .= '<label class="grid user-review-email-code-field"><span>邮箱验证码</span><div class="user-review-email-code-row"><input type="text" name="email_code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位验证码" required><button type="button" data-user-review-send-code data-interval-ms="' . (user_review_email_code_ip_interval_minutes() * 60000) . '" data-url="' . h(route_url('user_review_email_code')) . '">发送验证码</button></div><small data-user-review-code-tip aria-live="polite"></small></label>';
    }
    if (user_review_turnstile_enabled()) {
        $html .= user_review_turnstile_html();
    }
    return $html;
}

function user_review_profile_html($html, array $ctx): string
{
    if (!user_review_email_change_enabled()) return (string)$html;
    $code = '';
    if (user_review_email_verify_enabled()) {
        $code = '<label class="grid user-review-email-code-field"><span>邮箱验证码</span><div class="user-review-email-code-row"><input type="text" name="email_code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位验证码" required><button type="button" data-user-review-send-code data-interval-ms="' . (user_review_email_code_ip_interval_minutes() * 60000) . '" data-url="' . h(route_url('user_review_email_code')) . '">发送验证码</button></div><small data-user-review-code-tip aria-live="polite"></small></label>';
    }
    $form = '<form class="user-review-email-form" method="post" action="' . h(route_url('user_review_email_change')) . '" data-no-ajax="1">' . form_token() . input('新邮箱', 'email', '', 'email', true) . $code . '<button type="submit">保存新邮箱</button></form>';
    $head = '<div class="user-review-email-head"><span class="profile-disclosure-heading"><span>修改邮箱</span><small>' . (user_review_email_verify_enabled() ? '需要新邮箱验证码' : '无需验证码') . '</small></span><button class="profile-edit-action" type="button" data-profile-toggle aria-expanded="false">修改</button></div>';
    return (string)$html . '<section class="user-review-email-card" data-profile-disclosure>' . $head . '<div class="profile-disclosure-detail is-hidden" data-profile-edit>' . $form . '</div></section>';
}

function user_review_turnstile_html(): string
{
    $site_key = trim((string)(user_review_config()['turnstile_site_key'] ?? ''));
    return '<div class="user-review-turnstile"><div class="cf-turnstile" data-sitekey="' . h($site_key) . '" data-theme="auto" data-size="flexible"></div></div>';
}

function user_review_publish_turnstile_required(int $uid): bool
{
    return $uid > 0 && user_review_publish_turnstile_enabled() && user_review_publish_limit() > 0 && user_review_public_publish_count($uid) < user_review_publish_limit();
}

function user_review_topic_form_extra($html, array $ctx): string
{
    if (!empty($ctx['editing']) || can_access_admin() || !user_review_publish_turnstile_required(uid())) return (string)$html;
    return (string)$html . user_review_turnstile_html();
}

function user_review_auth_form_extra($html, array $ctx): string
{
    if (!user_review_auth_turnstile_enabled()) return (string)$html;
    return (string)$html . user_review_turnstile_html();
}

function user_review_auth_turnstile_check($value, array $ctx): mixed
{
    if (user_review_auth_turnstile_enabled()) user_review_verify_turnstile();
    return $value;
}

function user_review_css(): string
{
    return '.user-review-card{border-color:var(--brand-soft);background:linear-gradient(180deg,var(--success-soft) 0,var(--panel) 100%);box-shadow:0 8px 22px var(--shadow-base)}.user-review-wrap{display:grid;gap:10px;padding:12px 14px}.user-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.user-review-title{color:var(--text);font-size:13px;font-weight:700;line-height:1.35}.user-review-sub{margin-top:2px;color:var(--text-subtle);font-size:11px;line-height:1.35}.user-review-badge{display:inline-flex;align-items:center;justify-content:center;min-height:22px;padding:0 8px;border:1px solid var(--warning-soft);border-radius:999px;background:var(--warning-soft);color:var(--warning);font-size:11px;font-weight:600;white-space:nowrap}.user-review-tab-dot{display:inline-block;width:5px;height:5px;margin-left:4px;vertical-align:2px;border-radius:50%;background:var(--danger);box-shadow:0 0 0 3px var(--danger-soft)}.user-review-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}.user-review-stats>div{display:grid;gap:2px;padding:8px;border:1px solid var(--line-soft);border-radius:7px;background:var(--bg);text-align:center}.user-review-stats strong{color:var(--text);font-size:17px;line-height:1.1}.user-review-stats span{color:var(--text-subtle);font-size:11px;line-height:1.25}.user-review-action{display:block}.user-review-action a{display:flex;align-items:center;justify-content:center;width:100%;min-height:32px;padding:0 10px;border:1px solid var(--brand);border-radius:7px;background:var(--brand);color:var(--inverse-text);font-size:12px;font-weight:700;text-decoration:none}.user-review-action a:hover{border-color:var(--brand-hover);background:var(--brand-hover);color:var(--inverse-text)}.user-review-panel+.user-review-panel{margin-top:12px}.user-review-panel-body{padding:12px}.user-review-summary{display:grid;gap:2px;min-width:0}.user-review-summary strong{color:var(--text);font-size:14px;line-height:1.3}.user-review-summary span{color:var(--text-subtle);font-size:12px;line-height:1.35}.user-review-settings{display:grid;gap:8px;max-width:520px}.user-review-settings .grid{grid-template-columns:1fr;gap:5px;margin:0}.user-review-settings .grid>span{padding-top:0;color:var(--text-muted);font-size:12px}.user-review-settings input,.user-review-settings select{max-width:450px}.user-review-settings button{justify-self:start;margin-top:4px}.user-review-approve{border-color:var(--brand-soft);background:var(--brand-soft);color:var(--brand);font-weight:600}.user-review-approve:hover{border-color:var(--brand);background:var(--brand-soft);color:var(--brand-hover)}.admin-object-row.user-review-row{grid-template-columns:minmax(0,1fr) auto}.user-review-row .admin-content-text{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;color:var(--text-muted)}.user-review-turnstile{width:100%;max-width:450px;min-height:65px;margin-bottom:12px;overflow:hidden;border-radius:var(--radius-sm);background:transparent;line-height:0}.user-review-turnstile .cf-turnstile{width:100%;background:transparent}.user-review-turnstile iframe{display:block;width:100%!important;max-width:100%;border:0;border-radius:var(--radius-sm);background:transparent;color-scheme:light dark}.user-review-email-code-field{gap:6px}.user-review-email-code-row{display:grid;grid-template-columns:minmax(0,1fr) 112px;width:300px;max-width:100%;gap:0;align-items:center}.user-review-email-code-row input,.user-review-email-code-row button{height:34px;min-height:34px;box-sizing:border-box}.user-review-email-code-row input{min-width:0;letter-spacing:1px;border-radius:var(--radius-sm) 0 0 var(--radius-sm)}.user-review-email-code-row button{width:112px;padding:0 8px;border:1px solid var(--line);border-left:0;border-radius:0 var(--radius-sm) var(--radius-sm) 0;background:var(--panel);color:var(--text-muted);white-space:nowrap}.user-review-email-code-row button:hover{border-color:var(--brand);background:var(--brand-soft);color:var(--brand)}.user-review-email-code-row button:disabled{background:var(--bg);color:var(--text-muted);cursor:not-allowed}.user-review-email-code-field small{display:block;color:var(--text-muted);font-size:12px;line-height:18px}.user-review-email-code-field small:empty{display:none}.user-review-email-code-field small.success{color:var(--brand)}.user-review-email-code-field small.error{color:var(--danger)}@media(max-width:720px){.admin-object-row.user-review-row{grid-template-columns:minmax(0,1fr)}.admin-object-row.user-review-row .admin-inline-ops{grid-column:1;justify-content:flex-start;margin-left:0}}@media(max-width:340px){.user-review-email-code-row{grid-template-columns:minmax(0,1fr) 104px}.user-review-email-code-row button{width:104px}}';
}

function user_review_assets_css(): string
{
    return user_review_css() . '.user-review-settings{max-width:none;gap:12px}.user-review-setting-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.user-review-setting-card{display:grid;align-content:start;gap:12px;min-width:0;padding:14px;border:1px solid var(--line);border-radius:6px;background:var(--bg)}.user-review-setting-card-wide{grid-column:1/-1}.user-review-setting-card h3{margin:0;padding-bottom:9px;border-bottom:1px solid var(--line-soft);font-size:13px;line-height:1.4}.user-review-settings-fields{display:grid;gap:10px;min-width:0}.user-review-settings-fields-columns{grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.user-review-settings-fields .grid{min-width:0;align-self:start}.user-review-settings-fields-columns .user-review-review-count-field,.user-review-settings-fields-columns .user-review-keyword-field{grid-column:1/-1}.user-review-review-count-field input{max-width:180px}.user-review-settings-fields input:not([type=checkbox]),.user-review-settings-fields select,.user-review-settings-fields textarea{width:100%;max-width:none}.user-review-settings-fields .user-review-wide-field{grid-column:1/-1}.user-review-settings-actions{display:flex;align-items:center}.user-review-settings-actions button{margin:0}@media(max-width:720px){.user-review-setting-cards,.user-review-settings-fields-columns{grid-template-columns:1fr}.user-review-setting-card-wide,.user-review-settings-fields .user-review-wide-field{grid-column:auto}}';
}

function user_review_plugin_css(): string
{
    return user_review_assets_css() . '.user-review-mail-preview{display:grid;gap:12px;min-width:0}.user-review-mail-preview .note{margin:0}.user-review-mail-preview dl{display:grid;gap:1px;margin:0;overflow:hidden;border:1px solid var(--line-soft);border-radius:6px;background:var(--line-soft)}.user-review-mail-preview dl>div{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;padding:8px 10px;background:var(--bg)}.user-review-mail-preview dt{color:var(--text-muted);font-size:12px}.user-review-mail-preview dd{min-width:0;margin:0;overflow-wrap:anywhere}.user-review-mail-preview pre{max-height:45vh;margin:0;padding:12px;overflow:auto;border:1px solid var(--line-soft);border-radius:6px;background:var(--bg);white-space:pre-wrap;overflow-wrap:anywhere}.user-review-mail-preview .confirm-actions{justify-content:flex-end}.user-review-email-card{display:grid;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.user-review-email-card .profile-disclosure-detail{padding:0;border:0;background:transparent}.user-review-email-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.user-review-email-form{display:grid;gap:10px}.user-review-email-form .grid{grid-template-columns:1fr;gap:5px;margin:0}.user-review-email-form .grid>span{padding-top:0}.user-review-email-form input{max-width:450px}.user-review-email-form>button{justify-self:start;margin-top:6px}';
}

function user_review_js(): string
{
    return <<<'JS'
(() => {
    const showMail = modalData => {
        if (!modalData || typeof openModal !== "function") return;
        openModal(modalData.title || "邮件内容", modalData.html || "");
    };
    document.addEventListener("DOMContentLoaded", () => {
        const preview = document.querySelector("template[data-user-review-virtual-mail]");
        if (preview) showMail({title: preview.dataset.title, html: preview.innerHTML});
    });
    document.addEventListener("click", async e => {
        const btn = e.target.closest("[data-user-review-send-code]");
        if (!btn || btn.disabled) return;
        const form = btn.closest("form");
        const tip = form && form.querySelector("[data-user-review-code-tip]");
        const email = form && form.querySelector("input[name=email]");
        if (!form || !email) return;
        const setTip = (text, state) => {
            if (!tip) return;
            tip.textContent = text || "";
            tip.classList.toggle("success", state === "success");
            tip.classList.toggle("error", state === "error");
        };
        if (!email.value || !email.checkValidity()) {
            setTip("请输入正确的邮箱地址", "error");
            email.focus();
            email.reportValidity();
            return;
        }
        btn.disabled = true;
        btn.textContent = "发送中";
        setTip("正在发送验证码", "");
        try {
            const body = new FormData();
            body.append("_csrf", (form.querySelector("input[name=_csrf]") || {}).value || "");
            body.append("email", email.value || "");
            const response = await fetch(btn.dataset.url, {method: "POST", headers: {"X-Requested-With": "XMLHttpRequest"}, body});
            const data = await response.json();
            if (!data.ok) throw new Error(data.message || "发送失败");
            showMail(data.modal);
            setTip(data.message || "验证码已发送", "success");
            const end = Date.now() + Number(btn.dataset.intervalMs || 0);
            const format = total => {
                const minutes = Math.floor(total / 60);
                const seconds = total % 60;
                return minutes > 0 ? minutes + "分" + String(seconds).padStart(2, "0") + "秒" : seconds + "秒";
            };
            const update = () => {
                const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));
                if (left <= 0) {
                    clearInterval(timer);
                    btn.disabled = false;
                    btn.textContent = "重新发送";
                    return;
                }
                btn.textContent = format(left);
            };
            const timer = setInterval(update, 1000);
            update();
        } catch (error) {
            setTip(error && error.message || "发送失败", "error");
            btn.disabled = false;
            btn.textContent = "重新发送";
        }
    });
})();
JS;
}

function user_review_head($html, array $ctx): string
{
    if (user_review_turnstile_enabled() || user_review_publish_turnstile_enabled() || user_review_auth_turnstile_enabled()) return (string)$html . '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>';
    return (string)$html;
}

function user_review_register_pending_page(): never
{
    $message = '账号正在审核中，审核通过后才可以登录。';
    if (ajax_request()) {
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode([
            'ok' => 1,
            'message' => '注册申请已提交',
            'modal' => [
                'title' => '注册申请已提交',
                'html' => '<div class="confirm-box"><p class="confirm-message">' . h($message) . '</p><div class="confirm-actions"><button type="button" data-modal-close>知道了</button></div></div>',
            ],
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $main = auth_tabs_html('register') . '<div class="form-panel auth-panel"><h2>注册申请已提交</h2><p class="muted">账号正在审核中，审核通过后才可以登录。</p><p class="auth-extra"><a href="' . h(route_url('home')) . '">返回首页</a></p></div>';
    $sidebar = sidebar_stack_html([
        sidebar_notice_card_html('审核说明', [
            '管理员审核通过后即可登录。',
            '审核期间请不要重复提交注册。',
        ]),
    ]);
    page('注册申请已提交', shell_html($main, $sidebar));
    exit;
}

function user_review_validate_user($user, array $ctx): array
{
    if (!is_array($user)) return [];
    if (user_review_active() && empty($ctx['admin']) && !empty($ctx['creating']) && !user_review_email_allowed((string)($user['email'] ?? ''))) {
        $suffixes = user_review_suffixes();
        err('邮箱后缀不允许注册，请使用：' . implode('、', array_map(fn($v) => '@' . $v, $suffixes)));
    }
    if (empty($ctx['admin']) && !empty($ctx['creating'])) {
        user_review_check_email_code((string)($user['email'] ?? ''), trim((string)($_POST['email_code'] ?? '')));
        $username = (string)($user['username'] ?? '');
        if ((bool)val("SELECT 1 FROM app_users WHERE username=? LIMIT 1", [$username])) err('用户名已存在');
        if (empty($ctx['oauth_login'])) user_review_check_turnstile();
    }
    if (empty($ctx['admin']) && !empty($ctx['creating']) && user_review_registration_enabled()) {
        $username = (string)($user['username'] ?? '');
        if ((bool)val("SELECT 1 FROM app_users WHERE username=? LIMIT 1", [$username])) err('用户名已存在');
        if ((bool)val("SELECT 1 FROM plugin_user_review_registrations WHERE username=? AND status='pending' LIMIT 1", [$username])) err('用户名正在审核中');
        $pwd = (string)($_POST['password'] ?? '');
        if ($pwd === '') err('密码不能为空');
        $created_at = now();
        q("INSERT INTO plugin_user_review_registrations(username,password,email,bio,avatar_style,avatar_seed,group_id,points,status,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)", [
            $username,
            password_hash($pwd, PASSWORD_DEFAULT),
            (string)($user['email'] ?? ''),
            (string)($user['bio'] ?? ''),
            (string)($user['avatar_style'] ?? ''),
            (string)($user['avatar_seed'] ?? ''),
            (int)($user['group_id'] ?? setting('default_group_id', '2')),
            (int)($user['points'] ?? 0),
            'pending',
            $created_at,
        ]);
        user_review_stats_add('registrations', $created_at);
        user_review_mark_checked_email_code();
        fire('security.rate_hit', ['ip' => ip_addr(), 'bucket' => 'register']);
        user_review_register_pending_page();
    }
    return $user;
}

function user_review_after_user_save($value, array $ctx): mixed
{
    if (empty($ctx['admin']) && !empty($ctx['creating'])) user_review_mark_checked_email_code();
    return $value;
}

function user_review_pending_content_status(int $uid): string
{
    if (array_key_exists($uid, $GLOBALS['__user_review_pending_status'] ?? [])) return (string)$GLOBALS['__user_review_pending_status'][$uid];
    return $GLOBALS['__user_review_pending_status'][$uid] = (string)(val("SELECT status FROM plugin_user_review_topics WHERE user_id=? AND status IN ('pending','rejected') ORDER BY id DESC LIMIT 1", [$uid]) ?: '');
}

function user_review_public_publish_count(int $uid): int
{
    if (isset($GLOBALS['__user_review_publish_count'][$uid])) return $GLOBALS['__user_review_publish_count'][$uid];
    $count = (int)val("SELECT (SELECT COUNT(*) FROM app_topics WHERE user_id=?)+(SELECT COUNT(*) FROM app_replies WHERE user_id=?)", [$uid, $uid]);
    return $GLOBALS['__user_review_publish_count'][$uid] = $count;
}

function user_review_needs_publish_review(int $uid, bool $after_saved = false): bool
{
    $mode = user_review_mode();
    $limit = user_review_publish_limit();
    $count = user_review_public_publish_count($uid);
    if ($mode === 'first_posts') {
        if ($limit <= 0) return false;
        return $after_saved ? $count <= $limit : $count < $limit;
    }
    if ($mode === 'after_first_posts') {
        if ($limit <= 0) return true;
        return $after_saved ? $count > $limit : $count >= $limit;
    }
    return false;
}

function user_review_assert_no_pending_content(int $uid): void
{
    $status = $uid > 0 ? user_review_pending_content_status($uid) : '';
    if ($status === 'rejected') err('你有已拒绝内容，处理后才能继续发布');
    if ($status === 'pending') err('你有内容正在审核中，暂时不能继续发布');
}

function user_review_attachment_before_upload($allowed, array $ctx): mixed
{
    if ($allowed !== true || can_access_admin()) return $allowed;
    $uid = (int)($ctx['user_id'] ?? uid());
    $status = $uid > 0 ? user_review_pending_content_status($uid) : '';
    if ($status === 'rejected') return '你有已拒绝内容，处理后才能继续上传附件';
    return $status === 'pending' ? '你有内容正在审核中，暂时不能上传附件' : $allowed;
}

function user_review_capture_first_content(int $uid, int $forum_id, string $title, string $body, string $content_kind = 'topic', int $reply_topic_id = 0, string $status = 'pending'): void
{
    if ($title === '' || $body === '') err('标题和内容不能为空');
    $status = $status === 'rejected' ? 'rejected' : 'pending';
    $created_at = now();
    $reviewed_at = $status === 'rejected' ? $created_at : 0;
    q("INSERT INTO plugin_user_review_topics(user_id,forum_id,title,body,status,content_kind,reply_topic_id,reviewed_at,created_at) VALUES(?,?,?,?,?,?,?,?,?)", [$uid, $forum_id, $title, $body, $status, $content_kind, $reply_topic_id, $reviewed_at, $created_at]);
    if ($status === 'pending') user_review_stats_add('contents', $created_at);
    else user_review_stats_refresh('contents');
    $GLOBALS['__user_review_pending_status'][$uid] = $status;
    set_flash($status === 'rejected' ? '内容包含拒绝关键词，已进入拒绝队列' : '发帖已提交审核，通过后才可继续发布');
    go(route_url('home'));
}

function user_review_before_topic_save($topic, array $ctx): array
{
    if (!is_array($topic) || can_access_admin()) return is_array($topic) ? $topic : [];
    if ((int)($ctx['id'] ?? 0) > 0 || (string)($ctx['action'] ?? '') !== '') return $topic;
    $uid = uid();
    user_review_assert_no_pending_content($uid);
    if (user_review_publish_turnstile_required($uid)) user_review_verify_turnstile();
    $title = cut((string)($topic['title'] ?? ''), 120);
    $body = cut((string)($topic['body'] ?? ''), 20000);
    $keyword_status = user_review_keyword_status($title, $body);
    if ($keyword_status !== '') user_review_capture_first_content($uid, max(1, (int)($topic['forum_id'] ?? 0)), $title, $body, 'topic', 0, $keyword_status);
    if (!user_review_publish_review_mode()) return $topic;
    if (user_review_publish_flow() !== 'before_publish') return $topic;
    if (!$uid || !user_review_needs_publish_review($uid)) return $topic;
    $forum_id = max(1, (int)($topic['forum_id'] ?? 0));
    user_review_capture_first_content($uid, $forum_id, $title, $body);
}

function user_review_before_reply_save($reply, array $ctx): array
{
    if (!is_array($reply) || can_access_admin()) return is_array($reply) ? $reply : [];
    if ((int)($ctx['id'] ?? 0) > 0) return $reply;
    $uid = uid();
    user_review_assert_no_pending_content($uid);
    $mode_enabled = user_review_publish_review_mode();
    $needs_before_review = $mode_enabled && user_review_publish_flow() === 'before_publish' && $uid && user_review_needs_publish_review($uid);
    $body = cut((string)($reply['body'] ?? ''), 10000);
    $topic_id = max(1, (int)($reply['topic_id'] ?? 0));
    $topic = null;
    if (user_review_keyword_rules_configured() || $needs_before_review) {
        $topic = row('app_topics', 'id', $topic_id) ?: err('主题不存在');
        $title = '回复：' . (string)$topic['title'];
        $keyword_status = user_review_keyword_status($title, $body);
        if ($keyword_status !== '') user_review_capture_first_content($uid, (int)$topic['forum_id'], $title, $body, 'reply', (int)$topic['id'], $keyword_status);
    }
    if (!$needs_before_review) return $reply;
    if (!$topic) $topic = row('app_topics', 'id', $topic_id) ?: err('主题不存在');
    user_review_capture_first_content($uid, (int)$topic['forum_id'], '回复：' . (string)$topic['title'], $body, 'reply', (int)$topic['id']);
}

function user_review_after_topic_save($value, array $ctx): mixed
{
    if (!user_review_publish_review_mode() || user_review_publish_flow() !== 'after_publish' || can_access_admin()) return $value;
    if (!empty($ctx['editing'])) return $value;
    $uid = (int)($ctx['user_id'] ?? uid());
    if (!$uid || !user_review_needs_publish_review($uid, true)) return $value;
    $tid = (int)($ctx['id'] ?? 0);
    if ($tid <= 0 || (bool)val("SELECT 1 FROM plugin_user_review_topics WHERE content_kind='topic' AND published_id=? LIMIT 1", [$tid])) return $value;
    $created_at = now();
    q("INSERT INTO plugin_user_review_topics(user_id,forum_id,title,body,status,content_kind,published_id,created_at) VALUES(?,?,?,?,?,?,?,?)", [$uid, (int)($ctx['forum_id'] ?? 0), cut((string)($ctx['title'] ?? ''), 120), cut((string)($ctx['body'] ?? ''), 20000), 'pending', 'topic', $tid, $created_at]);
    user_review_stats_add('contents', $created_at);
    $GLOBALS['__user_review_pending_status'][$uid] = 'pending';
    set_flash('内容已发布，正在等待审核');
    return $value;
}

function user_review_after_reply_save($value, array $ctx): mixed
{
    if (!user_review_publish_review_mode() || user_review_publish_flow() !== 'after_publish' || can_access_admin()) return $value;
    if (!empty($ctx['editing'])) return $value;
    $uid = (int)($ctx['user_id'] ?? uid());
    if (!$uid || !user_review_needs_publish_review($uid, true)) return $value;
    $rid = (int)($ctx['id'] ?? 0);
    $topic_id = (int)($ctx['topic_id'] ?? 0);
    if ($rid <= 0 || $topic_id <= 0 || (bool)val("SELECT 1 FROM plugin_user_review_topics WHERE content_kind='reply' AND published_id=? LIMIT 1", [$rid])) return $value;
    $topic = row('app_topics', 'id', $topic_id);
    if (!$topic) return $value;
    $created_at = now();
    q("INSERT INTO plugin_user_review_topics(user_id,forum_id,title,body,status,content_kind,reply_topic_id,published_id,created_at) VALUES(?,?,?,?,?,?,?,?,?)", [$uid, (int)$topic['forum_id'], '回复：' . (string)$topic['title'], cut((string)($ctx['body'] ?? ''), 10000), 'pending', 'reply', $topic_id, $rid, $created_at]);
    user_review_stats_add('contents', $created_at);
    $GLOBALS['__user_review_pending_status'][$uid] = 'pending';
    set_flash('内容已发布，正在等待审核');
    return $value;
}

function user_review_publish_content(array $row): array
{
    if ((int)($row['published_id'] ?? 0) > 0) {
        $published_table = (string)($row['content_kind'] ?? 'topic') === 'reply' ? 'app_replies' : 'app_topics';
        if ((bool)val("SELECT 1 FROM $published_table WHERE id=? LIMIT 1", [(int)$row['published_id']])) {
            q("UPDATE plugin_user_review_topics SET status='approved',reviewed_at=?,reviewer_id=? WHERE id=?", [now(), uid(), (int)$row['id']]);
            user_review_stats_refresh('contents');
            return ['route' => (string)($row['content_kind'] ?? 'topic') === 'reply' ? route_url('topic', ['id' => (int)$row['reply_topic_id'], 'replyid' => (int)$row['published_id']]) : route_url('topic', ['id' => (int)$row['published_id']]), 'topic_id' => (string)($row['content_kind'] ?? 'topic') === 'reply' ? (int)$row['reply_topic_id'] : (int)$row['published_id'], 'reply_id' => (string)($row['content_kind'] ?? 'topic') === 'reply' ? (int)$row['published_id'] : 0];
        }
        $row['published_id'] = 0;
    }
    $ts = now();
    if ((string)($row['content_kind'] ?? 'topic') === 'reply') {
        $rid = tx(function () use ($row, $ts) {
            q("INSERT INTO app_replies(topic_id,user_id,body,created_at,updated_at) VALUES(?,?,?,?,?)", [(int)$row['reply_topic_id'], (int)$row['user_id'], (string)$row['body'], (int)$row['created_at'], $ts]);
            $rid = app_db_last_insert_id('app_replies');
            reply_fts_sync($rid, (string)$row['body']);
            q("UPDATE app_users SET last_post_at=? WHERE id=?", [$ts, (int)$row['user_id']]);
            q("UPDATE app_topics SET reply_count=reply_count+1,last_reply_at=?,last_reply_user_id=? WHERE id=?", [$ts, (int)$row['user_id'], (int)$row['reply_topic_id']]);
            create_reply_notifications((int)$row['reply_topic_id'], $rid, (string)$row['body'], (int)$row['user_id']);
            q("UPDATE plugin_user_review_topics SET status='approved',published_id=?,reviewed_at=?,reviewer_id=? WHERE id=?", [$rid, $ts, uid(), (int)$row['id']]);
            return $rid;
        });
        user_review_stats_refresh('contents');
        home_stats_record_insert('replies', $rid);
        return ['route' => route_url('topic', ['id' => (int)$row['reply_topic_id'], 'replyid' => $rid]), 'topic_id' => (int)$row['reply_topic_id'], 'reply_id' => $rid];
    }
    $tid = tx(function () use ($row, $ts) {
        q("INSERT INTO app_topics(forum_id,user_id,title,body,created_at,last_reply_at) VALUES(?,?,?,?,?,?)", [(int)$row['forum_id'], (int)$row['user_id'], (string)$row['title'], (string)$row['body'], (int)$row['created_at'], $ts]);
        $tid = app_db_last_insert_id('app_topics');
        topic_fts_sync($tid, (string)$row['title'], (string)$row['body']);
        q("UPDATE app_users SET last_post_at=? WHERE id=?", [$ts, (int)$row['user_id']]);
        create_topic_notifications($tid, (string)$row['body'], (int)$row['user_id']);
        q("UPDATE plugin_user_review_topics SET status='approved',published_id=?,reviewed_at=?,reviewer_id=? WHERE id=?", [$tid, $ts, uid(), (int)$row['id']]);
        return $tid;
    });
    user_review_stats_refresh('contents');
    home_stats_refresh_topics();
    return ['route' => route_url('topic', ['id' => $tid]), 'topic_id' => $tid, 'reply_id' => 0];
}

function user_review_reject_content(array $row): void
{
    $published_id = (int)($row['published_id'] ?? 0);
    $published_exists = false;
    if ($published_id > 0) {
        $published_table = (string)($row['content_kind'] ?? 'topic') === 'reply' ? 'app_replies' : 'app_topics';
        $published_exists = (bool)val("SELECT 1 FROM $published_table WHERE id=? LIMIT 1", [$published_id]);
        if ($published_exists) del($published_table === 'app_replies' ? 'replies' : 'topics', $published_id);
    }
    if ($published_id > 0 && !$published_exists) {
        q("DELETE FROM plugin_user_review_topics WHERE id=? AND status='pending'", [(int)$row['id']]);
    } else {
        q("UPDATE plugin_user_review_topics SET status='rejected',published_id=0,reviewed_at=?,reviewer_id=? WHERE id=? AND status='pending'", [now(), uid(), (int)$row['id']]);
    }
    user_review_stats_refresh('contents');
}

function user_review_pending_stats(int $uid): array
{
    if (isset($GLOBALS['__user_review_pending_stats'][$uid])) return $GLOBALS['__user_review_pending_stats'][$uid];
    $seen_at = user_review_seen_at($uid);
    $row = one('SELECT registrations,contents,registration_latest_at,content_latest_at FROM plugin_user_review_stats WHERE id=1') ?: [];
    $registrations = (int)($row['registrations'] ?? 0);
    $contents = (int)($row['contents'] ?? 0);
    $new_count = max((int)($row['registration_latest_at'] ?? 0), (int)($row['content_latest_at'] ?? 0)) > $seen_at ? 1 : 0;
    $stats = ['registrations' => $registrations, 'contents' => $contents, 'total' => $registrations + $contents, 'new_count' => $new_count];
    return $GLOBALS['__user_review_pending_stats'][$uid] = $stats;
}

function user_review_pending_stats_forget(int $uid = 0): void
{
    $uid = $uid > 0 ? $uid : uid();
    if ($uid <= 0) return;
    unset($GLOBALS['__user_review_pending_stats'][$uid]);
}

function user_review_pending_counts(): array
{
    return user_review_pending_stats(uid());
}

function user_review_seen_setting_name(int $uid): string
{
    return 'plugin_user_review_seen_at_' . $uid;
}

function user_review_seen_at(int $uid): int
{
    if ($uid <= 0) return 0;
    return (int)setting(user_review_seen_setting_name($uid), '0');
}

function user_review_new_pending_count(int $uid): int
{
    if ($uid <= 0) return 0;
    return (int)user_review_pending_stats($uid)['new_count'];
}

function user_review_mark_seen(int $uid): void
{
    if ($uid <= 0) return;
    save_settings_values([user_review_seen_setting_name($uid) => (string)now()]);
    user_review_pending_stats_forget($uid);
}

function user_review_sidebar($parts, array $ctx): array
{
    $parts = is_array($parts) ? $parts : [];
    if (empty($ctx['is_home_first_page'])) return $parts;
    if (!can_access_admin()) return $parts;
    $counts = user_review_pending_counts();
    if ((int)$counts['total'] <= 0) return $parts;
    $card = '<div class="card sidebar-card user-review-card"><div class="user-review-wrap"><div class="user-review-head"><div><div class="user-review-title">待审核</div><div class="user-review-sub">需要后台处理</div></div><span class="user-review-badge">' . (int)$counts['total'] . ' 项</span></div><div class="user-review-stats"><div><strong>' . (int)$counts['registrations'] . '</strong><span>注册审核</span></div><div><strong>' . (int)$counts['contents'] . '</strong><span>发帖审核</span></div></div><div class="user-review-action"><a href="' . h(admin_url(['tab' => 'user_review'])) . '">进入审核</a></div></div></div>';
    array_splice($parts, 1, 0, [$card]);
    return $parts;
}

function user_review_index_tabs($items, array $ctx): array
{
    $items = is_array($items) ? $items : [];
    if (!can_access_admin() || !is_home_first_page_request()) return $items;
    $counts = user_review_pending_counts();
    $label = '审核';
    if ((int)$counts['total'] > 0) $label .= ' <span class="user-review-tab-dot" title="有待审核"></span>';
    $items['user_review'] = ['label' => $label, 'href' => admin_url(['tab' => 'user_review'])];
    return $items;
}

function user_review_feature_links($links, array $ctx): array
{
    $links = is_array($links) ? $links : [];
    if (!can_access_admin()) return $links;
    $counts = user_review_pending_counts();
    $has_new = user_review_new_pending_count(uid()) > 0;
    $links[] = ['text' => '审核中心', 'url' => admin_url(['tab' => 'user_review']), 'badge' => !$has_new && (int)$counts['total'] > 0 ? (string)(int)$counts['total'] : '', 'badge_dot' => $has_new];
    return $links;
}

function user_review_action_form(string $label, array $fields, string $class = '', string $confirm = '', bool $remove_target = true, bool $no_ajax = false): string
{
    $confirm_attr = $confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '';
    $remove_attr = $remove_target ? ' data-remove-target=".user-review-row"' : '';
    $ajax_attr = $no_ajax ? ' data-no-ajax="1"' : '';
    return '<form class="post-action-form" method="post" action="' . h(admin_url(['tab' => 'user_review'])) . '"' . $remove_attr . $ajax_attr . $confirm_attr . '>' . form_token() . hidden_inputs($fields) . '<button type="submit"' . ($class !== '' ? ' class="' . h($class) . '"' : '') . '>' . h($label) . '</button></form>';
}

function user_review_action_done(string $message): void
{
    user_review_pending_stats_forget();
    if (ajax_request()) {
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(['ok' => 1, 'message' => $message], JSON_UNESCAPED_UNICODE);
        exit;
    }
    set_flash($message);
}

function user_review_notify(int $uid, string $message, int $topic_id = 0, int $reply_id = 0): void
{
    if ($uid <= 0) return;
    create_notification($uid, 0, 'user_review', $message, $topic_id, $reply_id);
}

function user_review_content_label(array $row): string
{
    $title = trim((string)($row['title'] ?? ''));
    return $title !== '' ? $title : ((string)($row['content_kind'] ?? 'topic') === 'reply' ? '回帖' : '主题');
}

function user_review_admin_page(array $plugin): string
{
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $action = (string)($_POST['review_action'] ?? '');
        if ($action === 'save_config') {
            $publish_mode = (string)($_POST['publish_review_mode'] ?? 'off');
            $flow = (string)($_POST['publish_review_flow'] ?? 'before_publish');
            $current_config = user_review_config();
            $smtp_password = (string)($_POST['smtp_password'] ?? '');
            if ($smtp_password === '') $smtp_password = (string)($current_config['smtp_password'] ?? '');
            $smtp_host = trim((string)($_POST['smtp_host'] ?? ''));
            $smtp_from = trim((string)($_POST['smtp_from'] ?? ''));
            $virtual_send = isset($_POST['virtual_send']);
            if (!$virtual_send && $smtp_host !== '' && !filter_var($smtp_from, FILTER_VALIDATE_EMAIL)) err('使用 SMTP 时请填写正确的发件邮箱');
            $turnstile_enabled = isset($_POST['turnstile_enabled']);
            $publish_turnstile_enabled = isset($_POST['publish_turnstile_enabled']);
            $auth_turnstile_enabled = isset($_POST['auth_turnstile_enabled']);
            $turnstile_site_key = trim((string)($_POST['turnstile_site_key'] ?? ''));
            $turnstile_secret_key = trim((string)($_POST['turnstile_secret_key'] ?? ''));
            if ($turnstile_secret_key === '') $turnstile_secret_key = trim((string)($current_config['turnstile_secret_key'] ?? ''));
            if (($turnstile_enabled || $publish_turnstile_enabled || $auth_turnstile_enabled) && ($turnstile_site_key === '' || $turnstile_secret_key === '')) err('请填写 Cloudflare Turnstile Site Key 和 Secret Key');
            plugin_save_config('user_review', [
                'email_suffixes' => post('email_suffixes', 1000),
                'review_mode' => 'off',
                'registration_review_enabled' => isset($_POST['registration_review_enabled']) ? '1' : '0',
                'publish_review_mode' => in_array($publish_mode, ['off', 'first_posts', 'after_first_posts'], true) ? $publish_mode : 'off',
                'active_from' => user_review_daily_time_input((string)($_POST['active_from'] ?? '')),
                'active_to' => user_review_daily_time_input((string)($_POST['active_to'] ?? '')),
                'publish_review_flow' => in_array($flow, ['before_publish', 'after_publish'], true) ? $flow : 'before_publish',
                'pending_keywords' => post('pending_keywords', 5000),
                'reject_keywords' => post('reject_keywords', 5000),
                'email_verify_enabled' => isset($_POST['email_verify_enabled']) ? '1' : '0',
                'email_change_enabled' => isset($_POST['email_change_enabled']) ? '1' : '0',
                'email_code_ip_interval_minutes' => (string)max(1, min(1440, (int)($_POST['email_code_ip_interval_minutes'] ?? 5))),
                'turnstile_enabled' => $turnstile_enabled ? '1' : '0',
                'publish_turnstile_enabled' => $publish_turnstile_enabled ? '1' : '0',
                'auth_turnstile_enabled' => $auth_turnstile_enabled ? '1' : '0',
                'turnstile_site_key' => $turnstile_site_key,
                'turnstile_secret_key' => $turnstile_secret_key,
                'smtp_host' => $smtp_host,
                'smtp_port' => (string)max(1, min(65535, (int)($_POST['smtp_port'] ?? 465))),
                'smtp_secure' => in_array((string)($_POST['smtp_secure'] ?? 'ssl'), ['ssl', 'tls', 'none'], true) ? (string)($_POST['smtp_secure'] ?? 'ssl') : 'ssl',
                'smtp_username' => trim((string)($_POST['smtp_username'] ?? '')),
                'smtp_password' => $smtp_password,
                'smtp_from' => $smtp_from,
                'smtp_from_name' => trim((string)($_POST['smtp_from_name'] ?? '')),
                'virtual_send' => $virtual_send ? '1' : '0',
                'review_publish_count' => (string)max(0, min(100, (int)($_POST['review_publish_count'] ?? 1))),
                'reserved_usernames' => post('reserved_usernames', 2000),
                'register_per_hour' => (string)max(1, min(100, (int)($_POST['register_per_hour'] ?? 1))),
                'login_fail_per_hour' => (string)max(1, min(100, (int)($_POST['login_fail_per_hour'] ?? 5))),
                'reset_fail_per_hour' => (string)max(1, min(100, (int)($_POST['reset_fail_per_hour'] ?? 5))),
            ]);
            set_flash('插件设置已保存');
        } elseif ($action === 'approve_user') {
            $id = max(1, (int)($_POST['registration_id'] ?? 0));
            $row = one("SELECT * FROM plugin_user_review_registrations WHERE id=? AND status='pending'", [$id]) ?: err('待审用户不存在');
            if ((bool)val("SELECT 1 FROM app_users WHERE username=? LIMIT 1", [(string)$row['username']])) err('用户名已存在');
            $gid = group_by_id((int)$row['group_id']) ? (int)$row['group_id'] : (int)setting('default_group_id', '2');
            if (!group_by_id($gid)) err('用户组不存在');
            q("INSERT INTO app_users(username,password,email,bio,avatar_style,avatar_seed,group_id,points,is_banned,is_muted,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", [(string)$row['username'], (string)$row['password'], (string)$row['email'], (string)$row['bio'], (string)$row['avatar_style'], (string)$row['avatar_seed'], $gid, (int)$row['points'], 0, 0, (int)$row['created_at']]);
            $uid = app_db_last_insert_id('app_users');
            q("UPDATE plugin_user_review_registrations SET status='approved',user_id=?,reviewed_at=?,reviewer_id=? WHERE id=?", [$uid, now(), uid(), $id]);
            user_review_stats_refresh('registrations');
            home_stats_record_insert('users', $uid);
            user_review_notify($uid, '你的注册审核已通过，现在可以正常登录发言。');
            user_review_action_done('用户已通过审核');
        } elseif ($action === 'reject_user') {
            $id = max(1, (int)($_POST['registration_id'] ?? 0));
            q("UPDATE plugin_user_review_registrations SET status='rejected',reviewed_at=?,reviewer_id=? WHERE id=? AND status='pending'", [now(), uid(), $id]);
            user_review_stats_refresh('registrations');
            user_review_action_done('用户已拒绝');
        } elseif ($action === 'approve_topic') {
            $id = max(1, (int)($_POST['topic_review_id'] ?? 0));
            $row = one("SELECT * FROM plugin_user_review_topics WHERE id=? AND status IN ('pending','rejected')", [$id]) ?: err('待审或已拒绝内容不存在');
            $published = user_review_publish_content($row);
            user_review_notify((int)$row['user_id'], '你的' . ((string)($row['content_kind'] ?? 'topic') === 'reply' ? '回帖' : '主题') . '《' . user_review_content_label($row) . '》审核已通过，内容已发布。', (int)($published['topic_id'] ?? 0), (int)($published['reply_id'] ?? 0));
            user_review_action_done((string)$row['status'] === 'rejected' ? '已拒绝内容已重新通过并发布' : '发帖已通过审核');
        } elseif ($action === 'reject_topic') {
            $id = max(1, (int)($_POST['topic_review_id'] ?? 0));
            $row = one("SELECT * FROM plugin_user_review_topics WHERE id=? AND status='pending'", [$id]) ?: err('待审内容不存在');
            user_review_reject_content($row);
            user_review_notify((int)$row['user_id'], '你的' . ((string)($row['content_kind'] ?? 'topic') === 'reply' ? '回帖' : '主题') . '《' . user_review_content_label($row) . '》审核未通过，请修改后重新提交。');
            user_review_action_done('发帖已拒绝');
        } elseif ($action === 'delete_rejected_topic') {
            $id = max(1, (int)($_POST['topic_review_id'] ?? 0));
            $row = one("SELECT id FROM plugin_user_review_topics WHERE id=? AND status='rejected'", [$id]) ?: err('已拒绝内容不存在');
            q("DELETE FROM plugin_user_review_topics WHERE id=? AND status='rejected'", [(int)$row['id']]);
            user_review_action_done('已拒绝内容已彻底删除');
        } elseif ($action === 'mute_topic_user') {
            $id = max(1, (int)($_POST['topic_review_id'] ?? 0));
            $row = one("SELECT user_id FROM plugin_user_review_topics WHERE id=?", [$id]) ?: err('审核内容不存在');
            $user_id = (int)$row['user_id'];
            if ($user_id <= 0 || !(bool)val("SELECT 1 FROM app_users WHERE id=? LIMIT 1", [$user_id])) err('用户不存在');
            q("UPDATE app_users SET is_muted=1 WHERE id=?", [$user_id]);
            user_review_action_done('用户已禁言');
        } else err('参数错误');
        go(admin_url(['tab' => 'user_review']));
    }
    user_review_mark_seen(uid());

    $config = user_review_config();
    $mode = user_review_publish_mode_config();
    $registration_review = checkbox('开启用户审核', 'registration_review_enabled', user_review_registration_config_enabled());
    $mode_select = '<label class="grid"><span>发布审核模式</span><select name="publish_review_mode"><option value="off"' . ($mode === 'off' ? ' selected' : '') . '>关闭发布审核</option><option value="first_posts"' . ($mode === 'first_posts' ? ' selected' : '') . '>用户前 X 个发布审核</option><option value="after_first_posts"' . ($mode === 'after_first_posts' ? ' selected' : '') . '>用户前 X 个发布不审核</option></select></label>';
    $flow = user_review_publish_flow();
    $flow_select = '<label class="grid"><span>发布审核</span><select name="publish_review_flow"><option value="before_publish"' . ($flow === 'before_publish' ? ' selected' : '') . '>审核后发布</option><option value="after_publish"' . ($flow === 'after_publish' ? ' selected' : '') . '>发布后审核</option></select></label>';
    $secure = (string)($config['smtp_secure'] ?? 'ssl');
    $secure_select = '<label class="grid"><span>SMTP加密</span><select name="smtp_secure"><option value="ssl"' . ($secure === 'ssl' ? ' selected' : '') . '>SSL</option><option value="tls"' . ($secure === 'tls' ? ' selected' : '') . '>STARTTLS</option><option value="none"' . ($secure === 'none' ? ' selected' : '') . '>不加密</option></select></label>';
    $email_verify = checkbox('开启邮箱验证码', 'email_verify_enabled', (string)($config['email_verify_enabled'] ?? '0') === '1');
    $email_change = checkbox('允许用户修改邮箱', 'email_change_enabled', (string)($config['email_change_enabled'] ?? '0') === '1');
    $turnstile_enabled = checkbox('注册开启 Cloudflare 验证码', 'turnstile_enabled', (string)($config['turnstile_enabled'] ?? '0') === '1');
    $publish_turnstile_enabled = checkbox('前几个发帖开启 Cloudflare 验证码', 'publish_turnstile_enabled', (string)($config['publish_turnstile_enabled'] ?? '0') === '1');
    $auth_turnstile_enabled = checkbox('登录开启 Cloudflare 验证码', 'auth_turnstile_enabled', (string)($config['auth_turnstile_enabled'] ?? '0') === '1');
    $keyword_fields = textarea('待审关键词', 'pending_keywords', (string)($config['pending_keywords'] ?? ''), false, '多个关键词使用 | 分隔，* 匹配任意字符；标题或内容匹配后进入待审队列。', 'user-review-keyword-field') . textarea('拒绝关键词', 'reject_keywords', (string)($config['reject_keywords'] ?? ''), false, '多个关键词使用 | 分隔，* 匹配任意字符；标题或内容匹配后进入拒绝队列。', 'user-review-keyword-field');
    $review_fields = input('每日生效开始', 'active_from', user_review_daily_time_input((string)($config['active_from'] ?? '')), 'time') . input('每日关闭时间', 'active_to', user_review_daily_time_input((string)($config['active_to'] ?? '')), 'time') . input('允许邮箱后缀', 'email_suffixes', (string)$config['email_suffixes']) . $registration_review . $mode_select . $flow_select . input('前几个发布', 'review_publish_count', user_review_publish_limit(), 'number', true, '', 'user-review-review-count-field') . $keyword_fields;
    $turnstile_secret_help = (string)($config['turnstile_secret_key'] ?? '') !== '' ? '已保存，留空不修改。' : '';
    $turnstile_secret = '<label class="grid"><span>Turnstile Secret Key' . ($turnstile_secret_help !== '' ? '<small>' . h($turnstile_secret_help) . '</small>' : '') . '</span><input type="password" name="turnstile_secret_key" value="" autocomplete="new-password" spellcheck="false" data-lpignore="true"></label>';
    $turnstile_fields = $turnstile_enabled . $publish_turnstile_enabled . $auth_turnstile_enabled . input('Turnstile Site Key', 'turnstile_site_key', (string)($config['turnstile_site_key'] ?? '')) . $turnstile_secret;
    $account_fields = $email_verify . $email_change . input('同 IP 发送间隔（分钟）', 'email_code_ip_interval_minutes', user_review_email_code_ip_interval_minutes(), 'number', true);
    $mail_fields = checkbox('虚拟发送邮件', 'virtual_send', user_review_virtual_send_enabled(), '适用于无法提供发送邮件的服务器，启用后不发送邮件，直接显示邮件内容') . input('SMTP服务器', 'smtp_host', (string)($config['smtp_host'] ?? ''), 'text', false, '留空时使用系统自带的邮件发送功能。') . input('SMTP端口', 'smtp_port', (string)($config['smtp_port'] ?? '465'), 'number') . $secure_select . input('SMTP账号', 'smtp_username', (string)($config['smtp_username'] ?? '')) . input('SMTP密码', 'smtp_password', '', 'password') . input('发件邮箱', 'smtp_from', (string)($config['smtp_from'] ?? ''), 'email') . input('发件名称', 'smtp_from_name', (string)($config['smtp_from_name'] ?? ''));
    $security_fields = textarea('注册保留用户名', 'reserved_usernames', (string)($config['reserved_usernames'] ?? ''), false, '', 'user-review-wide-field') . input('1小时内注册限制', 'register_per_hour', (string)($config['register_per_hour'] ?? '1'), 'number', true) . input('1小时内登录错误限制', 'login_fail_per_hour', (string)($config['login_fail_per_hour'] ?? '5'), 'number', true) . input('1小时内操作错误限制', 'reset_fail_per_hour', (string)($config['reset_fail_per_hour'] ?? '5'), 'number', true);
    $settings = '<form class="user-review-settings" method="post" action="' . h(admin_url(['tab' => 'user_review'])) . '" autocomplete="off">' . form_token() . hidden_inputs(['review_action' => 'save_config']) . '<div class="user-review-setting-cards"><section class="user-review-setting-card user-review-setting-card-wide"><h3>审核规则</h3><div class="user-review-settings-fields user-review-settings-fields-columns">' . $review_fields . '</div></section><section class="user-review-setting-card"><h3>人机验证</h3><div class="user-review-settings-fields">' . $turnstile_fields . '</div></section><section class="user-review-setting-card"><h3>邮箱验证与密码找回</h3><div class="user-review-settings-fields">' . $account_fields . '</div></section><section class="user-review-setting-card user-review-setting-card-wide"><h3>邮件发送</h3><div class="user-review-settings-fields user-review-settings-fields-columns">' . $mail_fields . '</div></section><section class="user-review-setting-card user-review-setting-card-wide"><h3>安全限制</h3><div class="user-review-settings-fields user-review-settings-fields-columns">' . $security_fields . '</div></section></div><div class="user-review-settings-actions"><button>保存设置</button></div></form>';
    $settings_panel = '<div class="admin-list-panel user-review-panel">' . admin_list_head('<div class="user-review-summary"><strong>用户审核与安全设置</strong><span>设置审核规则、注册限制和账号保护。</span></div>', '') . '<div class="user-review-panel-body">' . $settings . '</div></div>';
    $html = '';

    $users = q("SELECT id,username,email,created_at FROM plugin_user_review_registrations WHERE status='pending' ORDER BY created_at DESC LIMIT 50")->fetchAll();
    $html .= '<div class="admin-list-panel user-review-panel">' . admin_list_head('<div class="user-review-summary"><strong>待审注册</strong><span>审核通过后才正式创建用户</span></div>', '') . '<ul class="admin-manage-list">';
    foreach ($users as $row) {
        $ops = user_review_action_form('通过', ['review_action' => 'approve_user', 'registration_id' => (int)$row['id']], 'user-review-approve') . user_review_action_form('拒绝', ['review_action' => 'reject_user', 'registration_id' => (int)$row['id']], 'danger', '确定拒绝该注册申请？');
        $html .= '<li class="admin-list-item admin-object-row user-review-row"><div class="admin-row-main"><strong class="admin-content-title">' . h((string)$row['username']) . '</strong><div class="admin-row-meta"><span>' . h((string)$row['email']) . '</span><span>' . date('Y-m-d H:i', (int)$row['created_at']) . '</span></div></div><div class="admin-inline-ops">' . $ops . '</div></li>';
    }
    if (!$users) $html .= '<li class="empty-state">暂无待审注册</li>';
    $html .= '</ul></div>';

    $topics = attach_users(q("SELECT id,user_id,forum_id,title,body,status,content_kind,published_id,created_at FROM plugin_user_review_topics WHERE status='pending' ORDER BY created_at DESC LIMIT 50")->fetchAll());
    $html .= '<div class="admin-list-panel user-review-panel">' . admin_list_head('<div class="user-review-summary"><strong>待审发帖</strong><span>当前规则按用户总发布数计算，阈值 ' . user_review_publish_limit() . ' 个</span></div>', '') . '<ul class="admin-manage-list">';
    foreach ($topics as $row) {
        $forum = forum_by_id((int)$row['forum_id']) ?: ['name' => '版块已删除'];
        $mute_op = (int)($row['is_muted'] ?? 0) ? '' : user_review_action_form('禁言用户', ['review_action' => 'mute_topic_user', 'topic_review_id' => (int)$row['id']], 'danger', '确定禁言该用户？', false, true);
        $ops = user_review_action_form('通过', ['review_action' => 'approve_topic', 'topic_review_id' => (int)$row['id']], 'user-review-approve') . user_review_action_form('拒绝', ['review_action' => 'reject_topic', 'topic_review_id' => (int)$row['id']], 'danger', '确定拒绝该发帖？') . $mute_op;
        $html .= '<li class="admin-list-item admin-object-row user-review-row"><div class="admin-row-main"><strong class="admin-content-title">' . h((string)$row['title']) . '</strong><div class="admin-row-meta"><span><a href="' . h(route_url('user', ['id' => (int)$row['user_id']])) . '">' . h((string)$row['username']) . '</a></span><span>' . h((string)$forum['name']) . '</span><span>' . ((string)($row['content_kind'] ?? 'topic') === 'reply' ? '回帖' : '主题') . '</span><span>' . ((int)($row['published_id'] ?? 0) > 0 ? '发布后审核' : '审核后发布') . '</span><span>' . date('Y-m-d H:i', (int)$row['created_at']) . '</span></div><div class="admin-content-text">' . h(notification_excerpt((string)$row['body'], 120)) . '</div></div><div class="admin-inline-ops">' . $ops . '</div></li>';
    }
    if (!$topics) $html .= '<li class="empty-state">暂无待审发帖</li>';
    $html .= '</ul></div>';

    $rejected_topics = attach_users(q("SELECT id,user_id,forum_id,title,body,status,content_kind,published_id,created_at,reviewed_at FROM plugin_user_review_topics WHERE status='rejected' ORDER BY reviewed_at DESC,id DESC LIMIT 50")->fetchAll());
    $html .= '<div class="admin-list-panel user-review-panel">' . admin_list_head('<div class="user-review-summary"><strong>已拒绝内容</strong><span>可重新通过并发布，或彻底删除记录和内容。</span></div>', '') . '<ul class="admin-manage-list">';
    foreach ($rejected_topics as $row) {
        $forum = forum_by_id((int)$row['forum_id']) ?: ['name' => '版块已删除'];
        $mute_op = (int)($row['is_muted'] ?? 0) ? '' : user_review_action_form('禁言用户', ['review_action' => 'mute_topic_user', 'topic_review_id' => (int)$row['id']], 'danger', '确定禁言该用户？', false, true);
        $ops = user_review_action_form('重新通过', ['review_action' => 'approve_topic', 'topic_review_id' => (int)$row['id']], 'user-review-approve', '确定重新通过并发布该内容？') . user_review_action_form('彻底删除', ['review_action' => 'delete_rejected_topic', 'topic_review_id' => (int)$row['id']], 'danger', '确定彻底删除该拒绝内容？此操作无法恢复。') . $mute_op;
        $html .= '<li class="admin-list-item admin-object-row user-review-row"><div class="admin-row-main"><strong class="admin-content-title">' . h((string)$row['title']) . '</strong><div class="admin-row-meta"><span><a href="' . h(route_url('user', ['id' => (int)$row['user_id']])) . '">' . h((string)$row['username']) . '</a></span><span>' . h((string)$forum['name']) . '</span><span>' . ((string)($row['content_kind'] ?? 'topic') === 'reply' ? '回帖' : '主题') . '</span><span>拒绝于 ' . date('Y-m-d H:i', (int)$row['reviewed_at']) . '</span></div><div class="admin-content-text">' . h(notification_excerpt((string)$row['body'], 120)) . '</div></div><div class="admin-inline-ops">' . $ops . '</div></li>';
    }
    if (!$rejected_topics) $html .= '<li class="empty-state">暂无已拒绝内容</li>';
    return $html . '</ul></div>' . $settings_panel;
}

return [
    'id' => 'user_review',
    'name' => '用户审核',
    'version' => '1.3.0',
    'description' => '审核新用户和发布内容，提供邮箱验证、密码找回和账号安全保护。',
    'author' => 'bbs1org',
    'assets' => ['css' => 'user_review_plugin_css', 'js' => 'user_review_js'],
    'hooks' => [
        'user.before_save' => 'user_review_validate_user',
        'user.username_reserved' => 'user_review_username_reserved',
        'security.rate_allow' => 'user_review_rate_allow',
        'security.rate_hit' => 'user_review_rate_hit',
        'user.after_save' => 'user_review_after_user_save',
        'register.form_extra' => 'user_review_register_form_extra',
        'topic.form_extra' => 'user_review_topic_form_extra',
        'login.form_extra' => 'user_review_auth_form_extra',
        'login.before_submit' => 'user_review_auth_turnstile_check',
        'login.after_form' => 'user_review_password_recovery_login_link',
        'forgot_password.form_extra' => 'user_review_auth_form_extra',
        'forgot_password.before_submit' => 'user_review_auth_turnstile_check',
        'site.closed_allow' => 'user_review_password_recovery_site_closed_allow',
        'page.head' => 'user_review_head',
        'topic.before_save' => 'user_review_before_topic_save',
        'reply.before_save' => 'user_review_before_reply_save',
        'topic.after_save' => 'user_review_after_topic_save',
        'reply.after_save' => 'user_review_after_reply_save',
        'attachment.before_upload' => 'user_review_attachment_before_upload',
        'topic.index_tabs' => 'user_review_index_tabs',
        'sidebar.feature_links' => 'user_review_feature_links',
        'sidebar.stack' => 'user_review_sidebar',
        'profile.after_form' => 'user_review_profile_html',
    ],
    'admin_tabs' => [
        'user_review' => 'user_review_admin_page',
    ],
    'routes' => [
        'user_review_email_code' => 'user_review_send_email_code_page',
        'user_review_email_change' => 'user_review_email_change_page',
        'password_recovery_forgot' => 'user_review_password_recovery_forgot_page',
        'password_recovery_reset' => 'user_review_password_recovery_reset_page',
    ],
    'install' => 'user_review_install',
    'uninstall' => 'user_review_uninstall',
];