#!/usr/bin/env python3
"""
CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown/HTML 日报
用法:
  python3 scripts/ci/ci_dashboard.py --days 7
  python3 scripts/ci/ci_dashboard.py --days 30 --output ci_report.md
  python3 scripts/ci/ci_dashboard.py --workflow ci-cd.yml --days 7
  python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output dashboard.html
环境变量:
  GITEA_URL      Gitea 地址 (默认 https://git.xiaoxiajianji.com)
  GITEA_REPO     仓库 (默认 xiaoxia/xiaoxia-saas)
  GITEA_TOKEN    API Token (优先) 或 GITEA_USERNAME + GITEA_PASSWORD
"""

import argparse
import base64
import json
import math
import os
import statistics
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone

# ── 配置 ──────────────────────────────────────────────
DEFAULT_GITEA_URL = "https://git.xiaoxiajianji.com"
DEFAULT_REPO = "xiaoxia/xiaoxia-saas"
DEFAULT_DAYS = 7
PAGE_LIMIT = 50  # 每页数量，最大50


# ── API 封装 ─────────────────────────────────────────
class GiteaActions:
    def __init__(self, base_url, repo, token=None, username=None, password=None):
        self.base_url = base_url.rstrip("/")
        self.repo = repo
        self.token = token
        self.username = username
        self.password = password
        self.api_base = f"{self.base_url}/api/v1/repos/{self.repo}/actions"

    def _request(self, path):
        url = f"{self.api_base}/{path}"
        req = urllib.request.Request(url)
        if self.token:
            req.add_header("Authorization", f"token {self.token}")
        elif self.username and self.password:
            auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
            req.add_header("Authorization", f"Basic {auth}")
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                return json.loads(resp.read().decode())
        except urllib.error.HTTPError as e:
            print(f"[WARN] HTTP {e.code}: {url}", file=sys.stderr)
            return None
        except Exception as e:
            print(f"[WARN] 请求失败 {url}: {e}", file=sys.stderr)
            return None

    def list_runs(self, status=None, branch=None, event=None, page=1, limit=PAGE_LIMIT):
        """获取 workflow runs 列表"""
        params = []
        if status:
            params.append(f"status={status}")
        if branch:
            params.append(f"branch={branch}")
        if event:
            params.append(f"event={event}")
        params.append(f"page={page}")
        params.append(f"limit={limit}")
        path = f"runs?{'&'.join(params)}"
        data = self._request(path)
        if not data:
            return [], 0
        runs = data.get("workflow_runs", [])
        total = data.get("total_count", 0)
        return runs, total

    def get_run_jobs(self, run_id):
        """获取 run 的所有 job"""
        data = self._request(f"runs/{run_id}/jobs")
        if not data:
            return []
        return data.get("jobs", [])

    def list_workflows(self):
        """获取所有 workflow"""
        data = self._request("workflows")
        if not data:
            return []
        return data.get("workflows", [])


# ── 工具函数 ─────────────────────────────────────────
def parse_datetime(s):
    """解析 ISO 格式时间字符串"""
    if not s or s.startswith("1970") or s.startswith("0001"):
        return None
    try:
        if s.endswith("Z"):
            s = s[:-1] + "+00:00"
        return datetime.fromisoformat(s)
    except Exception:
        return None


def to_shanghai(dt):
    """转换为上海时区"""
    if dt is None:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone(timedelta(hours=8)))


def duration_seconds(start_str, end_str):
    """计算耗时（秒）"""
    start = parse_datetime(start_str)
    end = parse_datetime(end_str)
    if not start or not end:
        return None
    return (end - start).total_seconds()


def fmt_duration(seconds):
    """格式化耗时显示"""
    if seconds is None:
        return "N/A"
    seconds = int(seconds)
    if seconds < 60:
        return f"{seconds}s"
    mins, secs = divmod(seconds, 60)
    if mins < 60:
        return f"{mins}m{secs:02d}s"
    hours, mins = divmod(mins, 60)
    return f"{hours}h{mins:02d}m"


def percentile(sorted_values, p):
    """计算百分位数"""
    if not sorted_values:
        return None
    k = (len(sorted_values) - 1) * (p / 100)
    f = math.floor(k)
    c = math.ceil(k)
    if f == c:
        return sorted_values[int(k)]
    return sorted_values[f] * (c - k) + sorted_values[c] * (k - f)


def classify_failure(job_name, step_name=None):
    """根据失败的 job/step 名称分类失败原因"""
    name = f"{job_name} {step_name or ''}".lower()
    if any(k in name for k in ["lint", "ruff", "flake8", "eslint", "prettier", "black", "mypy"]):
        return "代码质量 / Lint"
    if any(k in name for k in ["unit test", "pytest", "vitest", "jest"]):
        return "单元测试失败"
    if any(k in name for k in ["integration", "e2e"]):
        return "集成测试 / E2E"
    if any(k in name for k in ["build", "compile", "docker", "image"]):
        return "构建失败"
    if any(k in name for k in ["deploy", "preview", "release"]):
        return "部署失败"
    if any(k in name for k in ["setup", "checkout", "cache", "install", "deps"]):
        return "环境 / 依赖"
    if any(k in name for k in ["migrate", "migration", "schema"]):
        return "数据库迁移"
    return "其他"


# ── 数据收集 ─────────────────────────────────────────
def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
    """拉取指定日期范围内的所有 completed runs"""
    all_runs = []
    page = 1
    print(f"[INFO] 拉取 {start_date} ~ {end_date} 的 CI runs...", file=sys.stderr)
    while True:
        runs, total = ga.list_runs(status="completed", page=page, limit=PAGE_LIMIT)
        if not runs:
            break
        if workflow_filter:
            runs = [r for r in runs if workflow_filter in r.get("path", "")]
        in_range = []
        out_range_old = False
        for run in runs:
            started = to_shanghai(parse_datetime(run.get("started_at")))
            if not started:
                continue
            run_date = started.date()
            if start_date <= run_date <= end_date:
                in_range.append(run)
            elif run_date < start_date:
                out_range_old = True
        all_runs.extend(in_range)
        print(
            f"[INFO] 第 {page} 页: {len(runs)} 条, 范围内 {len(in_range)} 条, 累计 {len(all_runs)} 条", file=sys.stderr
        )
        if out_range_old or len(runs) < PAGE_LIMIT:
            break
        page += 1
        if page > 100:
            print("[WARN] 超过100页，停止拉取", file=sys.stderr)
            break
    print(f"[INFO] 共获取 {len(all_runs)} 条 run 数据", file=sys.stderr)
    return all_runs


def enrich_with_jobs(ga, runs, max_failures=50):
    """为 runs 补充 job 详情（失败原因分析 + runner 统计）
    失败 run 按时间倒序取最近 N 个（避免 API 调用过多），
    成功 run 采样用于 runner 分布统计。
    """
    # 失败 run 取最近 N 个
    failure_runs = [r for r in runs if r.get("conclusion") != "success"]
    failure_runs = failure_runs[:max_failures]  # 已经是时间倒序
    print(f"[INFO] 为最近 {len(failure_runs)} 个失败 run 拉取 job 详情...", file=sys.stderr)
    for i, run in enumerate(failure_runs):
        jobs = ga.get_run_jobs(run["id"])
        run["_jobs"] = jobs
        if (i + 1) % 10 == 0:
            print(f"[INFO] 已处理 {i+1}/{len(failure_runs)}", file=sys.stderr)
    # 成功 run 采样用于 runner 分布
    success_runs = [r for r in runs if r.get("conclusion") == "success"]
    sample_size = min(50, len(success_runs))
    if sample_size > 0:
        sampled = success_runs[:: max(1, len(success_runs) // sample_size)]
        print(f"[INFO] 采样 {len(sampled)} 个成功 run 用于 runner 统计...", file=sys.stderr)
        for run in sampled:
            if "_jobs" not in run:
                jobs = ga.get_run_jobs(run["id"])
                run["_jobs"] = jobs
    return runs


# ── 统计分析 ─────────────────────────────────────────
def analyze_runs(runs):
    """对 runs 做全面统计分析"""
    if not runs:
        return {}

    # 基础统计
    total = len(runs)
    success = sum(1 for r in runs if r.get("conclusion") == "success")
    failure = sum(1 for r in runs if r.get("conclusion") == "failure")
    cancelled = sum(1 for r in runs if r.get("conclusion") == "cancelled")
    other = total - success - failure - cancelled
    success_rate = (success / total * 100) if total > 0 else 0

    # 耗时统计
    durations = []
    for r in runs:
        d = duration_seconds(r.get("started_at"), r.get("completed_at"))
        if d and d > 0:
            durations.append(d)
    durations.sort()
    avg_dur = statistics.mean(durations) if durations else None
    median_dur = percentile(durations, 50)
    p95_dur = percentile(durations, 95)

    # 按日期统计
    daily_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0, "durations": []})
    for r in runs:
        started = to_shanghai(parse_datetime(r.get("started_at")))
        if not started:
            continue
        day = started.date().isoformat()
        daily_stats[day]["total"] += 1
        if r.get("conclusion") == "success":
            daily_stats[day]["success"] += 1
        elif r.get("conclusion") == "failure":
            daily_stats[day]["failure"] += 1
        d = duration_seconds(r.get("started_at"), r.get("completed_at"))
        if d and d > 0:
            daily_stats[day]["durations"].append(d)

    # 按 workflow 统计
    wf_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0, "durations": []})
    for r in runs:
        path = r.get("path", "")
        wf_name = path.split("@")[0] if "@" in path else path
        wf_stats[wf_name]["total"] += 1
        if r.get("conclusion") == "success":
            wf_stats[wf_name]["success"] += 1
        elif r.get("conclusion") == "failure":
            wf_stats[wf_name]["failure"] += 1
        d = duration_seconds(r.get("started_at"), r.get("completed_at"))
        if d and d > 0:
            wf_stats[wf_name]["durations"].append(d)

    # 按触发事件统计
    event_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
    for r in runs:
        evt = r.get("event", "unknown")
        event_stats[evt]["total"] += 1
        if r.get("conclusion") == "success":
            event_stats[evt]["success"] += 1
        elif r.get("conclusion") == "failure":
            event_stats[evt]["failure"] += 1

    # 失败原因 + runner + job 耗时（需要 _jobs 数据）
    failure_categories = defaultdict(int)
    failed_jobs_by_name = defaultdict(int)
    job_success_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
    runner_stats = defaultdict(lambda: {"jobs": 0, "success": 0, "failure": 0, "durations": []})
    job_time_stats = defaultdict(list)
    infra_failures = 0
    business_failures = 0
    other_failures_count = 0

    # 基础设施关键词（与 ci_health_check.py 保持一致的分类逻辑）
    infra_job_keywords = ["checkout", "build", "deploy", "cleanup", "setup", "cache", "install", "docker"]
    business_job_keywords = [
        "unit test",
        "pytest",
        "vitest",
        "jest",
        "lint",
        "eslint",
        "prettier",
        "integration",
        "e2e",
        "validate",
        "code quality",
        "mypy",
        "ruff",
        "flake8",
    ]

    for r in runs:
        jobs = r.get("_jobs", [])
        if not jobs:
            continue
        for job in jobs:
            runner = job.get("runner_name", "unknown")
            conclusion = job.get("conclusion", "unknown")
            job_name = job.get("name", "unknown")
            job_name_lower = job_name.lower()

            runner_stats[runner]["jobs"] += 1
            job_success_stats[job_name]["total"] += 1
            if conclusion == "success":
                runner_stats[runner]["success"] += 1
                job_success_stats[job_name]["success"] += 1
            elif conclusion == "failure":
                runner_stats[runner]["failure"] += 1
                job_success_stats[job_name]["failure"] += 1

            jd = duration_seconds(job.get("started_at"), job.get("completed_at"))
            if jd and jd > 0:
                runner_stats[runner]["durations"].append(jd)
                job_time_stats[job_name].append(jd)

            if conclusion == "failure":
                failed_jobs_by_name[job_name] += 1
                failed_step = None
                for step in job.get("steps", []):
                    if step.get("conclusion") == "failure":
                        failed_step = step.get("name")
                        break
                category = classify_failure(job_name, failed_step)
                failure_categories[category] += 1

                # 基础设施 vs 业务代码分类
                is_infra = any(k in job_name_lower for k in infra_job_keywords) and not any(
                    k in job_name_lower for k in business_job_keywords
                )
                is_business = any(k in job_name_lower for k in business_job_keywords)
                if is_infra:
                    infra_failures += 1
                elif is_business:
                    business_failures += 1
                else:
                    other_failures_count += 1

    return {
        "total": total,
        "success": success,
        "failure": failure,
        "cancelled": cancelled,
        "other": other,
        "success_rate": success_rate,
        "avg_duration": avg_dur,
        "median_duration": median_dur,
        "p95_duration": p95_dur,
        "durations": durations,
        "daily_stats": dict(sorted(daily_stats.items())),
        "workflow_stats": dict(wf_stats),
        "event_stats": dict(event_stats),
        "failure_categories": dict(failure_categories),
        "failed_jobs_top": dict(sorted(failed_jobs_by_name.items(), key=lambda x: -x[1])[:15]),
        "runner_stats": dict(runner_stats),
        "job_time_stats": dict(job_time_stats),
        "job_success_stats": dict(job_success_stats),
        "infra_failures": infra_failures,
        "business_failures": business_failures,
        "other_failures_combined": other_failures_count,
    }


# ── Markdown 报表生成 ────────────────────────────────
def generate_markdown(stats, start_date, end_date, repo):
    """生成 Markdown 格式的日报"""
    lines = []
    lines.append("# CI 运行状态看板")
    lines.append("")
    lines.append(f"> 统计周期: **{start_date} ~ {end_date}**")
    lines.append(f"> 仓库: `{repo}`")
    lines.append(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append("")

    # 概览
    lines.append("## 📊 整体概览")
    lines.append("")
    lines.append("| 指标 | 数值 |")
    lines.append("|------|------|")
    lines.append(f"| 总构建次数 | **{stats['total']}** |")
    lines.append(f"| ✅ 成功 | {stats['success']} |")
    lines.append(f"| ❌ 失败 | {stats['failure']} |")
    lines.append(f"| ⏹️ 取消 | {stats['cancelled']} |")
    lines.append(f"| 📈 成功率 | **{stats['success_rate']:.1f}%** |")
    lines.append(f"| ⏱️ 平均耗时 | {fmt_duration(stats['avg_duration'])} |")
    lines.append(f"| ⏱️ P50 耗时 | {fmt_duration(stats['median_duration'])} |")
    lines.append(f"| ⏱️ P95 耗时 | {fmt_duration(stats['p95_duration'])} |")
    lines.append("")

    # 每日趋势
    lines.append("## 📈 每日趋势")
    lines.append("")
    lines.append("| 日期 | 总次数 | 成功 | 失败 | 成功率 | 平均耗时 | P95 耗时 |")
    lines.append("|------|--------|------|------|--------|----------|----------|")
    for day, s in stats["daily_stats"].items():
        rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
        durations = sorted(s["durations"])
        avg = statistics.mean(durations) if durations else None
        p95 = percentile(durations, 95) if durations else None
        lines.append(
            f"| {day} | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} | {fmt_duration(p95)} |"
        )
    lines.append("")

    # 成功率趋势图
    lines.append("### 成功率趋势图")
    lines.append("")
    lines.append("```")
    max_bar = 40
    days = list(stats["daily_stats"].keys())
    if len(days) > 14:
        days = days[-14:]
    for day in days:
        s = stats["daily_stats"][day]
        rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
        bar_len = int(rate / 100 * max_bar)
        bar = "█" * bar_len + "░" * (max_bar - bar_len)
        lines.append(f"{day} {bar} {rate:5.1f}% ({s['total']}次)")
    lines.append("```")
    lines.append("")

    # 按 Workflow 统计
    lines.append("## 🧩 各 Workflow 统计")
    lines.append("")
    wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
    lines.append("| Workflow | 次数 | 成功 | 失败 | 成功率 | 平均耗时 | P95 耗时 |")
    lines.append("|----------|------|------|------|--------|----------|----------|")
    for wf, s in wf_sorted:
        rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
        durations = sorted(s["durations"])
        avg = statistics.mean(durations) if durations else None
        p95 = percentile(durations, 95) if durations else None
        wf_short = wf.split("/")[-1] if "/" in wf else wf
        lines.append(
            f"| `{wf_short}` | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} | {fmt_duration(p95)} |"
        )
    lines.append("")

    # 失败原因分析
    if stats["failure_categories"]:
        lines.append("## ❌ 失败原因分析")
        lines.append("")
        lines.append("> ⚠️ 基于最近 N 个失败 run 采样分析，用于趋势参考")
        lines.append("")
        lines.append("### 按分类统计")
        lines.append("")
        total_failures = sum(stats["failure_categories"].values())
        fc_sorted = sorted(stats["failure_categories"].items(), key=lambda x: -x[1])
        lines.append("| 分类 | 次数 | 占比 |")
        lines.append("|------|------|------|")
        for cat, cnt in fc_sorted:
            pct = (cnt / total_failures * 100) if total_failures > 0 else 0
            lines.append(f"| {cat} | {cnt} | {pct:.1f}% |")
        lines.append("")

        lines.append("### Top 失败 Job")
        lines.append("")
        lines.append("| Job 名称 | 失败次数 |")
        lines.append("|----------|----------|")
        for job, cnt in stats["failed_jobs_top"].items():
            lines.append(f"| `{job}` | {cnt} |")
        lines.append("")

    # Runner 利用率
    if stats["runner_stats"]:
        lines.append("## 🏃 Runner 利用率")
        lines.append("")
        runner_sorted = sorted(stats["runner_stats"].items(), key=lambda x: -x[1]["jobs"])
        lines.append("| Runner | Job 数 | 成功 | 失败 | 成功率 | 平均耗时 |")
        lines.append("|--------|--------|------|------|--------|----------|")
        for runner, s in runner_sorted:
            rate = (s["success"] / s["jobs"] * 100) if s["jobs"] > 0 else 0
            avg = statistics.mean(s["durations"]) if s["durations"] else None
            lines.append(
                f"| `{runner}` | {s['jobs']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} |"
            )
        lines.append("")

    # Job 耗时排行
    if stats["job_time_stats"]:
        lines.append("## ⏱️ Job 耗时排行 (Top 20 by P95)")
        lines.append("")
        job_stats = []
        for name, durs in stats["job_time_stats"].items():
            if not durs:
                continue
            durs_sorted = sorted(durs)
            job_stats.append(
                {
                    "name": name,
                    "count": len(durs_sorted),
                    "avg": statistics.mean(durs_sorted),
                    "p50": percentile(durs_sorted, 50),
                    "p95": percentile(durs_sorted, 95),
                }
            )
        job_stats.sort(key=lambda x: -x["p95"])
        top_n = min(20, len(job_stats))
        lines.append("| Job 名称 | 次数 | 平均 | P50 | P95 |")
        lines.append("|----------|------|------|-----|-----|")
        for j in job_stats[:top_n]:
            lines.append(
                f"| `{j['name']}` | {j['count']} | {fmt_duration(j['avg'])} | {fmt_duration(j['p50'])} | {fmt_duration(j['p95'])} |"
            )
        lines.append("")

    # 触发事件分布
    lines.append("## 📋 触发事件分布")
    lines.append("")
    evt_sorted = sorted(stats["event_stats"].items(), key=lambda x: -x[1]["total"])
    lines.append("| 事件类型 | 次数 | 成功 | 失败 | 成功率 |")
    lines.append("|----------|------|------|------|--------|")
    for evt, s in evt_sorted:
        rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
        lines.append(f"| `{evt}` | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% |")
    lines.append("")

    return "\n".join(lines)


# ── HTML 看板生成 ────────────────────────────────────
def generate_html(stats, start_date, end_date, repo):
    """生成 HTML 格式的可视化看板（内嵌 ECharts）"""
    # 准备图表数据

    # 1. 每日成功率趋势
    daily_dates = list(stats["daily_stats"].keys())
    daily_success_rates = []
    daily_run_counts = []
    for day in daily_dates:
        s = stats["daily_stats"][day]
        rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
        daily_success_rates.append(round(rate, 1))
        daily_run_counts.append(s["total"])

    # 2. 各 Workflow 耗时对比
    wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
    wf_names = []
    wf_avg_durations = []
    for wf, s in wf_sorted:
        wf_short = wf.split("/")[-1] if "/" in wf else wf
        wf_names.append(wf_short)
        avg = statistics.mean(s["durations"]) if s["durations"] else 0
        wf_avg_durations.append(round(avg / 60, 1))  # 转为分钟

    # 3. 失败原因分布（饼图数据 - 基础设施 vs 业务 vs 其他）
    total_infra_biz = stats["infra_failures"] + stats["business_failures"] + stats["other_failures_combined"]
    infra_rate = (stats["infra_failures"] / total_infra_biz * 100) if total_infra_biz > 0 else 0
    failure_pie_data = [
        {"value": stats["infra_failures"], "name": "基础设施问题"},
        {"value": stats["business_failures"], "name": "业务代码问题"},
        {"value": stats["other_failures_combined"], "name": "其他"},
    ]

    # 4. 各 Job 成功率排行（横向柱状图，取成功率最低的 Top 15）
    job_stats_list = []
    for name, s in stats["job_success_stats"].items():
        if s["total"] >= 3:  # 至少有3次才统计
            rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
            job_stats_list.append(
                {
                    "name": name,
                    "rate": round(rate, 1),
                    "total": s["total"],
                    "success": s["success"],
                }
            )
    job_stats_list.sort(key=lambda x: x["rate"])
    job_stats_list = job_stats_list[:15]  # 取成功率最低的15个
    job_names = [j["name"] for j in job_stats_list]
    job_rates = [j["rate"] for j in job_stats_list]

    # 核心指标
    total_runs = stats["total"]
    success_rate = round(stats["success_rate"], 1)
    avg_dur_min = round(stats["avg_duration"] / 60, 1) if stats["avg_duration"] else 0
    infra_fail_rate = round(infra_rate, 1)

    now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    # 序列化数据为 JSON（供 JS 使用）
    data_json = json.dumps(
        {
            "daily_dates": daily_dates,
            "daily_success_rates": daily_success_rates,
            "daily_run_counts": daily_run_counts,
            "wf_names": wf_names,
            "wf_avg_durations": wf_avg_durations,
            "failure_pie_data": failure_pie_data,
            "job_names": job_names,
            "job_rates": job_rates,
        },
        ensure_ascii=False,
    )

    # HTML 模板（注意：不使用 f-string，避免与 CSS/JS 的大括号冲突）
    html_parts = []
    html_parts.append("<!DOCTYPE html>")
    html_parts.append('<html lang="zh-CN">')
    html_parts.append("<head>")
    html_parts.append('    <meta charset="UTF-8">')
    html_parts.append('    <meta name="viewport" content="width=device-width, initial-scale=1.0">')
    html_parts.append(f"    <title>CI 健康度看板 - {repo}</title>")
    html_parts.append('    <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>')
    html_parts.append("    <style>")
    html_parts.append("        * { margin: 0; padding: 0; box-sizing: border-box; }")
    html_parts.append("        body {")
    html_parts.append(
        '            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",'
    )
    html_parts.append('                         "Microsoft YaHei", sans-serif;')
    html_parts.append("            background: #f0f2f5;")
    html_parts.append("            color: #333;")
    html_parts.append("            padding: 20px;")
    html_parts.append("        }")
    html_parts.append("        .container { max-width: 1400px; margin: 0 auto; }")
    html_parts.append("        .header {")
    html_parts.append("            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);")
    html_parts.append("            color: white;")
    html_parts.append("            padding: 24px 32px;")
    html_parts.append("            border-radius: 12px;")
    html_parts.append("            margin-bottom: 20px;")
    html_parts.append("        }")
    html_parts.append("        .header h1 { font-size: 24px; margin-bottom: 8px; }")
    html_parts.append("        .header .subtitle { font-size: 14px; opacity: 0.9; }")
    html_parts.append("        .header .meta { font-size: 12px; opacity: 0.8; margin-top: 8px; }")
    html_parts.append("        .metrics-row {")
    html_parts.append("            display: grid;")
    html_parts.append("            grid-template-columns: repeat(4, 1fr);")
    html_parts.append("            gap: 16px;")
    html_parts.append("            margin-bottom: 20px;")
    html_parts.append("        }")
    html_parts.append("        .metric-card {")
    html_parts.append("            background: white;")
    html_parts.append("            border-radius: 12px;")
    html_parts.append("            padding: 20px;")
    html_parts.append("            box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
    html_parts.append("            transition: transform 0.2s;")
    html_parts.append("        }")
    html_parts.append(
        "        .metric-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }"
    )
    html_parts.append("        .metric-card .label { font-size: 13px; color: #8c8c8c; margin-bottom: 8px; }")
    html_parts.append("        .metric-card .value { font-size: 28px; font-weight: 600; }")
    html_parts.append("        .metric-card .unit { font-size: 14px; color: #8c8c8c; margin-left: 4px; }")
    html_parts.append("        .metric-card.success .value { color: #52c41a; }")
    html_parts.append("        .metric-card.warning .value { color: #faad14; }")
    html_parts.append("        .metric-card.danger .value { color: #ff4d4f; }")
    html_parts.append("        .metric-card.info .value { color: #1890ff; }")
    html_parts.append("        .charts-grid {")
    html_parts.append("            display: grid;")
    html_parts.append("            grid-template-columns: 1fr 1fr;")
    html_parts.append("            gap: 16px;")
    html_parts.append("            margin-bottom: 20px;")
    html_parts.append("        }")
    html_parts.append("        .chart-card {")
    html_parts.append("            background: white;")
    html_parts.append("            border-radius: 12px;")
    html_parts.append("            padding: 20px;")
    html_parts.append("            box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
    html_parts.append("        }")
    html_parts.append("        .chart-card.full-width { grid-column: 1 / -1; }")
    html_parts.append("        .chart-card h3 {")
    html_parts.append("            font-size: 16px;")
    html_parts.append("            margin-bottom: 12px;")
    html_parts.append("            color: #262626;")
    html_parts.append("            font-weight: 600;")
    html_parts.append("        }")
    html_parts.append("        .chart-container { width: 100%; height: 320px; }")
    html_parts.append("        .chart-container.tall { height: 400px; }")
    html_parts.append("        .footer {")
    html_parts.append("            text-align: center;")
    html_parts.append("            color: #8c8c8c;")
    html_parts.append("            font-size: 12px;")
    html_parts.append("            padding: 16px 0;")
    html_parts.append("        }")
    html_parts.append("        @media (max-width: 900px) {")
    html_parts.append("            .metrics-row { grid-template-columns: repeat(2, 1fr); }")
    html_parts.append("            .charts-grid { grid-template-columns: 1fr; }")
    html_parts.append("        }")
    html_parts.append("        @media (max-width: 600px) {")
    html_parts.append("            .metrics-row { grid-template-columns: 1fr; }")
    html_parts.append("            body { padding: 12px; }")
    html_parts.append("        }")
    html_parts.append("    </style>")
    html_parts.append("</head>")
    html_parts.append("<body>")
    html_parts.append('    <div class="container">')
    html_parts.append('        <div class="header">')
    html_parts.append("            <h1>📊 CI 健康度看板</h1>")
    html_parts.append(f'            <div class="subtitle">仓库: {repo}</div>')
    html_parts.append(f'            <div class="meta">统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}</div>')
    html_parts.append("        </div>")
    html_parts.append('        <div class="metrics-row">')
    html_parts.append('            <div class="metric-card success">')
    html_parts.append('                <div class="label">总成功率</div>')
    html_parts.append(f'                <div class="value">{success_rate}<span class="unit">%</span></div>')
    html_parts.append("            </div>")
    html_parts.append('            <div class="metric-card info">')
    html_parts.append('                <div class="label">总 Run 数</div>')
    html_parts.append(f'                <div class="value">{total_runs}<span class="unit">次</span></div>')
    html_parts.append("            </div>")
    html_parts.append('            <div class="metric-card warning">')
    html_parts.append('                <div class="label">平均耗时</div>')
    html_parts.append(f'                <div class="value">{avg_dur_min}<span class="unit">分钟</span></div>')
    html_parts.append("            </div>")
    html_parts.append('            <div class="metric-card danger">')
    html_parts.append('                <div class="label">基础设施故障率</div>')
    html_parts.append(f'                <div class="value">{infra_fail_rate}<span class="unit">%</span></div>')
    html_parts.append("            </div>")
    html_parts.append("        </div>")
    html_parts.append('        <div class="charts-grid">')
    html_parts.append('            <div class="chart-card full-width">')
    html_parts.append("                <h3>📈 CI 成功率趋势</h3>")
    html_parts.append('                <div id="chart-success-rate" class="chart-container"></div>')
    html_parts.append("            </div>")
    html_parts.append("        </div>")
    html_parts.append('        <div class="charts-grid">')
    html_parts.append('            <div class="chart-card">')
    html_parts.append("                <h3>⏱️ 各 Workflow 平均耗时</h3>")
    html_parts.append('                <div id="chart-wf-duration" class="chart-container"></div>')
    html_parts.append("            </div>")
    html_parts.append('            <div class="chart-card">')
    html_parts.append("                <h3>❌ 失败原因分布</h3>")
    html_parts.append('                <div id="chart-failure-pie" class="chart-container"></div>')
    html_parts.append("            </div>")
    html_parts.append("        </div>")
    html_parts.append('        <div class="charts-grid">')
    html_parts.append('            <div class="chart-card full-width">')
    html_parts.append("                <h3>📋 各 Job 成功率排行（最低 15 名）</h3>")
    html_parts.append('                <div id="chart-job-success" class="chart-container tall"></div>')
    html_parts.append("            </div>")
    html_parts.append("        </div>")
    html_parts.append('        <div class="charts-grid">')
    html_parts.append('            <div class="chart-card full-width">')
    html_parts.append("                <h3>📊 每日 Run 数量趋势</h3>")
    html_parts.append('                <div id="chart-run-count" class="chart-container"></div>')
    html_parts.append("            </div>")
    html_parts.append("        </div>")
    html_parts.append('        <div class="footer">')
    html_parts.append("            由 ci_dashboard.py 自动生成 | ECharts 可视化")
    html_parts.append("        </div>")
    html_parts.append("    </div>")
    html_parts.append("    <script>")
    html_parts.append(f"    const DATA = {data_json};")
    html_parts.append("")
    # 图表 1: 成功率趋势
    html_parts.append("    (function() {")
    html_parts.append('        const chart = echarts.init(document.getElementById("chart-success-rate"));')
    html_parts.append("        chart.setOption({")
    html_parts.append('            tooltip: { trigger: "axis", formatter: function(params) {')
    html_parts.append('                const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
    html_parts.append("            }},")
    html_parts.append('            grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
    html_parts.append('            xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
    html_parts.append("                axisLabel: { rotate: 30, fontSize: 11 } },")
    html_parts.append('            yAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
    html_parts.append(
        '            series: [{ name: "成功率", type: "line", smooth: true, data: DATA.daily_success_rates,'
    )
    html_parts.append('                itemStyle: { color: "#52c41a" },')
    html_parts.append("                areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
    html_parts.append('                    { offset: 0, color: "rgba(82, 196, 26, 0.3)" },')
    html_parts.append('                    { offset: 1, color: "rgba(82, 196, 26, 0.05)" }')
    html_parts.append("                ])},")
    html_parts.append('                markLine: { silent: true, data: [{ type: "average", name: "平均值",')
    html_parts.append('                    label: { formatter: "均值 {c}%" } }] }')
    html_parts.append("            }]")
    html_parts.append("        });")
    html_parts.append('        window.addEventListener("resize", () => chart.resize());')
    html_parts.append("    })();")
    html_parts.append("")
    # 图表 2: Workflow 耗时对比
    html_parts.append("    (function() {")
    html_parts.append('        const chart = echarts.init(document.getElementById("chart-wf-duration"));')
    html_parts.append("        chart.setOption({")
    html_parts.append('            tooltip: { trigger: "axis", formatter: function(params) {')
    html_parts.append(
        '                const p = params[0]; return p.name + "<br/>平均耗时: <b>" + p.value + " 分钟</b>";'
    )
    html_parts.append("            }},")
    html_parts.append('            grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },')
    html_parts.append('            xAxis: { type: "category", data: DATA.wf_names,')
    html_parts.append("                axisLabel: { rotate: 30, fontSize: 10, interval: 0 } },")
    html_parts.append('            yAxis: { type: "value", name: "分钟", axisLabel: { formatter: "{value} min" } },')
    html_parts.append('            series: [{ name: "平均耗时", type: "bar", data: DATA.wf_avg_durations,')
    html_parts.append("                itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
    html_parts.append('                    { offset: 0, color: "#1890ff" },')
    html_parts.append('                    { offset: 1, color: "#096dd9" }')
    html_parts.append("                ]), borderRadius: [4, 4, 0, 0] },")
    html_parts.append("                barMaxWidth: 40")
    html_parts.append("            }]")
    html_parts.append("        });")
    html_parts.append('        window.addEventListener("resize", () => chart.resize());')
    html_parts.append("    })();")
    html_parts.append("")
    # 图表 3: 失败原因饼图
    html_parts.append("    (function() {")
    html_parts.append('        const chart = echarts.init(document.getElementById("chart-failure-pie"));')
    html_parts.append("        chart.setOption({")
    html_parts.append('            tooltip: { trigger: "item", formatter: "{b}: {c} 次 ({d}%)" },')
    html_parts.append('            legend: { orient: "vertical", right: "5%", top: "center" },')
    html_parts.append(
        '            series: [{ name: "失败原因", type: "pie", radius: ["40%", "70%"], center: ["35%", "50%"],'
    )
    html_parts.append("                avoidLabelOverlap: false,")
    html_parts.append('                itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },')
    html_parts.append('                label: { show: false, position: "center" },')
    html_parts.append('                emphasis: { label: { show: true, fontSize: 16, fontWeight: "bold" } },')
    html_parts.append("                labelLine: { show: false },")
    html_parts.append("                data: DATA.failure_pie_data,")
    html_parts.append('                color: ["#ff4d4f", "#faad14", "#8c8c8c"]')
    html_parts.append("            }]")
    html_parts.append("        });")
    html_parts.append('        window.addEventListener("resize", () => chart.resize());')
    html_parts.append("    })();")
    html_parts.append("")
    # 图表 4: Job 成功率排行（横向柱状图）
    html_parts.append("    (function() {")
    html_parts.append('        const chart = echarts.init(document.getElementById("chart-job-success"));')
    html_parts.append("        const barData = DATA.job_rates.map(function(rate, i) {")
    html_parts.append("            return { value: rate, itemStyle: {")
    html_parts.append('                color: rate >= 90 ? "#52c41a" : (rate >= 70 ? "#faad14" : "#ff4d4f")')
    html_parts.append("            }};")
    html_parts.append("        });")
    html_parts.append("        chart.setOption({")
    html_parts.append('            tooltip: { trigger: "axis", formatter: function(params) {')
    html_parts.append('                const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
    html_parts.append("            }},")
    html_parts.append('            grid: { left: "3%", right: "8%", bottom: "3%", top: "3%", containLabel: true },')
    html_parts.append('            xAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
    html_parts.append('            yAxis: { type: "category", data: DATA.job_names, axisLabel: { fontSize: 11 } },')
    html_parts.append('            series: [{ name: "成功率", type: "bar", data: barData, barWidth: "60%",')
    html_parts.append('                label: { show: true, position: "right", formatter: "{c}%", fontSize: 11 }')
    html_parts.append("            }]")
    html_parts.append("        });")
    html_parts.append('        window.addEventListener("resize", () => chart.resize());')
    html_parts.append("    })();")
    html_parts.append("")
    # 图表 5: 每日 Run 数量趋势（面积图）
    html_parts.append("    (function() {")
    html_parts.append('        const chart = echarts.init(document.getElementById("chart-run-count"));')
    html_parts.append("        chart.setOption({")
    html_parts.append('            tooltip: { trigger: "axis", formatter: function(params) {')
    html_parts.append(
        '                const p = params[0]; return p.name + "<br/>Run 数量: <b>" + p.value + " 次</b>";'
    )
    html_parts.append("            }},")
    html_parts.append('            grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
    html_parts.append('            xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
    html_parts.append("                axisLabel: { rotate: 30, fontSize: 11 } },")
    html_parts.append('            yAxis: { type: "value", name: "次数" },')
    html_parts.append(
        '            series: [{ name: "Run 数量", type: "line", smooth: true, data: DATA.daily_run_counts,'
    )
    html_parts.append('                itemStyle: { color: "#722ed1" },')
    html_parts.append("                areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
    html_parts.append('                    { offset: 0, color: "rgba(114, 46, 209, 0.3)" },')
    html_parts.append('                    { offset: 1, color: "rgba(114, 46, 209, 0.05)" }')
    html_parts.append("                ])},")
    html_parts.append('                markLine: { silent: true, data: [{ type: "average", name: "平均值",')
    html_parts.append('                    label: { formatter: "均值 {c} 次" } }] }')
    html_parts.append("            }]")
    html_parts.append("        });")
    html_parts.append('        window.addEventListener("resize", () => chart.resize());')
    html_parts.append("    })();")
    html_parts.append("    </script>")
    html_parts.append("</body>")
    html_parts.append("</html>")

    return "\n".join(html_parts)


# ── 主函数 ───────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="CI 可观测性看板 - 生成 Gitea Actions 运行状态报表")
    parser.add_argument("--days", type=int, default=DEFAULT_DAYS, help=f"统计最近 N 天 (默认 {DEFAULT_DAYS})")
    parser.add_argument("--output", "-o", type=str, help="输出文件路径 (默认输出到 stdout)")
    parser.add_argument("--workflow", type=str, help="只统计指定 workflow (如 ci-cd.yml)")
    parser.add_argument("--gitea-url", type=str, default=os.environ.get("GITEA_URL", DEFAULT_GITEA_URL))
    parser.add_argument("--repo", type=str, default=os.environ.get("GITEA_REPO", DEFAULT_REPO))
    parser.add_argument("--token", type=str, default=os.environ.get("GITEA_TOKEN"))
    parser.add_argument("--username", type=str, default=os.environ.get("GITEA_USERNAME"))
    parser.add_argument("--password", type=str, default=os.environ.get("GITEA_PASSWORD"))
    parser.add_argument("--no-job-detail", action="store_true", help="不拉取 job 详情")
    parser.add_argument("--max-failures", type=int, default=50, help="最多分析多少个失败 run 的 job 详情 (默认 50)")

    # HTML 输出相关参数
    parser.add_argument("--html", action="store_true", help="生成 HTML 可视化看板")
    parser.add_argument("--html-output", type=str, help="HTML 输出文件路径 (默认 ci_dashboard.html)")

    args = parser.parse_args()

    ga = GiteaActions(
        base_url=args.gitea_url,
        repo=args.repo,
        token=args.token,
        username=args.username,
        password=args.password,
    )

    end_date = datetime.now().date()
    start_date = end_date - timedelta(days=args.days - 1)

    runs = fetch_runs_in_range(ga, start_date, end_date, args.workflow)
    if not runs:
        print("[ERROR] 未获取到任何数据", file=sys.stderr)
        sys.exit(1)

    if not args.no_job_detail:
        runs = enrich_with_jobs(ga, runs, max_failures=args.max_failures)

    stats = analyze_runs(runs)

    # HTML 模式
    if args.html:
        html = generate_html(stats, start_date, end_date, args.repo)
        html_output = args.html_output or args.output or "ci_dashboard.html"
        with open(html_output, "w", encoding="utf-8") as f:
            f.write(html)
        print(f"[INFO] HTML 看板已保存到 {html_output}", file=sys.stderr)
        return

    # 默认 Markdown 模式（向后兼容）
    md = generate_markdown(stats, start_date, end_date, args.repo)
    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(md)
        print(f"[INFO] 报表已保存到 {args.output}", file=sys.stderr)
    else:
        print(md)


if __name__ == "__main__":
    main()
