#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""error 意外成功甄别结果 → 按接口逐个提单(GitCode Alice_zhou/issue 仓)

读取 out_res/error意外成功甄别_终版.xlsx「意外成功甄别明细」:
  - 甄别结论 ∈ {是bug候选, 需要人工确认} 的场景为有效意外成功问题点
    (FORMAT 类"不是bug"为框架规则无效用例,不提单)
  - 按 接口名 分组:一个接口提一个 issue(https://gitcode.com/Alice_zhou/issue)
  - 提单前上传该接口 error CPP 到 Alice_zhou/issue 仓 artifacts/{日期}/{api}_error.cpp,
    issue 正文含 CPP 附件下载链接
  - issue 正文含:问题描述 + 超预期的问题点明细表(场景ID/类别/异常场景描述/执行次数/
    甄别结论/判定依据)+ 四步复现步骤(执行步骤)+ 附件链接
  - 提单完成后生成含 issue 列的新 xlsx(--output-xlsx)

用法:
  python scripts/file_unexpected_success_issues.py                     # 预览(dry-run,不提单不上传)
  python scripts/file_unexpected_success_issues.py --file              # 正式提单
  python scripts/file_unexpected_success_issues.py --file --api aclnnTan   # 只提指定接口
退出码: 0=完成; 1=配置/数据错误; 2=部分或全部提单失败
"""
import argparse
import datetime
import json
import os
import re
import sys
import urllib.error
import urllib.request

import openpyxl

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from upload_to_issue_repo import IssueRepoClient  # noqa: E402  Alice_zhou/issue 仓文件上传

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")

DEFAULT_CONFIG = "config/issue_tracker.json"
DEFAULT_XLSX = "out_res/error意外成功甄别_终版.xlsx"
VALID_CONCLUSIONS = ("是bug候选", "需要人工确认")  # 有效意外成功问题点(不是bug=框架无效用例,不提单)
DETAIL_SHEET = "意外成功甄别明细"
MD_COL = "md资料链接"  # 接口 md 文档链接列(GitCode cann/ops-* blob/master)
OPS_REPOS = ("ops-math", "ops-nn", "ops-cv", "ops-transformer")

# 甄别依据明确的接口显式指定 md 基准版本(默认规则:test-design 记录 > 非 experimental 仓内搜索)
MD_OVERRIDE = {
    # 甄别依据"md未见dtype支持行":experimental 版有显式"支持数据类型"行不符,基准为正式版(互推导关系表格)
    "aclnnTan": "ops-math/math/tan/docs/aclnnTan&aclnnInplaceTan.md",
    "aclnnInplaceTan": "ops-math/math/tan/docs/aclnnTan&aclnnInplaceTan.md",
}


def log(tag, msg):
    print("[{}] {}".format(tag, msg))


def esc(text):
    """markdown 表格转义:竖线替换为全角。"""
    return str(text if text is not None else "").replace("|", "|").replace("\n", " ")


# ---------------- GitCode issue 创建(Alice_zhou/issue) ----------------

class IssueCreator:
    def __init__(self, api_base, token, owner, repo):
        self.api_base = api_base.rstrip("/")
        self.token = token
        self.owner = owner
        self.repo = repo

    def _request(self, path, method="GET", data=None):
        url = "{}/repos/{}/{}{}".format(self.api_base, self.owner, self.repo, path)
        body = json.dumps(data).encode("utf-8") if data is not None else None
        req = urllib.request.Request(
            url, data=body, method=method,
            headers={"PRIVATE-TOKEN": self.token, "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                return resp.status, json.loads(resp.read().decode("utf-8", errors="replace"))
        except urllib.error.HTTPError as exc:
            try:
                return exc.code, json.loads(exc.read().decode("utf-8", errors="replace"))
            except Exception:
                return exc.code, {}
        except Exception as exc:
            return -1, {"error_message": str(exc)}

    def list_issues(self, state="all", limit=100):
        code, data = self._request("/issues?state={}&limit={}&page=1".format(state, limit))
        return data if code == 200 and isinstance(data, list) else []

    def create_issue(self, title, body):
        return self._request("/issues", "POST", {"title": title, "body": body})


# ---------------- 数据读取 ----------------

def load_detail_rows(xlsx_path):
    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
    ws = wb[DETAIL_SHEET]
    header, rows = None, []
    for r in ws.iter_rows(values_only=True):
        if header is None:
            header = [str(c).strip() if c is not None else "" for c in r]
            continue
        if r[0] is None or str(r[0]).strip() in ("", "接口名"):
            continue
        rows.append(dict(zip(header, r)))
    wb.close()
    return rows


def group_valid_by_api(rows):
    groups = {}
    for rec in rows:
        concl = str(rec.get("甄别结论") or "").strip()
        if concl in VALID_CONCLUSIONS:
            api = str(rec.get("接口名") or "").strip()
            groups.setdefault(api, []).append(rec)
    for api in groups:
        groups[api].sort(key=lambda x: (int(x.get("场景ID") or 0)))
    return groups


def log_final_line(api):
    """从 output/{api}/fuzz_logs/{api}_error_fuzz_output.log 提取 FINAL 汇总行。"""
    path = os.path.join("output", api, "fuzz_logs", "{}_error_fuzz_output.log".format(api))
    if not os.path.isfile(path):
        return ""
    for line in open(path, encoding="utf-8", errors="replace"):
        if line.startswith("FINAL:"):
            return line.strip()
    return ""


# ---------------- issue 内容构建 ----------------

def build_title(api, recs):
    n = len(recs)
    if all(str(r.get("甄别结论")).strip() == "是bug候选" for r in recs):
        return "[Bug|算子缺陷]: {} error场景 存在{}处意外成功(资料描述不支持但实际执行成功)".format(api, n)
    return "[Bug|算子缺陷]: {} error场景 存在{}处意外成功(资料描述不支持但实际执行成功,待人工复核)".format(api, n)


def build_body(api, recs, cfg, cpp_url):
    bug_cnt = sum(1 for r in recs if str(r.get("甄别结论")).strip() == "是bug候选")
    manual_cnt = len(recs) - bug_cnt
    final = log_final_line(api)
    cpp_name = "{}_error.cpp".format(api)
    lines = []
    lines.append("####  一、问题描述(必填)")
    lines.append("- 接口名:`{}`;测试场景:`error`(异常约束违反压测);来源:error意外成功甄别_终版.xlsx(意外成功甄别明细)".format(api))
    if final:
        lines.append("- 压测结果:{}(每个异常场景注入资料描述不支持的约束后检查返回码)".format(final))
    lines.append("- 意外成功甄别:共 **{} 处**有效意外成功——预期接口应报 161001/161002 参数错误,"
                 "实际执行成功 ret=0(`[FUZZ_VERIFY] match=NO, actual_code=0`);"
                 "其中 是bug候选 {} 处 / 需要人工确认 {} 处".format(len(recs), bug_cnt, manual_cnt))
    lines.append("- 说明:FORMAT 类意外成功(如 ACL_FORMAT_NCHW)已按测试框架规则甄别排除"
                 "(ND 通用布局无有效 format 违反,属框架无效用例,不计入本单问题点)")
    lines.append("")
    lines.append("####  二、超预期的问题点(意外成功明细)")
    lines.append("| 场景ID | 类别 | 异常场景描述(注入的约束违反) | 执行次数 | 甄别结论 | 判定依据 |")
    lines.append("|---|---|---|---|---|---|")
    for r in recs:
        lines.append("| {} | {} | {} | {} | {} | {} |".format(
            esc(r.get("场景ID")), esc(r.get("类别")), esc(r.get("异常场景描述")),
            esc(r.get("执行次数")), esc(r.get("甄别结论")), esc(r.get("依据"))))
    lines.append("")
    lines.append("上述场景均为按资料约束注入异常(不支持 dtype / 维度超限 / dtype 配对不一致)后接口实际执行成功"
                 "(`[FUZZ_VERIFY] expected_code=161001或161002, actual_code=0, match=NO`),"
                 "接口未按资料描述的约束拒绝非法输入。")
    lines.append("")
    lines.append("####  三、问题复现步骤(必填)")
    lines.append("1. 下载测试框架:{}".format(cfg.get("framework_url")))
    lines.append("2. 环境准备(清除原有内容,将附件 cpp 放入对应目录下):")
    lines.append("   ```bash")
    lines.append("   rm -rf aclnnAPI_fuzz/src/op/*")
    lines.append("   # 将附件 {} 放入 aclnnAPI_fuzz/src/op/ 下".format(cpp_name))
    lines.append("   ```")
    lines.append("3. 编译:")
    lines.append("   ```bash")
    lines.append("   cd aclnnAPI_fuzz/ ; make")
    lines.append("   ```")
    lines.append("4. 执行:")
    lines.append("   ```bash")
    lines.append("   cd out ; ./aclnnAPI_fuzz {}_error_fuzz".format(api))
    lines.append("   ```")
    lines.append("   执行后在打屏日志中查看 `[FUZZ_ERROR_POINT]`(注入的异常点,即上表场景)与 "
                 "`[FUZZ_VERIFY]`(expected_code vs actual_code),上表场景均复现为 match=NO 且 actual_code=0(意外成功)。")
    lines.append("")
    lines.append("####  四、附件与证据(必填)")
    lines.append("- CPP 附件下载链接:{}".format(cpp_url))
    lines.append("- 日志证据:`output/{}/fuzz_logs/{}_error_fuzz_output.log`(意外成功场景 [FUZZ_VERIFY] match=NO, actual_code=0)".format(api, api))
    lines.append("")
    lines.append("💡 存在的问题(选填)")
    lines.append("该问题由 fuzz 自动化压测平台 error 场景压测 + 意外成功甄别产出(按接口提单,一接口一单);"
                 "如需更多上下文(完整打屏日志/plog/覆盖度报告),请访问平台 `output/{}/fuzz_logs/` 目录。".format(api))
    return "\n".join(lines)


def cpp_blob_url(owner, repo, branch, date_str, api):
    return "https://gitcode.com/{}/{}/blob/{}/artifacts/{}/{}_error.cpp".format(
        owner, repo, branch, date_str, api)


# ---------------- 接口 md 资料链接 ----------------

def collect_docs_mds():
    """单次遍历 ops-* 仓 docs/ 目录收集全部 md(接口 md 定位数据源)。"""
    mds = []
    for repo in OPS_REPOS:
        if not os.path.isdir(repo):
            continue
        for root, _dirs, files in os.walk(repo):
            if not root.replace(os.sep, "/").endswith("/docs"):
                continue
            for f in files:
                if f.endswith(".md"):
                    mds.append(os.path.join(root, f).replace(os.sep, "/"))
    return sorted(set(mds))


def resolve_md_repo_path(api, all_mds):
    """定位接口的 md 文档仓内路径。

    优先级:① output/{api}/test-design 中记录的 md 源(约束推导实际使用的资料)
           ② MD_OVERRIDE 显式覆盖(甄别依据明确的接口)
           ③ 仓内搜索:md 文件名按 & 拆分后精确含接口名,非 experimental 优先
    """
    # ① test-design 记录的 md 源
    td_dir = os.path.join("output", api, "test-design")
    if os.path.isdir(td_dir):
        pat = re.compile(r"((?:ops-(?:math|nn|cv|transformer))/[^\s\"'\\\]>]+\.md)")
        for fn in sorted(os.listdir(td_dir)):
            if not fn.endswith((".yaml", ".json", ".txt")):
                continue
            try:
                text = open(os.path.join(td_dir, fn), encoding="utf-8").read()
            except Exception:
                continue
            m = pat.search(text)
            if m and os.path.isfile(m.group(1)):
                return m.group(1)
    # ② 显式覆盖
    if api in MD_OVERRIDE and os.path.isfile(MD_OVERRIDE[api]):
        return MD_OVERRIDE[api]
    # ③ 仓内搜索
    cands = [m for m in all_mds if api in m.rsplit("/", 1)[-1][:-3].split("&")]
    if not cands:
        return ""
    non_exp = [m for m in cands if "/experimental/" not in m]
    pool = non_exp or cands
    return sorted(pool, key=lambda p: (p.count("/"), len(p), p))[0]


def md_doc_url(repo_path):
    """md 仓内路径(含仓目录前缀,如 ops-math/math/.../xx.md)→ GitCode blob 链接(master 分支)。"""
    if not repo_path:
        return ""
    repo, _, path_in_repo = repo_path.partition("/")
    return "https://gitcode.com/cann/{}/blob/master/{}".format(repo, path_in_repo)


# ---------------- 主流程 ----------------

def main():
    parser = argparse.ArgumentParser(description="error 意外成功甄别结果按接口提单(Alice_zhou/issue)")
    parser.add_argument("--xlsx", default=DEFAULT_XLSX, help="甄别结果 xlsx")
    parser.add_argument("--output-xlsx", default="", help="提单回填后的新 xlsx(默认 {原文件名}_issue.xlsx)")
    parser.add_argument("--config", default=DEFAULT_CONFIG)
    parser.add_argument("--api", action="append", default=None, help="只处理指定接口(可多次)")
    parser.add_argument("--file", action="store_true", help="正式提单(默认 dry-run 预览)")
    args = parser.parse_args()

    if not os.path.isfile(args.xlsx):
        log("ERROR", "甄别 xlsx 不存在: {}".format(args.xlsx))
        return 1
    if not os.path.isfile(args.config):
        log("ERROR", "配置不存在: {}(模板 config/issue_tracker.example.json)".format(args.config))
        return 1

    cfg = json.load(open(args.config, encoding="utf-8"))
    token = str(cfg.get("access_token") or "")
    if token.startswith("${") and token.endswith("}"):
        token = os.environ.get(token[2:-1], "")
    if not token:
        log("ERROR", "access_token 未配置")
        return 1
    owner = cfg.get("issue_repo_owner", "Alice_zhou")
    repo = cfg.get("issue_repo_name", "issue")
    branch = cfg.get("issue_repo_branch", "main")
    cfg.setdefault("framework_url",
                   "https://gitcode.com/Alice_zhou/Vector_fuzz_cpp_testing/tree/main/turing_sec/aclnnAPI_fuzz/")
    date_str = datetime.date.today().strftime("%Y-%m-%d")

    rows = load_detail_rows(args.xlsx)
    groups = group_valid_by_api(rows)
    if args.api:
        want = set(args.api)
        groups = {k: v for k, v in groups.items() if k in want}
    if not groups:
        log("ERROR", "无有效意外成功问题点(甄别结论 ∈ {})".format(VALID_CONCLUSIONS))
        return 1
    log("INFO", "待提单接口 {} 个: {}".format(len(groups), ", ".join(sorted(groups))))

    # 去重:本地全局注册表 + 仓内已有 issue(按标题匹配)
    global_registry_path = "out_res/issues/filed_all.json"
    global_registry = {}
    if os.path.isfile(global_registry_path):
        try:
            global_registry = json.load(open(global_registry_path, encoding="utf-8"))
        except Exception:
            global_registry = {}
    creator = IssueCreator(cfg.get("api_base", "https://gitcode.com/api/v5"), token, owner, repo)
    existing_titles = {}
    if args.file:
        for it in creator.list_issues(state="all"):
            existing_titles[str(it.get("title") or "")] = it.get("html_url") or ""

    issue_dir = os.path.join("out_res", "issues", date_str)
    os.makedirs(issue_dir, exist_ok=True)
    filed_path = os.path.join(issue_dir, "filed.json")
    filed = {}
    if os.path.isfile(filed_path):
        try:
            filed = json.load(open(filed_path, encoding="utf-8"))
        except Exception:
            filed = {}

    repo_client = IssueRepoClient(args.config)
    issue_urls = {}   # api -> issue url
    failed = []
    filed_ok = []

    for api in sorted(groups):
        recs = groups[api]
        sig = "{}__err:意外成功甄别:merged".format(api)
        title = build_title(api, recs)
        blob_url = cpp_blob_url(owner, repo, branch, date_str, api)
        cpp_local = os.path.join("output", api, "{}_error.cpp".format(api))
        repo_path = "artifacts/{}/{}_error.cpp".format(date_str, api)

        # 去重:已提过(本地 filed / 全局注册表 / 仓内同标题 issue)
        if filed.get("{}_error意外成功甄别".format(api)):
            issue_urls[api] = filed["{}_error意外成功甄别".format(api)]
            log("REUSE", "{} 已提单(本地 filed.json),复用: {}".format(api, issue_urls[api]))
            continue
        if global_registry.get(sig):
            issue_urls[api] = global_registry[sig]
            log("REUSE", "{} 已提单(全局注册表),复用: {}".format(api, issue_urls[api]))
            continue
        if existing_titles.get(title):
            issue_urls[api] = existing_titles[title]
            log("REUSE", "{} 仓内已存在同标题 issue,复用: {}".format(api, issue_urls[api]))
            continue

        if not os.path.isfile(cpp_local):
            log("WARN", "{} error CPP 不存在: {}(issue 仍提交,附件链接回退 blob 占位)".format(api, cpp_local))
            cpp_url = blob_url
        elif not args.file:
            cpp_url = blob_url
        else:
            ok, dl_url, _html = repo_client.upload_file(cpp_local, repo_path,
                                                         "cpp: {} error 意外成功甄别复现附件".format(api))
            cpp_url = dl_url or blob_url
            if ok:
                log("UPLOAD", "{} cpp 附件上传成功 → {}".format(api, cpp_url))
            else:
                log("WARN", "{} cpp 附件上传失败: {}(回退 blob 链接)".format(api, dl_url))

        body = build_body(api, recs, cfg, cpp_url)
        md_path = os.path.join(issue_dir, "{}_error意外成功甄别.md".format(api))
        with open(md_path, "w", encoding="utf-8") as fh:
            fh.write("# {}\n\n> 目标仓: {}/{}\n\n{}\n".format(title, owner, repo, body))

        if not args.file:
            log("DRYRUN", "[{}处意外成功] {} → {}(目标 {}/{},附件 {})".format(
                len(recs), api, md_path, owner, repo, repo_path))
            issue_urls[api] = "(dry-run 未提单)"
            continue

        code, resp = creator.create_issue(title, body)
        if code in (200, 201):
            url = resp.get("html_url") or "https://gitcode.com/{}/{}/issues/{}".format(owner, repo, resp.get("number", ""))
            issue_urls[api] = url
            filed["{}_error意外成功甄别".format(api)] = url
            global_registry[sig] = url
            filed_ok.append((api, url, len(recs)))
            log("FILED", "[{}处意外成功] {} → {}/{} issue #{}: {}".format(
                len(recs), api, owner, repo, resp.get("number", ""), url))
        else:
            failed.append(api)
            log("ERROR", "{} 提单失败 [{}]: {}".format(api, code, json.dumps(resp, ensure_ascii=False)[:300]))

    # 汇总 + 注册表写回
    if args.file:
        json.dump(filed, open(filed_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
        json.dump(global_registry, open(global_registry_path, "w", encoding="utf-8"),
                  ensure_ascii=False, indent=2)
    summary = ["# {} error 意外成功甄别按接口提单汇总".format(date_str),
               "- 目标仓: https://gitcode.com/{}".format("/{}/issues".format(repo) if owner == "Alice_zhou" else owner + "/" + repo),
               "- 待提接口: {} 个 | 提单成功: {} | 失败/跳过: {}".format(len(groups), len(filed_ok), len(failed))]
    for api, url, n in filed_ok:
        summary.append("- [{}]({}) — {} 处意外成功".format(api, url, n))
    if failed:
        summary.append("- 失败清单: {}".format(",".join(failed)))
    with open(os.path.join(issue_dir, "summary_error意外成功甄别.md"), "w", encoding="utf-8") as fh:
        fh.write("\n".join(summary) + "\n")

    # 生成含 md资料链接 + issue 列的新 xlsx
    all_mds = collect_docs_mds()
    md_links = {}   # api -> md url
    for api in sorted(set(str(r.get("接口名") or "").strip() for r in rows if r.get("接口名"))):
        repo_path = resolve_md_repo_path(api, all_mds)
        if repo_path:
            md_links[api] = md_doc_url(repo_path)
        else:
            log("WARN", "{} 未定位到 md 文档(md资料链接列留空)".format(api))
    out_xlsx = args.output_xlsx or args.xlsx.replace(".xlsx", "_issue.xlsx")
    wb = openpyxl.load_workbook(args.xlsx)
    ws = wb[DETAIL_SHEET]
    header = [str(c.value).strip() if c.value is not None else "" for c in ws[1]]
    if MD_COL not in header:
        ws.cell(row=1, column=len(header) + 1, value=MD_COL)
        header.append(MD_COL)
    if "issue" not in header:
        ws.cell(row=1, column=len(header) + 1, value="issue")
        header.append("issue")
    md_col = header.index(MD_COL) + 1
    issue_col = header.index("issue") + 1
    for row_no in range(2, ws.max_row + 1):
        api = str(ws.cell(row=row_no, column=header.index("接口名") + 1).value or "").strip()
        concl = str(ws.cell(row=row_no, column=header.index("甄别结论") + 1).value or "").strip()
        md_cell = ws.cell(row=row_no, column=md_col)
        if api in md_links:
            md_cell.value = md_links[api]
            md_cell.hyperlink = md_links[api]
        cell = ws.cell(row=row_no, column=issue_col)
        if concl in VALID_CONCLUSIONS and api in issue_urls and issue_urls[api].startswith("http"):
            cell.value = issue_urls[api]
            cell.hyperlink = issue_urls[api]
        elif concl in VALID_CONCLUSIONS:
            cell.value = issue_urls.get(api, "")  # dry-run 标记
        else:
            cell.value = "无需提单"
    # 汇总 sheet 补充提单结果
    if "汇总" in wb.sheetnames:
        ws2 = wb["汇总"]
        ws2.append(("md资料链接", "已按接口回填 {} 个(GitCode cann/ops-* blob/master,甄别依据所用 md 基准版本)".format(len(md_links))))
        ws2.append(("提单仓", "https://gitcode.com/{}/{}".format(owner, repo)))
        if args.file:
            reused = sum(1 for u in issue_urls.values() if str(u).startswith("http")) - len(filed_ok)
            new_filed = len(filed_ok)
            if reused > 0:
                ws2.append(("提单结果", "共 {} 个接口已提单(本次新建 {} 个 / 复用已有 {} 个,明细见「意外成功甄别明细」issue 列)".format(
                    len(groups), new_filed, reused)))
            else:
                ws2.append(("提单结果", "共 {} 个接口提交 {} 个 issue(明细见「意外成功甄别明细」issue 列)".format(
                    len(groups), new_filed)))
        else:
            ws2.append(("提单结果", "dry-run 预览(--file 正式提单)"))
    wb.save(out_xlsx)
    log("RESULT", "提单成功 {} | 失败 {} | 结果表格 → {}".format(len(filed_ok), len(failed), out_xlsx))
    if args.file and failed:
        return 2
    return 0


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