#!/usr/bin/env python3
"""Build issue tag profile reports from a Feishu issue export JSON."""

from __future__ import annotations

import argparse
import html
import json
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime
from itertools import product
from pathlib import Path
from typing import Any, Iterable


DIMENSION_LABELS = {
    "target": "目标",
    "problem": "问题",
    "scene": "场景",
}
REPORT_DIMENSION_KEYS = ("target", "problem")
MISSING_LABELS = {
    "target": "未标目标",
    "problem": "未标问题",
    "scene": "未标场景",
}
DEFAULT_FUNCTION_KEYWORDS = ("ACC", "AEB", "FCW", "HMI", "LCC", "TSR", "CNCAP")
FIELD_TEST_TYPE = "场地问题"
ROAD_TEST_TYPE = "路测问题"
FIELD_TEST_NAME_KEYWORDS = ("AEB", "FCW", "场地", "CNCAP")
STALE_REPORT_SUFFIXES = (
    ".md",
    ".summary.csv",
    ".issue_tags.csv",
    ".tag_quality.csv",
    ".target_distribution.csv",
    ".problem_distribution.csv",
    ".scene_distribution.csv",
    ".function_distribution.csv",
    ".target_problem_combinations.csv",
    ".target_problem_scene_combinations.csv",
    ".target_problem_matrix.csv",
    ".function_problem_matrix.csv",
    ".manifest.json",
)
CHART_COLORS = (
    "#1769aa",
    "#2e7d32",
    "#c2410c",
    "#7b3fb3",
    "#be8a00",
    "#00838f",
    "#ad1457",
    "#5d6d7e",
    "#6b7280",
    "#9a3412",
    "#047857",
    "#4338ca",
)


@dataclass(frozen=True)
class IssueTagProfile:
    issue_id: int
    name: str
    status: str
    function_domain: str
    test_type: str
    updated_at: str
    targets: tuple[str, ...]
    problems: tuple[str, ...]
    scenes: tuple[str, ...]
    label_paths: tuple[str, ...]
    unknown_label_paths: tuple[str, ...]


@dataclass(frozen=True)
class ProfileReportData:
    view_id: str
    title: str
    profiles: list[IssueTagProfile]
    summary_rows: list[dict[str, Any]]
    target_rows: list[dict[str, Any]]
    problem_rows: list[dict[str, Any]]
    function_rows: list[dict[str, Any]]
    target_problem_rows: list[dict[str, Any]]
    target_problem_matrix_rows: list[dict[str, Any]]
    target_problem_matrix_fields: list[str]
    function_problem_matrix_rows: list[dict[str, Any]]
    function_problem_matrix_fields: list[str]
    quality_rows: list[dict[str, Any]]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--input-json",
        required=True,
        type=Path,
        help="Path to the Feishu issue export JSON.",
    )
    parser.add_argument(
        "--output-dir",
        required=True,
        type=Path,
        help="Directory where the HTML report will be written.",
    )
    parser.add_argument(
        "--report-name",
        default="issue_tag_profile",
        help="Base filename for generated report artifacts.",
    )
    parser.add_argument(
        "--label-field",
        default="缺陷标签池",
        help="Issue field containing the nested tag tree.",
    )
    parser.add_argument(
        "--top-limit",
        type=int,
        default=15,
        help="Maximum rows shown in compact HTML top tables and donut legends.",
    )
    parser.add_argument(
        "--function-keyword",
        action="append",
        default=[],
        help=(
            "Function-domain keyword used to classify issue names. Can be repeated. "
            f"Defaults to: {', '.join(DEFAULT_FUNCTION_KEYWORDS)}."
        ),
    )
    return parser.parse_args()


def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def remove_stale_non_html_outputs(output_dir: Path, report_name: str) -> None:
    for suffix in STALE_REPORT_SUFFIXES:
        stale_path = output_dir / f"{report_name}{suffix}"
        if stale_path.is_file():
            stale_path.unlink()


def compact_text(value: Any, max_len: int = 96) -> str:
    text = "" if value is None else str(value).strip()
    text = re.sub(r"\s+", " ", text)
    if len(text) <= max_len:
        return text
    return f"{text[: max_len - 3]}..."


def format_timestamp(value: Any) -> str:
    if value in (None, ""):
        return ""
    if isinstance(value, (int, float)):
        return datetime.fromtimestamp(value / 1000).astimezone().isoformat(timespec="seconds")
    return str(value)


def ratio(count: int, total: int) -> float:
    if total <= 0:
        return 0.0
    return count / total


def percent(count: int, total: int) -> str:
    return f"{ratio(count, total) * 100:.1f}%"


def normalize_report_name(value: str) -> str:
    name = value.strip()
    if not name:
        raise ValueError("--report-name must not be empty")
    if "/" in name or "\\" in name:
        raise ValueError("--report-name must be a filename stem, not a path")
    return name


def iter_label_paths(node: Any) -> Iterable[list[str]]:
    if node is None:
        return
    if isinstance(node, list):
        for child in node:
            yield from iter_label_paths(child)
        return
    if not isinstance(node, dict):
        text = compact_text(node, max_len=10_000)
        if text:
            yield [text]
        return

    label = node.get("label")
    prefix = [str(label)] if label not in (None, "") else []
    children = node.get("children")
    if children:
        for child_path in iter_label_paths(children):
            yield prefix + child_path
        return
    if prefix:
        yield prefix


def extract_dimension_tags(label_value: Any) -> tuple[dict[str, set[str]], list[str], list[str]]:
    dimensions: dict[str, set[str]] = {
        "target": set(),
        "problem": set(),
        "scene": set(),
    }
    label_paths: list[str] = []
    unknown_paths: list[str] = []

    for path in iter_label_paths(label_value):
        cleaned = [part.strip() for part in path if part and part.strip()]
        if not cleaned:
            continue
        label_paths.append("/".join(cleaned))
        matched = False
        for dimension_key, dimension_label in DIMENSION_LABELS.items():
            if dimension_label not in cleaned:
                continue
            index = cleaned.index(dimension_label)
            if index + 1 < len(cleaned):
                dimensions[dimension_key].add(cleaned[index + 1])
                matched = True
        if not matched:
            unknown_paths.append("/".join(cleaned))

    return dimensions, label_paths, unknown_paths


def infer_function_domain(name: str, keywords: tuple[str, ...]) -> str:
    normalized = name.upper()
    leading = re.sub(r"^[^A-Z0-9]+", "", normalized)
    for keyword in keywords:
        key = keyword.upper()
        if leading.startswith(key):
            return keyword
    return "其他"


def classify_test_type(name: str) -> str:
    normalized = name.upper()
    if any(keyword.upper() in normalized for keyword in FIELD_TEST_NAME_KEYWORDS):
        return FIELD_TEST_TYPE
    return ROAD_TEST_TYPE


def build_issue_profiles(
    items: list[dict[str, Any]],
    label_field: str,
    function_keywords: tuple[str, ...],
) -> list[IssueTagProfile]:
    profiles: list[IssueTagProfile] = []
    for item in items:
        dimensions, label_paths, unknown_paths = extract_dimension_tags(item.get(label_field))
        name = str(item.get("name") or "")
        profiles.append(
            IssueTagProfile(
                issue_id=int(item["id"]),
                name=name,
                status=str(item.get("status") or ""),
                function_domain=infer_function_domain(name, function_keywords),
                test_type=classify_test_type(name),
                updated_at=format_timestamp(item.get("updated_at")),
                targets=tuple(sorted(dimensions["target"])),
                problems=tuple(sorted(dimensions["problem"])),
                scenes=tuple(sorted(dimensions["scene"])),
                label_paths=tuple(sorted(set(label_paths))),
                unknown_label_paths=tuple(sorted(set(unknown_paths))),
            )
        )
    return profiles


def tags_for(profile: IssueTagProfile, dimension_key: str, include_missing: bool = False) -> tuple[str, ...]:
    values = getattr(profile, f"{dimension_key}s")
    if values:
        return values
    if include_missing:
        return (MISSING_LABELS[dimension_key],)
    return ()


def sort_counter(counter: Counter[str]) -> list[tuple[str, int]]:
    return sorted(counter.items(), key=lambda item: (-item[1], item[0]))


def issue_ids_text(issue_ids: Iterable[int]) -> str:
    return ",".join(str(issue_id) for issue_id in sorted(set(issue_ids)))


def build_distribution_rows(
    profiles: list[IssueTagProfile],
    dimension_key: str,
    *,
    include_missing: bool,
) -> list[dict[str, Any]]:
    total = len(profiles)
    counter: Counter[str] = Counter()
    issue_ids_by_tag: dict[str, list[int]] = defaultdict(list)
    for profile in profiles:
        for tag in tags_for(profile, dimension_key, include_missing=include_missing):
            counter[tag] += 1
            issue_ids_by_tag[tag].append(profile.issue_id)

    rows: list[dict[str, Any]] = []
    for tag, count in sort_counter(counter):
        rows.append(
            {
                "dimension": DIMENSION_LABELS[dimension_key],
                "tag": tag,
                "issue_count": count,
                "issue_ratio": f"{ratio(count, total):.6f}",
                "issue_percent": percent(count, total),
                "issue_ids": issue_ids_text(issue_ids_by_tag[tag]),
            }
        )
    return rows


def build_function_distribution_rows(profiles: list[IssueTagProfile]) -> list[dict[str, Any]]:
    total = len(profiles)
    counter = Counter(profile.function_domain for profile in profiles)
    issue_ids_by_function: dict[str, list[int]] = defaultdict(list)
    for profile in profiles:
        issue_ids_by_function[profile.function_domain].append(profile.issue_id)
    return [
        {
            "function_domain": function_domain,
            "issue_count": count,
            "issue_ratio": f"{ratio(count, total):.6f}",
            "issue_percent": percent(count, total),
            "issue_ids": issue_ids_text(issue_ids_by_function[function_domain]),
        }
        for function_domain, count in sort_counter(counter)
    ]


def build_combination_rows(
    profiles: list[IssueTagProfile],
    dimension_keys: tuple[str, ...],
    *,
    include_missing_scene: bool = True,
) -> list[dict[str, Any]]:
    total = len(profiles)
    counter: Counter[tuple[str, ...]] = Counter()
    issue_ids_by_combo: dict[tuple[str, ...], list[int]] = defaultdict(list)

    for profile in profiles:
        tag_lists: list[tuple[str, ...]] = []
        for dimension_key in dimension_keys:
            include_missing = include_missing_scene or dimension_key != "scene"
            tag_lists.append(tags_for(profile, dimension_key, include_missing=include_missing))
        if any(not tags for tags in tag_lists):
            continue
        for combo in product(*tag_lists):
            counter[combo] += 1
            issue_ids_by_combo[combo].append(profile.issue_id)

    rows: list[dict[str, Any]] = []
    for combo, count in sorted(counter.items(), key=lambda item: (-item[1], item[0])):
        row = {
            "issue_count": count,
            "issue_ratio": f"{ratio(count, total):.6f}",
            "issue_percent": percent(count, total),
            "issue_ids": issue_ids_text(issue_ids_by_combo[combo]),
        }
        for dimension_key, value in zip(dimension_keys, combo):
            row[DIMENSION_LABELS[dimension_key]] = value
        rows.append(row)
    return rows


def build_matrix_rows(
    profiles: list[IssueTagProfile],
    row_dimension: str,
    col_dimension: str,
    *,
    include_missing_row: bool = False,
    include_missing_col: bool = False,
) -> tuple[list[dict[str, Any]], list[str]]:
    row_tags = sorted(
        {
            tag
            for profile in profiles
            for tag in tags_for(profile, row_dimension, include_missing=include_missing_row)
        }
    )
    col_counts: Counter[str] = Counter()
    matrix: dict[str, Counter[str]] = {tag: Counter() for tag in row_tags}

    for profile in profiles:
        rows = tags_for(profile, row_dimension, include_missing=include_missing_row)
        cols = tags_for(profile, col_dimension, include_missing=include_missing_col)
        for row_tag in rows:
            for col_tag in cols:
                matrix[row_tag][col_tag] += 1
                col_counts[col_tag] += 1

    col_tags = [tag for tag, _ in sort_counter(col_counts)]
    row_tags = sorted(row_tags, key=lambda tag: (-sum(matrix[tag].values()), tag))
    rows: list[dict[str, Any]] = []
    for row_tag in row_tags:
        row = {DIMENSION_LABELS[row_dimension]: row_tag}
        total = 0
        for col_tag in col_tags:
            value = matrix[row_tag][col_tag]
            row[col_tag] = value
            total += value
        row["total"] = total
        rows.append(row)
    return rows, [DIMENSION_LABELS[row_dimension], *col_tags, "total"]


def build_function_problem_matrix_rows(profiles: list[IssueTagProfile]) -> tuple[list[dict[str, Any]], list[str]]:
    function_tags = sorted({profile.function_domain for profile in profiles})
    problem_counts: Counter[str] = Counter()
    matrix: dict[str, Counter[str]] = {tag: Counter() for tag in function_tags}

    for profile in profiles:
        problems = tags_for(profile, "problem", include_missing=True)
        for problem in problems:
            matrix[profile.function_domain][problem] += 1
            problem_counts[problem] += 1

    problem_tags = [tag for tag, _ in sort_counter(problem_counts)]
    function_tags = sorted(function_tags, key=lambda tag: (-sum(matrix[tag].values()), tag))
    rows: list[dict[str, Any]] = []
    for function_domain in function_tags:
        row = {"function_domain": function_domain}
        total = 0
        for problem in problem_tags:
            value = matrix[function_domain][problem]
            row[problem] = value
            total += value
        row["total"] = total
        rows.append(row)
    return rows, ["function_domain", *problem_tags, "total"]


def build_issue_detail_rows(profiles: list[IssueTagProfile]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for profile in profiles:
        missing_dimensions = [
            DIMENSION_LABELS[dimension_key]
            for dimension_key in DIMENSION_LABELS
            if not tags_for(profile, dimension_key)
        ]
        multi_dimensions = [
            DIMENSION_LABELS[dimension_key]
            for dimension_key in DIMENSION_LABELS
            if len(tags_for(profile, dimension_key)) > 1
        ]
        rows.append(
            {
                "issue_id": profile.issue_id,
                "name": profile.name,
                "function_domain": profile.function_domain,
                "test_type": profile.test_type,
                "status": profile.status,
                "updated_at": profile.updated_at,
                "targets": "|".join(profile.targets),
                "problems": "|".join(profile.problems),
                "scenes": "|".join(profile.scenes),
                "missing_dimensions": "|".join(missing_dimensions),
                "multi_dimensions": "|".join(multi_dimensions),
                "label_paths": "|".join(profile.label_paths),
                "unknown_label_paths": "|".join(profile.unknown_label_paths),
            }
        )
    return rows


def build_quality_rows(profiles: list[IssueTagProfile]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for profile in profiles:
        missing_dimensions = [
            DIMENSION_LABELS[dimension_key]
            for dimension_key in REPORT_DIMENSION_KEYS
            if not tags_for(profile, dimension_key)
        ]
        multi_dimensions = [
            DIMENSION_LABELS[dimension_key]
            for dimension_key in REPORT_DIMENSION_KEYS
            if len(tags_for(profile, dimension_key)) > 1
        ]
        if not missing_dimensions and not multi_dimensions and not profile.unknown_label_paths:
            continue
        rows.append(
            {
                "issue_id": profile.issue_id,
                "name": profile.name,
                "function_domain": profile.function_domain,
                "test_type": profile.test_type,
                "missing_dimensions": "|".join(missing_dimensions),
                "multi_dimensions": "|".join(multi_dimensions),
                "unknown_label_paths": "|".join(profile.unknown_label_paths),
                "targets": "|".join(profile.targets),
                "problems": "|".join(profile.problems),
                "scenes": "|".join(profile.scenes),
            }
        )
    return rows


def build_summary_rows(profiles: list[IssueTagProfile]) -> list[dict[str, Any]]:
    total = len(profiles)
    rows = [{"metric": "total_issues", "value": total, "percent": "", "detail": "问题单总数"}]
    for dimension_key in REPORT_DIMENSION_KEYS:
        dimension_label = DIMENSION_LABELS[dimension_key]
        complete_count = sum(1 for profile in profiles if tags_for(profile, dimension_key))
        multi_count = sum(1 for profile in profiles if len(tags_for(profile, dimension_key)) > 1)
        rows.append(
            {
                "metric": f"{dimension_key}_complete_issues",
                "value": complete_count,
                "percent": percent(complete_count, total),
                "detail": f"{dimension_label}维度已标问题单数",
            }
        )
        rows.append(
            {
                "metric": f"{dimension_key}_multi_label_issues",
                "value": multi_count,
                "percent": percent(multi_count, total),
                "detail": f"{dimension_label}维度多标签问题单数",
            }
        )

    unknown_count = sum(1 for profile in profiles if profile.unknown_label_paths)
    rows.append(
        {
            "metric": "unknown_label_path_issues",
            "value": unknown_count,
            "percent": percent(unknown_count, total),
            "detail": "存在未识别标签路径的问题单数",
        }
    )
    return rows


def build_profile_report_data(view_id: str, title: str, profiles: list[IssueTagProfile]) -> ProfileReportData:
    target_problem_matrix_rows, target_problem_matrix_fields = build_matrix_rows(
        profiles,
        "target",
        "problem",
        include_missing_row=True,
        include_missing_col=True,
    )
    function_problem_matrix_rows, function_problem_matrix_fields = build_function_problem_matrix_rows(profiles)
    return ProfileReportData(
        view_id=view_id,
        title=title,
        profiles=profiles,
        summary_rows=build_summary_rows(profiles),
        target_rows=build_distribution_rows(profiles, "target", include_missing=True),
        problem_rows=build_distribution_rows(profiles, "problem", include_missing=True),
        function_rows=build_function_distribution_rows(profiles),
        target_problem_rows=build_combination_rows(profiles, ("target", "problem")),
        target_problem_matrix_rows=target_problem_matrix_rows,
        target_problem_matrix_fields=target_problem_matrix_fields,
        function_problem_matrix_rows=function_problem_matrix_rows,
        function_problem_matrix_fields=function_problem_matrix_fields,
        quality_rows=build_quality_rows(profiles),
    )


def html_text(value: Any) -> str:
    return html.escape(str(value), quote=True)


def html_table(headers: list[str], rows: list[list[Any]], class_name: str = "") -> str:
    class_attr = f' class="{html_text(class_name)}"' if class_name else ""
    header_html = "".join(f"<th>{html_text(header)}</th>" for header in headers)
    body_lines = []
    for row in rows:
        body_lines.append(
            "<tr>" + "".join(f"<td>{html_text(value)}</td>" for value in row) + "</tr>"
        )
    return (
        f"<table{class_attr}>"
        f"<thead><tr>{header_html}</tr></thead>"
        f"<tbody>{''.join(body_lines)}</tbody>"
        "</table>"
    )


def distribution_html(rows: list[dict[str, Any]], top_limit: int) -> str:
    return html_table(
        ["标签", "问题数", "占比"],
        [[row["tag"], row["issue_count"], row["issue_percent"]] for row in rows[:top_limit]],
    )


def combination_html(rows: list[dict[str, Any]], dimensions: list[str], top_limit: int) -> str:
    headers = [*dimensions, "问题数", "占比"]
    table_rows = [
        [*(row[dimension] for dimension in dimensions), row["issue_count"], row["issue_percent"]]
        for row in rows[:top_limit]
    ]
    return html_table(headers, table_rows)


def matrix_html(rows: list[dict[str, Any]], fieldnames: list[str]) -> str:
    table_rows = [[row.get(field, "") for field in fieldnames] for row in rows]
    return html_table(fieldnames, table_rows, class_name="matrix-table")


def build_chart_slices(
    rows: list[dict[str, Any]],
    label_key: str,
    *,
    count_key: str = "issue_count",
    max_slices: int = 10,
) -> list[dict[str, Any]]:
    visible_rows = rows[:max_slices]
    hidden_rows = rows[max_slices:]
    slices = [
        {
            "label": str(row[label_key]),
            "count": int(row[count_key]),
        }
        for row in visible_rows
        if int(row[count_key]) > 0
    ]
    hidden_count = sum(int(row[count_key]) for row in hidden_rows)
    if hidden_count > 0:
        slices.append({"label": "其他", "count": hidden_count})
    return slices


def donut_chart_html(
    title: str,
    rows: list[dict[str, Any]],
    label_key: str,
    *,
    max_slices: int = 10,
) -> str:
    slices = build_chart_slices(rows, label_key, max_slices=max_slices)
    total = sum(item["count"] for item in slices)
    if total <= 0:
        return (
            "<div class=\"chart-card empty-chart\">"
            f"<strong>{html_text(title)}</strong>"
            "<p>暂无可用统计数据</p>"
            "</div>"
        )

    circle_segments: list[str] = []
    legend_items: list[str] = []
    offset = 0.0
    for index, item in enumerate(slices):
        count = item["count"]
        share = count / total * 100
        color = CHART_COLORS[index % len(CHART_COLORS)]
        circle_segments.append(
            "<circle class=\"donut-segment\" cx=\"21\" cy=\"21\" r=\"15.9155\" "
            "fill=\"transparent\" pathLength=\"100\" "
            f"stroke=\"{html_text(color)}\" stroke-width=\"8\" "
            f"stroke-dasharray=\"{share:.4f} {100 - share:.4f}\" "
            f"stroke-dashoffset=\"{-offset:.4f}\" />"
        )
        legend_items.append(
            "<li>"
            f"<span class=\"legend-swatch\" style=\"background:{html_text(color)}\"></span>"
            f"<span class=\"legend-label\">{html_text(item['label'])}</span>"
            f"<strong>{count}</strong>"
            f"<em>{share:.1f}%</em>"
            "</li>"
        )
        offset += share

    return (
        "<div class=\"chart-card\">"
        f"<strong>{html_text(title)}</strong>"
        "<div class=\"donut-layout\">"
        "<svg class=\"donut-chart\" viewBox=\"0 0 42 42\" role=\"img\" "
        f"aria-label=\"{html_text(title)}饼图\">"
        "<circle class=\"donut-hole\" cx=\"21\" cy=\"21\" r=\"15.9155\" fill=\"transparent\" "
        "stroke=\"#e7ebf0\" stroke-width=\"8\" />"
        "<g transform=\"rotate(-90 21 21)\">"
        + "".join(circle_segments)
        + "</g>"
        "<circle cx=\"21\" cy=\"21\" r=\"10.8\" fill=\"#ffffff\" />"
        f"<text x=\"21\" y=\"19.8\" text-anchor=\"middle\" class=\"donut-total\">{total}</text>"
        "<text x=\"21\" y=\"24.2\" text-anchor=\"middle\" class=\"donut-caption\">标签计数</text>"
        "</svg>"
        "<ol class=\"chart-legend\">"
        + "".join(legend_items)
        + "</ol>"
        "</div>"
        "</div>"
    )


def distribution_panel_html(
    title: str,
    rows: list[dict[str, Any]],
    label_key: str,
    table_html: str,
    *,
    top_limit: int,
    note: str,
) -> str:
    max_slices = max(1, top_limit)
    return (
        "<section>"
        f"<h2>{html_text(title)}</h2>"
        "<div class=\"chart-table-layout\">"
        f"{donut_chart_html(title, rows, label_key, max_slices=max_slices)}"
        f"<div class=\"table-wrap\">{table_html}</div>"
        "</div>"
        f"<p class=\"chart-note\">{html_text(note)}</p>"
        "</section>"
    )


def find_summary_metric(summary_rows: list[dict[str, Any]], metric: str) -> dict[str, Any] | None:
    return next((row for row in summary_rows if row["metric"] == metric), None)


def build_insight_lines(
    *,
    total: int,
    summary_rows: list[dict[str, Any]],
    target_rows: list[dict[str, Any]],
    problem_rows: list[dict[str, Any]],
    target_problem_rows: list[dict[str, Any]],
    quality_rows: list[dict[str, Any]],
) -> list[str]:
    top_target = target_rows[0] if target_rows else None
    top_problem = problem_rows[0] if problem_rows else None
    top_combo = target_problem_rows[0] if target_problem_rows else None

    insight_lines: list[str] = []
    if top_problem:
        insight_lines.append(
            f"高频问题类型是 {top_problem['tag']}，覆盖 {top_problem['issue_count']}/{total} 条。"
        )
    if top_target:
        insight_lines.append(
            f"高频目标类型是 {top_target['tag']}，覆盖 {top_target['issue_count']}/{total} 条。"
        )
    if top_combo:
        insight_lines.append(
            f"高频目标-问题组合是 {top_combo['目标']} x {top_combo['问题']}，"
            f"覆盖 {top_combo['issue_count']}/{total} 条。"
        )

    if quality_rows:
        insight_lines.append(f"标签质量待检查问题单 {len(quality_rows)} 条，主要来自目标/问题维度缺失或多标签。")
    return insight_lines


def render_profile_panel_html(report: ProfileReportData, top_limit: int, *, active: bool) -> str:
    total = len(report.profiles)
    target_complete = find_summary_metric(report.summary_rows, "target_complete_issues")
    problem_complete = find_summary_metric(report.summary_rows, "problem_complete_issues")
    problem_multi = find_summary_metric(report.summary_rows, "problem_multi_label_issues")
    top_target = report.target_rows[0] if report.target_rows else {}
    top_problem = report.problem_rows[0] if report.problem_rows else {}
    top_combo = report.target_problem_rows[0] if report.target_problem_rows else {}
    insight_lines = build_insight_lines(
        total=total,
        summary_rows=report.summary_rows,
        target_rows=report.target_rows,
        problem_rows=report.problem_rows,
        target_problem_rows=report.target_problem_rows,
        quality_rows=report.quality_rows,
    )

    def metric_percent(row: dict[str, Any] | None) -> str:
        return "" if row is None else str(row["percent"])

    cards = [
        ("问题类型", report.title, "当前视图"),
        ("问题单数", total, "issues"),
        ("高频问题", top_problem.get("tag", "-"), f"{top_problem.get('issue_count', 0)}/{total}"),
        ("高频目标", top_target.get("tag", "-"), f"{top_target.get('issue_count', 0)}/{total}"),
        (
            "高频组合",
            f"{top_combo.get('目标', '-')} x {top_combo.get('问题', '-')}",
            f"{top_combo.get('issue_count', 0)}/{total}",
        ),
        ("目标完整率", metric_percent(target_complete), "target completeness"),
        ("问题完整率", metric_percent(problem_complete), "problem completeness"),
        ("问题多标签", metric_percent(problem_multi), "multi-label issues"),
    ]
    card_html = "".join(
        "<article class=\"metric-card\">"
        f"<span>{html_text(label)}</span>"
        f"<strong>{html_text(value)}</strong>"
        f"<em>{html_text(detail)}</em>"
        "</article>"
        for label, value, detail in cards
    )

    insight_html = "".join(f"<li>{html_text(line)}</li>" for line in insight_lines)
    summary_table = html_table(
        ["指标", "数值", "占比", "说明"],
        [[row["metric"], row["value"], row["percent"], row["detail"]] for row in report.summary_rows],
    )
    function_table = html_table(
        ["功能域", "问题数", "占比"],
        [
            [row["function_domain"], row["issue_count"], row["issue_percent"]]
            for row in report.function_rows[:top_limit]
        ],
    )
    hidden = "" if active else " hidden"

    return f"""
    <div class="view-panel" data-view="{html_text(report.view_id)}"{hidden}>
      <div class="metrics">{card_html}</div>
      <section>
        <h2>{html_text(report.title)}核心判断</h2>
        <ul class="insights">{insight_html}</ul>
      </section>
      <section class="warning">
        <h2>标签完整性</h2>
        <div class="table-wrap">{summary_table}</div>
      </section>
      <div class="grid">
        {distribution_panel_html(
            "目标分布",
            report.target_rows,
            "tag",
            distribution_html(report.target_rows, top_limit),
            top_limit=top_limit,
            note=f"图中比例按目标标签计数计算；表格占比按 {total} 条问题单计算。",
        )}
        {distribution_panel_html(
            "问题分布",
            report.problem_rows,
            "tag",
            distribution_html(report.problem_rows, top_limit),
            top_limit=top_limit,
            note="问题维度允许多标签，图中比例按问题标签计数计算；表格占比按问题单覆盖率计算。",
        )}
        {distribution_panel_html(
            "功能域分布",
            report.function_rows,
            "function_domain",
            function_table,
            top_limit=top_limit,
            note=f"功能域按 issue 名称前缀归类；图中比例按 {total} 条问题单计算。",
        )}
      </div>
      <section>
        <h2>目标 x 问题矩阵</h2>
        <div class="table-wrap">{matrix_html(report.target_problem_matrix_rows, report.target_problem_matrix_fields)}</div>
      </section>
      <section>
        <h2>功能域 x 问题矩阵</h2>
        <div class="table-wrap">{matrix_html(report.function_problem_matrix_rows, report.function_problem_matrix_fields)}</div>
      </section>
      <div class="grid">
        <section>
          <h2>高频目标-问题组合</h2>
          <div class="table-wrap">{combination_html(report.target_problem_rows, ["目标", "问题"], top_limit)}</div>
        </section>
      </div>
    </div>
"""


def build_html_report(
    *,
    payload: dict[str, Any],
    input_json: Path,
    reports: list[ProfileReportData],
    all_profiles: list[IssueTagProfile],
    top_limit: int,
) -> str:
    total = int(payload.get("summary", {}).get("total") or len(all_profiles))
    exported_at = payload.get("exported_at", "")
    generated_at = datetime.now().astimezone().isoformat(timespec="seconds")
    report_by_id = {report.view_id: report for report in reports}
    default_view = "field"
    field_total = len(report_by_id["field"].profiles) if "field" in report_by_id else 0
    road_total = len(report_by_id["road"].profiles) if "road" in report_by_id else 0
    toggle_buttons = "".join(
        "<button type=\"button\" "
        f"class=\"view-button{' active' if report.view_id == default_view else ''}\" "
        f"data-view-target=\"{html_text(report.view_id)}\">"
        f"{html_text(report.title)} <span>{len(report.profiles)}</span>"
        "</button>"
        for report in reports
    )
    panel_html = "".join(
        render_profile_panel_html(report, top_limit, active=(report.view_id == default_view))
        for report in reports
    )
    rule_text = "分类规则：问题名称包含 AEB / FCW / 场地 / CNCAP 归为场地问题，其余归为路测问题。"

    return f"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>问题画像统计</title>
  <style>
    :root {{
      color-scheme: light;
      --bg: #f6f7f9;
      --panel: #ffffff;
      --ink: #17202a;
      --muted: #5f6b7a;
      --line: #d9dee7;
      --accent: #1769aa;
      --accent-soft: #e7f1fb;
      --warn: #b05d00;
      --warn-soft: #fff3df;
      --radius: 8px;
    }}
    * {{ box-sizing: border-box; }}
    body {{
      margin: 0;
      background: var(--bg);
      color: var(--ink);
      font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
    }}
    header {{
      background: #101820;
      color: #fff;
      padding: 28px 32px;
    }}
    header h1 {{
      margin: 0 0 8px;
      font-size: 28px;
      font-weight: 700;
      letter-spacing: 0;
    }}
    header p {{
      margin: 0;
      color: #c7d1dc;
      overflow-wrap: anywhere;
    }}
    main {{
      max-width: 1440px;
      margin: 0 auto;
      padding: 24px 28px 40px;
    }}
    [hidden] {{
      display: none !important;
    }}
    .report-toolbar {{
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 18px;
    }}
    .view-toggle {{
      display: inline-flex;
      border: 1px solid var(--line);
      border-radius: var(--radius);
      overflow: hidden;
      background: var(--panel);
    }}
    .view-button {{
      appearance: none;
      border: 0;
      border-right: 1px solid var(--line);
      background: var(--panel);
      color: var(--ink);
      cursor: pointer;
      font: inherit;
      font-weight: 700;
      padding: 9px 14px;
    }}
    .view-button:last-child {{
      border-right: 0;
    }}
    .view-button span {{
      margin-left: 4px;
      color: var(--muted);
      font-weight: 600;
    }}
    .view-button.active {{
      background: var(--accent);
      color: #fff;
    }}
    .view-button.active span {{
      color: #e6f1fb;
    }}
    .classification-rule {{
      margin: 0;
      color: var(--muted);
      max-width: 760px;
    }}
    .metrics {{
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
      gap: 12px;
      margin-bottom: 20px;
    }}
    .metric-card {{
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: var(--radius);
      padding: 14px 16px;
      min-height: 108px;
    }}
    .metric-card span, .metric-card em {{
      display: block;
      color: var(--muted);
      font-style: normal;
      overflow-wrap: anywhere;
    }}
    .metric-card strong {{
      display: block;
      margin: 8px 0 4px;
      font-size: 24px;
      line-height: 1.2;
      overflow-wrap: anywhere;
    }}
    section {{
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: var(--radius);
      padding: 18px;
      margin: 16px 0;
    }}
    h2 {{
      margin: 0 0 14px;
      font-size: 18px;
      letter-spacing: 0;
    }}
    .insights {{
      margin: 0;
      padding-left: 20px;
    }}
    .insights li {{
      margin: 6px 0;
    }}
    .grid {{
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr));
      gap: 16px;
      align-items: start;
    }}
    .chart-table-layout {{
      display: grid;
      grid-template-columns: minmax(0, 1fr);
      gap: 14px;
      align-items: stretch;
    }}
    .chart-card {{
      border: 1px solid var(--line);
      border-radius: var(--radius);
      padding: 14px;
      background: #fbfcfe;
      min-width: 0;
      overflow: hidden;
    }}
    .chart-card > strong {{
      display: block;
      margin-bottom: 10px;
      font-size: 14px;
    }}
    .donut-layout {{
      display: grid;
      grid-template-columns: minmax(128px, 150px) minmax(0, 1fr);
      gap: 12px;
      align-items: center;
    }}
    .donut-chart {{
      width: 150px;
      height: 150px;
      max-width: 100%;
    }}
    .donut-segment {{
      transition: opacity .15s ease;
    }}
    .donut-total {{
      fill: var(--ink);
      font-size: 6px;
      font-weight: 700;
    }}
    .donut-caption {{
      fill: var(--muted);
      font-size: 2.7px;
    }}
    .chart-legend {{
      list-style: none;
      margin: 0;
      padding: 0;
      display: grid;
      gap: 5px;
    }}
    .chart-legend li {{
      display: grid;
      grid-template-columns: 12px minmax(0, 1fr) auto auto;
      gap: 7px;
      align-items: center;
      color: var(--muted);
    }}
    .legend-swatch {{
      width: 10px;
      height: 10px;
      border-radius: 50%;
    }}
    .legend-label {{
      color: var(--ink);
      overflow-wrap: anywhere;
    }}
    .chart-legend strong {{
      color: var(--ink);
    }}
    .chart-legend em {{
      font-style: normal;
      color: var(--muted);
    }}
    .chart-note {{
      margin: 10px 0 0;
      color: var(--muted);
      font-size: 12px;
    }}
    .empty-chart p {{
      margin: 0;
      color: var(--muted);
    }}
    .table-wrap {{
      width: 100%;
      overflow-x: auto;
    }}
    table {{
      width: 100%;
      border-collapse: collapse;
      min-width: 520px;
    }}
    .chart-table-layout table {{
      min-width: 0;
    }}
    .chart-table-layout th, .chart-table-layout td {{
      white-space: normal;
    }}
    th, td {{
      border-bottom: 1px solid var(--line);
      padding: 8px 10px;
      text-align: left;
      vertical-align: top;
      white-space: nowrap;
    }}
    th {{
      background: var(--accent-soft);
      color: #0d4778;
      font-weight: 700;
    }}
    tbody tr:hover {{
      background: #f8fbfd;
    }}
    .matrix-table td:not(:first-child), .matrix-table th:not(:first-child) {{
      text-align: right;
    }}
    .warning {{
      border-color: #f0d19a;
      background: var(--warn-soft);
    }}
    .warning h2 {{
      color: var(--warn);
    }}
    .path-table td:last-child {{
      white-space: normal;
      overflow-wrap: anywhere;
      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      font-size: 12px;
    }}
    @media (max-width: 920px) {{
      .chart-table-layout, .donut-layout {{
        grid-template-columns: 1fr;
      }}
      .donut-chart {{
        justify-self: center;
      }}
    }}
  </style>
</head>
<body>
  <header>
    <h1>问题画像统计</h1>
    <p>输入：{html_text(input_json)} · 导出时间：{html_text(exported_at)} · 生成时间：{html_text(generated_at)} · 总数：{total} · 场地：{field_total} · 路测：{road_total}</p>
  </header>
  <main>
    <div class="report-toolbar">
      <div class="view-toggle" role="tablist" aria-label="问题类型切换">
        {toggle_buttons}
      </div>
      <p class="classification-rule">{html_text(rule_text)}</p>
    </div>
    {panel_html}
  </main>
  <script>
    (() => {{
      const buttons = Array.from(document.querySelectorAll("[data-view-target]"));
      const panels = Array.from(document.querySelectorAll("[data-view]"));
      const selectView = (viewId) => {{
        buttons.forEach((button) => {{
          const active = button.dataset.viewTarget === viewId;
          button.classList.toggle("active", active);
          button.setAttribute("aria-selected", active ? "true" : "false");
        }});
        panels.forEach((panel) => {{
          panel.hidden = panel.dataset.view !== viewId;
        }});
      }};
      buttons.forEach((button) => {{
        button.addEventListener("click", () => selectView(button.dataset.viewTarget));
      }});
      selectView("{html_text(default_view)}");
    }})();
  </script>
</body>
</html>
"""


def main() -> int:
    args = parse_args()
    input_json = args.input_json.resolve()
    output_dir = args.output_dir.resolve()
    report_name = normalize_report_name(args.report_name)
    function_keywords = tuple(args.function_keyword or DEFAULT_FUNCTION_KEYWORDS)

    if args.top_limit <= 0:
        raise ValueError("--top-limit must be greater than 0")
    if not input_json.is_file():
        raise FileNotFoundError(f"Input JSON not found: {input_json}")

    payload = load_json(input_json)
    items = payload.get("items", [])
    if not isinstance(items, list):
        raise ValueError("Input JSON must contain an 'items' list")

    profiles = build_issue_profiles(items, args.label_field, function_keywords)
    output_dir.mkdir(parents=True, exist_ok=True)

    field_profiles = [profile for profile in profiles if profile.test_type == FIELD_TEST_TYPE]
    road_profiles = [profile for profile in profiles if profile.test_type == ROAD_TEST_TYPE]
    reports = [
        build_profile_report_data("field", FIELD_TEST_TYPE, field_profiles),
        build_profile_report_data("road", ROAD_TEST_TYPE, road_profiles),
    ]
    remove_stale_non_html_outputs(output_dir, report_name)

    html_report_path = output_dir / f"{report_name}.html"
    html_report = build_html_report(
        payload=payload,
        input_json=input_json,
        reports=reports,
        all_profiles=profiles,
        top_limit=args.top_limit,
    )
    html_report_path.write_text(html_report, encoding="utf-8")

    print(f"input_json: {input_json}")
    print(f"output_dir: {output_dir}")
    print(f"html_report: {html_report_path}")
    print(f"total_issues: {len(profiles)}")
    print(f"field_issues: {len(field_profiles)}")
    print(f"road_issues: {len(road_profiles)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
