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

function ip_history_install(array $plugin): void
{
    ip_history_schema();
    ip_history_mute_cache_rebuild();
}

function ip_history_uninstall(array $plugin): void
{
    app_db_drop_table('plugin_ip_history_mutes');
    app_db_drop_table('plugin_ip_history_addresses');
    app_db_drop_table('plugin_ip_history_users');
    app_db_drop_column('app_topics', 'plugin_ip_history_ip');
    app_db_drop_column('app_replies', 'plugin_ip_history_ip');
    save_settings_values(['plugin_ip_history_mute_cache' => '']);
    unset($GLOBALS['__ip_history_mute_cache'], $GLOBALS['__ip_history_mute_matches']);
}

function ip_history_schema(): void
{
    static $ready = false;
    if ($ready) return;
    $t = app_db_types();
    app_db_ensure_columns('app_topics', ['plugin_ip_history_ip' => "{$t['key']} NOT NULL DEFAULT ''"]);
    app_db_ensure_columns('app_replies', ['plugin_ip_history_ip' => "{$t['key']} NOT NULL DEFAULT ''"]);
    app_db_create_table('plugin_ip_history_users', "user_id {$t['uint']} NOT NULL,ip {$t['key']} NOT NULL,first_seen_at {$t['uint']} NOT NULL,last_seen_at {$t['uint']} NOT NULL,topic_count {$t['uint']} NOT NULL DEFAULT 0,reply_count {$t['uint']} NOT NULL DEFAULT 0,PRIMARY KEY(user_id,ip)");
    app_db_create_index('idx_plugin_ip_history_ip_user', 'plugin_ip_history_users(ip,user_id)');
    if (db_driver() === 'pgsql') app_db_create_index('idx_plugin_ip_history_ip_pattern', 'plugin_ip_history_users(ip text_pattern_ops,user_id)');
    app_db_create_table('plugin_ip_history_addresses', "ip {$t['key']} NOT NULL PRIMARY KEY,ip_address {$t['string']} NOT NULL DEFAULT '',updated_at {$t['uint']} NOT NULL");
    app_db_create_index('idx_plugin_ip_history_addresses_ip_address', 'plugin_ip_history_addresses(ip_address)');
    app_db_create_table('plugin_ip_history_mutes', "id {$t['id']},pattern {$t['key']} NOT NULL UNIQUE,note {$t['string']} NOT NULL,enabled INTEGER NOT NULL DEFAULT 1,created_by {$t['uint']} NOT NULL,created_at {$t['uint']} NOT NULL,updated_at {$t['uint']} NOT NULL");
    app_db_create_index('idx_plugin_ip_history_mutes_state', 'plugin_ip_history_mutes(enabled,updated_at DESC)');
    $ready = true;
}

function ip_history_normalize_ip(string $ip): string
{
    $ip = strtolower(trim($ip));
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return '';
    $packed = inet_pton($ip);
    if ($packed === false) return '';
    $normalized = inet_ntop($packed);
    return is_string($normalized) ? strtolower($normalized) : '';
}

function ip_history_client_ip(): string
{
    $fallback = '';
    foreach (['HTTP_CLIENT_IP', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'] as $key) {
        foreach (explode(',', (string)($_SERVER[$key] ?? '')) as $value) {
            $ip = clean_ip($value);
            if ($ip === '') continue;
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return $ip;
            $fallback = $fallback !== '' ? $fallback : $ip;
        }
    }
    return $fallback !== '' ? $fallback : '0.0.0.0';
}

function ip_history_normalize_pattern(string $value): string
{
    $pattern = strtolower(trim($value));
    $pattern = preg_replace('/\*+/', '*', $pattern) ?? '';
    if ($pattern === '' || strlen($pattern) > 64) err('IP 规则长度应为 1 至 64 个字符');
    if (!preg_match('/^[0-9a-f:.*]+$/D', $pattern)) err('IP 规则只能包含 IP 字符和 *');
    if (!str_contains($pattern, '*')) {
        $pattern = ip_history_normalize_ip($pattern);
        if ($pattern === '') err('IP 地址格式不正确');
    }
    return $pattern;
}

function ip_history_match_condition(string $pattern, string $alias = ''): array
{
    $column = ($alias !== '' ? $alias . '.' : '') . 'ip';
    if (!str_contains($pattern, '*')) return [$column . '=?', [$pattern]];
    if (db_driver() === 'sqlite') return [$column . ' GLOB ?', [$pattern]];
    return [$column . ' LIKE ?', [str_replace('*', '%', $pattern)]];
}

function ip_history_record_event(string $type, array $ctx): void
{
    if (!empty($ctx['editing'])) return;
    $user_id = (int)($ctx['user_id'] ?? uid());
    if ($user_id <= 0) return;
    $ip = ip_history_normalize_ip(ip_history_client_ip());
    if ($ip === '' || $ip === '0.0.0.0' || $ip === '::') return;
    $ts = now();
    $topic_count = $type === 'topic' ? 1 : 0;
    $reply_count = $type === 'reply' ? 1 : 0;
    $params = [$user_id, $ip, $ts, $ts, $topic_count, $reply_count];
    if (db_driver() === 'mysql') {
        $sql = 'INSERT INTO plugin_ip_history_users(user_id,ip,first_seen_at,last_seen_at,topic_count,reply_count) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE last_seen_at=VALUES(last_seen_at),topic_count=plugin_ip_history_users.topic_count+VALUES(topic_count),reply_count=plugin_ip_history_users.reply_count+VALUES(reply_count)';
    } else {
        $sql = 'INSERT INTO plugin_ip_history_users(user_id,ip,first_seen_at,last_seen_at,topic_count,reply_count) VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,ip) DO UPDATE SET last_seen_at=excluded.last_seen_at,topic_count=plugin_ip_history_users.topic_count+excluded.topic_count,reply_count=plugin_ip_history_users.reply_count+excluded.reply_count';
    }
    q($sql, $params);
}

function ip_history_topic_after_save($value, array $ctx)
{
    ip_history_record_event('topic', $ctx);
    if (empty($ctx['editing'])) ip_history_record_post_ip('topic', (int)($ctx['id'] ?? 0));
    return $value;
}

function ip_history_reply_after_save($value, array $ctx)
{
    ip_history_record_event('reply', $ctx);
    if (empty($ctx['editing'])) ip_history_record_post_ip('reply', (int)($ctx['id'] ?? 0));
    return $value;
}

function ip_history_record_post_ip(string $type, int $post_id): void
{
    if ($post_id <= 0) return;
    $ip = ip_history_normalize_ip(ip_history_client_ip());
    if ($ip === '' || $ip === '0.0.0.0' || $ip === '::') return;
    app_db_insert_ignore('plugin_ip_history_addresses', ['ip' => $ip, 'ip_address' => '', 'updated_at' => now()], ['ip']);
    $table = $type === 'reply' ? 'app_replies' : ($type === 'topic' ? 'app_topics' : '');
    if ($table === '') return;
    q("UPDATE $table SET plugin_ip_history_ip=? WHERE id=? AND plugin_ip_history_ip=''", [$ip, $post_id]);
}

function ip_history_mute_cache_rebuild(): array
{
    $rows = q("SELECT pattern FROM plugin_ip_history_mutes WHERE enabled=1 ORDER BY id")->fetchAll();
    $cache = ['exact' => [], 'wildcards' => []];
    foreach ($rows as $row) {
        $pattern = (string)$row['pattern'];
        if (str_contains($pattern, '*')) $cache['wildcards'][] = $pattern;
        else $cache['exact'][$pattern] = true;
    }
    save_settings_values(['plugin_ip_history_mute_cache' => json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
    unset($GLOBALS['__ip_history_mute_matches']);
    return $GLOBALS['__ip_history_mute_cache'] = $cache;
}

function ip_history_mute_cache(): array
{
    if (is_array($GLOBALS['__ip_history_mute_cache'] ?? null)) return $GLOBALS['__ip_history_mute_cache'];
    $cache = json_decode(setting('plugin_ip_history_mute_cache', ''), true);
    if (is_array($cache) && is_array($cache['exact'] ?? null) && is_array($cache['wildcards'] ?? null)) {
        return $GLOBALS['__ip_history_mute_cache'] = $cache;
    }
    return ip_history_mute_cache_rebuild();
}

function ip_history_muted_pattern(string $ip): string
{
    if (array_key_exists($ip, $GLOBALS['__ip_history_mute_matches'] ?? [])) return (string)$GLOBALS['__ip_history_mute_matches'][$ip];
    $cache = ip_history_mute_cache();
    if (isset($cache['exact'][$ip])) return $GLOBALS['__ip_history_mute_matches'][$ip] = $ip;
    foreach ($cache['wildcards'] as $pattern) {
        $regex = '~\A' . str_replace('\\*', '.*', preg_quote((string)$pattern, '~')) . '\z~D';
        if (preg_match($regex, $ip)) return $GLOBALS['__ip_history_mute_matches'][$ip] = (string)$pattern;
    }
    return $GLOBALS['__ip_history_mute_matches'][$ip] = '';
}

function ip_history_can_speak($allowed, array $ctx)
{
    if ($allowed !== true || !uid() || is_super_user() || can_access_admin()) return $allowed;
    $ip = ip_history_normalize_ip(ip_history_client_ip());
    if ($ip !== '' && ip_history_muted_pattern($ip) !== '') return '当前 IP 已被禁言';
    return true;
}

function ip_history_search_users(string $ip, int $page, int $size): array
{
    [$condition, $params] = ip_history_match_condition($ip, 'h');
    $offset = (max(1, $page) - 1) * $size;
    $rows = q("SELECT h.user_id,h.ip,1 matched_ip_count,h.first_seen_at,h.last_seen_at,h.topic_count,h.reply_count FROM plugin_ip_history_users h WHERE $condition ORDER BY h.last_seen_at DESC LIMIT ? OFFSET ?", array_merge($params, [$size + 1, $offset]))->fetchAll();
    $has_next = count($rows) > $size;
    $rows = array_slice($rows, 0, $size);
    return [attach_users($rows), $has_next];
}

function ip_history_detail_rows(string $pattern, int $page, int $size): array
{
    $where = '';
    $params = [];
    if ($pattern !== '') {
        [$condition, $params] = ip_history_match_condition($pattern, 'h');
        $where = 'WHERE ' . $condition;
    }
    $offset = (max(1, $page) - 1) * $size;
    $rows = q("SELECT h.user_id,h.ip,h.first_seen_at,h.last_seen_at,h.topic_count,h.reply_count FROM plugin_ip_history_users h $where ORDER BY h.ip,h.user_id LIMIT ? OFFSET ?", array_merge($params, [$size + 1, $offset]))->fetchAll();
    $has_next = count($rows) > $size;
    return [attach_users(array_slice($rows, 0, $size)), $has_next];
}

function ip_history_admin_post(): never
{
    $action = post('ip_history_action', 30);
    $return_pattern = trim((string)($_POST['return_pattern'] ?? ''));
    $return_rule_page = max(1, (int)($_POST['return_rule_page'] ?? 1));
    if ($action === 'add_mute') {
        $pattern = ip_history_normalize_pattern((string)($_POST['mute_pattern'] ?? ''));
        $note = post('note', 200);
        $ts = now();
        app_db_insert_ignore('plugin_ip_history_mutes', [
            'pattern' => $pattern,
            'note' => '',
            'enabled' => 1,
            'created_by' => uid(),
            'created_at' => $ts,
            'updated_at' => $ts,
        ], ['pattern']);
        q("UPDATE plugin_ip_history_mutes SET note=?,enabled=1,updated_at=? WHERE pattern=?", [$note, $ts, $pattern]);
        ip_history_mute_cache_rebuild();
        set_flash('IP 禁言规则已启用');
        $return_pattern = $pattern;
    } elseif ($action === 'toggle_mute') {
        $id = max(1, (int)($_POST['mute_id'] ?? 0));
        $enabled = (string)($_POST['enabled'] ?? '0') === '1' ? 1 : 0;
        q("UPDATE plugin_ip_history_mutes SET enabled=?,updated_at=? WHERE id=?", [$enabled, now(), $id]);
        ip_history_mute_cache_rebuild();
        set_flash($enabled ? 'IP 禁言规则已启用' : 'IP 禁言规则已停用');
    } else err('参数错误');
    go(admin_url(['tab' => 'ip_history', 'ip_pattern' => $return_pattern, 'rule_p' => $return_rule_page > 1 ? $return_rule_page : null]));
}

function ip_history_rule_pagination(int $page, bool $has_next, string $pattern): string
{
    if ($page <= 1 && !$has_next) return '';
    $url = admin_url(['tab' => 'ip_history', 'ip_pattern' => $pattern]);
    $html = '<div class="pagination"><ul>';
    if ($page > 1) $html .= '<li><a href="' . h(append_url_query($url, ['rule_p' => $page - 1])) . '">上一页</a></li>';
    $html .= '<li class="active"><a href="' . h(append_url_query($url, ['rule_p' => $page])) . '">' . $page . '</a></li>';
    if ($has_next) $html .= '<li><a href="' . h(append_url_query($url, ['rule_p' => $page + 1])) . '">下一页</a></li>';
    return $html . '</ul></div>';
}

function ip_history_detail_pagination(int $page, bool $has_next, string $pattern): string
{
    if ($page <= 1 && !$has_next) return '';
    $url = admin_url(['tab' => 'ip_history', 'ip_pattern' => $pattern]);
    $html = '<div class="pagination"><ul>';
    if ($page > 1) $html .= '<li><a href="' . h(append_url_query($url, ['detail_p' => $page - 1])) . '">上一页</a></li>';
    $html .= '<li class="active"><a href="' . h(append_url_query($url, ['detail_p' => $page])) . '">' . $page . '</a></li>';
    if ($has_next) $html .= '<li><a href="' . h(append_url_query($url, ['detail_p' => $page + 1])) . '">下一页</a></li>';
    return $html . '</ul></div>';
}

function ip_history_css(): string
{
    return '
.ip-history-search{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ip-history-search input{min-width:min(360px,70vw)}
.ip-history-rule-row{grid-template-columns:minmax(0,1fr) auto}
.ip-history-rule-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ip-history-rule-pattern,.ip-history-ip{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.ip-history-ip-list{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}
.ip-history-ip{padding:2px 7px;border:1px solid var(--line);border-radius:999px;background:var(--bg);color:var(--text-muted);font-size:11px}
.ip-history-user-row,.ip-history-detail-row{grid-template-columns:minmax(0,1fr)}
.ip-history-detail-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ip-history-help{margin:0;color:var(--text-muted);font-size:12px;line-height:1.7}
@media (max-width:760px){.ip-history-rule-row{grid-template-columns:minmax(0,1fr)}.ip-history-rule-row>.admin-inline-ops{justify-content:flex-start}}
' . ip_history_location_css();
}

function ip_history_admin_page(array $plugin): string
{
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if ((string)($_POST['ip_history_location_save'] ?? '') === '1') ip_history_location_save();
        ip_history_admin_post();
    }
    $raw_pattern = trim((string)($_GET['ip_pattern'] ?? ''));
    $pattern = $raw_pattern !== '' ? ip_history_normalize_pattern($raw_pattern) : '';
    $page_no = max(1, (int)($_GET['p'] ?? 1));
    $rule_page = max(1, (int)($_GET['rule_p'] ?? 1));
    $detail_page = max(1, (int)($_GET['detail_p'] ?? 1));
    $search_form = '<form class="ip-history-search" method="get" action="' . h(index_url()) . '">' . hidden_inputs(['a' => 'admin', 'tab' => 'ip_history']) . '<input name="ip_pattern" value="' . h($raw_pattern) . '" placeholder="IP 或通配规则，如 192.168.*" maxlength="64" required><button type="submit">搜索马甲</button>' . ($pattern !== '' ? '<a class="admin-search-clear" href="' . h(admin_url(['tab' => 'ip_history'])) . '">清空</a>' : '') . '</form>';
        $html = '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>IP 马甲搜索</strong><span>支持 IPv4、IPv6 和任意位置通配符 *</span></div>', '') . '<div class="plugin-panel-body">' . $search_form . '</div></div>';

    if ($pattern !== '') {
        [$users, $has_next] = ip_history_search_users($pattern, $page_no, 50);
        $html .= '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>匹配用户</strong><span>规则 ' . h($pattern) . '，当前页 ' . count($users) . ' 人</span></div>', '') . '<ul class="admin-manage-list">';
        foreach ($users as $row) {
            $user_id = (int)$row['user_id'];
            $ips = '<span class="ip-history-ip">' . h((string)$row['ip']) . '</span>';
            $profile = route_url('user', ['id' => $user_id]);
            $html .= '<li class="admin-list-item admin-object-row ip-history-user-row"><div class="admin-row-main"><div class="admin-user-cell">' . avatar_tag($user_id, (string)$row['username'], (string)($row['avatar_style'] ?? ''), 'table-avatar', (string)($row['avatar_seed'] ?? '')) . '<div class="admin-user-text"><strong><a href="' . h($profile) . '">' . h((string)$row['username']) . '</a></strong><span>匹配 IP ' . (int)$row['matched_ip_count'] . ' 个 / 发帖 ' . (int)$row['topic_count'] . ' / 回帖 ' . (int)$row['reply_count'] . ' / 最近 ' . date('Y-m-d H:i', (int)$row['last_seen_at']) . '</span></div></div>' . ($ips !== '' ? '<div class="ip-history-ip-list">' . $ips . '</div>' : '') . '</div></li>';
        }
        if (!$users) $html .= '<li class="empty-state">没有找到使用该 IP 规则发帖或回帖的用户</li>';
        $html .= '</ul></div>';
        $pagination = simple_paginate($page_no > 1, $has_next, $page_no, admin_url(['tab' => 'ip_history', 'ip_pattern' => $pattern]));
        if ($pagination !== '') $html .= '<div class="pagination-bar">' . $pagination . '</div>';
    }

    [$details, $details_has_next] = ip_history_detail_rows($pattern, $detail_page, 50);
    $detail_subtitle = $pattern !== '' ? '仅显示匹配 ' . $pattern . ' 的记录' : '按 IP 排序，每页 50 条';
    $html .= '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>IP 明细</strong><span>' . h($detail_subtitle) . '</span></div>', '') . '<ul class="admin-manage-list">';
    foreach ($details as $row) {
        $user_id = (int)$row['user_id'];
        $username = trim((string)($row['username'] ?? '')) ?: '用户已删除';
        $profile = route_url('user', ['id' => $user_id]);
        $user_title = (string)($row['username'] ?? '') !== '' ? '<a href="' . h($profile) . '">' . h($username) . '</a>' : h($username);
        $html .= '<li class="admin-list-item admin-object-row ip-history-detail-row"><div class="admin-row-main"><div class="ip-history-detail-head">' . avatar_tag($user_id, $username, (string)($row['avatar_style'] ?? ''), 'table-avatar', (string)($row['avatar_seed'] ?? '')) . '<strong class="admin-content-title">' . $user_title . '</strong><span class="ip-history-ip">' . h((string)$row['ip']) . '</span></div><div class="admin-row-meta"><span>发帖 ' . (int)$row['topic_count'] . '</span><span>回帖 ' . (int)$row['reply_count'] . '</span><span>首次 ' . date('Y-m-d H:i', (int)$row['first_seen_at']) . '</span><span>最近 ' . date('Y-m-d H:i', (int)$row['last_seen_at']) . '</span></div></div></li>';
    }
    if (!$details) $html .= '<li class="empty-state">' . ($pattern !== '' ? '没有匹配的 IP 明细' : '暂无 IP 记录') . '</li>';
    $html .= '</ul></div>';
    $detail_pagination = ip_history_detail_pagination($detail_page, $details_has_next, $pattern);
    if ($detail_pagination !== '') $html .= '<div class="pagination-bar">' . $detail_pagination . '</div>';

    $mute_form = '<form class="plugin-settings-form" method="post" action="' . h(admin_url(['tab' => 'ip_history'])) . '" data-confirm="确定启用这条 IP 禁言规则？匹配该规则的普通用户将不能发帖或回帖。">' . form_token() . hidden_inputs(['ip_history_action' => 'add_mute', 'return_pattern' => $pattern]) . input('IP 禁言规则（支持 *）', 'mute_pattern', $pattern, 'text', true) . input('备注', 'note', '', 'text') . '<p class="ip-history-help">规则对当前及未来匹配此 IP 的普通用户生效；超级管理员和后台管理员不受影响。单独填写 * 会禁言所有普通用户，请谨慎使用。</p><button class="danger" type="submit">启用 IP 禁言</button></form>';
    $html .= '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>添加 IP 禁言</strong><span>无需先搜索，也可直接添加规则</span></div>', '') . '<div class="plugin-panel-body">' . $mute_form . '</div></div>';

    $rules = q("SELECT * FROM plugin_ip_history_mutes ORDER BY enabled DESC,updated_at DESC,id DESC LIMIT ? OFFSET ?", [51, ($rule_page - 1) * 50])->fetchAll();
    $rules_has_next = count($rules) > 50;
    $rules = array_slice($rules, 0, 50);
    $rule_creators = rows_by_ids('app_users', array_column($rules, 'created_by'), 'id,username');
    foreach ($rules as &$rule) $rule['creator_name'] = (string)($rule_creators[(int)$rule['created_by']]['username'] ?? '');
    unset($rule);
    $html .= '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>IP 禁言规则</strong><span>运行时从文件缓存匹配，不查询数据库</span></div>', '') . '<ul class="admin-manage-list">';
    foreach ($rules as $rule) {
        $enabled = (int)$rule['enabled'] === 1;
        $next = $enabled ? '0' : '1';
        $label = $enabled ? '停用' : '启用';
        $ops = '<form class="post-action-form" method="post" action="' . h(admin_url(['tab' => 'ip_history'])) . '" data-confirm="确定' . $label . '这条 IP 禁言规则？">' . form_token() . hidden_inputs(['ip_history_action' => 'toggle_mute', 'mute_id' => (int)$rule['id'], 'enabled' => $next, 'return_pattern' => $pattern, 'return_rule_page' => $rule_page]) . '<button type="submit"' . ($enabled ? ' class="danger"' : '') . '>' . $label . '</button></form>';
        $creator = trim((string)($rule['creator_name'] ?? '')) ?: '用户已删除';
        $html .= '<li class="admin-list-item admin-object-row ip-history-rule-row"><div class="admin-row-main"><div class="ip-history-rule-title"><strong class="admin-content-title ip-history-rule-pattern">' . h((string)$rule['pattern']) . '</strong><span class="admin-flag' . ($enabled ? ' on' : '') . '">' . ($enabled ? '禁言中' : '已停用') . '</span></div><div class="admin-row-meta"><span>创建者 ' . h($creator) . '</span><span>更新于 ' . date('Y-m-d H:i', (int)$rule['updated_at']) . '</span></div>' . ((string)$rule['note'] !== '' ? '<div class="admin-content-text">' . h((string)$rule['note']) . '</div>' : '') . '</div><div class="admin-inline-ops">' . $ops . '</div></li>';
    }
    if (!$rules) $html .= '<li class="empty-state">暂无 IP 禁言规则</li>';
    $html .= '</ul></div>';
    $rule_pagination = ip_history_rule_pagination($rule_page, $rules_has_next, $pattern);
    $html .= ip_history_location_settings_html();
    return $html . ($rule_pagination !== '' ? '<div class="pagination-bar">' . $rule_pagination . '</div>' : '');
}

const IP_HISTORY_LOCATION_DEFAULT_ENDPOINT = 'https://api.ip.sb/geoip/{ip}';
const IP_HISTORY_LOCATION_DEFAULT_FORMAT = '{region}';
const IP_HISTORY_LOCATION_SKIPPED = '__ip_history_skipped__';
const IP_HISTORY_LOCATION_MAX_RESPONSE_BYTES = 524288;

function ip_history_location_defaults(): array
{
    return ['endpoint' => IP_HISTORY_LOCATION_DEFAULT_ENDPOINT, 'format' => IP_HISTORY_LOCATION_DEFAULT_FORMAT, 'show_location' => 1, 'cron_interval_minutes' => 10, 'batch_size' => 20, 'timeout_seconds' => 8];
}

function ip_history_location_config(): array
{
    static $config;
    if (is_array($config)) return $config;
    $raw = plugin_config('ip_history', ip_history_location_defaults());
    $endpoint = ip_history_location_clean_endpoint((string)($raw['endpoint'] ?? '')) ?: IP_HISTORY_LOCATION_DEFAULT_ENDPOINT;
    $format = cut(trim((string)($raw['format'] ?? '')), 500);
    return $config = [
        'endpoint' => $endpoint,
        'format' => $format !== '' ? $format : IP_HISTORY_LOCATION_DEFAULT_FORMAT,
        'show_location' => (int)($raw['show_location'] ?? 1) === 1,
        'cron_interval_minutes' => min(1440, max(1, (int)($raw['cron_interval_minutes'] ?? 10))),
        'batch_size' => min(100, max(1, (int)($raw['batch_size'] ?? 20))),
        'timeout_seconds' => min(30, max(2, (int)($raw['timeout_seconds'] ?? 8))),
    ];
}

function ip_history_location_clean_endpoint(string $endpoint): string
{
    $endpoint = trim($endpoint);
    if ($endpoint === '' || strlen($endpoint) > 500) return '';
    if (!str_contains($endpoint, '{ip}')) return '';
    $parts = parse_url($endpoint);
    if (!is_array($parts)) return '';
    $scheme = strtolower((string)($parts['scheme'] ?? ''));
    $host = strtolower((string)($parts['host'] ?? ''));
    if (!in_array($scheme, ['http', 'https'], true) || $host === '' || isset($parts['user'], $parts['pass'], $parts['fragment'])) return '';
    if (in_array($host, ['localhost', 'localhost.localdomain'], true) || str_ends_with($host, '.localhost') || str_ends_with($host, '.local')) return '';
    if (filter_var($host, FILTER_VALIDATE_IP) !== false && filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) return '';
    return rtrim($endpoint, '/');
}

function ip_history_location_api_url(string $endpoint, string $ip): string
{
    if (!str_contains($endpoint, '{ip}')) return '';
    return str_replace('{ip}', rawurlencode($ip), $endpoint);
}

function ip_history_location_public_resolve(string $url): array
{
    $parts = parse_url($url);
    if (!is_array($parts)) throw new RuntimeException('接口地址无效');
    $host = strtolower((string)($parts['host'] ?? ''));
    $scheme = strtolower((string)($parts['scheme'] ?? ''));
    if ($host === '' || !in_array($scheme, ['http', 'https'], true)) throw new RuntimeException('接口地址无效');
    $ips = [];
    if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
        $ips[] = $host;
    } else {
        if (!function_exists('dns_get_record')) throw new RuntimeException('当前环境无法安全解析接口地址');
        foreach (@dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) {
            $ip = (string)($record['ip'] ?? $record['ipv6'] ?? '');
            if ($ip !== '') $ips[$ip] = $ip;
        }
        $ips = array_values($ips);
    }
    if (!$ips) throw new RuntimeException('无法解析接口地址');
    foreach ($ips as $ip) {
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) throw new RuntimeException('接口地址解析到内网或保留地址');
    }
    $port = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
    $addresses = implode(',', array_map(static fn(string $ip): string => str_contains($ip, ':') ? '[' . $ip . ']' : $ip, $ips));
    return [$host . ':' . $port . ':' . $addresses];
}

function ip_history_location_http_get(string $url, int $timeout): array
{
    if (!function_exists('curl_init')) return ['ok' => false, 'status' => 0, 'body' => '', 'error' => '服务器未启用 cURL'];
    try {
        $resolve = ip_history_location_public_resolve($url);
    } catch (Throwable $e) {
        return ['ok' => false, 'status' => 0, 'body' => '', 'error' => cut($e->getMessage(), 120)];
    }
    $curl = curl_init($url);
    if (!$curl) return ['ok' => false, 'status' => 0, 'body' => '', 'error' => '无法初始化请求'];
    $body = '';
    $too_large = false;
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => min(5, max(1, $timeout)),
        CURLOPT_TIMEOUT => max(2, $timeout),
        CURLOPT_USERAGENT => 'bbs1org-ip-history/1.0',
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
        CURLOPT_RESOLVE => $resolve,
        CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body, &$too_large): int {
            if (strlen($body) + strlen($chunk) > IP_HISTORY_LOCATION_MAX_RESPONSE_BYTES) {
                $too_large = true;
                return 0;
            }
            $body .= $chunk;
            return strlen($chunk);
        },
    ]);
    if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
    $ok = curl_exec($curl);
    $error = curl_error($curl);
    $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    curl_close($curl);
    if ($too_large) return ['ok' => false, 'status' => $status, 'body' => $body, 'error' => '接口响应超过大小限制'];
    if ($ok === false || $status < 200 || $status >= 300) return ['ok' => false, 'status' => $status, 'body' => $body, 'error' => $error !== '' ? $error : '接口返回 HTTP ' . ($status ?: '错误')];
    return ['ok' => true, 'status' => $status, 'body' => $body, 'error' => ''];
}

function ip_history_location_response_data(string $body): ?array
{
    try {
        $data = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
    } catch (Throwable) {
        return null;
    }
    if (!is_array($data) || (array_key_exists('ret', $data) && (int)$data['ret'] !== 200)) return null;
    return $data;
}

function ip_history_location_value(array $data, string $path): string
{
    $value = $data;
    foreach (explode('.', $path) as $part) {
        if (!is_array($value) || !array_key_exists($part, $value)) return '';
        $value = $value[$part];
    }
    return is_scalar($value) ? trim((string)$value) : '';
}

function ip_history_location_format(array $data, string $format): string
{
    $formatted = preg_replace_callback('/\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}/', static fn(array $match): string => ip_history_location_value($data, (string)$match[1]), $format);
    return trim(is_string($formatted) ? $formatted : '');
}

function ip_history_location_post_key(array $row): string
{
    return (array_key_exists('topic_id', $row) ? 'reply' : 'topic') . ':' . (int)($row['id'] ?? 0);
}

function ip_history_location_after_view($value, array $ctx): void
{
    $topic = is_array($ctx['topic'] ?? null) ? $ctx['topic'] : [];
    $GLOBALS['__ip_history_location_page_records'] = [];
    if (!ip_history_location_config()['show_location']) return;
    $rows = array_merge([$topic], (array)($ctx['replies'] ?? []));
    $ips = [];
    foreach ($rows as $row) {
        if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) continue;
        $ip = (string)($row['plugin_ip_history_ip'] ?? '');
        if ($ip !== '') $ips[$ip] = true;
    }
    $labels = [];
    $ips = array_keys($ips);
    if ($ips) {
        $marks = sql_marks(count($ips));
        foreach (q("SELECT ip,ip_address FROM plugin_ip_history_addresses WHERE ip IN ($marks)", $ips)->fetchAll() as $address) {
            $labels[(string)$address['ip']] = (string)$address['ip_address'];
        }
    }
    foreach ($rows as $row) {
        if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) continue;
        $ip = (string)($row['plugin_ip_history_ip'] ?? '');
        $label = $labels[$ip] ?? '';
        if ($ip === '' || $label === '' || $label === IP_HISTORY_LOCATION_SKIPPED) continue;
        $GLOBALS['__ip_history_location_page_records'][ip_history_location_post_key($row)] = ['ip' => $ip, 'label' => $label];
    }
}

function ip_history_location_after_render($html, array $ctx): string
{
    $html = is_string($html) ? $html : '';
    if ($html === '' || !empty($ctx['list'])) return $html;
    $row = is_array($ctx['row'] ?? null) ? $ctx['row'] : [];
    $record = $GLOBALS['__ip_history_location_page_records'][ip_history_location_post_key($row)] ?? null;
    if (!is_array($record) || str_contains($html, 'ip-history-location-subtitle')) return $html;
    $label = h((string)$record['label']);
    $updated = preg_replace_callback('~<div class="post-meta"><span>.*?</span></div>~s', static function (array $match) use ($label): string {
        $meta = str_replace('<div class="post-meta">', '<div class="post-meta ip-history-location-meta">', $match[0]);
        return str_replace('</span></div>', '</span><span class="ip-history-location-separator" aria-hidden="true">·</span><span class="ip-history-location-subtitle">' . $label . '</span></div>', $meta);
    }, $html, 1);
    return is_string($updated) ? $updated : $html;
}

function ip_history_sidebar_recent_card(): string
{
    $rows = q('SELECT ip,last_seen_at FROM plugin_ip_history_users WHERE user_id=? ORDER BY last_seen_at DESC,ip DESC LIMIT 5', [uid()])->fetchAll();
    $items = '';
    foreach ($rows as $row) {
        $items .= '<li class="ip-history-recent-item"><span class="ip-history-recent-ip">' . h((string)$row['ip']) . '</span><span class="ip-history-recent-time">' . h(human_time((int)$row['last_seen_at'])) . '</span></li>';
    }
    $content = $items !== '' ? '<ul class="ip-history-recent-list">' . $items . '</ul>' : '<p class="ip-history-recent-empty">暂无 IP 记录</p>';
    return '<div class="card sidebar-card quick-card ip-history-recent-card"><div class="quick-wrap"><div class="quick-title">最近使用的 IP</div>' . $content . '</div></div>';
}

function ip_history_sidebar_card($parts, array $ctx): array
{
    $parts = is_array($parts) ? $parts : [];
    if (!uid() || !empty($ctx['is_admin']) || empty($ctx['is_home_first_page'])) return $parts;
    $parts[] = ip_history_sidebar_recent_card();
    return $parts;
}

function ip_history_location_pending_count(): int
{
    return (int)val("SELECT COUNT(*) FROM plugin_ip_history_addresses WHERE ip_address=''");
}

function ip_history_location_skip_ip(string $ip): void
{
    app_db_upsert('plugin_ip_history_addresses', ['ip' => $ip, 'ip_address' => IP_HISTORY_LOCATION_SKIPPED, 'updated_at' => now()], ['ip']);
}

function ip_history_location_is_skipped_response(array $response): bool
{
    if ((int)($response['status'] ?? 0) === 403) return true;
    try {
        $data = json_decode((string)($response['body'] ?? ''), true, 16, JSON_THROW_ON_ERROR);
    } catch (Throwable) {
        return false;
    }
    return is_array($data) && ((int)($data['code'] ?? 0) === 403 || (int)($data['ret'] ?? 0) === 403);
}

function ip_history_location_update_ip(string $ip, array $config): string
{
    $url = ip_history_location_api_url((string)$config['endpoint'], $ip);
    $response = $url !== '' ? ip_history_location_http_get($url, (int)$config['timeout_seconds']) : ['ok' => false, 'body' => '', 'error' => '接口地址无效'];
    if (ip_history_location_is_skipped_response($response)) {
        ip_history_location_skip_ip($ip);
        return 'skipped';
    }
    $data = $response['ok'] ? ip_history_location_response_data((string)$response['body']) : null;
    $label = $data ? cut(ip_history_location_format($data, (string)$config['format']), 255) : '';
    if ($label === '') return 'failed';
    app_db_upsert('plugin_ip_history_addresses', ['ip' => $ip, 'ip_address' => $label, 'updated_at' => now()], ['ip']);
    return 'success';
}

function ip_history_location_cron_interval(): int
{
    return ip_history_location_config()['cron_interval_minutes'] * 60;
}

function ip_history_location_cron_run(array $plugin, array $task): string
{
    $config = ip_history_location_config();
    $limit = (int)$config['batch_size'];
    $rows = q("SELECT ip FROM plugin_ip_history_addresses WHERE ip_address='' LIMIT ?", [$limit])->fetchAll();
    $ips = array_values(array_unique(array_filter(array_map(fn(array $row): string => ip_history_normalize_ip((string)($row['ip'] ?? '')), $rows))));
    $success = $skipped = $failed = 0;
    foreach ($ips as $ip) {
        $status = ip_history_location_update_ip($ip, $config);
        if ($status === 'success') $success++;
        elseif ($status === 'skipped') $skipped++;
        else $failed++;
        \app\optional\Cron::cron_lease_touch();
    }
    return '本次处理 ' . count($ips) . ' 个 IP，成功 ' . $success . ' 个，跳过 ' . $skipped . ' 个，失败 ' . $failed . ' 个，待处理 ' . ip_history_location_pending_count() . ' 条';
}

function ip_history_location_save(): never
{
    need_admin();
    require_post();
    $endpoint = ip_history_location_clean_endpoint(post('endpoint', 500));
    if ($endpoint === '') err('接口地址必须包含 {ip}，且是有效的 HTTP 或 HTTPS 地址，不能指向本机或内网地址');
    $format = cut(trim(post('format', 500)), 500);
    if ($format === '') err('显示格式不能为空');
    plugin_save_config('ip_history', [
        'endpoint' => $endpoint,
        'format' => $format,
        'show_location' => (string)($_POST['show_location'] ?? '') === '1' ? 1 : 0,
        'cron_interval_minutes' => min(1440, max(1, (int)($_POST['cron_interval_minutes'] ?? 10))),
        'batch_size' => min(100, max(1, (int)($_POST['batch_size'] ?? 20))),
        'timeout_seconds' => min(30, max(2, (int)($_POST['timeout_seconds'] ?? 8))),
    ]);
    set_flash('IP 归属地设置已保存');
    go(admin_url(['tab' => 'ip_history']));
}

function ip_history_location_settings_html(): string
{
    $config = ip_history_location_config();
    $fields = input('接口地址', 'endpoint', $config['endpoint'], 'text', true, '地址中使用 {ip} 表示待查询的 IP，例如 https://api.ip.sb/geoip/{ip}。');
    $fields .= input('显示格式', 'format', $config['format'], 'text', true, '支持变量，例如 {region}。');
    $fields .= checkbox('查看页面显示 IP 归属地', 'show_location', $config['show_location'], '关闭后不在主题和回帖下方显示归属地。');
    $fields .= number_input('计划任务间隔（分钟）', 'cron_interval_minutes', $config['cron_interval_minutes'], 1, 1440, true);
    $fields .= number_input('每次解析 IP 数量', 'batch_size', $config['batch_size'], 1, 100, true);
    $fields .= number_input('接口超时（秒）', 'timeout_seconds', $config['timeout_seconds'], 2, 30, true);
    $form = '<form class="plugin-settings-form ip-history-location-settings" method="post" action="' . h(admin_url(['tab' => 'ip_history'])) . '">' . form_token() . hidden_inputs(['ip_history_location_save' => '1']) . $fields . '<button type="submit">保存归属地设置</button></form>';
    return '<div class="admin-list-panel plugin-manage-panel">' . admin_list_head('<div class="admin-plugin-summary"><strong>IP 归属地</strong><span>发帖和回帖只记录 IP，归属地由计划任务异步更新；当前待处理 ' . ip_history_location_pending_count() . ' 条。</span></div>', '') . '<div class="plugin-panel-body">' . $form . '</div></div>';
}

function ip_history_location_css(): string
{
    return '.post-meta.ip-history-location-meta{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.ip-history-location-separator{color:var(--text-subtle);font-size:11px}.ip-history-location-subtitle{display:inline-flex;min-width:0;max-width:100%;overflow:hidden;color:var(--text-subtle);font-size:11px;font-weight:400;line-height:1.25;overflow-wrap:anywhere;text-overflow:ellipsis}.topic-post-list .post-meta.ip-history-location-meta{display:flex}.ip-history-recent-card .quick-wrap{display:grid;gap:7px}.ip-history-recent-list{display:grid;gap:0;margin:0;padding:0;list-style:none}.ip-history-recent-item{display:flex;min-width:0;align-items:center;justify-content:space-between;gap:10px;padding:6px 0}.ip-history-recent-ip{min-width:0;overflow:hidden;color:var(--text);font-family:var(--font-mono);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.ip-history-recent-time{flex:0 0 auto;color:var(--text-muted);font-size:11px}.ip-history-recent-empty{margin:0;color:var(--text-muted);font-size:12px}.ip-history-location-settings{max-width:none}';
}


return [
    'id' => 'ip_history',
    'name' => 'IP 历史记录',
    'version' => '1.4.0',
    'description' => '记录用户发言 IP，异步显示 IP 归属地，帮助管理员查找关联账号并进行管理。',
    'author' => 'bbs1org',
    'assets' => ['css' => 'ip_history_css'],
    'hooks' => [
        'topic.after_save' => 'ip_history_topic_after_save',
        'reply.after_save' => 'ip_history_reply_after_save',
        'topic.after_view' => 'ip_history_location_after_view',
        'topic.after_render' => 'ip_history_location_after_render',
        'reply.after_render' => 'ip_history_location_after_render',
        'sidebar.stack' => 'ip_history_sidebar_card',
        'user.can_speak' => 'ip_history_can_speak',
    ],
    'cron' => ['resolve_location' => ['callback' => 'ip_history_location_cron_run', 'interval' => 'ip_history_location_cron_interval']],
    'admin_tabs' => [
        'ip_history' => 'ip_history_admin_page',
    ],
    'install' => 'ip_history_install',
    'uninstall' => 'ip_history_uninstall',
];