#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Security Assessment Report JSON -> HTML 报告转换脚本
用法: python generate_html.py <report.json> [output.html]
"""

import json
import sys
import os
from datetime import datetime

SEVERITY_MAP = {
    "critical": {"label": "严重", "cls": "badge-critical", "color": "#C0392B"},
    "high":     {"label": "高危", "cls": "badge-high",     "color": "#E67E22"},
    "medium":   {"label": "中危", "cls": "badge-medium",   "color": "#F1C40F"},
    "low":      {"label": "低危", "cls": "badge-low",      "color": "#2ECC71"},
    "info":     {"label": "信息", "cls": "badge-info",     "color": "#3498DB"},
}


def escape_html(s):
    return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def format_http_block(lines, first_cls=""):
    """将多行文本转为 HTML，每行独立 div 保证换行，首行可加特殊样式"""
    parts = []
    for i, line in enumerate(lines):
        cls = "http-line" if line else "http-line http-line-blank"
        if i == 0 and first_cls:
            cls += " " + first_cls
        parts.append(f'<div class="{cls}">{escape_html(line)}</div>')
    return "".join(parts)


def build_http_interactions(interactions):
    parts = []
    total = len(interactions)
    for hi in interactions:
        req = hi.get("request", {})
        res = hi.get("response", {})

        # Build raw HTTP request packet
        req_lines = []
        req_lines.append(f'{req.get("method", "")} {req.get("url", "")}')
        req_headers = req.get("headers") or {}
        for k, val in req_headers.items():
            req_lines.append(f'{k}: {val}')
        req_body = req.get("body")
        if req_body:
            req_lines.append("")  # blank line between headers and body
            for bline in str(req_body).split("\n"):
                req_lines.append(bline)
        req_html = format_http_block(req_lines, first_cls="http-first-line")

        # Build raw HTTP response packet
        res_lines = []
        status_code = res.get("status_code", 0)
        status_cls = "http-status-line-ok" if status_code < 400 else "http-status-line-err"
        res_lines.append(f'HTTP/1.1 {status_code}')
        res_headers = res.get("headers") or {}
        for k, val in res_headers.items():
            res_lines.append(f'{k}: {val}')
        res_body = res.get("body")
        if res_body:
            res_lines.append("")  # blank line between headers and body
            for bline in str(res_body).split("\n"):
                res_lines.append(bline)
        res_html = format_http_block(res_lines, first_cls=status_cls)

        label = escape_html(hi.get('label', ''))
        seq_label = f'步骤 {hi["seq"]} · {label}' if total > 1 else label

        parts.append(f"""
      <div class="http-interaction">
        <div class="http-seq-label">{seq_label}</div>
        <div class="http-pair">
          <div class="http-block">
            <div class="http-block-label">REQUEST</div>
            {req_html}
          </div>
          <div class="http-block">
            <div class="http-block-label">RESPONSE</div>
            {res_html}
          </div>
        </div>
      </div>""")
    return "".join(parts)


def build_suggestions(text):
    import re
    items = re.split(r'[；\n]\s*', text or "")
    items = [s.strip() for s in items if s.strip()]
    parts = []
    for s in items:
        clean = re.sub(r'^\d+\.\s*', '', s)
        parts.append(f"<li>{escape_html(clean)}</li>")
    return "<ol>" + "".join(parts) + "</ol>"


def convert(data):
    meta = data.get("report_meta", {})
    summary = data.get("summary", {})
    vulns = data.get("vulnerabilities", [])
    scope = meta.get("scope", {})

    report_id = meta.get("report_id", "UNKNOWN")
    generated_at = meta.get("generated_at", "")
    tester = meta.get("tester", "")
    target_url = scope.get("target_url", "")
    tech_stack = scope.get("tech_stack", [])

    # Format date
    if generated_at:
        try:
            dt = datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
            date_str = dt.strftime("%Y-%m-%d")
            time_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
        except Exception:
            date_str = generated_at[:10]
            time_str = generated_at
    else:
        date_str = time_str = "N/A"

    # Tech stack display
    tech_display = " / ".join(
        t.replace("_", " ").title() for t in tech_stack
    ) if tech_stack else "N/A"

    # Subtitle: first 3 tech items
    subtitle_items = tech_stack[:3] if tech_stack else []
    subtitle = target_url
    if subtitle_items:
        subtitle = target_url + " · " + " / ".join(
            s.replace("_", " ").title() for s in subtitle_items
        )

    # Build tech tags
    tech_tags_html = "".join(
        f'<span class="tech-tag">{escape_html(t.replace("_", " ").title())}</span>'
        for t in tech_stack
    )

    # Build vulnerability rows (each summary row immediately followed by its detail row)
    vuln_rows = ""
    for i, v in enumerate(vulns):
        sev = SEVERITY_MAP.get(v.get("severity", "info"), {"label": v.get("severity", ""), "cls": ""})
        detail_id = f"detail-{i}"

        http_html = build_http_interactions(v.get("http_interactions", []))
        sug_html = build_suggestions(v.get("RepairSuggestions", ""))

        target_short = v.get("target_url", "").replace("http://192.168.0.119", "").replace("https://192.168.0.119", "")

        # Summary row
        vuln_rows += f"""
      <tr class="vuln-summary-row" data-severity="{v.get('severity', 'info')}" onclick="toggleDetail('{detail_id}', this)">
        <td class="vuln-id">{escape_html(v.get('vuln_id', ''))}</td>
        <td class="vuln-title">{escape_html(v.get('title', ''))}</td>
        <td><span class="severity-badge {sev['cls']}">{sev['label']}</span></td>
        <td><span style="font-size:12px;color:var(--text-muted)">{escape_html(v.get('type_zh', ''))}</span></td>
        <td><span class="conf-badge">{escape_html(v.get('confidence', ''))}</span></td>
        <td><div class="vuln-target" title="{escape_html(v.get('target_url', ''))}">{escape_html(target_short)}</div></td>
        <td><span class="toggle-arrow" id="arrow-{i}">▼</span></td>
      </tr>"""

        # Detail row — immediately after its summary row
        vuln_rows += f"""
      <tr class="vuln-detail-row" data-severity="{v.get('severity', 'info')}">
        <td colspan="7">
          <div class="detail-inner" id="{detail_id}">
            <div class="detail-block-title">漏洞描述</div>
            <div class="detail-desc" style="margin-bottom:18px">{escape_html(v.get('description', ''))}</div>
            <div class="http-interactions">
              <div class="detail-block-title">HTTP 交互记录</div>
              {http_html}
            </div>
            <div style="margin-top:20px">
              <div class="detail-block-title">修复建议</div>
              <div class="detail-suggestions">{sug_html}</div>
            </div>
          </div>
        </td>
      </tr>"""

    # Severity bar data
    bar_data = [
        summary.get("critical", 0),
        summary.get("high", 0),
        summary.get("medium", 0),
        summary.get("low", 0),
        summary.get("info", 0),
    ]

    # Type counts for donut
    type_counts = {}
    for v in vulns:
        tz = v.get("type_zh", "未知")
        type_counts[tz] = type_counts.get(tz, 0) + 1
    
    # Sort by count descending
    sorted_types = sorted(type_counts.items(), key=lambda x: x[1], reverse=True)
    type_labels = json.dumps([t[0] for t in sorted_types])
    type_values = json.dumps([t[1] for t in sorted_types])
    
    # Generate diverse color palette
    DONUT_COLORS = [
        '#E74C3C', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6',
        '#1ABC9C', '#E67E22', '#34495E', '#16A085', '#C0392B',
        '#2980B9', '#27AE60', '#D35400', '#8E44AD', '#F1C40F',
        '#7F8C8D', '#2C3E50', '#E84393', '#00B894', '#6C5CE7',
    ]
    type_colors = json.dumps([DONUT_COLORS[i % len(DONUT_COLORS)] for i in range(len(sorted_types))])
    
    # Calculate total for percentages
    total_vulns = sum(type_counts.values())
    # Build percentage labels for top 5
    pct_labels = []
    for i, (label, count) in enumerate(sorted_types[:5]):
        pct = round(count / total_vulns * 100, 1) if total_vulns > 0 else 0
        pct_labels.append(f"{label}: {pct}%")
    top5_pct_str = json.dumps(pct_labels)
    top5_values = json.dumps([sorted_types[i][1] for i in range(min(5, len(sorted_types)))])

    # Risk banner
    risk_text = f"目标 · {escape_html(target_url)} &nbsp;|&nbsp; 技术栈 · {escape_html(tech_display)}"

    html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全渗透测试报告 · {escape_html(report_id)}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js"></script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');

  :root {{
    --navy:     #1B3A5C;
    --navy-dark:#122A45;
    --navy-mid: #234972;
    --teal:     #3BBFBF;
    --teal-light:#5DD3D3;
    --teal-pale: #E6F7F7;
    --white:    #FFFFFF;
    --offwhite: #F4F7FA;
    --grey-100: #EEF2F6;
    --grey-200: #D8E2EC;
    --grey-500: #7A9AB5;
    --grey-700: #3D5A73;
    --text:     #1A2D3F;
    --text-muted:#5C7A92;
    --critical: #C0392B;
    --high:     #E67E22;
    --medium:   #F1C40F;
    --low:      #2ECC71;
    --info:     #3498DB;
    --critical-bg: #FDECEA;
    --high-bg:    #FEF3E8;
    --medium-bg:  #FEFCE8;
    --low-bg:     #EAFAF1;
    --info-bg:    #EBF5FB;
  }}

  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{
    font-family: 'Noto Sans SC', sans-serif;
    background: var(--offwhite);
    color: var(--text);
    font-size: 14px;
    line-height: 1.7;
  }}

  .header {{
    background: linear-gradient(135deg, var(--navy-dark) 0%, var(--navy) 60%, var(--navy-mid) 100%);
    padding: 0; position: relative; overflow: hidden;
  }}
  .header::before {{
    content: ''; position: absolute; right: -80px; top: -80px;
    width: 320px; height: 320px; border-radius: 50%;
    background: radial-gradient(circle, rgba(59,191,191,0.12) 0%, transparent 70%);
  }}
  .header::after {{
    content: ''; position: absolute; left: 40%; bottom: -60px;
    width: 200px; height: 200px; border-radius: 50%;
    background: radial-gradient(circle, rgba(59,191,191,0.07) 0%, transparent 70%);
  }}
  .header-inner {{
    max-width: 1100px; margin: 0 auto; padding: 40px 48px 36px;
    display: flex; align-items: center; justify-content: space-between;
    position: relative; z-index: 1;
  }}
  .header-left {{ display: flex; align-items: center; gap: 28px; }}
  .logo-wrap img {{ height: 56px; filter: brightness(0) invert(1) opacity(0.95); }}
  .header-title-block {{ border-left: 2px solid rgba(59,191,191,0.5); padding-left: 24px; }}
  .header-eyebrow {{
    font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
    color: var(--teal-light); font-weight: 500; margin-bottom: 4px;
  }}
  .header-title {{ font-size: 22px; font-weight: 700; color: var(--white); line-height: 1.2; }}
  .header-subtitle {{
    font-size: 12px; color: rgba(255,255,255,0.55); margin-top: 2px;
    font-family: 'IBM Plex Mono', monospace;
  }}
  .header-meta {{ text-align: right; }}
  .header-badge {{
    display: inline-block; background: rgba(59,191,191,0.15);
    border: 1px solid rgba(59,191,191,0.4); border-radius: 4px;
    padding: 4px 12px; font-family: 'IBM Plex Mono', monospace;
    font-size: 11px; color: var(--teal-light); letter-spacing: 0.08em; margin-bottom: 10px;
  }}
  .header-info-row {{ font-size: 11px; color: rgba(255,255,255,0.5); line-height: 1.8; }}
  .header-info-row span {{ color: rgba(255,255,255,0.8); }}

  .risk-banner {{
    background: var(--navy-dark); border-top: 1px solid rgba(59,191,191,0.2); padding: 10px 48px;
  }}
  .risk-banner-inner {{
    max-width: 1100px; margin: 0 auto; display: flex; align-items: center; gap: 6px;
    font-size: 11px; color: rgba(255,255,255,0.45); font-family: 'IBM Plex Mono', monospace;
  }}
  .risk-banner-inner::before {{ content: '\\25B6'; color: var(--teal); font-size: 8px; }}

  .page-body {{ max-width: 1100px; margin: 0 auto; padding: 36px 48px 60px; }}

  .section-title {{
    font-size: 11px; font-weight: 700; letter-spacing: 0.16em;
    text-transform: uppercase; color: var(--teal); margin-bottom: 16px;
    display: flex; align-items: center; gap: 10px;
  }}
  .section-title::after {{ content: ''; flex: 1; height: 1px; background: var(--grey-200); }}

  .summary-grid {{ display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 32px; }}
  .stat-card {{
    background: var(--white); border-radius: 8px; padding: 18px 16px 14px;
    border: 1px solid var(--grey-200); text-align: center; position: relative;
    overflow: hidden; transition: transform 0.18s, box-shadow 0.18s;
  }}
  .stat-card:hover {{ transform: translateY(-2px); box-shadow: 0 6px 20px rgba(27,58,92,0.1); }}
  .stat-card::before {{ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }}
  .stat-card.total::before    {{ background: var(--navy); }}
  .stat-card.critical::before {{ background: var(--critical); }}
  .stat-card.high::before     {{ background: var(--high); }}
  .stat-card.medium::before   {{ background: var(--medium); }}
  .stat-card.low::before      {{ background: var(--low); }}
  .stat-card.info::before     {{ background: var(--info); }}
  .stat-number {{
    font-size: 36px; font-weight: 700; line-height: 1; margin-bottom: 6px;
    font-family: 'IBM Plex Mono', monospace;
  }}
  .stat-card.total    .stat-number {{ color: var(--navy); }}
  .stat-card.critical .stat-number {{ color: var(--critical); }}
  .stat-card.high     .stat-number {{ color: var(--high); }}
  .stat-card.medium   .stat-number {{ color: var(--medium); }}
  .stat-card.low      .stat-number {{ color: var(--low); }}
  .stat-card.info     .stat-number {{ color: var(--info); }}
  .stat-label {{
    font-size: 11px; font-weight: 500; letter-spacing: 0.06em;
    color: var(--text-muted); text-transform: uppercase;
  }}

  .charts-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 32px; }}
  .chart-card {{ background: var(--white); border: 1px solid var(--grey-200); border-radius: 8px; padding: 24px; }}
  .chart-card-title {{
    font-size: 12px; font-weight: 700; letter-spacing: 0.1em;
    text-transform: uppercase; color: var(--grey-700); margin-bottom: 18px;
  }}
  .chart-wrap {{ position: relative; height: 200px; }}

  .scope-card {{
    background: var(--white); border: 1px solid var(--grey-200); border-radius: 8px;
    padding: 20px 24px; margin-bottom: 32px; display: flex; gap: 40px; flex-wrap: wrap;
  }}
  .scope-item {{ display: flex; flex-direction: column; gap: 4px; }}
  .scope-key {{
    font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--text-muted); font-weight: 600;
  }}
  .scope-val {{
    font-family: 'IBM Plex Mono', monospace; font-size: 13px;
    color: var(--text); font-weight: 500;
  }}
  .tech-tags {{ display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }}
  .tech-tag {{
    background: var(--teal-pale); border: 1px solid rgba(59,191,191,0.3);
    color: var(--navy-mid); font-size: 11px; font-family: 'IBM Plex Mono', monospace;
    padding: 2px 8px; border-radius: 3px;
  }}

  .vuln-table-wrap {{ margin-bottom: 32px; }}
  .severity-filter {{
    background: var(--white); border: 1px solid var(--grey-200); border-radius: 8px;
    padding: 14px 20px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  }}
  .severity-filter-label {{
    font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
    color: var(--text-muted); margin-right: 4px;
  }}
  .sev-filter-item {{
    display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
    padding: 4px 12px; border-radius: 20px; border: 1.5px solid var(--grey-200);
    transition: all 0.18s; user-select: none; font-size: 12px;
  }}
  .sev-filter-item:hover {{ background: var(--offwhite); }}
  .sev-filter-item.active {{ border-color: currentColor; }}
  .sev-filter-check {{
    width: 16px; height: 16px; border-radius: 3px; border: 2px solid var(--grey-500);
    display: inline-flex; align-items: center; justify-content: center;
    transition: all 0.15s; flex-shrink: 0;
  }}
  .sev-filter-item.active .sev-filter-check {{ border-color: currentColor; background: currentColor; }}
  .sev-filter-item.active .sev-filter-check::after {{
    content: '\\2713'; color: white; font-size: 10px; font-weight: bold;
  }}
  .sev-filter-count {{
    font-family: 'IBM Plex Mono', monospace; font-size: 11px;
    color: var(--text-muted); background: var(--grey-100);
    padding: 1px 6px; border-radius: 8px;
  }}
  .sev-filter-item.active .sev-filter-count {{ background: rgba(0,0,0,0.08); }}
  .vuln-row-hidden {{ display: none !important; }}
  .filter-select-all {{
    font-size: 11px; color: var(--teal); cursor: pointer;
    border: none; background: none; font-weight: 600; padding: 4px 8px;
    border-radius: 4px; transition: background 0.15s;
  }}
  .filter-select-all:hover {{ background: var(--teal-pale); }}
  .vuln-table {{
    width: 100%; border-collapse: collapse; background: var(--white);
    border: 1px solid var(--grey-200); border-radius: 8px; overflow: hidden;
  }}
  .vuln-table thead th {{
    background: var(--navy); color: rgba(255,255,255,0.75);
    font-size: 10px; font-weight: 700; letter-spacing: 0.14em;
    text-transform: uppercase; padding: 10px 14px; text-align: left;
  }}
  .vuln-table tbody tr {{
    border-bottom: 1px solid var(--grey-100); cursor: pointer; transition: background 0.14s;
  }}
  .vuln-table tbody tr:last-child {{ border-bottom: none; }}
  .vuln-table tbody tr:hover {{ background: var(--offwhite); }}
  .vuln-table tbody td {{ padding: 12px 14px; vertical-align: middle; }}
  .vuln-id {{
    font-family: 'IBM Plex Mono', monospace; font-size: 12px;
    color: var(--navy); font-weight: 600;
  }}
  .vuln-title {{ font-size: 13px; font-weight: 500; color: var(--text); max-width: 380px; }}
  .vuln-target {{
    font-family: 'IBM Plex Mono', monospace; font-size: 11px;
    color: var(--text-muted); max-width: 200px;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }}
  .severity-badge {{
    display: inline-flex; align-items: center; gap: 5px;
    padding: 3px 10px; border-radius: 20px; font-size: 11px;
    font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap;
  }}
  .severity-badge::before {{
    content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor;
  }}
  .badge-critical {{ background: var(--critical-bg); color: var(--critical); }}
  .badge-high     {{ background: var(--high-bg);     color: #B7500A; }}
  .badge-medium   {{ background: var(--medium-bg);   color: #9A7D00; }}
  .badge-low      {{ background: var(--low-bg);      color: #1A8A4A; }}
  .badge-info     {{ background: var(--info-bg);     color: #1A6FA0; }}
  .conf-badge {{
    font-size: 10px; font-family: 'IBM Plex Mono', monospace;
    color: var(--text-muted); background: var(--grey-100);
    padding: 2px 7px; border-radius: 3px; letter-spacing: 0.05em;
  }}
  .toggle-arrow {{
    color: var(--grey-500); font-size: 10px; transition: transform 0.2s; display: inline-block;
  }}
  .vuln-detail-row td {{ padding: 0; }}
  .detail-inner {{
    display: none; background: #F8FAFC; border-top: 1px solid var(--grey-200);
    padding: 24px 28px; border-bottom: 1px solid var(--grey-200);
  }}
  .detail-inner.open {{ display: block; }}
  .detail-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 20px; }}
  .detail-block-title {{
    font-size: 10px; font-weight: 700; letter-spacing: 0.14em;
    text-transform: uppercase; color: var(--teal); margin-bottom: 8px;
  }}
  .detail-desc {{ font-size: 13px; color: var(--text); line-height: 1.75; }}
  .detail-suggestions ol {{
    padding-left: 18px; font-size: 12.5px; color: var(--grey-700); line-height: 1.85;
  }}
  .http-interactions {{ margin-top: 20px; }}
  .http-interaction {{ margin-bottom: 14px; }}
  .http-seq-label {{
    font-size: 10px; font-weight: 700; letter-spacing: 0.12em;
    text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px;
  }}
  .http-pair {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }}
  .http-block {{
    background: var(--navy-dark); border-radius: 6px; padding: 12px 14px;
    font-family: 'IBM Plex Mono', monospace; font-size: 11px;
    color: #A8C7E0; line-height: 1.65; overflow-x: auto;
  }}
  .http-block-label {{
    font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--teal); margin-bottom: 6px; font-weight: 700;
  }}
  .http-line {{ min-height: 1.65em; }}
  .http-line-blank {{ height: 1.65em; min-height: 1.65em; }}
  .http-first-line {{ color: #FFD580; font-weight: 600; }}
  .http-status-line-ok {{ color: #5FD55F; font-weight: 600; }}
  .http-status-line-err {{ color: #FF6B6B; font-weight: 600; }}
  .http-header-key {{ color: #C8A0D8; }}

  .footer {{ background: var(--navy-dark); padding: 22px 48px; text-align: center; }}
  .footer-inner {{
    max-width: 1100px; margin: 0 auto; display: flex;
    align-items: center; justify-content: space-between;
  }}
  .footer-logo img {{ height: 28px; filter: brightness(0) invert(1) opacity(0.5); }}
  .footer-text {{
    font-size: 11px; color: rgba(255,255,255,0.35);
    font-family: 'IBM Plex Mono', monospace;
  }}

  @media (max-width: 800px) {{
    .header-inner {{ flex-direction: column; align-items: flex-start; gap: 18px; }}
    .header-meta {{ text-align: left; }}
    .summary-grid {{ grid-template-columns: repeat(3, 1fr); }}
    .charts-row {{ grid-template-columns: 1fr; }}
    .detail-grid {{ grid-template-columns: 1fr; }}
    .http-pair {{ grid-template-columns: 1fr; }}
    .page-body {{ padding: 24px 20px 48px; }}
    .header-inner {{ padding: 28px 20px 24px; }}
  }}
</style>
</head>
<body>

<header class="header">
  <div class="header-inner">
    <div class="header-left">
      <div class="header-title-block" style="border-left: none; padding-left: 0;">
        <div class="header-eyebrow">渗透测试报告</div>
        <div class="header-title">Web 应用安全评估</div>
        <div class="header-subtitle">{escape_html(subtitle)}</div>
      </div>
    </div>
    <div class="header-meta">
      <div class="header-badge">{escape_html(report_id)}</div>
      <div class="header-info-row">
        测试时间 <span>{escape_html(date_str)}</span><br>
        测试工具 <span>{escape_html(tester)}</span><br>
        置信级别 <span>Confirmed</span>
      </div>
    </div>
  </div>
  <div class="risk-banner">
    <div class="risk-banner-inner">{risk_text}</div>
  </div>
</header>

<div class="page-body">

  <div class="section-title">执行摘要</div>
  <div class="summary-grid">
    <div class="stat-card total"><div class="stat-number">{summary.get('total', 0)}</div><div class="stat-label">总计</div></div>
    <div class="stat-card critical"><div class="stat-number">{summary.get('critical', 0)}</div><div class="stat-label">严重</div></div>
    <div class="stat-card high"><div class="stat-number">{summary.get('high', 0)}</div><div class="stat-label">高危</div></div>
    <div class="stat-card medium"><div class="stat-number">{summary.get('medium', 0)}</div><div class="stat-label">中危</div></div>
    <div class="stat-card low"><div class="stat-number">{summary.get('low', 0)}</div><div class="stat-label">低危</div></div>
    <div class="stat-card info"><div class="stat-number">{summary.get('info', 0)}</div><div class="stat-label">信息</div></div>
  </div>

  <div class="charts-row">
    <div class="chart-card">
      <div class="chart-card-title">漏洞数量分布</div>
      <div class="chart-wrap"><canvas id="barChart"></canvas></div>
    </div>
    <div class="chart-card">
      <div class="chart-card-title">漏洞类型占比</div>
      <div class="chart-wrap"><canvas id="typeChart"></canvas></div>
    </div>
  </div>

  <div class="section-title">测试范围</div>
  <div class="scope-card">
    <div class="scope-item">
      <div class="scope-key">目标 URL</div>
      <div class="scope-val">{escape_html(target_url)}</div>
    </div>
    <div class="scope-item">
      <div class="scope-key">报告编号</div>
      <div class="scope-val">{escape_html(report_id)}</div>
    </div>
    <div class="scope-item">
      <div class="scope-key">生成时间</div>
      <div class="scope-val">{escape_html(time_str)}</div>
    </div>
    <div class="scope-item" style="flex:1">
      <div class="scope-key">技术栈</div>
      <div class="tech-tags">{tech_tags_html}</div>
    </div>
  </div>

  <div class="section-title">漏洞详情</div>
  <div class="vuln-table-wrap">
    <div class="severity-filter" id="severityFilter">
      <span class="severity-filter-label">等级筛选:</span>
      <button class="filter-select-all" onclick="toggleAllSev()">全选</button>
      <div class="sev-filter-item active" style="color: var(--critical)" data-sev="critical" onclick="toggleSevFilter(this)">
        <span class="sev-filter-check"></span>严重<span class="sev-filter-count">{summary.get('critical', 0)}</span>
      </div>
      <div class="sev-filter-item active" style="color: var(--high)" data-sev="high" onclick="toggleSevFilter(this)">
        <span class="sev-filter-check"></span>高危<span class="sev-filter-count">{summary.get('high', 0)}</span>
      </div>
      <div class="sev-filter-item active" style="color: var(--medium)" data-sev="medium" onclick="toggleSevFilter(this)">
        <span class="sev-filter-check"></span>中危<span class="sev-filter-count">{summary.get('medium', 0)}</span>
      </div>
      <div class="sev-filter-item active" style="color: var(--low)" data-sev="low" onclick="toggleSevFilter(this)">
        <span class="sev-filter-check"></span>低危<span class="sev-filter-count">{summary.get('low', 0)}</span>
      </div>
      <div class="sev-filter-item active" style="color: var(--info)" data-sev="info" onclick="toggleSevFilter(this)">
        <span class="sev-filter-check"></span>信息<span class="sev-filter-count">{summary.get('info', 0)}</span>
      </div>
    </div>
    <table class="vuln-table" id="vulnTable">
      <thead>
        <tr>
          <th style="width:72px">编号</th>
          <th>漏洞标题</th>
          <th style="width:90px">危险等级</th>
          <th style="width:100px">漏洞类型</th>
          <th style="width:80px">置信度</th>
          <th style="width:180px">目标地址</th>
          <th style="width:32px"></th>
        </tr>
      </thead>
      <tbody id="vulnBody">
        {vuln_rows}
      </tbody>
    </table>
  </div>

</div>

<div style="max-width: 1100px; margin: 0 auto; padding: 0 48px 60px;">
  <div class="section-title">免责声明</div>
  <div style="background: var(--white); border: 1px solid var(--grey-200); border-radius: 8px; padding: 20px; font-size: 13px; line-height: 1.8; color: var(--text-muted);">
    <p style="margin-bottom: 10px; font-weight: 600; color: var(--text);">重要声明</p>
    <p style="margin-bottom: 8px;">
      本报告由人工智能辅助安全评估系统自动生成。尽管本系统采用了行业标准的漏洞检测方法和验证流程，
      但基于自动化测试的固有局限性，报告中的发现可能存在误报、漏报或上下文理解偏差。
    </p>
    <p style="margin-bottom: 8px;">
      使用本报告的各方应注意以下事项：
    </p>
    <ul style="margin-left: 20px; margin-bottom: 10px;">
      <li>报告中的所有发现应经过人工验证和专业判断后再作为决策依据</li>
      <li>本报告不构成绝对安全保证，不应替代专业的安全审计和渗透测试</li>
      <li>对于依赖本报告内容而采取的任何行动所产生的后果，报告生成方不承担责任</li>
      <li>建议在关键业务系统中聘请具备资质的安全专家进行独立复核</li>
    </ul>
    <p style="margin-bottom: 0; font-size: 12px; color: var(--grey-500);">
      本报告仅供参考，最终安全决策应由具备专业知识的安全团队做出。
    </p>
  </div>
</div>

<footer class="footer">
  <div class="footer-inner">
    <div class="footer-text">© 2026 · 报告编号 {escape_html(report_id)}</div>
  </div>
</footer>

<script>
const severityMap = {{
  critical: {{ label: '严重', cls: 'badge-critical' }},
  high:     {{ label: '高危', cls: 'badge-high' }},
  medium:   {{ label: '中危', cls: 'badge-medium' }},
  low:      {{ label: '低危', cls: 'badge-low' }},
  info:     {{ label: '信息', cls: 'badge-info' }},
}};

function toggleDetail(id, row) {{
  const el = document.getElementById(id);
  const idx = id.split('-')[1];
  const arrow = document.getElementById('arrow-' + idx);
  const isOpen = el.classList.contains('open');
  el.classList.toggle('open', !isOpen);
  arrow.style.transform = isOpen ? '' : 'rotate(180deg)';
  row.style.background = isOpen ? '' : 'var(--teal-pale)';
}}

// Severity filter
function toggleSevFilter(el) {{
  el.classList.toggle('active');
  applySevFilter();
}}

function toggleAllSev() {{
  const items = document.querySelectorAll('.sev-filter-item');
  const allActive = Array.from(items).every(i => i.classList.contains('active'));
  items.forEach(item => {{
    if (allActive) {{
      item.classList.remove('active');
    }} else {{
      item.classList.add('active');
    }}
  }});
  applySevFilter();
}}

function applySevFilter() {{
  const activeSevs = new Set();
  document.querySelectorAll('.sev-filter-item').forEach(item => {{
    if (item.classList.contains('active')) {{
      activeSevs.add(item.getAttribute('data-sev'));
    }}
  }});
  
  const tbody = document.getElementById('vulnBody');
  const rows = tbody.querySelectorAll('tr');
  for (let i = 0; i < rows.length; i++) {{
    const row = rows[i];
    const sev = row.getAttribute('data-severity');
    if (!sev) continue;
    if (activeSevs.has(sev)) {{
      row.classList.remove('vuln-row-hidden');
    }} else {{
      row.classList.add('vuln-row-hidden');
      // Also close any open detail
      const detail = row.querySelector('.detail-inner');
      if (detail) detail.classList.remove('open');
    }}
  }}
}}

// Bar chart
const barCtx = document.getElementById('barChart').getContext('2d');
new Chart(barCtx, {{
  type: 'bar',
  data: {{
    labels: ['严重', '高危', '中危', '低危', '信息'],
    datasets: [{{
      data: {json.dumps(bar_data)},
      backgroundColor: ['#C0392B','#E67E22','#F1C40F','#2ECC71','#3498DB'],
      borderRadius: 5, borderSkipped: false,
    }}]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{
      legend: {{ display: false }},
      tooltip: {{ callbacks: {{ label: ctx => ` ${{ctx.parsed.y}} 个漏洞` }} }}
    }},
    scales: {{
      x: {{ grid: {{ display: false }}, ticks: {{ color: '#5C7A92', font: {{ family: "'Noto Sans SC'", size: 12 }} }} }},
      y: {{ beginAtZero: true, grid: {{ color: '#EEF2F6' }}, ticks: {{ color: '#5C7A92', font: {{ family: "'IBM Plex Mono'" }}, stepSize: 1 }} }}
    }}
  }}
}});

// Donut chart
const typeCtx = document.getElementById('typeChart').getContext('2d');
const typeLabels = {type_labels};
const typeData = {type_values};
const totalVulns = typeData.reduce((a, b) => a + b, 0);

// Build datalabels: show percentage for top 5
const datalabelsData = typeData.map((val, idx) => {{
  if (idx >= 5) return '';
  const pct = totalVulns > 0 ? ((val / totalVulns) * 100).toFixed(1) : 0;
  return pct + '%';
}});

new Chart(typeCtx, {{
  type: 'doughnut',
  data: {{
    labels: {type_labels},
    datasets: [{{
      data: {type_values},
      backgroundColor: {type_colors},
      borderWidth: 2, borderColor: '#FFFFFF', hoverOffset: 6
    }}]
  }},
  plugins: [ChartDataLabels],
  options: {{
    responsive: true, maintainAspectRatio: false, cutout: '58%',
    plugins: {{
      legend: {{
        position: 'right',
        labels: {{ color: '#3D5A73', font: {{ family: "'Noto Sans SC'", size: 11 }}, padding: 14, usePointStyle: true, pointStyleWidth: 8 }}
      }},
      tooltip: {{ callbacks: {{ label: ctx => ` ${{ctx.label}}: ${{ctx.parsed}} 个 (${{totalVulns > 0 ? ((ctx.parsed / totalVulns) * 100).toFixed(1) : 0}}%)` }} }},
      datalabels: {{
        color: '#fff',
        font: {{ weight: 'bold', size: 11 }},
        formatter: (value, ctx) => {{
          const idx = ctx.dataIndex;
          if (idx >= 5) return '';
          const pct = totalVulns > 0 ? ((value / totalVulns) * 100).toFixed(1) : 0;
          return pct + '%';
        }},
        display: function(ctx) {{
          return ctx.dataIndex < 5;
        }}
      }}
    }}
  }}
}});
</script>
</body>
</html>"""
    return html


def main():
    if len(sys.argv) < 2:
        print("用法: python generate_html.py <report.json> [output.html]")
        sys.exit(1)

    json_path = sys.argv[1]
    if not os.path.isfile(json_path):
        print(f"错误: 文件不存在: {json_path}")
        sys.exit(1)

    with open(json_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    html_content = convert(data)

    if len(sys.argv) >= 3:
        out_path = sys.argv[2]
    else:
        base = os.path.splitext(json_path)[0]
        report_id = data.get("report_meta", {}).get("report_id", "")
        if report_id:
            out_path = f"report_{report_id}.html"
        else:
            out_path = base + ".html"

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(html_content)

    print(f"已生成: {out_path}")


if __name__ == "__main__":
    main()
