#!/usr/bin/env python3
"""debt-worklist — 還債線：每天固定撈一次「今天可以領什麼」，整包整包地領。

leo 2026-08-27（inkstone/ISEP#83 的來由，他當場訂正總管的誤解）：
  「這是兩件不同的事，你根本沒搞清楚，距離是在確定有個緊急事件要儘速達成時，
   現在每天都在追新的，所以**要用 Routine 去消化舊的**，可以有多個條件」

⇒ **兩條線，各用各的判準**，不是二選一：

    緊急線   有事要趕        → 用「距離目標的遠近」排（＝掛在 open milestone 上的那些）
    還債線   每天消化舊的     → 用多條件排（事故／擋人／承諾／票齡／估工）  ← 本工具

病根：全 org 229 張 open 票，每天在動的只有掛在 active milestone 上的那些。
沒掛的不是不重要，是**沒有任何機制會去拿它們**。`worklist-guard.sh` 擋的是
「這一輪已經領到的工作沒做完就收工」——它不會去把舊票撈出來。本工具補的是供給面。

三個動詞：

    debt-worklist list                   撈一次，排好序印出來（預設動作）
    debt-worklist claim <票...>          領走（今天領的，明天就不會再出現在清單上）
    debt-worklist forget <票...>         放回去（領錯了、或那條線改方向了）

票的寫法：`owner/repo#N`，例：`inkstone/InkStoneCo#44`

🔴 **不是輪詢。** 這支腳本自己不會醒——是人跑它、或 SessionStart 那一次順手跑。
   不准掛 Gitea Actions／webhook fan-out（CLAUDE.md 避免再被 flag 的硬規則，
   `issue-handle` 明寫「有事才讀」，換成 Gitea 也不放寬）。

離線可測：設 `ISEP_DEBT_FIXTURE=<json 檔>` 就完全不打網路（測試用）。
狀態檔位置可用 `ISEP_DEBT_STATE_DIR` 改（測試用，預設 `~/.claude/isep-debt/`）。
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = os.environ.get("ISEP_DEBT_ORG") or "inkstone"

# ── 「現在的主線是哪一個」只有一份答案（inkstone/ISEP#82 → hooks/lib/mainline.py）──
#   本工具排除的是**緊急線正在管的那些**。在 #82 之前，「哪個是 active」沒有指涉對象
#   （14 個 open milestone 同時亮著），所以第一版只能粗暴地「掛 open milestone 就排除」。
#   現在有唯一答案了 ⇒ 接上去，**不要自己養第二套判斷**（兩份必然漂移）。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
    import mainline as ML
except Exception:      # 拿不到共用零件就退回舊行為（fail-open），不讓還債線整個癱掉
    ML = None

# 等 leo 的票不進「可領清單」——不是它們不重要，是**沒有人能替他領**：
#   Human／human/exec ＝ 要他親手批示或動手
#   s/stage           ＝ 已經部到 stage，等他驗（session-start-recall 就是這樣定義的）
# 這些的正確動作是「催」不是「領」，而催辦是 inkstone/ISEP#93 的事。本票是供給面。
WAITING_LEO_LABELS = {"Human", "human/exec", "s/stage"}

# 已經有人在做／已經有 PR 的狀態：這是「答應過人家的」，債比沒開工的還硬。
STARTED_LABELS = {"s/doing", "s/review"}


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


# ── 連線（跟 scripts/ticket 同一套：先找帶憑證的 remote，再退環境變數）────────
def token():
    root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
    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]
    die(f"""🔴 拿不到 {host} 的 token

擇一：
  • 讓某個 remote 帶憑證（多數 repo 的 gitea/origin 本來就有）
  • 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")


def api(path):
    url = f"{HOST}/api/v1{path}"
    req = urllib.request.Request(url, headers={"Authorization": f"token {token()}"})
    try:
        return json.load(urllib.request.urlopen(req, timeout=60))
    except urllib.error.HTTPError as e:
        die(f"🔴 Gitea {e.code}：{e.read().decode()[:300]}\n   （path={path}）")
    except Exception as e:
        die(f"🔴 打不到 {HOST}：{e}\n   （path={path}）")


# ── 收集：全 org 每個 repo 的 open issue ＋ open PR ──────────────────────────
def collect():
    """回 (repos, items)。repos 是「我查了哪些」的證明，items 是原始票。

    fixture 模式（測試用）：完全不打網路，資料從 json 檔讀。
    """
    fx = os.environ.get("ISEP_DEBT_FIXTURE")
    if fx:
        with open(fx) as f:
            data = json.load(f)
        return data["repos"], data["items"]

    repos, items = [], []
    for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
        full = r["full_name"]
        if not r.get("has_issues", True):
            repos.append({"name": full, "issues": 0, "prs": 0, "skipped": "issues 關閉"})
            continue
        got, page = [], 1
        while True:
            q = urllib.parse.urlencode({"state": "open", "limit": 50, "page": page})
            batch = api(f"/repos/{full}/issues?{q}") or []
            got.extend(batch)
            if len(batch) < 50:
                break
            page += 1
            if page > 20:      # 保險絲：不可能有一千張，有的話是我寫錯了
                break
        for it in got:
            it.setdefault("repository", {"full_name": full})
        n_pr = sum(1 for it in got if it.get("pull_request"))
        repos.append({"name": full, "issues": len(got) - n_pr, "prs": n_pr})
        items.extend(got)
    return repos, items


# ── 工具 ───────────────────────────────────────────────────────────────────
def ref_of(it):
    return f"{it['repository']['full_name']}#{it['number']}"


def labels_of(it):
    return {l["name"] for l in (it.get("labels") or [])}


def is_pr(it):
    return bool(it.get("pull_request"))


def parse_ts(s):
    if not s:
        return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00"))
    except Exception:
        return None


def now():
    """時鐘可以被定住——測試要能重現，不能今天跑綠明天跑紅。"""
    inj = os.environ.get("ISEP_DEBT_NOW")
    if inj:
        d = parse_ts(inj)
        if d:
            return d
    return datetime.now(timezone.utc)


def days_since(s):
    d = parse_ts(s)
    if not d:
        return 0.0
    return max(0.0, (now() - d).total_seconds() / 86400)


REF_RE = re.compile(r"(?:([\w.-]+)/([\w.-]+))?#(\d+)")


def refs_in(it):
    """從標題＋內文抓出它指到的別張票。裸 `#12` 算同 repo。

    只認得出「文字上指了誰」——這是零成本的訊號（內文本來就在清單裡）。
    Gitea 原生的 dependencies 要一張票打一次 API，229 張＝229 次，不值得。
    """
    owner, repo = it["repository"]["full_name"].split("/")
    out = set()
    text = f"{it.get('title') or ''}\n{it.get('body') or ''}"
    for o, r, n in REF_RE.findall(text):
        out.add(f"{o or owner}/{r or repo}#{n}")
    out.discard(ref_of(it))
    return out


# ── 排序：多個條件，每一條都看得見自己貢獻了幾分 ─────────────────────────────
#
# leo 給的順序（票上原文）：出事的 → 擋住別人的 → 答應過人家的 → 放最久的 → 最快做完的。
# 分數只是把這個順序寫成可重現的東西；**每一項都會印在票旁邊**，
# 排出來覺得不對可以直接指著說「這一項給太多」，而不是猜它為什麼在第一名。
def score(it, in_degree):
    lab = labels_of(it)
    parts = []

    if "type/bug" in lab:
        parts.append(("事故", 40))
    if "p/high" in lab:
        parts.append(("高優先", 20))
    if "p/low" in lab:
        parts.append(("低優先", -12))

    blocked = in_degree.get(ref_of(it), 0)
    if blocked:
        parts.append((f"擋住{blocked}張", min(12 * blocked, 36)))
    if is_pr(it):
        # 躺著沒結論的 PR 擋的是 merge 那條路（inkstone/ISEP#81 是它的另一半）
        parts.append(("PR躺著", 25))

    started = lab & STARTED_LABELS
    if started:
        parts.append((f"已開工({'/'.join(sorted(started))})", 18))
    if it.get("assignees") or it.get("assignee"):
        parts.append(("有人接了", 12))

    ms = it.get("milestone") or {}
    if ms and (ms.get("state") or "open") == "open" and ML is not None:
        due = ML.due_of(ms)
        if due and due < now():
            # 掛在**別條**（非主線）而且期限已經過去的 milestone ⇒ 這是承諾過的日期，
            # 而那個日期已經過了。它之所以還在這份清單上，正是因為沒有人在管那條線。
            parts.append((f"逾期{int((now() - due).total_seconds() // 86400)}天的承諾", 15))

    age = days_since(it.get("updated_at"))
    if age >= 1:
        parts.append((f"擱置{int(age)}天", round(min(age * 0.6, 30), 1)))

    body_len = len(it.get("body") or "")
    if "type/chore" in lab or body_len < 800:
        parts.append(("看起來小", 10))

    total = round(sum(p[1] for p in parts), 1)
    return total, parts


def pick(items, claimed):
    """把原始票分成三堆：可領的／緊急線的／要 leo 動手的。並回統計。"""
    open_refs = {ref_of(it) for it in items}
    in_degree = {}
    for it in items:
        for target in refs_in(it):
            if target in open_refs:
                in_degree[target] = in_degree.get(target, 0) + 1

    ml = ML.load() if ML is not None else None
    pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items),
                       "other_ms": 0, "mainline": ml}
    for it in items:
        ref = ref_of(it)
        ms = it.get("milestone")
        if ms and (ms.get("state") or "open") == "open":
            if ml is None:
                # 現在沒有主線 ⇒ 分不出哪一條是緊急線在管的。退回保守做法：
                # 掛 open milestone 的一律不領（寧可少領，不要跟緊急線搶同一張）。
                stats["milestone"] += 1
                continue
            if ML.belongs(ref, ml, ms.get("title")):
                stats["milestone"] += 1   # 這張真的在主線上 ⇒ 緊急線在管，不重複領
                continue
            # 掛在**別條** milestone 上：那條線現在沒有人在推 ⇒ 它就是債
            stats["other_ms"] += 1
        if labels_of(it) & WAITING_LEO_LABELS:
            stats["human"] += 1           # 沒有人能替 leo 領（催辦是 ISEP#93）
            continue
        if ref in claimed:
            stats["claimed"] += 1         # 昨天領走的，今天不再出現
            continue
        s, parts = score(it, in_degree)
        pool.append({"ref": ref, "title": it.get("title") or "", "score": s,
                     "why": parts, "labels": sorted(labels_of(it)),
                     "pr": is_pr(it), "url": it.get("html_url"),
                     "age": round(days_since(it.get("updated_at")), 1),
                     "refs": sorted(refs_in(it) & open_refs)})
    pool.sort(key=lambda d: (-d["score"], d["ref"]))
    return pool, stats, in_degree


# ── 成包：互相指著的必然同包（票上原文「相關的票要成包一起領，不要拆散」）──────
#
# 🔴 **只認強訊號，寧可少成包**。第一版用「單向文字引用」成包，結果是
#    **前 190 張裡有 121 張黏成一包**——因為大家都會順手引用 hub 票。
#    一包 121 張＝沒有人領得動＝跟沒分包一樣。所以只認這兩種：
#      ① 互相指著（A 的內文提 B，且 B 的內文提 A）——單向不算
#      ② Gitea 原生相依（`ticket subtask` 建的母子票就是這個）
#    代價：同一條使用者旅程但沒有互指、也沒建相依的兩張票不會成包。
#    這是誠實的取捨——**寧可漏成包，不要成一包沒人領得動的**。
BUNDLE_CAP = 8            # 一包最多印幾張；超過就說「還有幾張」
DEPS_TOP_K = 25           # 只對前 K 名查原生相依（一張一次 API，不對全部查）


def dep_edges(pool, enabled=True):
    """回 [(a, b)…]：前 K 名的原生相依邊，只留兩端都在 pool 裡的。"""
    fx = os.environ.get("ISEP_DEBT_FIXTURE")
    if fx:
        with open(fx) as f:
            deps = (json.load(f).get("deps") or {})
        in_pool = {d["ref"] for d in pool}
        return [(a, b) for a, bs in deps.items() for b in bs
                if a in in_pool and b in in_pool]
    if not enabled:
        return []
    in_pool = {d["ref"] for d in pool}
    edges = []
    for d in pool[:DEPS_TOP_K]:
        owner, rest = d["ref"].split("/", 1)
        repo, num = rest.split("#")
        try:
            got = api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
        except SystemExit:
            continue
        for i in got:
            other = f"{i['repository']['full_name']}#{i['number']}"
            if other in in_pool:
                edges.append((d["ref"], other))
    return edges


def mutual_edges(pool):
    idx = {d["ref"]: set(d["refs"]) for d in pool}
    out = []
    for ref, targets in idx.items():
        for t in targets:
            if t in idx and ref in idx[t] and ref < t:
                out.append((ref, t))
    return out


def bundle(pool, edges):
    idx = {d["ref"]: d for d in pool}
    parent = {r: r for r in idx}

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra

    for a, b in edges:
        if a in idx and b in idx:
            union(a, b)

    groups = {}
    for d in pool:
        groups.setdefault(find(d["ref"]), []).append(d)
    out = []
    for members in groups.values():
        members.sort(key=lambda d: (-d["score"], d["ref"]))
        out.append({"lead": members[0], "members": members,
                    "score": members[0]["score"]})
    out.sort(key=lambda b: (-b["score"], b["lead"]["ref"]))
    return out


# ── 狀態：領走了什麼（明天的清單要看得出昨天領走的不見了）───────────────────
def state_dir():
    d = os.environ.get("ISEP_DEBT_STATE_DIR") or os.path.expanduser("~/.claude/isep-debt")
    os.makedirs(d, exist_ok=True)
    return d


def state_path():
    return os.path.join(state_dir(), "claimed.json")


def load_state():
    try:
        with open(state_path()) as f:
            d = json.load(f)
    except Exception:
        d = {}
    d.setdefault("claims", {})
    d.setdefault("last_run", None)
    return d


def save_state(d):
    with open(state_path(), "w") as f:
        json.dump(d, f, ensure_ascii=False, indent=1)


# ── 印 ─────────────────────────────────────────────────────────────────────
def print_proof(repos, stats):
    scanned = [r for r in repos if not r.get("skipped")]
    n_iss = sum(r["issues"] for r in scanned)
    n_pr = sum(r["prs"] for r in scanned)
    print(f"🔎 查過的 repo：{len(repos)} 個（{ORG} 全組織）"
          f"　open issue {n_iss}、open PR {n_pr}")
    for r in sorted(repos, key=lambda x: -(x["issues"] + x["prs"])):
        mark = f"（跳過：{r['skipped']}）" if r.get("skipped") else ""
        print(f"     · {r['name']}　issue {r['issues']}／PR {r['prs']} {mark}")
    ml = stats.get("mainline")
    if ml:
        print(f"\n🎯 現在的主線：{ML.ref_of(ml)}「{ml.get('title')}」"
              f"（唯一答案來自 scripts/mainline，本工具不自己判）")
        print(f"     掛在**別條** milestone 上的 {stats['other_ms']} 張仍算債"
              "——那條線現在沒有人在推")
    else:
        print("\n🎯 現在沒有標主線（`scripts/mainline set …`）"
              "⇒ 分不出哪條是緊急線在管的，保守起見：掛 open milestone 的一律不領")
    print(f"\n   扣掉：掛在 open milestone 上的 {stats['milestone']} 張（那些歸緊急線管）"
          f"／等 leo 的 {stats['human']} 張"
          f"（Human・human/exec・s/stage——那些要催不是要領，見 inkstone/ISEP#93）"
          f"／已經領走的 {stats['claimed']} 張")


def print_carry(state, items):
    """昨天領走的，今天怎麼了——**這一段就是「看得出領走的不在清單上」**。"""
    claims = state.get("claims") or {}
    if not claims:
        return
    open_refs = {ref_of(it) for it in items}
    done = [r for r in claims if r not in open_refs]
    doing = [r for r in claims if r in open_refs]
    print(f"\n📦 之前領走的 {len(claims)} 張：已關 {len(done)}、還開著 {len(doing)}")
    for r in sorted(doing):
        c = claims[r]
        print(f"     ◻ {r}　（{c.get('at', '?')[:10]} 領的）{c.get('title', '')[:40]}")
    for r in sorted(done):
        print(f"     ✅ {r}　已關")
    if doing:
        print("     🔴 還開著的沒有回到可領清單裡——做完就關票，或 "
              "`debt-worklist forget <票>` 把它放回去。")


def print_bundle(i, b, verbose):
    lead = b["lead"]
    kind = "PR" if lead["pr"] else "票"
    print(f"\n  {i}. [{lead['score']}] {lead['ref']}（{kind}）")
    print(f"     {lead['title'][:72]}")
    print("     為什麼排這裡：" + "、".join(f"{n}{v:+g}" for n, v in lead["why"]))
    if lead["labels"]:
        print(f"     標籤：{lead['labels']}")
    others = b["members"][1:]
    if others:
        print(f"     ↳ 同一包（互相指著／原生相依，要一起領）共 {len(b['members'])} 張：")
        shown = others if verbose else others[:BUNDLE_CAP]
        for m in shown:
            print(f"        · [{m['score']}] {m['ref']}　{m['title'][:56]}")
        if len(others) > len(shown):
            print(f"        · …另外 {len(others) - len(shown)} 張（`--all` 看全部）")


def cmd_list(argv):
    top = int(argv[argv.index("--top") + 1]) if "--top" in argv else 6
    verbose = "--all" in argv
    as_json = "--json" in argv

    repos, items = collect()
    state = load_state()
    pool, stats, _ = pick(items, set(state.get("claims") or {}))
    edges = mutual_edges(pool) + dep_edges(pool, enabled="--no-deps" not in argv)
    bundles = bundle(pool, edges)

    if as_json:
        print(json.dumps({
            "at": now().isoformat(), "repos": repos, "stats": stats,
            "claimed": sorted(state.get("claims") or {}),
            "bundles": [{"score": b["score"],
                         "members": [{"ref": m["ref"], "score": m["score"],
                                      "title": m["title"], "why": m["why"]}
                                     for m in b["members"]]} for b in bundles],
        }, ensure_ascii=False, indent=1))
    else:
        print(f"🧾 還債清單　{now().strftime('%Y-%m-%d %H:%M UTC')}\n")
        print_proof(repos, stats)
        print_carry(state, items)
        print(f"\n── 今天可以領的：{len(pool)} 張，收斂成 {len(bundles)} 包 "
              f"────────────────────")
        if not pool:
            # 🔴 空清單要說話。默默結束＝人分不出「查過沒東西」跟「根本沒查」。
            print("\n  ✅ **查詢跑過了，結果是零。**"
                  f"\n     {len(repos)} 個 repo 都掃過（見上面那份清單），"
                  "沒有一張是「沒掛 milestone、沒等 leo、還沒被領走」的。"
                  "\n     這代表舊債清乾淨了——不是查詢壞掉。")
        for i, b in enumerate(bundles[:len(bundles) if verbose else top], 1):
            print_bundle(i, b, verbose)
        if not verbose and len(bundles) > top:
            print(f"\n  …另外 {len(bundles) - top} 包（`--top N` 或 `--all` 看更多）")
        if pool:
            first = " ".join(m["ref"] for m in bundles[0]["members"][:BUNDLE_CAP])
            print(f"""
── 領走它 ──────────────────────────────────────────────────
    debt-worklist claim {first}

  領走 ＝ 今天要做它。領了之後它就不會再出現在這份清單上，
  直到你把票關掉、或 `debt-worklist forget` 把它放回去。
  🔴 **整包一起領**：同一包是互相指著的，拆開做會做到一半發現卡在另一張。""")

    state["last_run"] = now().isoformat()
    save_state(state)
    return 0


def _titles(items):
    return {ref_of(it): (it.get("title") or "") for it in items}


def cmd_claim(argv):
    refs = [a for a in argv if not a.startswith("-")]
    if not refs:
        die("用法：debt-worklist claim <owner/repo#N> [更多…]")
    for r in refs:
        if not re.match(r"^[\w.-]+/[\w.-]+#\d+$", r):
            die(f"🔴 票的寫法是 owner/repo#N，你給的是：{r}")
    _, items = collect()
    titles = _titles(items)
    unknown = [r for r in refs if r not in titles]
    if unknown:
        die("🔴 這幾張不在 open 清單裡（打錯了？已經關了？）：" + "、".join(unknown))
    state = load_state()
    for r in refs:
        state["claims"][r] = {"at": now().isoformat(), "title": titles[r]}
    save_state(state)
    print(f"📦 領走 {len(refs)} 張，明天的清單不會再出現它們：")
    for r in refs:
        print(f"   · {r}　{titles[r][:60]}")
    print("\n🔴 領了就是今天要做它。做完關票；改方向就 `debt-worklist forget`。")
    return 0


def cmd_forget(argv):
    refs = [a for a in argv if not a.startswith("-")]
    if not refs:
        die("用法：debt-worklist forget <owner/repo#N> [更多…]")
    state = load_state()
    gone = [r for r in refs if state["claims"].pop(r, None) is not None]
    save_state(state)
    print(f"↩️ 放回清單 {len(gone)} 張：" + ("、".join(gone) or "（本來就沒領過）"))
    return 0


CMDS = {"list": cmd_list, "claim": cmd_claim, "forget": cmd_forget}

if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(cmd_list([]))
    if sys.argv[1] in ("-h", "--help", "help"):
        print(__doc__)
        sys.exit(0)
    if sys.argv[1] not in CMDS:
        print(__doc__)
        sys.exit(2)
    sys.exit(CMDS[sys.argv[1]](sys.argv[2:]))
