from __future__ import annotations

import argparse
import datetime as dt
import html
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple

from tensorboard.backend.event_processing.event_accumulator import EventAccumulator

from src.utils import ensure_artifact_paths

RUN_TS_PATTERN = re.compile(r"(\d{8}_\d{6})$")
CKPT_STEPS_PATTERN = re.compile(r"ppo_mario_ckpt_(\d+)_steps\.zip$")


@dataclass(frozen=True)
class ScalarPoint:
    step: int
    value: float


@dataclass(frozen=True)
class MetricSpec:
    key: str
    label: str
    tag_candidates: Tuple[str, ...]
    meaning: str
    scale: float = 1.0
    decimals: int = 2
    suffix: str = ""


METRIC_SPECS: List[MetricSpec] = [
    MetricSpec(
        key="max_x",
        label="最大前进距离",
        tag_candidates=("rollout/episode_max_x_pos", "episode_end/episode_max_x_pos"),
        meaning="马里奥在单回合内到达的最远 x 坐标，越大通常代表策略能跑得更远。",
        decimals=1,
    ),
    MetricSpec(
        key="ep_rew",
        label="平均回报",
        tag_candidates=("rollout/ep_rew_mean",),
        meaning="训练窗口内的平均 episode reward，会随 reward shaping 配置变化。",
        decimals=2,
    ),
    MetricSpec(
        key="ep_len",
        label="平均回合步数",
        tag_candidates=("rollout/ep_len_mean",),
        meaning="训练窗口内平均每回合步数；若步数高但 max_x 不涨，常见于卡住。",
        decimals=1,
    ),
    MetricSpec(
        key="clear_rate",
        label="通关率",
        tag_candidates=("rollout/flag_get", "episode_end/flag_get"),
        meaning="到达旗帜终点的比例。0 表示从未通关，1 表示全部通关。",
        scale=100.0,
        decimals=1,
        suffix="%",
    ),
    MetricSpec(
        key="no_progress_rate",
        label="无进展终止率",
        tag_candidates=("rollout/done_reason_no_progress", "episode_end/done_reason_no_progress"),
        meaning="因 no-progress 规则提前结束的比例，越高说明越容易卡住。",
        scale=100.0,
        decimals=1,
        suffix="%",
    ),
    MetricSpec(
        key="death_rate",
        label="死亡终止率",
        tag_candidates=("rollout/done_reason_death", "episode_end/done_reason_death"),
        meaning="因死亡结束 episode 的比例。",
        scale=100.0,
        decimals=1,
        suffix="%",
    ),
    MetricSpec(
        key="timeout_rate",
        label="超时终止率",
        tag_candidates=("rollout/done_reason_timeout", "episode_end/done_reason_timeout"),
        meaning="因时间耗尽或 TimeLimit 截断的比例。",
        scale=100.0,
        decimals=1,
        suffix="%",
    ),
    MetricSpec(
        key="hard_stuck_rate",
        label="硬卡死终止率",
        tag_candidates=("rollout/done_reason_hard_stuck", "episode_end/done_reason_hard_stuck"),
        meaning="触发 hard-stuck 截断的比例（仅在启用 hard_stuck 后有数据）。",
        scale=100.0,
        decimals=1,
        suffix="%",
    ),
]
METRIC_BY_KEY: Dict[str, MetricSpec] = {spec.key: spec for spec in METRIC_SPECS}


@dataclass(frozen=True)
class ModelItem:
    run_name: str
    run_time: dt.datetime
    model_path: Path
    model_kind: str
    model_step: Optional[int]


@dataclass(frozen=True)
class ModelMetric:
    index: int
    run_name: str
    model_file: str
    model_path: str
    model_kind: str
    model_step: Optional[int]
    metric_values: Dict[str, Optional[float]]
    metric_steps: Dict[str, Optional[int]]
    metric_tags: Dict[str, Optional[str]]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="可视化 artifacts/models 中模型在多维指标上的变化趋势，输出中文 HTML 或 Markdown 报告。"
    )
    parser.add_argument("--models-dir", type=str, default="", help="模型目录，默认 artifacts/models")
    parser.add_argument("--logs-dir", type=str, default="", help="日志目录，默认 artifacts/logs")
    parser.add_argument(
        "--format",
        type=str,
        default="html",
        choices=("html", "markdown"),
        help="输出格式：html 或 markdown",
    )
    parser.add_argument(
        "--output",
        type=str,
        default="",
        help="输出报告路径。默认 html: artifacts/reports/model_max_x_trend.html，markdown: artifacts/reports/model_max_x_trend.md",
    )
    return parser.parse_args()


def _parse_run_time(run_name: str, fallback: float) -> dt.datetime:
    match = RUN_TS_PATTERN.search(run_name)
    if match:
        try:
            return dt.datetime.strptime(match.group(1), "%Y%m%d_%H%M%S")
        except ValueError:
            pass
    return dt.datetime.fromtimestamp(fallback)


def _list_model_items(models_dir: Path) -> List[ModelItem]:
    items: List[ModelItem] = []
    for run_dir in sorted(path for path in models_dir.iterdir() if path.is_dir()):
        run_name = run_dir.name
        run_time = _parse_run_time(run_name, run_dir.stat().st_mtime)

        zip_files = sorted(path for path in run_dir.glob("*.zip") if path.is_file())
        staged: List[Tuple[int, str, Path, Optional[int], str]] = []
        for zip_path in zip_files:
            name = zip_path.name
            ckpt_match = CKPT_STEPS_PATTERN.match(name)
            if ckpt_match:
                step = int(ckpt_match.group(1))
                staged.append((0, f"{step:012d}", zip_path, step, "checkpoint"))
                continue
            if name == "final_model.zip":
                staged.append((1, "final_model", zip_path, None, "final"))
                continue
            staged.append((2, name, zip_path, None, "other"))

        for _, _, model_path, model_step, model_kind in sorted(staged, key=lambda row: (row[0], row[1])):
            items.append(
                ModelItem(
                    run_name=run_name,
                    run_time=run_time,
                    model_path=model_path,
                    model_kind=model_kind,
                    model_step=model_step,
                )
            )

    items.sort(
        key=lambda item: (
            item.run_time,
            10**18 if item.model_step is None and item.model_kind == "final" else (item.model_step or 0),
            item.model_path.name,
        )
    )
    return items


def _load_scalar_map_from_dir(event_dir: Path) -> Dict[str, List[ScalarPoint]]:
    if not event_dir.exists():
        return {}
    event_files = list(event_dir.glob("events.out.tfevents.*"))
    if not event_files:
        return {}

    accumulator = EventAccumulator(str(event_dir), size_guidance={"scalars": 0})
    try:
        accumulator.Reload()
    except Exception:
        return {}

    scalar_map: Dict[str, List[ScalarPoint]] = {}
    for tag in accumulator.Tags().get("scalars", []):
        try:
            points = [ScalarPoint(step=int(s.step), value=float(s.value)) for s in accumulator.Scalars(tag)]
        except Exception:
            continue
        if points:
            scalar_map[tag] = points
    return scalar_map


def _load_run_scalar_map(logs_dir: Path, run_name: str) -> Dict[str, List[ScalarPoint]]:
    run_dir = logs_dir / run_name
    merged: Dict[str, List[ScalarPoint]] = {}

    for sub in (("tb", "ppo_1"), ("tb", "episode_end")):
        event_dir = run_dir.joinpath(*sub)
        part = _load_scalar_map_from_dir(event_dir)
        for tag, points in part.items():
            if tag not in merged or len(points) > len(merged[tag]):
                merged[tag] = points

    return merged


def _value_at_or_before(points: Sequence[ScalarPoint], target_step: int) -> Tuple[float, int]:
    best = points[0]
    for point in points:
        if point.step <= target_step:
            best = point
        else:
            break
    return best.value, best.step


def _pick_series_for_metric(
    scalar_map: Dict[str, List[ScalarPoint]],
    spec: MetricSpec,
) -> Tuple[Optional[str], Optional[List[ScalarPoint]]]:
    for tag in spec.tag_candidates:
        points = scalar_map.get(tag)
        if points:
            return tag, points
    return None, None


def _resolve_model_metric(item: ModelItem, scalar_map: Dict[str, List[ScalarPoint]], index: int) -> ModelMetric:
    metric_values: Dict[str, Optional[float]] = {}
    metric_steps: Dict[str, Optional[int]] = {}
    metric_tags: Dict[str, Optional[str]] = {}

    for spec in METRIC_SPECS:
        tag, points = _pick_series_for_metric(scalar_map, spec)
        metric_tags[spec.key] = tag
        if not points:
            metric_values[spec.key] = None
            metric_steps[spec.key] = None
            continue

        if item.model_step is None:
            point = points[-1]
            metric_values[spec.key] = point.value
            metric_steps[spec.key] = point.step
            continue

        value, matched_step = _value_at_or_before(points, item.model_step)
        metric_values[spec.key] = value
        metric_steps[spec.key] = matched_step

    return ModelMetric(
        index=index,
        run_name=item.run_name,
        model_file=item.model_path.name,
        model_path=str(item.model_path.resolve()),
        model_kind=item.model_kind,
        model_step=item.model_step,
        metric_values=metric_values,
        metric_steps=metric_steps,
        metric_tags=metric_tags,
    )


def _scaled_value(metric: ModelMetric, key: str) -> Optional[float]:
    spec = METRIC_BY_KEY[key]
    raw = metric.metric_values.get(key)
    if raw is None:
        return None
    return raw * spec.scale


def _format_value(metric: ModelMetric, key: str) -> str:
    spec = METRIC_BY_KEY[key]
    scaled = _scaled_value(metric, key)
    if scaled is None:
        return ""
    return f"{scaled:.{spec.decimals}f}{spec.suffix}"


def _render_metric_svg(metrics: Sequence[ModelMetric], key: str, width: int, height: int) -> str:
    spec = METRIC_BY_KEY[key]
    valid: List[Tuple[int, float]] = []
    for idx, metric in enumerate(metrics):
        value = _scaled_value(metric, key)
        if value is not None:
            valid.append((idx, value))

    if not valid:
        return f'<div class="chart-empty">{html.escape(spec.label)}：暂无可用数据</div>'

    margin_left = 58
    margin_right = 22
    margin_top = 18
    margin_bottom = 48
    plot_w = width - margin_left - margin_right
    plot_h = height - margin_top - margin_bottom

    y_values = [v for _, v in valid]
    y_min = min(0.0, min(y_values))
    y_max = max(y_values)
    if y_max <= y_min:
        y_max = y_min + 1.0

    def sx(index: int) -> float:
        if len(metrics) == 1:
            return margin_left + plot_w / 2.0
        return margin_left + (index / (len(metrics) - 1)) * plot_w

    def sy(value: float) -> float:
        ratio = (value - y_min) / (y_max - y_min)
        return margin_top + (1.0 - ratio) * plot_h

    y_grid_count = 4
    y_grid: List[str] = []
    for i in range(y_grid_count + 1):
        ratio = i / y_grid_count
        y = margin_top + ratio * plot_h
        value = y_max - ratio * (y_max - y_min)
        y_grid.append(
            f'<line x1="{margin_left}" y1="{y:.2f}" x2="{margin_left + plot_w}" y2="{y:.2f}" '
            'stroke="#e5e7eb" stroke-width="1" />'
        )
        y_grid.append(
            f'<text x="{margin_left - 6}" y="{y + 4:.2f}" text-anchor="end" font-size="10" fill="#4b5563">'
            f"{value:.1f}{html.escape(spec.suffix)}</text>"
        )

    points_attr = " ".join(f"{sx(idx):.2f},{sy(value):.2f}" for idx, value in valid)
    polyline = (
        f'<polyline points="{points_attr}" fill="none" stroke="#2563eb" stroke-width="2.2" '
        'stroke-linejoin="round" stroke-linecap="round" />'
    )

    point_nodes: List[str] = []
    for idx, value in valid:
        metric = metrics[idx]
        point_nodes.append(
            f'<circle cx="{sx(idx):.2f}" cy="{sy(value):.2f}" r="3" fill="#1d4ed8">'
            f"<title>{html.escape(spec.label)}={value:.2f}{spec.suffix} | 序号={metric.index} | {metric.model_file}</title>"
            "</circle>"
        )

    return "\n".join(
        [
            f'<div class="chart-title">{html.escape(spec.label)}趋势</div>',
            f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
            "<rect x=\"0\" y=\"0\" width=\"100%\" height=\"100%\" fill=\"#ffffff\" />",
            *y_grid,
            f'<line x1="{margin_left}" y1="{margin_top + plot_h}" x2="{margin_left + plot_w}" y2="{margin_top + plot_h}" '
            'stroke="#111827" stroke-width="1.2" />',
            f'<line x1="{margin_left}" y1="{margin_top}" x2="{margin_left}" y2="{margin_top + plot_h}" '
            'stroke="#111827" stroke-width="1.2" />',
            polyline,
            *point_nodes,
            f'<text x="{margin_left + plot_w / 2:.2f}" y="{height - 14}" text-anchor="middle" font-size="11" fill="#111827">模型序号（按时间排序）</text>',
            "</svg>",
        ]
    )


def _metric_payload(metric: ModelMetric) -> Dict[str, object]:
    payload: Dict[str, object] = {
        "index": metric.index,
        "run_name": metric.run_name,
        "model_file": metric.model_file,
        "model_path": metric.model_path,
        "model_kind": metric.model_kind,
        "model_step": metric.model_step,
    }
    for spec in METRIC_SPECS:
        payload[spec.key] = metric.metric_values.get(spec.key)
        payload[f"{spec.key}_matched_step"] = metric.metric_steps.get(spec.key)
        payload[f"{spec.key}_source_tag"] = metric.metric_tags.get(spec.key)
    return payload


def _render_table(metrics: Sequence[ModelMetric]) -> str:
    header = (
        "<tr>"
        "<th>序号</th><th>训练Run</th><th>模型文件</th><th>类型</th><th>checkpoint步数</th>"
        "<th>最大前进距离</th><th>平均回报</th><th>平均回合步数</th><th>通关率</th>"
        "<th>无进展率</th><th>死亡率</th><th>超时率</th><th>硬卡死率</th>"
        "<th>max_x匹配步</th><th>max_x来源Tag</th><th>模型路径</th>"
        "</tr>"
    )

    rows: List[str] = []
    for metric in metrics:
        rows.append(
            "<tr>"
            f"<td>{metric.index}</td>"
            f"<td>{html.escape(metric.run_name)}</td>"
            f"<td>{html.escape(metric.model_file)}</td>"
            f"<td>{html.escape(metric.model_kind)}</td>"
            f"<td>{'' if metric.model_step is None else metric.model_step}</td>"
            f"<td>{html.escape(_format_value(metric, 'max_x'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'ep_rew'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'ep_len'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'clear_rate'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'no_progress_rate'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'death_rate'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'timeout_rate'))}</td>"
            f"<td>{html.escape(_format_value(metric, 'hard_stuck_rate'))}</td>"
            f"<td>{'' if metric.metric_steps.get('max_x') is None else metric.metric_steps.get('max_x')}</td>"
            f"<td>{html.escape(metric.metric_tags.get('max_x') or '')}</td>"
            f"<td><code>{html.escape(metric.model_path)}</code></td>"
            "</tr>"
        )

    return f"<table>{header}{''.join(rows)}</table>"


def _glossary_items() -> List[Tuple[str, str]]:
    return [
        ("Run", "一次完整训练实验，目录名通常形如 ppo_..._YYYYMMDD_HHMMSS。"),
        ("Checkpoint", "训练中间保存的模型快照，例如 ppo_mario_ckpt_100000_steps.zip。"),
        ("Final Model", "训练结束时保存的最终模型（final_model.zip）。"),
        ("model_step", "从 checkpoint 文件名解析出的训练步数。final_model 没有固定 step。"),
        ("matched_step", "从 TensorBoard 曲线中用于取值的实际步数（不超过 model_step 的最近点）。"),
        ("TensorBoard Tag", "标量指标的键名，例如 rollout/episode_max_x_pos。"),
        ("max_x", "最远横坐标，表示策略在关卡中的推进深度。"),
        ("ep_rew_mean", "平均回报，受 reward shaping 配置影响。"),
        ("ep_len_mean", "平均回合步数。"),
        ("flag_get / clear_rate", "通关比例（0~1，页面按百分比显示）。"),
        ("done_reason_*", "不同终止原因的比例（例如 no_progress、death、timeout、hard_stuck）。"),
    ]


def _render_glossary() -> str:
    rows = "".join(f"<li><b>{html.escape(name)}</b>：{html.escape(desc)}</li>" for name, desc in _glossary_items())
    return f"<ul>{rows}</ul>"


def _render_glossary_markdown() -> str:
    return "\n".join(f"- **{name}**：{desc}" for name, desc in _glossary_items())


def _summarize_metrics(
    metrics: Sequence[ModelMetric],
) -> Tuple[int, int, Optional[float], Optional[ModelMetric], Optional[ModelMetric]]:
    total = len(metrics)
    with_max_x = sum(1 for metric in metrics if metric.metric_values.get("max_x") is not None)

    best_max_x = None
    best_entry = None
    for metric in metrics:
        value = metric.metric_values.get("max_x")
        if value is None:
            continue
        if best_max_x is None or value > best_max_x:
            best_max_x = value
            best_entry = metric

    latest_with_max = next((metric for metric in reversed(metrics) if metric.metric_values.get("max_x") is not None), None)
    return total, with_max_x, best_max_x, best_entry, latest_with_max


def _build_best_text(best_max_x: Optional[float], best_entry: Optional[ModelMetric]) -> str:
    if best_entry is None or best_max_x is None:
        return "无"
    return f"{best_max_x:.1f}（序号#{best_entry.index}，{best_entry.model_file}）"


def _build_latest_text(latest_with_max: Optional[ModelMetric]) -> str:
    if latest_with_max is None:
        return "无"
    return f"{_format_value(latest_with_max, 'max_x')}（序号#{latest_with_max.index}，{latest_with_max.model_file}）"


def _md_escape(text: str) -> str:
    return text.replace("|", "\\|").replace("\n", " ")


def build_html(metrics: Sequence[ModelMetric], models_dir: Path, logs_dir: Path) -> str:
    total, with_max_x, best_max_x, best_entry, latest_with_max = _summarize_metrics(metrics)

    best_text = _build_best_text(best_max_x=best_max_x, best_entry=best_entry)
    latest_text = _build_latest_text(latest_with_max=latest_with_max)

    main_chart = _render_metric_svg(metrics, key="max_x", width=1260, height=390)
    extra_keys = ["ep_rew", "ep_len", "clear_rate", "no_progress_rate", "death_rate", "timeout_rate"]
    extra_charts = "".join(
        f'<div class="mini-chart card">{_render_metric_svg(metrics, key=key, width=610, height=250)}</div>' for key in extra_keys
    )

    table = _render_table(metrics)
    glossary = _render_glossary()
    payload = json.dumps([_metric_payload(metric) for metric in metrics], ensure_ascii=False, indent=2)

    return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
  <meta charset=\"utf-8\" />
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
  <title>模型趋势报告（max_x + 多维指标）</title>
  <style>
    body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; margin: 24px; color: #111827; background: #f8fafc; }}
    h1 {{ margin: 0 0 12px; }}
    h2 {{ margin: 6px 0 10px; font-size: 18px; }}
    .meta {{ color: #374151; margin-bottom: 14px; line-height: 1.6; }}
    .card {{ border: 1px solid #dbe2ea; border-radius: 12px; padding: 12px; background: #fff; margin-bottom: 14px; overflow-x: auto; }}
    .chart-title {{ font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #0f172a; }}
    .chart-empty {{ padding: 20px; color: #64748b; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(620px, 1fr)); gap: 14px; }}
    table {{ border-collapse: collapse; width: 100%; margin-top: 12px; font-size: 12px; }}
    th, td {{ border: 1px solid #e2e8f0; padding: 6px 8px; text-align: left; vertical-align: top; }}
    th {{ background: #f8fafc; position: sticky; top: 0; z-index: 1; }}
    code {{ font-size: 11px; }}
    details {{ margin-top: 12px; }}
    ul {{ margin: 8px 0 0 20px; line-height: 1.6; }}
  </style>
</head>
<body>
  <h1>模型趋势报告（max_x + 多维指标）</h1>
  <div class=\"meta\">
    <div>模型目录：<code>{html.escape(str(models_dir.resolve()))}</code></div>
    <div>日志目录：<code>{html.escape(str(logs_dir.resolve()))}</code></div>
    <div>模型总数：<b>{total}</b>；有 max_x 数据：<b>{with_max_x}</b></div>
    <div>历史最佳 max_x：<b>{html.escape(best_text)}</b></div>
    <div>最近模型 max_x：<b>{html.escape(latest_text)}</b></div>
    <div>说明：模型序号按时间排序（run 时间 + checkpoint 步数）。鼠标悬停图中点可看细节。</div>
  </div>

  <div class=\"card\">
    <h2>主趋势：最大前进距离（max_x）</h2>
    {main_chart}
  </div>

  <div class=\"grid\">{extra_charts}</div>

  <div class=\"card\">
    <h2>模型明细表</h2>
    {table}
  </div>

  <div class=\"card\">
    <h2>术语解释（专有名词）</h2>
    {glossary}
  </div>

  <details class=\"card\">
    <summary>原始数据（JSON）</summary>
    <pre>{html.escape(payload)}</pre>
  </details>
</body>
</html>
"""


def build_markdown(metrics: Sequence[ModelMetric], models_dir: Path, logs_dir: Path) -> str:
    total, with_max_x, best_max_x, best_entry, latest_with_max = _summarize_metrics(metrics)
    best_text = _build_best_text(best_max_x=best_max_x, best_entry=best_entry)
    latest_text = _build_latest_text(latest_with_max=latest_with_max)
    glossary = _render_glossary_markdown()
    payload = json.dumps([_metric_payload(metric) for metric in metrics], ensure_ascii=False, indent=2)

    lines: List[str] = []
    lines.append("# 模型趋势报告（max_x + 多维指标）")
    lines.append("")
    lines.append("## 概览")
    lines.append(f"- 模型目录：`{models_dir.resolve()}`")
    lines.append(f"- 日志目录：`{logs_dir.resolve()}`")
    lines.append(f"- 模型总数：**{total}**")
    lines.append(f"- 含 max_x 数据：**{with_max_x}**")
    lines.append(f"- 历史最佳 max_x：**{best_text}**")
    lines.append(f"- 最近模型 max_x：**{latest_text}**")
    lines.append("- 说明：模型序号按时间排序（run 时间 + checkpoint 步数）。")
    lines.append("")
    lines.append("## 指标说明")
    for spec in METRIC_SPECS:
        lines.append(f"- **{spec.label}**（`{spec.key}`）：{spec.meaning}")
    lines.append("")
    lines.append("## 模型明细")
    lines.append(
        "| 序号 | Run | 模型文件 | 类型 | checkpoint步数 | 最大前进距离 | 平均回报 | 平均回合步数 | 通关率 | 无进展率 | 死亡率 | 超时率 | 硬卡死率 | max_x匹配步 | max_x来源Tag |"
    )
    lines.append("|---:|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|")
    for metric in metrics:
        lines.append(
            "| "
            f"{metric.index} | "
            f"{_md_escape(metric.run_name)} | "
            f"{_md_escape(metric.model_file)} | "
            f"{metric.model_kind} | "
            f"{'' if metric.model_step is None else metric.model_step} | "
            f"{_format_value(metric, 'max_x')} | "
            f"{_format_value(metric, 'ep_rew')} | "
            f"{_format_value(metric, 'ep_len')} | "
            f"{_format_value(metric, 'clear_rate')} | "
            f"{_format_value(metric, 'no_progress_rate')} | "
            f"{_format_value(metric, 'death_rate')} | "
            f"{_format_value(metric, 'timeout_rate')} | "
            f"{_format_value(metric, 'hard_stuck_rate')} | "
            f"{'' if metric.metric_steps.get('max_x') is None else metric.metric_steps.get('max_x')} | "
            f"{_md_escape(metric.metric_tags.get('max_x') or '')} |"
        )
    lines.append("")
    lines.append("## 术语解释（专有名词）")
    lines.append(glossary)
    lines.append("")
    lines.append("## 原始数据（JSON）")
    lines.append("```json")
    lines.append(payload)
    lines.append("```")
    return "\n".join(lines)


def main() -> None:
    args = parse_args()
    paths = ensure_artifact_paths()

    models_dir = Path(args.models_dir).expanduser().resolve() if args.models_dir else paths.models.resolve()
    logs_dir = Path(args.logs_dir).expanduser().resolve() if args.logs_dir else paths.logs.resolve()
    default_name = "model_max_x_trend.md" if args.format == "markdown" else "model_max_x_trend.html"
    output_path = (
        Path(args.output).expanduser().resolve()
        if args.output
        else (paths.root / "reports" / default_name).resolve()
    )
    output_path.parent.mkdir(parents=True, exist_ok=True)

    model_items = _list_model_items(models_dir)
    scalar_cache: Dict[str, Dict[str, List[ScalarPoint]]] = {}
    metrics: List[ModelMetric] = []

    for idx, item in enumerate(model_items, start=1):
        if item.run_name not in scalar_cache:
            scalar_cache[item.run_name] = _load_run_scalar_map(logs_dir=logs_dir, run_name=item.run_name)
        metrics.append(_resolve_model_metric(item=item, scalar_map=scalar_cache[item.run_name], index=idx))

    if args.format == "markdown":
        content = build_markdown(metrics=metrics, models_dir=models_dir, logs_dir=logs_dir)
    else:
        content = build_html(metrics=metrics, models_dir=models_dir, logs_dir=logs_dir)
    output_path.write_text(content, encoding="utf-8")

    with_max_x = sum(1 for metric in metrics if metric.metric_values.get("max_x") is not None)
    print(f"[趋势] 报告已生成：{output_path} (format={args.format})")
    print(f"[趋势] 模型总数={len(metrics)}，含max_x数据={with_max_x}")


if __name__ == "__main__":
    main()
