#!/usr/bin/env python3
"""mainline — 「現在的主線是哪一個」這個問題的**唯一指令**（inkstone/ISEP#82）。

leo 2026-08-27：「**14 個里程碑同時亮著卻不知道該看哪個**」。
當天實查 `GET /repos/inkstone/*/milestones?state=open` 回 14 個，其中「Mira 現代化」
這個名字同時活在 5 個 repo，另有 5 個已經逾期。
⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象——
  要注入不知道注哪一個，要擋跳線也不知道拿哪一個當基準。

    mainline                      現在的主線是哪一個（＝ show，唯一答案，離線）
    mainline list                 有哪些 open milestone 可以選（打網路）
    mainline set <owner/repo#id> [--hub <owner/repo#N>]
                                  把某一個 milestone 標成主線（打網路，寫進 state）；
                                  --hub 指定這條線的載體票（別 repo 的票用相依指到它）
    mainline clear                現在沒有主線（例：一條線收掉了，下一條還沒開始）
    mainline refresh [--members]  更新進度／期限（SessionStart 跑一次，不輪詢）；--members 順便重收成員
    mainline adopt <owner/repo#N> [--via <hub 票>]
                                  「補收」：把一張票掛進主線（打網路）。同 repo ⇒ 掛進 hub 里程碑；
                                  別 repo ⇒ 成為 hub 票的相依。**不建同名里程碑。**
    mainline has <owner/repo#N>   這張票在不在主線上（exit 0 ＝在，1 ＝不在）

🔴 **`<owner/repo#id>` 的 id 是 milestone 的 id，不是 issue 的號碼。**
   兩者在 Gitea 是兩套獨立編號，`mainline list` 印出來的就是可以直接貼的那一個。

── 答案為什麼唯一 ─────────────────────────────────────────────────
主線住在一個檔裡（`~/.claude/isep-countdown/mainline.json`），那個檔**放得下一條**。
（inkstone/ISEP#130 起它有一份隨 repo 走的鏡像：`InkStoneCo/system-dev/mainline.json`——
 雲端沒有家目錄那份，讀的就是它；`set`／`adopt`／`clear` 兩份一起寫，`refresh` 只寫家目錄。
 inkstone/ISEP#140 起：兩份都不在時（雲端薄殼 bootstrap 之前的形狀），`refresh` 會去 Gitea
 讀 `inkstone/InkStoneCo` main 上那份一次、寫進家目錄——優先序寫在 `hooks/lib/mainline.py` 檔頭。）
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
⇒ 問一次只會拿到 0 或 1 個答案，不會拿到 14 個。

── 一條線只有一個里程碑；別的 repo 用相依指過來（inkstone/ISEP#133）──────
🔴 這支的第一版檔頭寫「跨 repo 同名是既有做法，不動它」，成員也是照標題跨 repo 收。
   那句話被總管 09-07 當成規則抄進 InkStoneCo 的 ops-facts，還在 arcrun-rag 多建了一個同名的。
   leo 當天：「拆，誰說『同名跨 repo 是 mainline 機制的一部分』，早就定了只有一個 milestone，
   不同 repo 用指針」（規則本身在 inkstone/ISEP#30、inkstone/InkStoneCo#44）。
現在的形狀（判準全部是 Gitea 的欄位，沒有標題比對）：
  · **錨** ＝ `owner/repo#<milestone id>`：整個 Gitea 只有這一個 milestone 物件，
    期限／進度／目標宣告都讀它。
  · **hub 票**（`set --hub`）＝ 這條線的載體票。別 repo 的票要進主線 ⇒ 成為它的 dependency。
  · **成員** ＝ hub 里程碑裡的票 ＋ hub 票，沿 `/dependencies` 邊（跨 repo）收下去
    （`hooks/lib/mainline.py::collect_members`）。同名里程碑關掉之後成員一張都不會掉——
    因為成員從來不是靠名字算的。
  · `adopt` 別 repo 的票 ⇒ 在 hub 票上加一條 dependency，**不會在那個 repo 建同名里程碑**。
"""
import importlib.machinery
import importlib.util
import os
import sys
from datetime import datetime, timezone

HERE = os.path.dirname(os.path.abspath(__file__))


def _load(name, path):
    """把一個檔載進來當模組。`scripts/ticket` 沒有 `.py` 副檔名，所以要明著給 loader
    （`spec_from_file_location` 只靠副檔名認不出它，會回 None）。"""
    loader = importlib.machinery.SourceFileLoader(name, path)
    spec = importlib.util.spec_from_file_location(name, path, loader=loader)
    mod = importlib.util.module_from_spec(spec)
    loader.exec_module(mod)
    return mod


# 憑證怎麼拿、API 怎麼打、票號怎麼解析——**全部沿用 `scripts/ticket` 那一份**。
# 🔴 刻意不自己再寫一份 token()：金鑰的取得只准有一條路（頂層 CLAUDE.md 金鑰鐵律
#   ＋ 2026-07-29 t145 的實害——同一把金鑰兩種寫法並存，必然漂移）。
ML = _load("isep_mainline", os.path.join(HERE, "..", "hooks", "lib", "mainline.py"))
try:
    T = _load("isep_ticket", os.path.join(HERE, "ticket"))
except Exception:
    # 🔴 載不到就只是「打不了網路的那幾個動作不能用」，不是整支壞掉。
    #   ISEP#82 驗收第 4 條要的是「沒有主線也不能整組壞掉」，同一個道理：
    #   `show` 是離線的，它不該因為別的零件壞了而回答不出來。
    T = None


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


def need_net():
    if T is None:
        die("🔴 載不到 `scripts/ticket`（憑證與 API 都沿用它），這個動作打不了網路。\n"
            "   `scripts/mainline show` 仍然可以用——它是離線的。")


def parse_ms_ref(s):
    """`owner/repo#<milestone id>`。與票號同形，所以錯手貼票號會拿到 404，訊息要講清楚。"""
    import re
    m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (s or "").strip())
    if not m:
        die("🔴 寫法是 owner/repo#<milestone id>，你給的是：%s\n"
            "（先跑 `scripts/mainline list` 看有哪些，那裡印的就是可以直接貼的）" % s)
    return m.group(1), m.group(2), int(m.group(3))


def org_repos(owner):
    rows = T.api_soft("/orgs/%s/repos?limit=100" % owner) or []
    return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")]


def collect_members(ms):
    """成員＝hub 里程碑裡的票 ＋ hub 票 ＋ 它們的相依（跨 repo）。判準在 `hooks/lib/mainline.py`。"""
    return ML.collect_members(T.api_soft, ms)


def members_line(refs, info):
    byrepo = {}
    for r in refs:
        byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
    dist = "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items()))
    return ("   成員 %d 張（里程碑裡 %d ＋ 相依收到 %d）%s" % (
        len(refs), len(info.get("milestone") or []), len(info.get("via_deps") or []),
        ("：" + dist) if dist else ""))


def fetch_issue(ref):
    parts = ML.split_ref(ref)
    if not parts:
        die("🔴 票的寫法是 owner/repo#N，你給的是：%s" % ref)
    it = T.api_soft("/repos/%s/%s/issues/%d" % parts)
    return parts, (it if isinstance(it, dict) and it.get("number") else None)


def fetch_blocks(parts):
    """誰把這張票當相依（`/blocks`）。讀不到 ⇒ None（讀不到 ≠ 沒有人）。"""
    rows = T.api_soft("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)
    if not isinstance(rows, list):
        return None
    return [r for r in (ML.issue_ref(x) for x in rows) if r]


def fetch_milestone(owner, repo, mid):
    d = T.api_soft("/repos/%s/%s/milestones/%d" % (owner, repo, mid))
    if not isinstance(d, dict) or not d.get("title"):
        die("🔴 讀不到 %s/%s#%d 這個 milestone。\n"
            "  · id 是 milestone 的 id，不是 issue 的號碼（`scripts/mainline list` 會印對的）\n"
            "  · 或是這台機器現在拿不到 Gitea（那就先別標，不要標一個猜的）" % (owner, repo, mid))
    return d


# ── show ─────────────────────────────────────────────────────────────────
def cmd_show(argv):
    """離線。**永遠回一個答案**，包括「現在沒有主線」（ISEP#82 驗收第 4 條）。"""
    ms = ML.load()
    if not ms:
        print("🎯 現在沒有主線。")
        print()
        print("  這不是故障——沒有人標過，或上一條收掉了。")
        print("  要標一條：`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`")
        print()
        print("📌 在標之前，每回合的 🎯 那一行也會照樣講「現在沒有主線」，不會編一條出來。")
        print("   雲端：SessionStart 的 `mainline refresh` 會先去 Gitea 讀 inkstone/InkStoneCo main 的")
        print("   system-dev/mainline.json；還是沒有 ⇒ 那份不存在，或這台拿不到 Gitea（inkstone/ISEP#140）。")
        return
    c, total, pct = ML.progress(ms)
    print("🎯 現在的主線：%s「%s」" % (ML.ref_of(ms), ms.get("title")))
    desc = (ms.get("description") or "").strip()
    if desc:
        first = desc.splitlines()[0].strip()
        # 只印第一行、最多 150 字：這是**一眼掃**的答案，不是 milestone 的全文。
        # leo 一天看兩眼（白話鐵律），整段貼過來他就不會看。
        print("   目標：" + (first[:150] + "…" if len(first) > 150 else first))
    print("   期限：%s" % ML.due_phrase(ms))
    print("   進度：%d／%d 張已關（%d%%）" % (c, total, pct))
    if ML.hub_ticket(ms):
        print("   hub 票：%s（別 repo 的票用相依指到它）" % ML.hub_ticket(ms))
    mem = sorted(ML.members(ms))
    byrepo = {}
    for r in mem:
        byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
    print("   屬於主線的票：%d 張%s" % (
        len(mem), ("（" + "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) + "）") if byrepo else ""))
    if mem:
        print("   " + "、".join(mem))
    if ms.get("set_at"):
        print("   標定於：%s" % ms["set_at"])
    if ms.get("refreshed_at"):
        print("   最後更新：%s" % ms["refreshed_at"])
    print()
    print("📌 這是唯一答案——主線住在一個檔裡，那個檔放得下一條：%s" % ML.path())


# ── list ─────────────────────────────────────────────────────────────────
def cmd_list(argv):
    need_net()
    owner = argv[0] if argv else T.ORG
    rows = []
    for r in org_repos(owner):
        for m in (T.api_soft("/repos/%s/%s/milestones?state=open" % (owner, r)) or []):
            if not isinstance(m, dict):
                continue
            rows.append((owner, r, m))
    if not rows:
        die("🔴 一個 open milestone 都沒讀到——多半是拿不到 Gitea，不是真的沒有。\n"
            "（讀不到 ≠ 不存在。先修連線，不要因此標一個猜的主線。）", 1)
    now = datetime.now(timezone.utc)
    cur = ML.load()
    print("open milestone 共 %d 個（%s）：\n" % (len(rows), owner))
    for o, r, m in sorted(rows, key=lambda x: (ML.due_of(x[2]) or datetime.max.replace(tzinfo=timezone.utc))):
        c, total, pct = ML.progress(m)
        mark = "  ← 現在的主線" if cur and ML.ref_of(cur) == "%s/%s#%s" % (o, r, m.get("id")) else ""
        print("  %-28s %-24s %3d%%  期限 %s%s" % (
            "%s/%s#%s" % (o, r, m.get("id")), (m.get("title") or "")[:24],
            pct, ML.due_phrase(m, now), mark))
    print("\n📌 貼左邊那一欄：`scripts/mainline set <owner/repo#id>`")


# ── set ──────────────────────────────────────────────────────────────────
def opt_of(argv, name):
    if name in argv and argv.index(name) + 1 < len(argv):
        return argv[argv.index(name) + 1]
    return None


def positional(argv, *flags_with_value):
    return [a for i, a in enumerate(argv)
            if not a.startswith("--") and (i == 0 or argv[i - 1] not in flags_with_value)]


def cmd_set(argv):
    need_net()
    pos = positional(argv, "--hub")
    if not pos:
        die("用法：mainline set <owner/repo#milestone_id> [--hub <owner/repo#N>]")
    owner, repo, mid = parse_ms_ref(pos[0])
    m = fetch_milestone(owner, repo, mid)
    title = m.get("title")
    hub = opt_of(argv, "--hub")
    if hub:
        parts, it = fetch_issue(hub)
        if not it:
            die("🔴 讀不到 hub 票 %s（票號寫法 owner/repo#N；讀不到就先別標，不要標一張猜的）" % hub)
        hub = "%s/%s#%d" % parts
    else:
        prev = ML.load() or {}
        # 重標同一條線而沒帶 --hub ⇒ 沿用原本的 hub 票；換一條線 ⇒ 不沿用（那是別條線的載體）
        hub = ML.hub_ticket(prev) if prev.get("ref") == "%s/%s#%d" % (owner, repo, mid) else None
    ms = {
        "ref": "%s/%s#%d" % (owner, repo, mid),
        "owner": owner, "repo": repo, "id": mid,
        "title": title,
        "description": m.get("description") or "",
        "due_on": m.get("due_on") or "",
        "open_issues": m.get("open_issues") or 0,
        "closed_issues": m.get("closed_issues") or 0,
        "hub_ticket": hub,
        "members": [],
    }
    refs, info = collect_members(ms)
    ms["members"] = refs
    now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
    ms["set_at"] = ms["refreshed_at"] = now
    if not ML.save(ms, mirror=True):
        die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
    print("✅ 主線已標定：%s/%s#%d「%s」" % (owner, repo, mid, title))
    if hub:
        print("   hub 票：%s" % hub)
    print(members_line(refs, info) if info.get("reached")
          else "   ⚠️ 讀不到 hub 里程碑裡的票，成員先留空（讀不到 ≠ 沒有）")
    if info.get("capped"):
        print("   ⚠️ 相依走到 %d 張就停了（上限），剩下的沒收" % ML.MEMBER_WALK_CAP)
    print(mirror_line())
    print()
    cmd_show([])


def mirror_line():
    """repo 那份有沒有跟著寫（inkstone/ISEP#130）：寫了要人去 commit，沒寫要講為什麼。"""
    rp = ML.repo_path(for_write=True)
    if rp and os.path.isfile(rp):
        return ("   已同步寫進 repo 那份：%s\n"
                "   📌 這份要 commit／開 PR 併進 inkstone/InkStoneCo，雲端 clone 才拿得到" % rp)
    return ("   ⚠️ 這台找不到 InkStoneCo 的 system-dev/（CLAUDE_PROJECT_DIR 或 cwd 底下都沒有），"
            "repo 那份沒更新——雲端仍會讀到舊主線")


def cmd_clear(argv):
    had = ML.load()
    rp = ML.repo_path()
    ML.clear(mirror=True)
    print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
          % ("（原本是 %s「%s」）" % (ML.ref_of(had), had.get("title")) if had else ""))
    if rp:
        print("   repo 那份也刪了：%s（要 commit 才會到雲端）" % rp)


# ── refresh ──────────────────────────────────────────────────────────────
def soft_token():
    """拿得到就給，拿不到回空字串。`refresh` 跑在 SessionStart，**不准因為沒 token 就整支炸**
    ——雲端薄殼的 remote 是 GitHub、家目錄是空的，那正是它最需要活著的時候。"""
    if T is None:
        return ""
    import contextlib
    import io
    try:
        with contextlib.redirect_stderr(io.StringIO()):     # token() 拿不到會先抱怨再 exit：兩個都吞掉
            return T.token() or ""
    except (SystemExit, Exception):
        return ""


def cmd_refresh(argv):
    """SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊，不要把主線弄丟。

    inkstone/ISEP#140：家目錄與 repo 那份都不在（雲端薄殼跑 SessionStart 時 `InkStoneCo/`
    還沒 clone）⇒ 去 Gitea 讀 `inkstone/InkStoneCo` main 上那份**一次**，寫進家目錄。
    之後每一則 UserPromptSubmit 讀的是家目錄，路徑上照舊沒有網路。
    讀不到 ⇒ 什麼都不寫，退回現行行為（`countdown.py` 的第二基準）。
    """
    ms = ML.load()
    if not ms:
        got = ML.fetch_gitea(token=soft_token)      # 傳函式：匿名讀得到就不去碰 token
        if not got:
            return                  # 兩份都沒有、Gitea 也問不到 ⇒ 真的沒有主線，安靜結束
        ML.save(got)                # 只寫家目錄：雲端沒有 InkStoneCo/ 可寫，本機不弄髒工作樹
        ms = ML.load()
        if not ms:
            return
    if T is None:
        return
    try:
        d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
    except SystemExit:
        return                      # 拿不到 token：主線已經在了，進度晚點再更新，不炸
    if not isinstance(d, dict) or not d.get("title"):
        return                      # 讀不到 ≠ 主線不存在
    ms.update({
        "title": d.get("title") or ms["title"],
        "description": d.get("description") or ms.get("description") or "",
        "due_on": d.get("due_on") or ms.get("due_on") or "",
        "open_issues": d.get("open_issues") or 0,
        "closed_issues": d.get("closed_issues") or 0,
        "refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"),
    })
    if "--members" in argv:
        # 成員靠相依邊算（inkstone/ISEP#133）：hub 里程碑一頁 ＋ 每張成員一次 /dependencies。
        # 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members（成本不變）；
        # `set`／`adopt` 會重收，`pick`／`has`／focus guard 本來就每次現問 Gitea，快取只餵 `show`。
        # 讀不到（reached=False）就保留舊快取——寧可資料舊，不要把成員弄丟。
        try:
            refs, info = collect_members(ms)
        except SystemExit:
            refs, info = [], {"reached": False}
        if info.get("reached"):
            ms["members"] = refs
    ML.save(ms)


# ── adopt（補收）──────────────────────────────────────────────────────────
def cmd_adopt(argv):
    """補收。同 repo ⇒ 掛進 hub 里程碑（原生欄位）；別 repo ⇒ 成為 hub 票的相依。
    🔴 不會在別的 repo 建同名里程碑——那是被 leo 推翻的做法（本檔檔頭）。"""
    need_net()
    pos = positional(argv, "--via")
    if not pos:
        die("用法：mainline adopt <owner/repo#N> [--via <hub 票 owner/repo#N>]（把一張票掛進主線＝「補收」）")
    ms = ML.load()
    if not ms:
        die("🔴 現在沒有主線，沒有東西可以補收進去。先 `scripts/mainline set …`。")
    owner, repo, num = T.parse_ref(pos[0])
    ref = "%s/%s#%d" % (owner, repo, num)
    if owner == ms["owner"] and repo == ms["repo"]:
        T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": ms["id"]}, method="PATCH")
        how = "掛進 hub 里程碑 %s" % ML.ref_of(ms)
    else:
        via = opt_of(argv, "--via") or ML.hub_ticket(ms)
        if not via:
            _, info = collect_members(ms)
            cands = info.get("milestone") or []
            die("""🔴 %s 在別的 repo，要進主線得成為 hub 票的相依——而這條線還沒有 hub 票。

兩條路（擇一，都不會建同名里程碑）：
  · 指定這條線的載體票，之後每次 adopt 都用它：
      %s set %s --hub <owner/repo#N>
  · 或這一次點名要掛在哪一張底下：
      %s adopt %s --via <owner/repo#N>
hub 里程碑裡現在有：%s

（在別的 repo 開一個同名里程碑**不是**出路——leo 2026-09-07：「早就定了只有一個 milestone，不同 repo 用指針」）""" % (
                ref, os.path.abspath(sys.argv[0]), ML.ref_of(ms), os.path.abspath(sys.argv[0]), ref,
                "、".join(cands) if cands else "（讀不到／沒有）"))
        vparts = ML.split_ref(via)
        if not vparts:
            die("🔴 --via 的寫法是 owner/repo#N，你給的是：%s" % via)
        via = "%s/%s#%d" % vparts
        T.add_dependency(vparts, (owner, repo, num))
        how = "成為 hub 票 %s 的相依（%s/%s 沒有多出任何里程碑）" % (via, owner, repo)
    refs, info = collect_members(ms)
    if info.get("reached"):
        ms["members"] = sorted(set(refs) | {ref})
    else:
        ms["members"] = sorted(set(ms.get("members") or []) | {ref})
    ML.save(ms, mirror=True)
    print("✅ 補收：%s → %s" % (ref, how))
    print(members_line(ms["members"], info) if info.get("reached") else "   （成員清單沿用舊快取＋這一張）")
    print(mirror_line())


# ── has ──────────────────────────────────────────────────────────────────
def cmd_has(argv):
    if not argv:
        die("用法：mainline has <owner/repo#N>")
    ms = ML.load()
    ref = argv[0].strip()
    if not ms:
        print("🎯 現在沒有主線 ⇒ 沒有「不屬於主線」這回事。")
        return
    v = ML.belongs(ref, ms)
    if v is None:
        need_net()
        parts, it = fetch_issue(ref)
        if not it:
            print("⚪ 讀不到 %s，判不出來（讀不到 ≠ 不屬於）。" % ref)
            sys.exit(0)
        blocks = fetch_blocks(parts)
        v = ML.belongs(ref, ms, ticket_milestone=it.get("milestone") or {}, blocks=blocks)
        if v is None:
            print("⚪ %s：讀得到票，但讀不到「誰把它當相依」，判不出來（讀不到 ≠ 不屬於）。" % ref)
            sys.exit(0)
    if v:
        print("✅ %s 在主線「%s」上。" % (ref, ms["title"]))
        sys.exit(0)
    print("🚧 %s **不在**主線「%s」上。\n"
          "   補收 ⇒ `scripts/mainline adopt %s`（別 repo 的票會成為 hub 票的相依）；"
          "真的是插件事 ⇒ 那是跳線，說清楚再做。"
          % (ref, ms["title"], ref))
    sys.exit(1)


CMDS = {"show": cmd_show, "list": cmd_list, "set": cmd_set, "clear": cmd_clear,
        "refresh": cmd_refresh, "adopt": cmd_adopt, "has": cmd_has}

if __name__ == "__main__":
    # `mainline | head` 是很自然的用法，別讓它吐一段 BrokenPipe 的 traceback
    # ——那會讓人以為這支壞了。
    try:
        import signal
        signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    except Exception:
        pass
    if len(sys.argv) < 2:
        cmd_show([])
        sys.exit(0)
    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)
    CMDS[sys.argv[1]](sys.argv[2:])
