#!/usr/bin/env python3
"""milestone-account — 每次結案都留下「估多久／花多久／差多少／為什麼」（inkstone/ISEP#85）。

leo 的問題（票上原文）：「leo 2026-08-27 說今天『**拖時間**』。但拖了多久、
比預計多拖多少、為什麼拖——**沒有任何數字**⋯⋯每次事後只能用『這次比較慢』
這種話帶過，下次還是一樣。」

    milestone-account codes                          七個代號是哪七個（封閉集合）
    milestone-account list --org inkstone [--overdue] 現在每個里程碑的三個數字
    milestone-account audit  inkstone/ISEP#12         從 Gitea 時間軸算證據
    milestone-account close  inkstone/ISEP#12 --reason idle [--dry-run]
    milestone-account report                          累計：哪一種原因最常發生
    milestone-account ledger-path                     帳本檔在哪（給閘問的）

🔴 這支工具的三條，全部長在機器上（不是寫在規範裡靠人記得）：

  ① **三個數字自己算，不用手填**：期限與實際都取自 Gitea 的 `created_at`／
     `due_on`／`closed_at`。手填的數字沒有人會去對，等於沒有。
  ② **差超過 25%（超時或提早都算）就一定要挑一個代號**，挑不出來不准結案。
     提早太多不是好消息，是**當初估太鬆**——所以兩個方向同一條線。
  ③ **代號要有時間軸撐得住**：`audit` 先從 Gitea 的時間軸算出哪幾個訊號成立，
     `close` 只接受**訊號成立的那幾個代號**；一個都沒成立時，唯一合法的代號
     就是 `misestimate`（＝票上那句「以上都沒有，但還是差很多」）。
     ⇒ **不採信我自己說的**，這是票上寫死的。

🔴 代號是**封閉集合**，這一點是刻意的——票上原文：「**能統計的前提是分類有限**」。
   真的歸不進去才提案加一個，而那要走 leo 核准（開票，不是自己加）。

離線測試：`scripts/test-milestone-account.sh`（走 `MILESTONE_ACCOUNT_FIXTURE`，不打網路）。
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me")
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def _resolve_dir():
    """帳本住哪裡。

    🔴 **不能住在 plugin 目錄裡**：ISEP 是用 marketplace 裝的，
       `claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零，
       而這張票的全部價值就在「累積」。
    順序：環境變數 → 這個 session 的專案根底下的 `system-dev/`（總管的跨專案知識庫，
    帳本本來就屬於那一層）→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。
    """
    v = os.environ.get("MILESTONE_ACCOUNT_DIR")
    if v:
        return v
    for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
        if base and os.path.isdir(os.path.join(base, "system-dev")):
            return os.path.join(base, "system-dev", "estimates")
    return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates")


DIR = _resolve_dir()
LEDGER = os.path.join(DIR, "ledger.jsonl")
FIXTURE = os.environ.get("MILESTONE_ACCOUNT_FIXTURE", "")
DRY = False
THRESHOLD = 25.0     # 差超過幾 % 就一定要挑代號（票上寫死「四分之一」）

# ── 封閉集合：七個代號，一個都不准多、不准少 ──────────────────────────────
CODES = [
    ("idle",         "磨洋工",   "小事拖成大事",       "時間軸上有長長的空白"),
    ("sidetrack",    "分心跳線", "講到別的就跑去做",   "期間出現不屬於這條線的動作"),
    ("undispatched", "忘了派工", "該派沒派，票停著",   "票很久沒有人被指派"),
    ("cherry-pick",  "偏科",     "挑喜歡的做，難的擱著", "完成順序跟優先順序倒過來"),
    ("waiting",      "等答案",   "問了就原地等，沒去做別的", "等待期間其他票零進度"),
    ("scope-creep",  "範圍變大", "一直塞新東西進來",   "補收的次數、票數成長曲線"),
    ("misestimate",  "純粹估錯", "過程沒異常，就是估錯", "以上都沒有，但還是差很多"),
]
CODE_IDS = [c[0] for c in CODES]
LABEL = {c[0]: c[1] for c in CODES}


def die(msg, code=2):
    print(msg, file=sys.stderr)
    sys.exit(code)


def token():
    root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE
    host = HOST.split("//")[-1].rstrip("/")
    try:
        out = subprocess.run(["git", "-C", root, "remote", "-v"],
                             capture_output=True, text=True, timeout=20).stdout
    except Exception:
        out = ""
    for line in out.splitlines():
        if host in line:
            m = re.search(r"//[^:/]+:([^@]+)@", line)
            if m:
                return m.group(1)
    for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
        if os.environ.get(env):
            return os.environ[env]
    return ""


_FX = None


def fx():
    global _FX
    if _FX is None:
        try:
            with open(FIXTURE) as f:
                _FX = json.load(f)
        except Exception:
            die("🔴 讀不到 MILESTONE_ACCOUNT_FIXTURE：%s" % FIXTURE)
    return _FX


def api(path, payload=None, method=None):
    verb = method or ("POST" if payload is not None else "GET")
    if FIXTURE:
        if verb == "GET":
            return (fx().get("api") or {}).get(path)
        print("  [fixture] %s %s %s" % (verb, path, json.dumps(payload or {})))
        return {}
    if DRY and verb != "GET":
        print("  [dry-run] %s %s %s" % (verb, path, json.dumps(payload or {})))
        return {}
    tk = token()
    if not tk:
        die("🔴 拿不到 %s 的 token（remote 沒帶憑證，也沒有 GITEA_TOKEN_CLAUDE_CODE）" % HOST)
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        HOST + "/api/v1" + path, data=data, method=verb,
        headers={"Authorization": "token " + tk, "Content-Type": "application/json"})
    try:
        raw = urllib.request.urlopen(req, timeout=30).read()
        return json.loads(raw) if raw.strip() else {}
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        die("🔴 Gitea %d on %s %s：%s" % (e.code, verb, path, e.read().decode()[:200]))
    except Exception as e:
        die("🔴 連不上 %s：%s" % (HOST, e))


# ── 時間 ─────────────────────────────────────────────────────────────────
_TS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})"
                 r"(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$")


def ts(s):
    """Gitea 的時間字串 → epoch 秒。認得 Z 與 +08:00 兩種尾巴；認不得回 None。"""
    if not s:
        return None
    m = _TS.match(str(s).strip())
    if not m:
        return None
    y, mo, d, h, mi, se, off = m.groups()
    t = time.mktime((int(y), int(mo), int(d), int(h), int(mi), int(se), 0, 1, 0)) \
        - time.timezone
    if off and off not in ("Z", "z"):
        sign = 1 if off[0] == "+" else -1
        off = off[1:].replace(":", "")
        t -= sign * (int(off[:2]) * 3600 + int(off[2:]) * 60)
    return t


def now():
    v = os.environ.get("MILESTONE_ACCOUNT_NOW")
    return ts(v) if v else time.time()


def days(sec):
    return sec / 86400.0


def parse_ref(s):
    m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
    if not m:
        die("🔴 寫法是 owner/repo#里程碑編號，你給的是：%s" % s)
    return m.group(1), m.group(2), int(m.group(3))


# ── 三個數字 ─────────────────────────────────────────────────────────────
def numbers(ms):
    """回 (planned_days, actual_days, deviation_pct)。算不出來的格子回 None。"""
    c, due = ts(ms.get("created_at")), ts(ms.get("due_on"))
    end = ts(ms.get("closed_at")) or now()
    if c is None:
        return None, None, None
    actual = days(end - c)
    if due is None or due <= c:
        return None, actual, None
    planned = days(due - c)
    return planned, actual, (actual - planned) / planned * 100.0


def fmt(v, unit=""):
    return "—" if v is None else ("%.1f%s" % (v, unit))


def three_lines(ms, planned, actual, dev):
    return ("   當初訂的期限　%s天　（%s → %s）\n"
            "   實際花掉的　　%s天　（到 %s）\n"
            "   差了　　　　　%s"
            % (fmt(planned), (ms.get("created_at") or "?")[:10],
               (ms.get("due_on") or "沒填期限")[:10],
               fmt(actual), (ms.get("closed_at") or "現在")[:10],
               "算不出來（期限沒填，或期限不晚於建立日）" if dev is None
               else "%+.0f%%%s" % (dev, "　🔴 超過 ±25%，要挑代號"
                                   if abs(dev) > THRESHOLD else "")))


# ── 從 Gitea 時間軸取證據 ────────────────────────────────────────────────
def fetch_issues(owner, repo):
    rows, page = [], 1
    while page <= 5:
        r = api("/repos/%s/%s/issues?state=all&type=issues&limit=50&page=%d"
                % (owner, repo, page))
        if not isinstance(r, list) or not r:
            break
        rows.extend(r)
        if len(r) < 50:
            break
        page += 1
    return rows


def fetch_comments(owner, repo, since):
    q = "?limit=100"
    if since:
        q += "&since=" + urllib.parse.quote(since)
    r = api("/repos/%s/%s/issues/comments%s" % (owner, repo, q))
    return r if isinstance(r, list) else []


def prio(issue):
    """優先度：p/high=3、p/med=2、p/low=1、沒標=0。"""
    v = 0
    for l in issue.get("labels") or []:
        n = (l.get("name") or "").lower()
        if n in ("p/high", "p/urgent", "p0", "p1"):
            v = max(v, 3)
        elif n in ("p/med", "p/medium", "p2"):
            v = max(v, 2)
        elif n in ("p/low", "p3"):
            v = max(v, 1)
    return v


def evidence(owner, repo, ms):
    """把 Gitea 時間軸算成七個訊號。回 (signals, detail)。

    🔴 這裡**一句話都不讀**：全部只看時間戳、指派欄位、標籤、票的歸屬。
       票上寫死「不採信我自己說的」，所以判準不能碰任何自然語言。
    """
    start = ts(ms.get("created_at"))
    end = ts(ms.get("closed_at")) or now()
    span = max(end - start, 1.0)
    mid = ms.get("id")

    issues = fetch_issues(owner, repo)
    if issues is None:
        issues = []
    mine = [i for i in issues if (i.get("milestone") or {}).get("id") == mid]
    others = [i for i in issues if (i.get("milestone") or {}).get("id") != mid]
    comments = fetch_comments(owner, repo, ms.get("created_at"))

    mine_ids = set(i.get("number") for i in mine)

    def cnum(c):
        u = c.get("issue_url") or c.get("html_url") or ""
        m = re.search(r"/(?:issues|pulls)/(\d+)", u)
        return int(m.group(1)) if m else c.get("issue_number")

    ev_mine, ev_other = [], []
    for i in mine:
        for k in ("created_at", "closed_at"):
            t = ts(i.get(k))
            if t and start <= t <= end:
                ev_mine.append(t)
    for c in comments:
        t = ts(c.get("created_at"))
        if not t or not (start <= t <= end):
            continue
        (ev_mine if cnum(c) in mine_ids else ev_other).append(t)
    for i in others:
        t = ts(i.get("created_at"))
        if t and start <= t <= end:
            ev_other.append(t)

    marks = sorted([start] + ev_mine + [end])
    gap, gap_at = 0.0, None
    for a, b in zip(marks, marks[1:]):
        if b - a > gap:
            gap, gap_at = b - a, a
    sig, det = {}, {}

    # ① 磨洋工：時間軸上有長長的空白
    sig["idle"] = days(gap) >= 2.0 and gap / span >= 0.35
    det["idle"] = ("最長空白 %.1f 天（佔整段 %.0f%%），起點 %s"
                   % (days(gap), gap / span * 100,
                      time.strftime("%Y-%m-%d", time.gmtime(gap_at)) if gap_at else "?"))

    # ② 分心跳線：期間出現不屬於這條線的動作
    tot = len(ev_mine) + len(ev_other)
    ratio = (len(ev_other) / tot) if tot else 0.0
    sig["sidetrack"] = len(ev_other) >= 5 and ratio >= 0.30
    det["sidetrack"] = ("這段期間 %d 個動作在這條線上、%d 個在別的票上（%.0f%%）"
                        % (len(ev_mine), len(ev_other), ratio * 100))

    # ③ 忘了派工：票很久沒有人被指派
    naked = [i for i in mine if not (i.get("assignees") or i.get("assignee"))
             and days(end - (ts(i.get("created_at")) or end)) >= days(span) * 0.5]
    sig["undispatched"] = len(naked) > 0
    det["undispatched"] = ("%d 張票整段期間沒有人被指派：%s"
                           % (len(naked), ", ".join("#%s" % i.get("number")
                                                    for i in naked[:6])) if naked
                           else "每一張票都有人被指派過")

    # ④ 偏科：完成順序跟優先順序倒過來
    inv = 0
    closed = [i for i in mine if ts(i.get("closed_at"))]
    openv = [i for i in mine if not ts(i.get("closed_at"))]
    for a in closed:
        for b in closed:
            if prio(a) < prio(b) and ts(a["closed_at"]) < ts(b["closed_at"]):
                inv += 1
    for a in closed:
        for b in openv:
            if prio(a) < prio(b):
                inv += 1
    sig["cherry-pick"] = inv >= 2
    det["cherry-pick"] = ("低優先先收掉、高優先還晾著的配對 %d 組" % inv)

    # ⑤ 等答案：等待期間其他票零進度
    waitset = [i for i in mine
               if any((l.get("name") or "").lower() == "human"
                      for l in (i.get("labels") or []))]
    wait_days = 0.0
    for i in waitset:
        s = ts(i.get("created_at")) or start
        e = ts(i.get("closed_at")) or end
        wait_days = max(wait_days, days(e - s))
    moved = sum(1 for i in mine
                if ts(i.get("closed_at")) and i not in waitset)
    sig["waiting"] = bool(waitset) and wait_days >= days(span) * 0.30 and moved == 0
    det["waiting"] = ("掛 Human 的票 %d 張、最長等 %.1f 天；期間其他票關掉 %d 張"
                      % (len(waitset), wait_days, moved))

    # ⑥ 範圍變大：補收的次數、票數成長曲線
    late = [i for i in mine
            if (ts(i.get("created_at")) or start) > start + span * 0.25]
    sig["scope-creep"] = len(late) >= 1 and len(mine) and len(late) / len(mine) >= 0.20
    det["scope-creep"] = ("開跑之後才補進來的票 %d／%d 張：%s"
                          % (len(late), len(mine),
                             ", ".join("#%s" % i.get("number") for i in late[:6])))

    # ⑦ 純粹估錯：以上都沒有
    sig["misestimate"] = not any(sig[c] for c in CODE_IDS if c != "misestimate")
    det["misestimate"] = ("上面六個訊號%s成立"
                          % ("一個都沒有" if sig["misestimate"] else "有"))
    return sig, det


def get_milestone(owner, repo, num):
    ms = api("/repos/%s/%s/milestones/%d" % (owner, repo, num))
    if not isinstance(ms, dict) or not ms.get("id"):
        die("🔴 抓不到里程碑 %s/%s#%d（編號對嗎？）" % (owner, repo, num))
    return ms


# ── 指令 ─────────────────────────────────────────────────────────────────
def cmd_codes(argv):
    print("七個代號（封閉集合，inkstone/ISEP#85）——「能統計的前提是分類有限」\n")
    for cid, label, plain, how in CODES:
        print("  %-13s %-8s %-22s 從哪看出來：%s" % (cid, label, plain, how))
    print("\n真的歸不進去 ⇒ 開一張票請 leo 核准加一個，**不要自己加**。")


def _repos(argv):
    rs = [r for r in (argv[i + 1] for i, a in enumerate(argv)
                      if a == "--repo" and i + 1 < len(argv))]
    if rs:
        return rs
    org = None
    for i, a in enumerate(argv):
        if a == "--org" and i + 1 < len(argv):
            org = argv[i + 1]
    if not org:
        die("用法：milestone-account list --org inkstone [--overdue]  或  --repo owner/repo")
    rows = api("/orgs/%s/repos?limit=100" % org)
    if not isinstance(rows, list):
        die("🔴 撈不到 %s 底下的 repo" % org)
    return [r["full_name"] for r in rows]


def cmd_list(argv):
    overdue = "--overdue" in argv
    n = 0
    for full in _repos(argv):
        owner, repo = full.split("/", 1)
        mss = api("/repos/%s/%s/milestones?state=open&limit=50" % (owner, repo))
        if not isinstance(mss, list):
            continue
        for ms in mss:
            planned, actual, dev = numbers(ms)
            late = dev is not None and dev > 0
            if overdue and not late:
                continue
            n += 1
            print("%s %-34s %-30s 估 %s天／已 %s天／%s"
                  % ("🔴" if (dev is not None and abs(dev) > THRESHOLD) else "  ",
                     "%s#%s" % (full, ms.get("id")), (ms.get("title") or "")[:28],
                     fmt(planned), fmt(actual),
                     "—" if dev is None else "%+.0f%%" % dev))
    print("\n共 %d 個%s里程碑。🔴 ＝ 差超過 ±25%%，結案時一定要挑一個代號。"
          % (n, "逾期的 " if overdue else "open "))
    print("挑代號之前先跑：milestone-account audit <owner/repo#編號>")


def cmd_audit(argv):
    if not argv:
        die("用法：milestone-account audit <owner/repo#里程碑編號>")
    owner, repo, num = parse_ref(argv[0])
    ms = get_milestone(owner, repo, num)
    planned, actual, dev = numbers(ms)
    print("【%s/%s#%d】%s\n" % (owner, repo, num, ms.get("title") or ""))
    print(three_lines(ms, planned, actual, dev))
    sig, det = evidence(owner, repo, ms)
    print("\n【時間軸怎麼說】（全部取自 Gitea 的時間戳／欄位，一句話都沒讀）")
    for cid, label, _plain, _how in CODES:
        print("  %s %-13s %-8s %s" % ("🔴" if sig[cid] else "  ", cid, label, det[cid]))
    ok = [c for c in CODE_IDS if sig[c]]
    print("\n可以挑的代號：%s" % " / ".join(ok))
    print("結案：milestone-account close %s/%s#%d --reason %s" % (owner, repo, num, ok[0]))


def _ledger():
    rows = []
    try:
        with open(LEDGER) as f:
            for line in f:
                line = line.strip()
                if line:
                    try:
                        rows.append(json.loads(line))
                    except Exception:
                        pass
    except Exception:
        pass
    return rows


def cmd_close(argv):
    if not argv:
        die("用法：milestone-account close <owner/repo#編號> [--reason 代號] [--dry-run]")
    owner, repo, num = parse_ref(argv[0])
    ref = "%s/%s#%d" % (owner, repo, num)
    reason = None
    for i, a in enumerate(argv):
        if a == "--reason" and i + 1 < len(argv):
            reason = argv[i + 1]

    ms = get_milestone(owner, repo, num)
    planned, actual, dev = numbers(ms)
    print("【%s】%s\n" % (ref, ms.get("title") or ""))
    print(three_lines(ms, planned, actual, dev))

    sig, det = evidence(owner, repo, ms)
    legal = [c for c in CODE_IDS if sig[c]]
    need = dev is None or abs(dev) > THRESHOLD

    if need and not reason:
        die("\n🚫 **差 %s，超過 ±25%%——挑不出代號不准結案。**\n\n"
            "【inkstone/ISEP#85】「差超過四分之一（不管是超時還是提早太多，\n"
            "  **提早太多代表當初估太鬆**）就要挑一個原因，而且**只能從固定的幾個裡面挑**，\n"
            "  不准用一段話帶過。」\n\n"
            "時間軸撐得住的代號：%s\n"
            "%s\n"
            "  再送一次：milestone-account close %s --reason %s\n"
            "  想看全部七個：milestone-account codes ／ 看證據：milestone-account audit %s"
            % ("算不出來（期限沒填）" if dev is None else "%+.0f%%" % dev,
               " / ".join(legal),
               "\n".join("    %-13s %s" % (c, det[c]) for c in legal), ref, legal[0], ref))

    if reason:
        if reason not in CODE_IDS:
            die("\n🚫 `%s` 不是那七個代號之一。\n"
                "   【inkstone/ISEP#85】代號是**封閉集合**——「能統計的前提是分類有限」。\n"
                "   七個：%s\n"
                "   真的歸不進去 ⇒ 開票請 leo 核准加一個，不要自己造。"
                % (reason, " / ".join(CODE_IDS)))
        if reason not in legal:
            die("\n🚫 `%s`（%s）**時間軸撐不住**——不接受。\n\n"
                "   【inkstone/ISEP#85】「證據一律從 Gitea 的時間軸取"
                "（留言、指派、標籤、commit 都有時間戳），**不採信我自己說的**。」\n\n"
                "   這個代號的訊號：%s\n"
                "   撐得住的是：%s\n"
                "   （訊號真的判錯了 ⇒ 那是 audit 的 bug，去修偵測器，"
                "不是在這裡繞過去——這條線的意義就是「可以統計、可以迭代」。）"
                % (reason, LABEL[reason], det[reason], " / ".join(legal)))

    rec = {"ref": ref, "title": ms.get("title") or "",
           "created_at": ms.get("created_at"), "due_on": ms.get("due_on"),
           "closed_at": ms.get("closed_at") or time.strftime(
               "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now())),
           "planned_days": None if planned is None else round(planned, 2),
           "actual_days": None if actual is None else round(actual, 2),
           "deviation_pct": None if dev is None else round(dev, 1),
           "over_threshold": bool(need),
           "reason": reason, "reason_label": LABEL.get(reason or "", ""),
           "signals": {c: bool(sig[c]) for c in CODE_IDS},
           "evidence": {c: det[c] for c in CODE_IDS},
           "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now()))}

    if DRY:
        print("\n[dry-run] 會寫進 %s：" % LEDGER)
        print(json.dumps(rec, ensure_ascii=False, indent=2)[:900])
        print("  [dry-run] PATCH /repos/%s/%s/milestones/%d {\"state\": \"closed\"}"
              % (owner, repo, num))
        print("[dry-run] 一筆都沒有真的寫出去。")
        return

    # 🔴 順序：**先記帳、再關**。反過來的話，關成功而記帳失敗，
    #    那個里程碑就永遠不會出現在統計裡——而那正是這張票要解的病。
    os.makedirs(DIR, exist_ok=True)
    with open(LEDGER, "a") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
    print("\n✅ 已記帳：%s　估 %s天／花 %s天／%s%s"
          % (ref, fmt(planned), fmt(actual),
             "—" if dev is None else "%+.0f%%" % dev,
             "　原因＝%s（%s）" % (reason, LABEL[reason]) if reason else ""))

    if (ms.get("state") or "") != "closed":
        api("/repos/%s/%s/milestones/%d" % (owner, repo, num),
            {"state": "closed"}, method="PATCH")
        print("✅ 里程碑已關閉")
    else:
        print("ℹ️ 里程碑本來就是 closed，只補記帳")
    write_report()
    print("📊 累計統計已更新：%s" % os.path.join(DIR, "REPORT.md"))


def write_report():
    rows = _ledger()
    cnt = {}
    for r in rows:
        if r.get("reason"):
            cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
    devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
    lines = ["# 估多久／花多久／差多少——累計統計", "",
             "> 這份檔案由 `scripts/milestone-account` 自動重寫，**不要手改**。",
             "> 【inkstone/ISEP#85】「每次事後只能用『這次比較慢』這種話帶過，"
             "下次還是一樣。」", "",
             "- 累計結案 **%d** 個里程碑" % len(rows)]
    if devs:
        lines.append("\t- 平均偏差 **%+.0f%%**（正＝超時，負＝提早）"
                     % (sum(devs) / len(devs)))
        lines.append("\t- 超過 ±25%% 的有 **%d** 個"
                     % sum(1 for d in devs if abs(d) > THRESHOLD))
    lines.append("- 哪一種原因最常發生（這才是迭代的依據）")
    if not cnt:
        lines.append("\t- 目前還沒有任何被挑過的代號。")
    for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
        lines.append("\t- **%s（%s）** ×%d" % (LABEL.get(cid, cid), cid, n))
    lines.append("- 逐筆")
    for r in sorted(rows, key=lambda r: r.get("recorded_at") or "", reverse=True):
        lines.append("\t- `%s` %s" % (r["ref"], r.get("title", "")))
        lines.append("\t\t- 估 %s 天／花 %s 天／差 %s"
                     % (r.get("planned_days"), r.get("actual_days"),
                        "—" if r.get("deviation_pct") is None
                        else "%+.0f%%" % r["deviation_pct"]))
        if r.get("reason"):
            lines.append("\t\t- 原因：**%s（%s）**" % (r.get("reason_label"), r["reason"]))
            lines.append("\t\t- 時間軸證據：%s"
                         % (r.get("evidence") or {}).get(r["reason"], "—"))
    try:
        os.makedirs(DIR, exist_ok=True)
        with open(os.path.join(DIR, "REPORT.md"), "w") as f:
            f.write("\n".join(lines) + "\n")
    except Exception as e:
        die("🔴 寫不進 %s：%s" % (DIR, e))


def cmd_report(argv):
    rows = _ledger()
    if not rows:
        print("（帳本還是空的：%s）" % LEDGER)
        print("關掉第一個里程碑就會有第一筆：milestone-account close <owner/repo#編號>")
        return
    cnt = {}
    for r in rows:
        if r.get("reason"):
            cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
    devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
    print("累計結案 %d 個里程碑" % len(rows))
    if devs:
        print("平均偏差 %+.0f%%（正＝超時，負＝提早）；超過 ±25%% 的有 %d 個"
              % (sum(devs) / len(devs), sum(1 for d in devs if abs(d) > THRESHOLD)))
    print("\n哪一種原因最常發生：")
    if not cnt:
        print("  （還沒有任何被挑過的代號）")
    for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
        print("  %-13s %-8s ×%d  %s" % (cid, LABEL.get(cid, cid), n, "█" * n))
    write_report()
    print("\n人看的版本：%s" % os.path.join(DIR, "REPORT.md"))


def main():
    global DRY
    argv = sys.argv[1:]
    if "--dry-run" in argv:
        DRY = True
        argv = [a for a in argv if a != "--dry-run"]
    if not argv:
        print(__doc__)
        return
    cmd, rest = argv[0], argv[1:]
    table = {"codes": cmd_codes, "list": cmd_list, "audit": cmd_audit,
             "close": cmd_close, "report": cmd_report,
             # 給 hooks/milestone-account-guard.sh 問「帳本在哪」用的。
             # 讓「怎麼找到帳本」只有一份答案，閘不必自己再實作一次解析。
             "ledger-path": lambda a: print(LEDGER)}
    if cmd not in table:
        print(__doc__)
        die("🔴 沒有這個子指令：%s" % cmd)
    table[cmd](rest)


if __name__ == "__main__":
    main()
