#!/usr/bin/env python3
"""AI Review 历史评论数据盘点 → markdown 报告。

工单 #259 第一目标：拉近 N 天所有 PR 的 AI Review 评论，按 PR / finding
两个维度聚合，输出事实报告，给后续工作流优化决策做依据。

输入：近 N 天的 PR（默认 30 天），筛选含 `## ... AI Review` 头的评论。
输出：testing/reports/ai-review-stats-YYYYMMDD.md（覆盖式）

不做的事：
  - 不做"采纳率"真值标注（DRY RUN 期没真值，只能 heuristic）
  - 不调 AI 二次判断 finding 是否误报（成本太高，且本报告就是为了做这个决策）
  - 不写回 Gitea（纯本地报告）

复用 weekly-review.py 的 Gitea API 基建（Api / paginate / detect_repo / get_token）。
后续如出现第三个统计脚本再考虑抽 module。

Usage:
    python3 scripts/ops/ai-review-stats.py                     # 默认 30 天
    python3 scripts/ops/ai-review-stats.py --since-days 14
    python3 scripts/ops/ai-review-stats.py --print             # stdout
    python3 scripts/ops/ai-review-stats.py --out /tmp/r.md
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path

GITEA_HOST = "43.130.59.228"
BASE = f"http://{GITEA_HOST}"

REPO_ROOT = Path(__file__).resolve().parents[2]
REPORTS_DIR = REPO_ROOT / "testing" / "reports"

VERDICT_ORDER = ("pass", "pass_with_risk", "should_fix", "needs_fix", "block")
SEVERITY_ORDER = ("hard_block", "risk", "suggestion")
MODE_VALUES = ("hard-rules-block", "batch-summary", "release-risk")

TITLE_MAX = 80
MSG_FINGERPRINT_LEN = 60  # finding 文本指纹长度（去噪后取前 60 字符判重复指控）


# ---------- 基建（同源于 weekly-review.py）----------

def _now_local() -> datetime:
    return datetime.now().astimezone()


def _safe_parse(s):
    if not s:
        return None
    try:
        return datetime.fromisoformat(s)
    except (ValueError, TypeError):
        return None


def get_token() -> str:
    tok = os.environ.get("GITEA_API_TOKEN") or os.environ.get("GITEA_TOKEN")
    if tok:
        return tok
    print("ERROR: GITEA_API_TOKEN not set", file=sys.stderr)
    sys.exit(1)


def detect_repo() -> str:
    url = subprocess.check_output(
        ["git", "config", "--get", "remote.origin.url"], cwd=REPO_ROOT
    ).decode().strip()
    m = re.search(rf"{re.escape(GITEA_HOST)}(?::\d+)?[:/]([^/]+/[^/.]+)", url)
    if not m:
        print(f"ERROR: cannot parse owner/repo from {url}", file=sys.stderr)
        sys.exit(1)
    return m.group(1)


class Api:
    def __init__(self, token: str, repo: str):
        self.token = token
        self.repo = repo

    def get(self, path: str, params: dict | None = None) -> tuple[int, str]:
        qs = ("?" + urllib.parse.urlencode(params)) if params else ""
        url = f"{BASE}/api/v1/repos/{self.repo}{path}{qs}"
        req = urllib.request.Request(
            url,
            headers={
                "Authorization": f"token {self.token}",
                "Accept": "application/json",
            },
        )
        try:
            resp = urllib.request.urlopen(req, timeout=30)
            return resp.status, resp.read().decode()
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode()


def paginate(api: Api, path: str, params: dict | None = None, max_pages: int = 20) -> list:
    results = []
    p = dict(params or {})
    p.setdefault("limit", 50)
    for page in range(1, max_pages + 1):
        p["page"] = page
        code, txt = api.get(path, p)
        if code != 200:
            print(f"WARN: GET {path} page={page} → HTTP {code}: {txt[:200]}", file=sys.stderr)
            break
        chunk = json.loads(txt)
        if not chunk:
            break
        results.extend(chunk)
        if len(chunk) < p["limit"]:
            break
    return results


# ---------- AI Review 评论解析 ----------

# 第一行：`## ✅ AI Review (hard-rules-block) — pass` （schema 化后的标准格式）
HEADER_RE = re.compile(
    r"^##\s+(?P<emoji>\S+)\s+AI\s+Review\s*\((?P<mode>[^)]+)\)\s*[—\-:]\s*(?P<verdict>\w+)",
    re.MULTILINE,
)
# 兼容老格式（schema 化前可能只有 `## AI Review`，verdict 在别处）
LEGACY_HEADER_RE = re.compile(r"^##\s+.*?AI\s+Review", re.MULTILINE)

DRY_RUN_BANNER_RE = re.compile(r"^>\s*🔬\s*\*\*DRY RUN", re.MULTILINE)

# 维度表行：`| 契约面 | ✓ ok | note |`（status 列含 emoji + 单词，note 列可空）
DIMENSION_ROW_RE = re.compile(
    r"^\|\s*([^|]+?)\s*\|\s*\S*\s*(ok|warn|block|n/a)\s*\|\s*([^|]*?)\s*\|\s*$",
    re.MULTILINE | re.IGNORECASE,
)

# Severity 切换标记 —— 兼容两种格式：
#   老格式（schema 化前，PR #297 之前）：`### ⚠️ 风险` 章节标题
#   新格式（schema 化后）：`- ⚠️ **风险**：N 项` 列表项 + 二级缩进 finding
SEVERITY_SECTION_RE = re.compile(
    r"^(?:"
    r"###\s+(?:🚫|⚠️|💡)?\s*(?:\*\*)?(?P<header_label>硬阻断|风险|建议)(?:\*\*)?\s*$"
    r"|"
    r"-\s+\S+\s+\*\*(?P<list_label>硬阻断|风险|建议)\*\*[:：]\s*\d+\s*项"
    r")",
    re.MULTILINE,
)

# Finding 详情行：`- **[category]**` 或 `  - **[category]**`（允许任意缩进）
# 与 SEVERITY_SECTION_RE 通过紧跟 `**[` 区分（severity 行紧跟的是 emoji 或 `\S+`）
FINDING_LINE_RE = re.compile(
    r"^[ \t]*-\s+\*\*\[(?P<category>[^\]]+)\]\*\*\s*"
    r"(?:\(\s*`(?P<file>[^`:]+)(?::(?P<line>\d+))?`\s*\)\s*)?"
    r"(?P<message>.+?)\s*$",
    re.MULTILINE,
)

SEVERITY_LABEL_MAP = {"硬阻断": "hard_block", "风险": "risk", "建议": "suggestion"}


@dataclass
class Finding:
    severity: str
    category: str
    message: str
    file: str | None = None
    line: int | None = None

    def fingerprint(self) -> tuple[str, str]:
        """规范化 (category, message 前 N 字) 用于跨 review 判重复指控。"""
        msg = re.sub(r"\s+", " ", self.message).strip().lower()
        msg = re.sub(r"[`*_]", "", msg)
        return (self.category.strip().lower(), msg[:MSG_FINGERPRINT_LEN])


@dataclass
class ReviewEntry:
    pr_number: int
    comment_id: int
    created_at: datetime
    body: str
    mode: str | None
    verdict: str | None
    is_dry_run: bool
    dim_status_counts: dict[str, int] = field(default_factory=dict)
    findings: list[Finding] = field(default_factory=list)
    parse_quality: str = "ok"  # ok / partial / unparseable

    def severity_counts(self) -> dict[str, int]:
        c = Counter(f.severity for f in self.findings)
        return {s: c.get(s, 0) for s in SEVERITY_ORDER}

    def severity_tuple_str(self) -> str:
        sc = self.severity_counts()
        return f"({sc['hard_block']}/{sc['risk']}/{sc['suggestion']})"


def is_ai_review_comment(body: str) -> bool:
    if not body:
        return False
    # 严格优先：schema 化后必含 mode 和 verdict
    if HEADER_RE.search(body):
        return True
    # 老格式兜底：只要 `## ... AI Review` 头存在
    return bool(LEGACY_HEADER_RE.search(body))


def parse_review(pr_number: int, comment: dict) -> ReviewEntry:
    body = comment.get("body") or ""
    created_at = _safe_parse(comment.get("created_at")) or _now_local()

    entry = ReviewEntry(
        pr_number=pr_number,
        comment_id=comment.get("id", 0),
        created_at=created_at,
        body=body,
        mode=None,
        verdict=None,
        is_dry_run=bool(DRY_RUN_BANNER_RE.search(body)),
    )

    h = HEADER_RE.search(body)
    if h:
        entry.mode = h.group("mode").strip()
        entry.verdict = h.group("verdict").strip().lower()
    else:
        entry.parse_quality = "partial"  # 老格式，verdict 解析不出来

    for m in DIMENSION_ROW_RE.finditer(body):
        name, status, _note = m.groups()
        if name.strip() == "维度":  # 跳表头行
            continue
        status_norm = status.lower()
        entry.dim_status_counts[status_norm] = entry.dim_status_counts.get(status_norm, 0) + 1

    # 找 finding 段，按 severity 顺序拆 body
    for m in FINDING_LINE_RE.finditer(body):
        category = m.group("category").strip()
        message = m.group("message").strip()
        file_ = m.group("file")
        line_ = m.group("line")
        # 推断 severity：找该匹配位置之前最近的 severity section（兼容章节标题 + 列表项两种格式）
        pos = m.start()
        sev = "suggestion"
        nearest = -1
        for sm in SEVERITY_SECTION_RE.finditer(body):
            if sm.start() < pos and sm.start() > nearest:
                nearest = sm.start()
                label = sm.group("header_label") or sm.group("list_label")
                sev = SEVERITY_LABEL_MAP.get(label, "suggestion")
        entry.findings.append(Finding(
            severity=sev,
            category=category,
            message=message,
            file=file_,
            line=int(line_) if line_ else None,
        ))

    if not h and not entry.findings:
        entry.parse_quality = "unparseable"
    entry.body = ""  # 解析完丢掉 raw body：后续聚合不再读，避免 N MB 闲置内存
    return entry


# ---------- 数据采集 ----------

def collect_prs_in_window(api: Api, cutoff: datetime) -> list[dict]:
    """拉 created/updated 在窗口内的 PR（state=all）。Gitea PR 列表按 newest 排序，
    一旦 updated_at 全 < cutoff 即可停止后续翻页。"""
    raw = paginate(api, "/pulls", {"state": "all", "sort": "newest"}, max_pages=20)
    out = []
    for pr in raw:
        upd = _safe_parse(pr.get("updated_at"))
        cre = _safe_parse(pr.get("created_at"))
        # 窗口内"有活动"：created 在窗口或 updated 在窗口
        if (upd and upd >= cutoff) or (cre and cre >= cutoff):
            out.append(pr)
    return out


def collect_ai_reviews_for_pr(api: Api, pr_number: int) -> list[ReviewEntry]:
    code, txt = api.get(f"/issues/{pr_number}/comments")
    if code != 200:
        print(f"WARN: GET comments for PR #{pr_number} → HTTP {code}", file=sys.stderr)
        return []
    comments = json.loads(txt) or []
    reviews = []
    for c in comments:
        body = c.get("body") or ""
        if not is_ai_review_comment(body):
            continue
        reviews.append(parse_review(pr_number, c))
    reviews.sort(key=lambda r: r.created_at)
    return reviews


# ---------- 聚合 ----------

@dataclass
class PRAggregate:
    pr: dict
    reviews: list[ReviewEntry]

    @property
    def num(self) -> int:
        return self.pr.get("number", 0)

    @property
    def title(self) -> str:
        t = (self.pr.get("title") or "").replace("\n", " ").strip()
        return t[: TITLE_MAX - 3] + "..." if len(t) > TITLE_MAX else t

    @property
    def base(self) -> str:
        return (self.pr.get("base") or {}).get("ref", "?")

    @property
    def state(self) -> str:
        if self.pr.get("merged_at"):
            return "merged"
        return self.pr.get("state") or "?"

    def repeat_findings(self) -> dict[tuple[str, str], int]:
        """同 (category, msg-fingerprint) 在本 PR 出现 ≥2 次的统计。"""
        c: Counter = Counter()
        for r in self.reviews:
            seen_this_review = set()
            for f in r.findings:
                fp = f.fingerprint()
                if fp in seen_this_review:
                    continue  # 同 review 内重复不算（罕见），跨 review 才算
                seen_this_review.add(fp)
                c[fp] += 1
        return {fp: n for fp, n in c.items() if n >= 2}


def aggregate(prs: list[dict], reviews_by_pr: dict[int, list[ReviewEntry]]) -> list[PRAggregate]:
    out = []
    for pr in prs:
        n = pr.get("number")
        rs = reviews_by_pr.get(n, [])
        if not rs:
            continue
        out.append(PRAggregate(pr=pr, reviews=rs))
    out.sort(key=lambda a: a.num, reverse=True)
    return out


# ---------- markdown 渲染 ----------

def fmt_pct(n: int, d: int) -> str:
    return f"{(100 * n / d):.0f}%" if d else "N/A"


def truncate(s: str, n: int) -> str:
    s = re.sub(r"\s+", " ", s).strip()
    return s[: n - 3] + "..." if len(s) > n else s


def render_report(ctx: dict) -> str:
    aggs: list[PRAggregate] = ctx["aggregates"]
    win_start: datetime = ctx["window_start"]
    win_end: datetime = ctx["window_end"]

    L = []
    L.append(f"# AI Review 数据盘点 · {win_end.strftime('%Y-%m-%d')}（近 {ctx['window_days']} 天）")
    L.append("")
    L.append(f"**生成时间**: {ctx['generated_at'].strftime('%Y-%m-%d %H:%M %z')}")
    L.append(f"**数据源**: Gitea API · {ctx['repo']}")
    L.append(f"**窗口**: {win_start.strftime('%Y-%m-%d %H:%M')} → {win_end.strftime('%Y-%m-%d %H:%M')} ({ctx['window_days']} 天)")
    L.append(f"**采样**: 窗口内活跃 PR {ctx['active_pr_count']} 个，其中 {len(aggs)} 个跑过 AI Review")
    L.append("")
    L.append("> 本报告**只列事实**。基于此报告做的 review 工作流优化决策见 issue #259。")
    L.append("")
    L.append("---")
    L.append("")

    # ===== §1 概览 =====
    total_reviews = sum(len(a.reviews) for a in aggs)
    dry_run_reviews = sum(1 for a in aggs for r in a.reviews if r.is_dry_run)
    by_verdict: Counter = Counter()
    by_mode: Counter = Counter()
    by_parse: Counter = Counter()
    review_count_dist: Counter = Counter()
    by_base: Counter = Counter()
    for a in aggs:
        review_count_dist[len(a.reviews)] += 1
        by_base[a.base] += 1
        for r in a.reviews:
            if r.verdict:
                by_verdict[r.verdict] += 1
            if r.mode:
                by_mode[r.mode] += 1
            by_parse[r.parse_quality] += 1

    avg = (total_reviews / len(aggs)) if aggs else 0
    max_reviews = max((len(a.reviews) for a in aggs), default=0)
    max_pr = next((a for a in aggs if len(a.reviews) == max_reviews), None)

    L.append("## 1. 概览")
    L.append("")
    L.append("| 指标 | 值 |")
    L.append("|---|---|")
    L.append(f"| 跑过 AI Review 的 PR | {len(aggs)} |")
    L.append(f"| AI Review 评论总数 | {total_reviews} |")
    L.append(f"| 每 PR 平均 review 次数 | {avg:.2f} |")
    if max_pr:
        L.append(f"| 单 PR 最多 review 次数 | {max_reviews} (PR #{max_pr.num}) |")
    L.append(f"| DRY RUN 期评论 | {dry_run_reviews} ({fmt_pct(dry_run_reviews, total_reviews)}) |")
    L.append("")

    L.append("### Review 次数分布（按 PR）")
    L.append("")
    L.append("| 次数 | PR 数 |")
    L.append("|---|---|")
    for k in sorted(review_count_dist.keys()):
        L.append(f"| {k} | {review_count_dist[k]} |")
    L.append("")

    L.append("### Verdict 分布（按评论）")
    L.append("")
    L.append("| verdict | 次数 | 占比 |")
    L.append("|---|---|---|")
    parsed_total = sum(by_verdict.values())
    for v in VERDICT_ORDER:
        n = by_verdict.get(v, 0)
        L.append(f"| {v} | {n} | {fmt_pct(n, parsed_total)} |")
    unparsed = total_reviews - parsed_total
    if unparsed:
        L.append(f"| _(未解析出 verdict)_ | {unparsed} | {fmt_pct(unparsed, total_reviews)} |")
    L.append("")

    L.append("### Mode 分布（按评论）")
    L.append("")
    L.append("| mode | 次数 |")
    L.append("|---|---|")
    for m in MODE_VALUES:
        n = by_mode.get(m, 0)
        if n:
            L.append(f"| {m} | {n} |")
    L.append("")

    L.append("### Base 分支分布（按 PR）")
    L.append("")
    L.append("| base | PR 数 |")
    L.append("|---|---|")
    for b, n in sorted(by_base.items(), key=lambda x: -x[1]):
        L.append(f"| {b} | {n} |")
    L.append("")

    if by_parse.get("partial", 0) or by_parse.get("unparseable", 0):
        L.append(f"> 解析质量：ok={by_parse.get('ok', 0)}, partial={by_parse.get('partial', 0)}, unparseable={by_parse.get('unparseable', 0)}")
        L.append("> (老格式评论或 PR 描述里被 quote 的 AI Review 节选可能算 partial/unparseable)")
        L.append("")

    # ===== §2 重复指控 =====
    L.append("## 2. 重复指控 Top（同 PR 内同 finding 跨 ≥2 次 review 出现）")
    L.append("")
    L.append("> **state 缺失的硬证据**：同一个问题在同一 PR 被反复指出，但 AI 不知道前一轮已指出过。")
    L.append("")

    repeat_rows: list[tuple[int, str, str, int]] = []  # (pr_num, category, sample_msg, count)
    for a in aggs:
        rep = a.repeat_findings()
        if not rep:
            continue
        # 拿到每个 fingerprint 的样本消息
        samples: dict[tuple[str, str], str] = {}
        for r in a.reviews:
            for f in r.findings:
                fp = f.fingerprint()
                if fp in rep and fp not in samples:
                    samples[fp] = f.message
        for fp, n in rep.items():
            cat, _ = fp
            repeat_rows.append((a.num, cat, samples.get(fp, ""), n))
    repeat_rows.sort(key=lambda x: -x[3])

    if repeat_rows:
        L.append(f"共 {len(repeat_rows)} 组重复指控。Top 20：")
        L.append("")
        L.append("| PR | category | message (≤60 字) | 出现次数 |")
        L.append("|---|---|---|---|")
        for pr_num, cat, msg, n in repeat_rows[:20]:
            L.append(f"| #{pr_num} | {cat} | {truncate(msg, 60)} | {n} |")
        L.append("")
    else:
        L.append("_（未检测到跨 review 重复指控；可能是 review 次数普遍 ≤1 或 finding 文本浮动大）_")
        L.append("")

    # ===== §3 PR 详表 =====
    L.append("## 3. PR 详表（review ≥2 次的 PR）")
    L.append("")
    high_freq = [a for a in aggs if len(a.reviews) >= 2]
    if high_freq:
        L.append("verdicts/findings 按时序排列；findings 列为 `hard/risk/sugg` 元组。")
        L.append("")
        L.append("| PR | base | state | reviews | verdicts | findings 演化 |")
        L.append("|---|---|---|---|---|---|")
        for a in high_freq:
            verdicts = " → ".join((r.verdict or "?") for r in a.reviews)
            findings_evo = " → ".join(r.severity_tuple_str() for r in a.reviews)
            L.append(f"| [#{a.num}](http://{GITEA_HOST}/{ctx['repo']}/pulls/{a.num}) {truncate(a.title, 40)} | {a.base} | {a.state} | {len(a.reviews)} | {verdicts} | {findings_evo} |")
        L.append("")
    else:
        L.append("_（窗口内没有 review 次数 ≥2 的 PR）_")
        L.append("")

    # ===== §4 关键发现 =====
    L.append("## 4. 关键发现")
    L.append("")
    n_high = len(high_freq)
    n_repeat_prs = len({pr for pr, _, _, _ in repeat_rows})
    block_count = by_verdict.get("block", 0)

    bullets = []
    if aggs:
        bullets.append(f"窗口内 **{len(aggs)} 个 PR** 跑过 AI Review，共 {total_reviews} 条评论，平均每 PR **{avg:.2f}** 次")
        if n_high:
            ratio = 100 * n_high / len(aggs)
            bullets.append(f"**{n_high} 个 PR ({ratio:.0f}%)** 跑了 ≥2 次 review —— **触发收敛 + state 去重的主要收益面**")
        if n_repeat_prs:
            bullets.append(f"**{n_repeat_prs} 个 PR 内部存在跨 review 重复指控**，共 {len(repeat_rows)} 组 —— state header 落地后这部分理论可清零")
        if dry_run_reviews:
            bullets.append(f"**全部 {dry_run_reviews} 条评论标 DRY RUN**，verdict=block 出现 {block_count} 次但**未实际阻断合并**（待 #171 转正后才生效）")
        if max_reviews >= 3:
            bullets.append(f"单 PR 最多跑了 **{max_reviews} 次** review（PR #{max_pr.num}）—— 重复消耗集中点")
    if not bullets:
        bullets.append("窗口内无 AI Review 数据。")
    for b in bullets:
        L.append(f"- {b}")
    L.append("")

    # ===== §5 数据局限 =====
    L.append("## 5. 数据局限")
    L.append("")
    L.append("- **采纳率没真值**：DRY RUN 期没有强制反馈通道，"
             "「finding 在下次 review 消失」既可能是被修了、也可能是 AI 这次没指出。"
             "本报告**不推断采纳率**，留给人工抽样")
    L.append("- **误报率没真值**：同上，需要 sample ~20 条人工标注（5 verdict=pass + 5 verdict=needs_fix + 10 finding 抽样）")
    L.append("- **finding 文本指纹**：用 (category, message 前 60 字 lower-cased) 模糊去重，"
             "AI 偶尔换措辞会算成两条，这部分误差未量化")
    L.append("- **老格式评论**：schema 化（PR #297）前的评论 verdict 解析为空，归入 `_(未解析)_`")
    L.append("")
    L.append("---")
    L.append("")
    L.append(f"_Generated by `scripts/ops/ai-review-stats.py`. 跟踪 [#259](http://{GITEA_HOST}/{ctx['repo']}/issues/259)._")
    L.append("")
    return "\n".join(L)


# ---------- main ----------

def main() -> int:
    p = argparse.ArgumentParser(description="AI Review 历史评论数据盘点")
    p.add_argument("--since-days", type=int, default=30)
    p.add_argument("--repo", default=None)
    p.add_argument("--out", default=None, help="输出 md 路径，默认 testing/reports/ai-review-stats-YYYYMMDD.md")
    p.add_argument("--print", dest="print_only", action="store_true", help="仅 stdout，不写文件")
    args = p.parse_args()

    token = get_token()
    repo = args.repo or detect_repo()
    api = Api(token, repo)

    now = _now_local()
    cutoff = now - timedelta(days=args.since_days)

    print(f"采样：repo={repo} since={cutoff:%Y-%m-%d %H:%M}", file=sys.stderr)
    prs = collect_prs_in_window(api, cutoff)
    print(f"  窗口内活跃 PR: {len(prs)}", file=sys.stderr)

    reviews_by_pr: dict[int, list[ReviewEntry]] = {}
    for i, pr in enumerate(prs, 1):
        n = pr.get("number")
        rs = collect_ai_reviews_for_pr(api, n)
        if rs:
            reviews_by_pr[n] = rs
        if i % 10 == 0:
            print(f"  …已扫描 {i}/{len(prs)} 个 PR（含 review 的 {len(reviews_by_pr)}）", file=sys.stderr)
    print(f"  含 AI Review 评论的 PR: {len(reviews_by_pr)}", file=sys.stderr)

    aggs = aggregate(prs, reviews_by_pr)

    ctx = {
        "generated_at": now,
        "window_start": cutoff,
        "window_end": now,
        "window_days": args.since_days,
        "repo": repo,
        "active_pr_count": len(prs),
        "aggregates": aggs,
    }
    md = render_report(ctx)

    if args.print_only:
        print(md)
        return 0

    out_path = Path(args.out) if args.out else REPORTS_DIR / f"ai-review-stats-{now.strftime('%Y%m%d')}.md"
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(md, encoding="utf-8")
    print(f"\nReport written: {out_path}", file=sys.stderr)
    return 0


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