#!/usr/bin/env python3
"""
把 E2E manifest + 截图生成一份可人工走查的 Word 验收报告。

依赖：
  pip install python-docx

用法：
  python3 build_test_report.py \
    --manifest docs/04-qa/manifests/US-004.json \
    --qa-root docs/04-qa \
    --out docs/04-qa/e2e-report-dev-20261030.docx

可选：
  --title "US-004 创建任务 - 开发环境 E2E 验收报告"
  --author "产品负责人"
  --project "TeamTask"
  --page-break-per-step
"""

from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path

try:
    from docx import Document
    from docx.enum.table import WD_TABLE_ALIGNMENT
    from docx.enum.text import WD_ALIGN_PARAGRAPH
    from docx.oxml import OxmlElement
    from docx.oxml.ns import qn
    from docx.shared import Inches, Pt, RGBColor
except ImportError:
    print("缺少依赖：pip install python-docx", file=sys.stderr)
    raise SystemExit(2)

FONT = "Microsoft YaHei"
MONO_FONT = "Consolas"

COLOR_PASS = RGBColor(0x16, 0xA3, 0x4A)
COLOR_FAIL = RGBColor(0xDC, 0x26, 0x26)
COLOR_SKIP = RGBColor(0x64, 0x74, 0x8B)
COLOR_MUTED = RGBColor(0x64, 0x74, 0x8B)


# ---------------------------------------------------------------------------
# 字体与基础排版
# ---------------------------------------------------------------------------

def set_run_font(run, name: str = FONT, size: float | None = None,
                 bold: bool | None = None, color: RGBColor | None = None) -> None:
    run.font.name = name
    rpr = run._element.get_or_add_rPr()
    rfonts = rpr.find(qn("w:rFonts"))
    if rfonts is None:
        rfonts = OxmlElement("w:rFonts")
        rpr.append(rfonts)
    rfonts.set(qn("w:eastAsia"), name)
    if size is not None:
        run.font.size = Pt(size)
    if bold is not None:
        run.bold = bold
    if color is not None:
        run.font.color.rgb = color


def set_style_font(style, name: str = FONT, size: float | None = None,
                   bold: bool | None = None) -> None:
    style.font.name = name
    rpr = style.element.get_or_add_rPr()
    rfonts = rpr.find(qn("w:rFonts"))
    if rfonts is None:
        rfonts = OxmlElement("w:rFonts")
        rpr.append(rfonts)
    rfonts.set(qn("w:eastAsia"), name)
    if size is not None:
        style.font.size = Pt(size)
    if bold is not None:
        style.font.bold = bold


def setup_document(doc: Document) -> None:
    set_style_font(doc.styles["Normal"], FONT, 10.5)
    for style_name in ("Title", "Subtitle", "Heading 1", "Heading 2", "Heading 3"):
        try:
            set_style_font(doc.styles[style_name], FONT)
        except KeyError:
            pass
    section = doc.sections[0]
    section.top_margin = Inches(0.7)
    section.bottom_margin = Inches(0.7)
    section.left_margin = Inches(0.8)
    section.right_margin = Inches(0.8)


def add_page_number(paragraph) -> None:
    run = paragraph.add_run()
    fld_begin = OxmlElement("w:fldChar")
    fld_begin.set(qn("w:fldCharType"), "begin")
    instr = OxmlElement("w:instrText")
    instr.set(qn("xml:space"), "preserve")
    instr.text = "PAGE"
    fld_end = OxmlElement("w:fldChar")
    fld_end.set(qn("w:fldCharType"), "end")
    run._r.append(fld_begin)
    run._r.append(instr)
    run._r.append(fld_end)
    set_run_font(run, FONT, 9, color=COLOR_MUTED)


def add_footer(doc: Document, text: str) -> None:
    section = doc.sections[0]
    p = section.footer.paragraphs[0]
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run(text + "    ")
    set_run_font(r, FONT, 9, color=COLOR_MUTED)
    add_page_number(p)


def add_heading(doc: Document, text: str, level: int):
    h = doc.add_heading(text, level=level)
    for run in h.runs:
        set_run_font(run, FONT)
    return h


def add_para(doc: Document, text: str = "", bold: bool = False,
             size: float = 10.5, color: RGBColor | None = None,
             align=None):
    p = doc.add_paragraph()
    if align is not None:
        p.alignment = align
    r = p.add_run(str(text))
    set_run_font(r, FONT, size, bold=bold, color=color)
    return p


def add_kv(doc: Document, label: str, value) -> None:
    p = doc.add_paragraph()
    r1 = p.add_run(f"{label}：")
    set_run_font(r1, FONT, 10.5, bold=True)
    r2 = p.add_run("" if value is None else str(value))
    set_run_font(r2, FONT, 10.5)


def add_table(doc: Document, headers: list[str], rows: list[list],
              style: str = "Light Grid Accent 1", font_size: float = 9):
    table = doc.add_table(rows=1, cols=len(headers))
    table.alignment = WD_TABLE_ALIGNMENT.CENTER
    try:
        table.style = style
    except KeyError:
        pass
    hdr = table.rows[0].cells
    for i, h in enumerate(headers):
        hdr[i].text = ""
        r = hdr[i].paragraphs[0].add_run(str(h))
        set_run_font(r, FONT, font_size, bold=True)
    for row in rows:
        cells = table.add_row().cells
        for i, value in enumerate(row):
            cells[i].text = ""
            r = cells[i].paragraphs[0].add_run("" if value is None else str(value))
            set_run_font(r, FONT, font_size)
    doc.add_paragraph()
    return table


# ---------------------------------------------------------------------------
# 图片
# ---------------------------------------------------------------------------

def resolve_image(value: str, qa_root: Path, manifest_path: Path) -> Path | None:
    if not value:
        return None
    p = Path(value)
    candidates = []
    if p.is_absolute():
        candidates.append(p)
    else:
        candidates.append(qa_root / p)
        candidates.append(manifest_path.parent / p)
    for c in candidates:
        if c.exists():
            return c
    return None


def add_screenshot(doc: Document, path: Path | None, caption: str) -> None:
    if path is None:
        add_para(doc, f"[截图缺失] {caption}", color=COLOR_FAIL)
        return
    try:
        doc.add_picture(str(path), width=Inches(6.2))
        last = doc.paragraphs[-1]
        last.alignment = WD_ALIGN_PARAGRAPH.CENTER
        cap = doc.add_paragraph()
        cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
        r = cap.add_run(caption)
        set_run_font(r, FONT, 8.5, color=COLOR_MUTED)
    except Exception as exc:  # noqa: BLE001
        add_para(doc, f"[截图无法插入] {caption}: {exc}", color=COLOR_FAIL)


# ---------------------------------------------------------------------------
# 报告生成
# ---------------------------------------------------------------------------

def result_color(result: str) -> RGBColor:
    return {"pass": COLOR_PASS, "fail": COLOR_FAIL, "skip": COLOR_SKIP}.get(
        (result or "").lower(), COLOR_MUTED
    )


def build(manifest_path: Path, qa_root: Path, out_path: Path,
          title: str | None, author: str, project: str,
          page_break_per_step: bool) -> None:
    data = json.loads(manifest_path.read_text(encoding="utf-8"))
    steps = data.get("steps") or []
    defects = data.get("defects") or []
    console_errors = data.get("console_errors") or []
    network_errors = data.get("network_errors") or []

    doc = Document()
    setup_document(doc)
    doc.core_properties.title = title or f"{data.get('story_id', '')} E2E 验收报告"
    doc.core_properties.author = author

    header_title = title or (
        f"{data.get('story_id', '')} {data.get('title', '')} - E2E 验收报告"
    )
    add_footer(doc, f"{project} | {header_title}")

    # 封面
    for _ in range(3):
        doc.add_paragraph()
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run(header_title)
    set_run_font(r, FONT, 24, bold=True)

    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run(f"{project} · {data.get('environment', '')} 环境")
    set_run_font(r, FONT, 13, color=COLOR_MUTED)

    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    result = str(data.get("result", "unknown")).upper()
    r = p.add_run(f"结果：{result}")
    set_run_font(r, FONT, 20, bold=True, color=result_color(str(data.get("result", ""))))

    doc.add_paragraph()
    add_table(doc, ["项目", "值"], [
        ["运行 ID", data.get("run_id", "")],
        ["故事", f"{data.get('story_id', '')} {data.get('title', '')}"],
        ["环境", data.get("environment", "")],
        ["开始时间", data.get("started_at", "")],
        ["结束时间", data.get("finished_at", "")],
        ["Commit", data.get("commit", "")],
        ["前端地址", data.get("base_url", "")],
        ["API 地址", data.get("api_base_url", "")],
        ["测试账号", data.get("account", "")],
        ["生成时间", datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")],
        ["报告作者", author],
    ], style="Light List Accent 1", font_size=9.5)

    doc.add_page_break()

    # 1. 概要
    add_heading(doc, "1. 概要", 1)
    total = len(steps)
    passed = sum(1 for s in steps if str(s.get("status", "")).lower() == "pass")
    failed = sum(1 for s in steps if str(s.get("status", "")).lower() == "fail")
    skipped = sum(1 for s in steps if str(s.get("status", "")).lower() == "skip")
    add_table(doc, ["指标", "值"], [
        ["总步骤", total],
        ["通过", passed],
        ["失败", failed],
        ["跳过", skipped],
        ["缺陷数", len(defects)],
        ["控制台错误", len(console_errors)],
        ["网络错误", len(network_errors)],
    ], style="Light List Accent 1", font_size=9.5)

    add_para(doc, "结论：", bold=True)
    if data.get("result") == "pass" and failed == 0:
        add_para(doc, "本次 E2E 全部通过，可以进入人工走查/发布流程。", color=COLOR_PASS)
    else:
        add_para(doc, "本次 E2E 存在失败项，请先修复缺陷并重新生成报告。", color=COLOR_FAIL)

    # 2. 环境
    add_heading(doc, "2. 环境信息", 1)
    add_kv(doc, "前端", data.get("base_url", ""))
    add_kv(doc, "API", data.get("api_base_url", ""))
    add_kv(doc, "环境", data.get("environment", ""))
    add_kv(doc, "Commit", data.get("commit", ""))
    add_kv(doc, "浏览器", data.get("browser", ""))
    add_kv(doc, "测试账号", data.get("account", ""))
    add_kv(doc, "测试数据前缀", data.get("data_prefix", ""))

    # 3. 验收标准覆盖（可选）
    ac = data.get("acceptance_criteria") or []
    if ac:
        add_heading(doc, "3. 验收标准覆盖", 1)
        rows = []
        for item in ac:
            rows.append([
                item.get("id", ""),
                item.get("description", ""),
                item.get("step", ""),
                item.get("result", ""),
            ])
        add_table(doc, ["AC", "描述", "步骤", "结果"], rows,
                  style="Light Grid Accent 1", font_size=9)

    # 4. 步骤
    add_heading(doc, "4. 测试步骤", 1)
    for step in steps:
        status = str(step.get("status", "")).lower()
        color = result_color(status)
        add_heading(
            doc,
            f"步骤 {step.get('index', '?')}：{step.get('action', '')} — {status.upper()}",
            2,
        )
        add_kv(doc, "目标", step.get("target", ""))
        add_kv(doc, "预期", step.get("expected", ""))
        add_kv(doc, "实际", step.get("actual", ""))
        add_kv(doc, "结果", status.upper())
        if step.get("notes"):
            add_kv(doc, "备注", step.get("notes"))

        assertions = step.get("assertions") or []
        if assertions:
            rows = []
            for a in assertions:
                rows.append([
                    a.get("type", ""),
                    a.get("expression", ""),
                    a.get("expected", ""),
                    a.get("actual", ""),
                ])
            add_table(doc, ["类型", "表达式", "预期", "实际"], rows,
                      style="Light Grid Accent 1", font_size=8)

        screenshot = step.get("screenshot")
        if screenshot:
            img = resolve_image(str(screenshot), qa_root, manifest_path)
            add_screenshot(doc, img, f"步骤 {step.get('index', '?')}：{screenshot}")

        if page_break_per_step and step is not steps[-1]:
            doc.add_page_break()

    # 5. 控制台与网络
    add_heading(doc, "5. 控制台与网络错误", 1)
    if console_errors:
        add_heading(doc, "5.1 控制台错误", 2)
        rows = [[e.get("step", ""), e.get("type", ""), e.get("text", "")]
                for e in console_errors]
        add_table(doc, ["步骤", "类型", "内容"], rows,
                  style="Light Grid Accent 1", font_size=8.5)
    else:
        add_para(doc, "无控制台错误。", color=COLOR_PASS)

    if network_errors:
        add_heading(doc, "5.2 网络错误", 2)
        rows = [[e.get("step", ""), e.get("method", ""), e.get("url", ""),
                 e.get("status", ""), e.get("request_id", "")]
                for e in network_errors]
        add_table(doc, ["步骤", "方法", "URL", "状态", "request_id"], rows,
                  style="Light Grid Accent 1", font_size=8.5)
    else:
        add_para(doc, "无 5xx/网络错误。", color=COLOR_PASS)

    # 6. 缺陷
    add_heading(doc, "6. 缺陷清单", 1)
    if defects:
        rows = [[d.get("id", ""), d.get("severity", ""), d.get("summary", ""),
                 d.get("expected", ""), d.get("actual", ""), d.get("status", "")]
                for d in defects]
        add_table(doc, ["ID", "严重度", "描述", "预期", "实际", "状态"], rows,
                  style="Light Grid Accent 1", font_size=8.5)
    else:
        add_para(doc, "未发现缺陷。", color=COLOR_PASS)

    # 7. 结论
    add_heading(doc, "7. 结论与签字", 1)
    add_para(doc, f"自动化结果：{result}", bold=True, color=result_color(str(data.get('result', ''))))
    add_para(doc, "人工走查结论：□ 通过   □ 有条件通过   □ 不通过")
    add_para(doc, "遗留问题：____________________________________________")
    add_para(doc, "")
    add_para(doc, "验收人：________________    日期：________________")

    # 附录
    add_heading(doc, "附录：证据文件", 1)
    add_kv(doc, "Manifest", manifest_path)
    add_kv(doc, "QA 根目录", qa_root)
    add_kv(doc, "报告文件", out_path)
    if data.get("request_ids"):
        add_heading(doc, "请求 ID", 2)
        for rid in data["request_ids"]:
            add_para(doc, str(rid), size=8.5)

    out_path.parent.mkdir(parents=True, exist_ok=True)
    doc.save(str(out_path))


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(description="生成 E2E Word 验收报告")
    p.add_argument("--manifest", required=True, type=Path)
    p.add_argument("--qa-root", type=Path, default=Path("docs/04-qa"),
                   help="截图路径的根目录")
    p.add_argument("--out", required=True, type=Path)
    p.add_argument("--title", default=None)
    p.add_argument("--author", default="")
    p.add_argument("--project", default="")
    p.add_argument("--page-break-per-step", action="store_true")
    return p.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    if not args.manifest.exists():
        print(f"错误：manifest 不存在：{args.manifest}", file=sys.stderr)
        return 2
    try:
        build(args.manifest, args.qa_root, args.out, args.title,
              args.author, args.project, args.page_break_per_step)
    except json.JSONDecodeError as exc:
        print(f"错误：manifest 不是合法 JSON：{exc}", file=sys.stderr)
        return 3
    except Exception as exc:  # noqa: BLE001
        print(f"生成报告失败：{exc}", file=sys.stderr)
        return 1
    print(f"已生成：{args.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
