<?php
/**
 * 二次风控（byApiRisk）判定过程解析：结合 reason、配置阈值与 fp_hdata 字段标注依据。
 */
class FpRiskExplain
{
    /** @var bool */
    private static $analyzerLoaded = false;

    /**
     * @param string|array|null $hdataRaw JSON 或已解码数组
     * @param string            $reason   visitor_logs.reason（API 返回）
     * @param string            $result   visitor_logs.result true/false/wait
     * @param string            $domain   用于读取站点 CLOAK_RISK_NUMBER
     * @return array{summary:string,html:string,data:array}
     */
    public static function build($hdataRaw, $reason, $result, $domain = '')
    {
        $hdata  = self::decodeHdata($hdataRaw);
        $reason = trim((string) $reason);
        $result = strtolower(trim((string) $result));
        $cfg    = self::readRiskConfigForDomain($domain);

        $parsedReason = self::parseApiReason($reason);
        $local        = self::runLocalAnalyzer($hdata);

        $localLevel   = isset($local['risk_level']) ? (int) $local['risk_level'] : null;
        $apiLevel     = $parsedReason['risk_level'];
        $riskLevel    = $localLevel !== null ? $localLevel : $apiLevel;
        $factors      = !empty($local['risk_factors']) ? $local['risk_factors'] : $parsedReason['factors'];
        $riskNumber   = (int) $cfg['risk_number'];
        $configEnhanced = strtoupper($cfg['risk_enhanced']) === 'ON';
        $hdataEnhanced  = self::isEnhancedHdata($hdata);
        $enhanced       = $hdataEnhanced || $configEnhanced;

        if ($riskNumber <= 0) {
            $verdictText = '二次风控已关闭（风险系数=0）';
            $compareText = '未与 API 阈值比较';
            $expectedBlocked = null;
        } else {
            $expectedBlocked = ($riskLevel !== null && $riskLevel >= $riskNumber);
            $verdictText     = $expectedBlocked ? '应拦截 → 安全页' : '应放行 → 真实页';
            $compareText     = ($riskLevel !== null)
                ? sprintf('%d %s %d → %s', $riskLevel, $expectedBlocked ? '>=' : '<', $riskNumber, $expectedBlocked ? '拦截' : '放行')
                : '未能解析风险分';
        }

        $actualPass = ($result === 'true' || $result === '1');
        $actualText = $result === 'wait' || $result === '检测中'
            ? '检测中'
            : ($actualPass ? '实际：放行(TRUE)' : '实际：拦截(FALSE)');

        $factorRows = self::buildFactorRows($factors, $hdata);
        $fieldMap   = self::collectHighlightedFields($factorRows);

        $data = [
            'risk_level'        => $riskLevel,
            'local_risk_level'  => $localLevel,
            'api_risk_level'    => $apiLevel,
            'risk_number'       => $riskNumber,
            'recommended_risk'  => self::getRecommendedRiskNumber($configEnhanced),
            'risk_enhanced'     => $enhanced,
            'config_enhanced'   => $configEnhanced,
            'hdata_enhanced'    => $hdataEnhanced,
            'config_name'       => $cfg['config_name'],
            'verdict_text'      => $verdictText,
            'compare_text'      => $compareText,
            'expected_blocked'  => $expectedBlocked,
            'actual_result'     => $result,
            'actual_pass'       => $actualPass,
            'actual_text'       => $actualText,
            'api_reason'        => $reason,
            'parsed_reason'     => $parsedReason,
            'local_analysis'    => $local,
            'factors'           => $factorRows,
            'highlight_fields'  => array_keys($fieldMap),
            'field_values'      => $fieldMap,
            'config_edit_url'   => 'dashboard.php?action=edit_config&name=' . rawurlencode($cfg['config_name']),
        ];

        $data['conclusions'] = self::buildAnalysisConclusions($data, $hdata);

        $summary = self::buildSummary($data);
        $html    = self::buildHtml($data, $hdata);

        return [
            'summary' => $summary,
            'html'    => $html,
            'data'    => $data,
        ];
    }

    private static function isEnhancedHdata(array $hdata): bool
    {
        if (!empty($hdata['risk_enhanced'])) {
            return true;
        }
        return !empty($hdata['canvas']) && is_array($hdata['canvas'])
            && !empty($hdata['webgl']) && is_array($hdata['webgl']);
    }

    private static function getRecommendedRiskNumber(bool $configEnhanced): int
    {
        return $configEnhanced ? 52 : 68;
    }

    private static function decodeHdata($raw)
    {
        if (is_array($raw)) {
            return $raw;
        }
        $raw = trim((string) $raw);
        if ($raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }

    private static function loadAnalyzer()
    {
        if (!self::$analyzerLoaded) {
            require_once __DIR__ . '/LogRiskAnalyzer.php';
            self::$analyzerLoaded = true;
        }
    }

    private static function runLocalAnalyzer(array $hdata)
    {
        if ($hdata === []) {
            return [];
        }
        self::loadAnalyzer();
        try {
            $analyzer = new LogRiskAnalyzer($hdata);
            return $analyzer->analyze();
        } catch (Throwable $e) {
            return ['error' => $e->getMessage()];
        }
    }

    /**
     * 从 config 文件读取阈值，避免 datalist 循环 include 污染常量。
     */
    public static function readRiskConfigForDomain($domain)
    {
        $baseDir = dirname(__DIR__, 2);
        $host    = parse_url((string) $domain, PHP_URL_HOST);
        if ($host === null || $host === '') {
            $host = preg_replace('#^https?://#i', '', (string) $domain);
            $host = explode('/', $host)[0];
        }

        if (!class_exists('DomainRepository', false)) {
            require_once __DIR__ . '/DomainRepository.php';
        }
        $configName = DomainRepository::resolveConfigNameForHost((string) $host, $baseDir);
        $file       = $baseDir . '/check_config/' . $configName . '_config.php';
        if (!is_file($file)) {
            $configName = 'index';
            $file       = $baseDir . '/check_config/index_config.php';
        }

        $riskNumber  = 68;
        $riskEnhanced = 'OFF';
        if (is_readable($file)) {
            $content = file_get_contents($file);
            if (preg_match("/define\s*\(\s*'CLOAK_RISK_NUMBER'\s*,\s*'(\d+)'\s*\)/", $content, $m)) {
                $riskNumber = (int) $m[1];
            }
            if (preg_match("/define\s*\(\s*'CLOAK_RISK_ENHANCED'\s*,\s*'([A-Z]+)'\s*\)/", $content, $m)) {
                $riskEnhanced = strtoupper($m[1]);
            }
        }

        return [
            'config_name'   => $configName,
            'risk_number'   => $riskNumber,
            'risk_enhanced' => $riskEnhanced,
        ];
    }

    /**
     * 解析 API reason：常见为「风险分 + 因子列表」。
     */
    public static function parseApiReason($reason)
    {
        $reason = trim((string) $reason);
        $out    = ['risk_level' => null, 'factors' => [], 'raw' => $reason];

        if ($reason === '') {
            return $out;
        }

        if (preg_match('/^\d+$/', $reason)) {
            $out['risk_level'] = (int) $reason;
            return $out;
        }

        if (preg_match('/风险(?:系数|值|分)?\s*[：:]\s*(\d+)/u', $reason, $m)) {
            $out['risk_level'] = (int) $m[1];
        } elseif (preg_match('/^(\d+)\s*[:：|\/\-—]\s*(.*)$/su', $reason, $m)) {
            $out['risk_level'] = (int) $m[1];
            $reason            = trim($m[2]);
        } elseif (preg_match('/\b(\d{1,3})\s*\/\s*(\d{1,3})\b/', $reason, $m)) {
            $out['risk_level'] = (int) $m[1];
        }

        $factorText = $reason;
        if (preg_match('/(?:理由|因素|规则)[：:]\s*(.+)$/su', $reason, $m)) {
            $factorText = trim($m[1]);
        }

        $parts = preg_split('/[；;|]+/u', $factorText) ?: [];
        foreach ($parts as $part) {
            $part = trim($part);
            if ($part === '' || preg_match('/^\d+$/', $part)) {
                continue;
            }
            $out['factors'][] = $part;
        }

        return $out;
    }

    private static function buildFactorRows(array $factors, array $hdata)
    {
        $rows = [];
        foreach ($factors as $i => $text) {
            $text   = trim((string) $text);
            $mapped = self::mapFactorToFields($text);
            $values = [];
            foreach ($mapped['fields'] as $path) {
                $val = self::getNestedValue($hdata, $path);
                if ($val !== null) {
                    $values[$path] = $val;
                }
            }
            $rows[] = [
                'index'    => $i + 1,
                'text'     => $text,
                'category' => $mapped['category'],
                'fields'   => $mapped['fields'],
                'values'   => $values,
            ];
        }
        return $rows;
    }

    private static function collectHighlightedFields(array $factorRows)
    {
        $map = [];
        foreach ($factorRows as $row) {
            foreach ($row['values'] as $path => $val) {
                $map[$path] = self::formatValue($val);
            }
        }
        return $map;
    }

    private static function mapFactorToFields($text)
    {
        $rules = [
            ['cat' => 'webdriver',   're' => '/WebDriver|webdriver/i',                         'fields' => ['webdriver']],
            ['cat' => 'automation',  're' => '/Phantom|Selenium|window\.chrome|无头|自动化|爬虫|UA/i', 'fields' => ['userAgent', 'automation']],
            ['cat' => 'canvas',      're' => '/Canvas|canvas/i',                               'fields' => ['canvas']],
            ['cat' => 'webgl',       're' => '/WebGL|renderer|渲染|虚拟|SwiftShader|GPU/i',     'fields' => ['renderer', 'vendor', 'webgl', 'webGpuData', 'webGPU']],
            ['cat' => 'hardware',    're' => '/硬件|并发|内存|deviceMemory|hardware/i',         'fields' => ['hardwareConcurrency', 'deviceMemory']],
            ['cat' => 'touch',       're' => '/触摸|touch|仿真器|Emulator/i',                   'fields' => ['touchscreen', 'isEmulators', 'platform', 'browser.device.type']],
            ['cat' => 'screen',      're' => '/分辨率|像素比|视口|availScreen|screen/i',        'fields' => ['screen', 'availScreen', 'devicePixelRatio', 'scale', 'visualViewportOffset']],
            ['cat' => 'browser',     're' => '/WebRTC|WebGPU|WebGL/i',                         'fields' => ['experimental', 'webGPU', 'webGpuData', 'webgl']],
            ['cat' => 'geo',         're' => '/语言|时区|country|ciso/i',                       'fields' => ['languages', 'language', 'ciso', 'timezone', 'timezoneOffset']],
            ['cat' => 'hostname',    're' => '/DNS|爬虫|Google|Facebook|Tiktok/i',              'fields' => ['ip']],
            ['cat' => 'fonts',       're' => '/字体|Calibri|Segoe|San Francisco/i',             'fields' => ['fonts', 'browser.os.name']],
        ];

        foreach ($rules as $rule) {
            if (preg_match($rule['re'], $text)) {
                return ['category' => $rule['cat'], 'fields' => $rule['fields']];
            }
        }

        return ['category' => 'other', 'fields' => ['userAgent', 'platform', 'risk_enhanced']];
    }

    private static function getNestedValue(array $data, $path)
    {
        if (strpos($path, '.') === false) {
            return array_key_exists($path, $data) ? $data[$path] : null;
        }
        $cur = $data;
        foreach (explode('.', $path) as $seg) {
            if (!is_array($cur) || !array_key_exists($seg, $cur)) {
                return null;
            }
            $cur = $cur[$seg];
        }
        return $cur;
    }

    private static function formatValue($val)
    {
        if ($val === null) {
            return 'null';
        }
        if (is_bool($val)) {
            return $val ? 'true' : 'false';
        }
        if (is_array($val)) {
            $json = json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if ($json !== false && strlen($json) > 200) {
                return substr($json, 0, 200) . '…';
            }
            return $json ?: '[]';
        }
        $s = (string) $val;
        return strlen($s) > 200 ? substr($s, 0, 200) . '…' : $s;
    }

    /**
     * 根据 reason、hdata、配置阈值与本地复算，生成可操作的诊断结论。
     *
     * @return array<int,array{level:string,text:string,action:string}>
     */
    private static function buildAnalysisConclusions(array $data, array $hdata)
    {
        $lines      = [];
        $riskNumber = (int) $data['risk_number'];
        $riskLevel  = $data['risk_level'];
        $localLevel = $data['local_risk_level'];
        $apiLevel   = $data['api_risk_level'];
        $recommended = (int) $data['recommended_risk'];
        $configUrl  = (string) ($data['config_edit_url'] ?? 'dashboard.php');
        $configHint = '可在 <a href="' . htmlspecialchars($configUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank">配置中心</a> 调整风险系数与加强判断';

        if ($hdata === [] && trim($data['api_reason']) === '') {
            $lines[] = [
                'level'  => 'warn',
                'text'   => '本条无指纹 hdata 且无 API reason，二次风控可能未执行、未完成或尚未落库。',
                'action' => '若 result 为 wait，请检查 cloakjs / f_check 回调；若长期为空，检查 CLOAK_RISK_NUMBER 是否为 0。',
            ];
            return $lines;
        }

        if ($riskNumber <= 0) {
            if ($hdata !== [] || trim($data['api_reason']) !== '') {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => '配置中二次风控已关闭（风险系数=0），但本条仍出现指纹数据或 reason。',
                    'action' => 'result/reason 可能来自首次判定或其他规则，不代表 byApiRisk 生效。' . $configHint . '。',
                ];
            } else {
                $lines[] = [
                    'level'  => 'ok',
                    'text'   => '二次风控已关闭，不会调用 byApiRisk。',
                    'action' => $configHint . ' 开启并设置合适分数后可启用。',
                ];
            }
            return $lines;
        }

        if ($data['config_enhanced'] && !$data['hdata_enhanced']) {
            $lines[] = [
                'level'  => 'warn',
                'text'   => '加强判断已开启，但 hdata 缺少 Canvas/WebGL 等深度字段，API 只能按基础规则计分。',
                'action' => '常见于弱网、老旧浏览器或前端采集超时。可暂时关闭加强判断，或排查 cloakjs 加载与采集耗时。',
            ];
        } elseif (!$data['config_enhanced'] && $data['hdata_enhanced']) {
            $lines[] = [
                'level'  => 'ok',
                'text'   => '配置为基础模式，但 hdata 含完整指纹；本地复算会识别为加强数据。',
                'action' => '若需利用 Canvas/WebGL 规则，请在配置中开启加强判断并将风险系数设为 52。',
            ];
        }

        if ($riskNumber === 51 && !$data['config_enhanced']) {
            $lines[] = [
                'level'  => 'warn',
                'text'   => '当前风险系数为 51（旧版默认值），基础模式下易误拦正常移动端访客。',
                'action' => '建议改为 68。' . $configHint . '。',
            ];
        } elseif ($riskNumber !== $recommended) {
            $delta = $riskNumber - $recommended;
            if (!$data['config_enhanced'] && $riskNumber < 65) {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => sprintf('基础模式下风险系数 %d 偏低（建议 %d），拦截偏严，真实用户更易被误拦。', $riskNumber, $recommended),
                    'action' => '建议提高 3~5 分。' . $configHint . '。',
                ];
            } elseif (!$data['config_enhanced'] && $riskNumber > 75) {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => sprintf('基础模式下风险系数 %d 偏高（建议 %d），自动化/爬虫更易漏过。', $riskNumber, $recommended),
                    'action' => '建议降低 3~5 分。' . $configHint . '。',
                ];
            } elseif ($data['config_enhanced'] && $riskNumber < 48) {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => sprintf('加强模式下风险系数 %d 偏低（建议 %d），拦截会非常严格。', $riskNumber, $recommended),
                    'action' => '若误拦过多，可提高到 52 附近。' . $configHint . '。',
                ];
            } elseif ($data['config_enhanced'] && $riskNumber > 55) {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => sprintf('加强模式下风险系数 %d 偏高（建议 %d），高信号自动化可能仍被放行。', $riskNumber, $recommended),
                    'action' => '建议降低到 52 附近。' . $configHint . '。',
                ];
            } elseif (abs($delta) >= 3) {
                $lines[] = [
                    'level'  => 'ok',
                    'text'   => sprintf('当前风险系数 %d，与%s模式建议值 %d 相差 %d 分。', $riskNumber, $data['config_enhanced'] ? '加强' : '基础', $recommended, abs($delta)),
                    'action' => '若误拦或漏拦明显，可按 ±3~5 微调。' . $configHint . '。',
                ];
            }
        }

        if ($localLevel !== null && $apiLevel !== null && abs($localLevel - $apiLevel) > 3) {
            $lines[] = [
                'level'  => 'alert',
                'text'   => sprintf('API reason 解析风险分 %d，日志复算风险分 %d，两者不一致。', $apiLevel, $localLevel),
                'action' => '线上 byApiRisk 版本可能与 lib/Cloak/LogRiskAnalyzer.php 不同步，或 reason 格式有变，需核对 API 部署。',
            ];
        }

        if ($data['expected_blocked'] !== null && $riskLevel !== null) {
            $expectedPass = !$data['expected_blocked'];
            if ($expectedPass !== $data['actual_pass']) {
                $lines[] = [
                    'level'  => 'alert',
                    'text'   => sprintf(
                        '本地复算：风险 %d %s 阈值 %d → 应%s；但 API 实际返回 %s。',
                        $riskLevel,
                        $data['expected_blocked'] ? '>=' : '<',
                        $riskNumber,
                        $expectedPass ? '放行' : '拦截',
                        $data['actual_pass'] ? 'TRUE(放行)' : 'FALSE(拦截)'
                    ),
                    'action' => '请确认 f_check 传给 API 的 risk_number 与配置一致，且线上 API 判定逻辑未单独覆盖阈值。',
                ];
            } else {
                $lines[] = [
                    'level'  => 'ok',
                    'text'   => sprintf('API 结果与本地复算一致：风险 %d，阈值 %d，结论为%s。', $riskLevel, $riskNumber, $data['actual_pass'] ? '放行' : '拦截'),
                    'action' => '',
                ];
            }

            $margin = abs($riskLevel - $riskNumber);
            if ($margin <= 5) {
                $lines[] = [
                    'level'  => 'warn',
                    'text'   => sprintf('本条为临界分值（距阈值仅 %d 分），小幅调整风险系数即可改变结果。', $margin),
                    'action' => $data['actual_pass']
                        ? '若认为应拦截，可将风险系数降低 3~5。'
                        : '若认为应放行，可将风险系数提高 3~5。',
                ];
            }
        } elseif ($riskLevel === null && trim($data['api_reason']) !== '') {
            $lines[] = [
                'level'  => 'warn',
                'text'   => 'API 返回了 reason，但未能解析出有效风险分。',
                'action' => '请检查 reason 格式是否变更；下方「API reason」与触发规则可帮助人工判断。',
            ];
        }

        if (!empty($data['factors']) && empty($data['parsed_reason']['factors']) && $localLevel === null) {
            $lines[] = [
                'level'  => 'warn',
                'text'   => 'reason 中未包含触发规则明细，仅有分数或简短文本。',
                'action' => '不利于排查误拦原因，建议在 API 侧返回 risk_factors 列表。',
            ];
        }

        if (!empty($hdata['webdriver']) && $data['actual_pass']) {
            $lines[] = [
                'level'  => 'alert',
                'text'   => 'hdata 显示 webdriver=true，但本条仍被放行。',
                'action' => sprintf('webdriver 单项约 55 分，当前阈值 %d 可能过高或未命中 API 规则。建议加强模式 + 阈值 52。', $riskNumber),
            ];
        }

        $strongHit = false;
        $weakOnly  = !empty($data['factors']);
        foreach ($data['factors'] as $row) {
            $cat = $row['category'] ?? 'other';
            if (in_array($cat, ['webdriver', 'automation', 'canvas', 'webgl'], true)) {
                $strongHit = true;
            }
            if (!in_array($cat, ['geo', 'hardware'], true)) {
                $weakOnly = false;
            }
        }
        if (!$data['actual_pass'] && $weakOnly && !empty($data['factors'])) {
            $lines[] = [
                'level'  => 'warn',
                'text'   => '本条被拦截，但触发信号以语言/时区/硬件等弱规则为主，无 webdriver/Canvas/WebGL 等强信号。',
                'action' => '若大量出现，可能阈值偏低或 IP 国家与浏览器环境不匹配，建议提高风险系数或检查误伤样本。',
            ];
        }
        if ($data['actual_pass'] && $strongHit && $riskLevel !== null && $riskLevel >= ($riskNumber - 8)) {
            $lines[] = [
                'level'  => 'warn',
                'text'   => '存在自动化/Canvas/WebGL 等强信号，风险分接近阈值但仍被放行。',
                'action' => '建议降低风险系数 3~5，或确认加强判断已开启。',
            ];
        }

        if ($lines === []) {
            $lines[] = [
                'level'  => 'ok',
                'text'   => '暂无异常，当前配置与 API 返回基本一致。',
                'action' => $configHint . '。',
            ];
        }

        return $lines;
    }

    private static function buildSummary(array $data)
    {
        if (empty($data['local_analysis']) && trim($data['api_reason']) === '') {
            return '';
        }
        $level = $data['risk_level'];
        $num   = $data['risk_number'];
        if ($num <= 0) {
            return '二次风控关闭';
        }
        if ($level === null) {
            return '风控 ' . $data['actual_result'];
        }
        $mode = $data['risk_enhanced'] ? '加强' : '基础';
        return sprintf('风险 %d/%d(%s) · %s', $level, $num, $mode, $data['actual_pass'] ? 'TRUE' : 'FALSE');
    }

    private static function buildHtml(array $data, array $hdata)
    {
        if ($hdata === [] && trim($data['api_reason']) === '') {
            return '';
        }

        $esc = static function ($s) {
            return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
        };

        $html  = '<div class="fp-risk-explain">';
        $html .= '<div class="fp-risk-explain-head">';
        $html .= '<strong>二次风控判定解析</strong>';
        $html .= '<span class="fp-risk-tag">' . $esc($data['actual_result'] === 'true' ? '放行 TRUE' : ($data['actual_result'] === 'false' ? '拦截 FALSE' : $data['actual_result'])) . '</span>';
        $html .= '</div>';

        if (!empty($data['conclusions'])) {
            $html .= '<div class="fp-risk-section-title">分析结论</div>';
            $html .= '<ul class="fp-risk-conclusions">';
            foreach ($data['conclusions'] as $item) {
                $cls = $esc($item['level'] ?? 'ok');
                $html .= '<li class="' . $cls . '">';
                $html .= $esc($item['text'] ?? '');
                if (!empty($item['action'])) {
                    $html .= '<span class="fp-conc-action">' . $item['action'] . '</span>';
                }
                $html .= '</li>';
            }
            $html .= '</ul>';
        }

        $html .= '<table class="fp-risk-kv"><tbody>';
        $html .= '<tr><th>配置</th><td>' . $esc($data['config_name']) . ' · 风险系数 <code>' . (int) $data['risk_number'] . '</code>'
            . '（建议 <code>' . (int) $data['recommended_risk'] . '</code>） · '
            . ($data['config_enhanced'] ? '加强判断 ON' : '加强判断 OFF')
            . ($data['hdata_enhanced'] ? ' · hdata 含深度指纹' : '')
            . '</td></tr>';
        $html .= '<tr><th>风险分</th><td>本地复算 <code>' . ($data['local_risk_level'] !== null ? (int) $data['local_risk_level'] : '—') . '</code>'
            . ' · API reason <code>' . ($data['api_risk_level'] !== null ? (int) $data['api_risk_level'] : '—') . '</code></td></tr>';
        $html .= '<tr><th>比较</th><td>' . $esc($data['compare_text']) . ' → ' . $esc($data['verdict_text']) . ' · ' . $esc($data['actual_text'] ?? '') . '</td></tr>';
        $html .= '<tr><th>API reason</th><td class="fp-risk-reason-raw">' . $esc($data['api_reason'] ?: '—') . '</td></tr>';
        if (!empty($data['local_analysis']['risk_category'])) {
            $html .= '<tr><th>风险档</th><td>' . $esc($data['local_analysis']['risk_category'] . ' — ' . ($data['local_analysis']['risk_description'] ?? '')) . '</td></tr>';
        }
        $html .= '</tbody></table>';

        if (!empty($data['factors'])) {
            $html .= '<div class="fp-risk-section-title">触发规则与依据字段</div><ol class="fp-risk-factor-list">';
            foreach ($data['factors'] as $row) {
                $html .= '<li><div class="fp-factor-text">' . $esc($row['text']) . '</div>';
                if (!empty($row['values'])) {
                    $html .= '<ul class="fp-factor-fields">';
                    foreach ($row['values'] as $path => $val) {
                        $html .= '<li><mark class="fp-field-key">' . $esc($path) . '</mark> = <code>' . $esc(self::formatValue($val)) . '</code></li>';
                    }
                    $html .= '</ul>';
                } else {
                    $html .= '<div class="fp-factor-miss text-muted">（未在 hdata 中找到对应字段值，可能为基础模式未采集）</div>';
                }
                $html .= '</li>';
            }
            $html .= '</ol>';
        }

        $html .= '<div class="fp-risk-section-title">完整 hdata（依据字段已高亮）</div>';
        $html .= '<pre class="fp-hdata-json">' . self::renderHighlightedHdata($hdata, $data['highlight_fields']) . '</pre>';
        $html .= '</div>';

        return $html;
    }

    private static function renderHighlightedHdata(array $hdata, array $highlightFields)
    {
        $lines   = [];
        $json    = json_encode($hdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            return '';
        }
        $hlKeys = array_flip($highlightFields);
        foreach (explode("\n", $json) as $line) {
            $escaped = htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
            if (preg_match('/^\s*"([^"]+)"\s*:/', $line, $m)) {
                $key = $m[1];
                if (isset($hlKeys[$key])) {
                    $escaped = '<span class="fp-hl-line">' . $escaped . '</span>';
                }
            }
            $lines[] = $escaped;
        }
        return implode("\n", $lines);
    }
}
