#!/usr/bin/env python3
"""release-manifest — 一次交貨 ＝ 一份清單（inkstone/ISEP#84）。

leo 的問題（票上原文）：「一次交貨常常同時動到好幾個 repo，但**沒有任何地方記著
『這一版 ＝ 哪幾個 repo 的哪幾個版本』**⋯⋯要退版的時候，只退了其中一個 repo，
剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」

    release-manifest freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.52 \
                                         --part inkstone/arcrun-collector@v0.18.36
    release-manifest current                     我現在手上是哪一版、由什麼組成
    release-manifest list                        全部清單，新的在上面
    release-manifest show    R-2026.08.30
    release-manifest verify  R-2026.08.30        清單上的版本號現在還抓得到嗎
    release-manifest diff    R-2026.08.29 R-2026.08.30
    release-manifest rollback R-2026.08.29       退版：**整份一起退**

🔴 三條硬規則，全部長在這支工具裡（不是寫在文件裡靠人記得）：

  ① **freeze 之前每一個版本號都真的去 Gitea 抓一次**。抓不到就不給凍結——
     「寫上去」跟「存在」是兩件事，而清單的用處完全建立在後者。
  ② **凍結了就不准再加東西**。同名清單已存在 ⇒ 拒絕覆寫，新的需求進下一份。
  ③ **退版沒有「只退一個」這個選項**。`rollback` 一定吐出整份清單的每一格；
     指名單一 repo（`--only`）會被擋下，因為那正是票上那個病：
     **剩下的還停在新版 ⇒ 一組從來沒測過的組合。**

📌 跟 `release-tag-guard.sh` 是包含關係不是重複：那支管「打 tag 那一刻，
   單一 repo 內版本號三處一致」；這支管它上面那一層——**跨 repo 的組合**。

📌 單一 repo 的交貨不必特例：清單就退化成只有一格，規則一字不改。

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

HOST = os.environ.get("RELEASE_MANIFEST_HOST", "https://git.uncle6.me")
HERE = os.path.dirname(os.path.abspath(__file__))
def _resolve_dir():
    """清單住哪裡。

    🔴 **不能住在 plugin 目錄裡**：ISEP 是用 marketplace 裝的，
       `claude plugin update` 會把那個目錄整個換掉 ⇒ 所有出貨清單一次消失，
       而清單的用處全部發生在「以後要退版的那一天」。
    順序：環境變數 → 這個 session 的專案根（有 `system-dev/` ＝總管的工作區，
    跨 repo 的出貨清單本來就屬於那一層）→ 這份原始碼所在 repo → `~/.claude/`。
    """
    v = os.environ.get("RELEASE_MANIFEST_DIR")
    if v:
        return v
    root = os.path.dirname(HERE)
    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, "releases")
    return os.path.join(os.path.expanduser("~"), ".claude", "isep-releases")


DIR = _resolve_dir()
FIXTURE = os.environ.get("RELEASE_MANIFEST_FIXTURE", "")
DRY = False

NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
PART_RE = re.compile(r"^([\w.-]+)/([\w.-]+)@(\S+)$")


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 ""


def _fixture():
    try:
        with open(FIXTURE) as f:
            return json.load(f)
    except Exception:
        die("🔴 讀不到 RELEASE_MANIFEST_FIXTURE：%s" % FIXTURE)


def api(path):
    """GET 一筆。回 dict／list，查不到回 None。fixture 模式完全不碰網路。"""
    if FIXTURE:
        return None
    tk = token()
    if not tk:
        die("🔴 拿不到 %s 的 token（remote 沒帶憑證，也沒有 GITEA_TOKEN_CLAUDE_CODE）" % HOST)
    try:
        req = urllib.request.Request(HOST + "/api/v1" + path,
                                     headers={"Authorization": "token " + tk})
        return json.load(urllib.request.urlopen(req, timeout=25))
    except urllib.error.HTTPError:
        return None
    except Exception as e:
        die("🔴 連不上 %s：%s" % (HOST, e))


def resolve(owner, repo, ver):
    """這個版本號**真的存在嗎**。存在回一段可以貼給人看的證據，不存在回 None。

    先問 release，再問 tag：有些 repo 只打 tag 沒建 release，那也算存在
    （票上要的是「抓得到那個 release」＝抓得到那個版本，不是逼每個 repo 都建 release 頁）。
    """
    if FIXTURE:
        fx = _fixture().get("versions") or {}
        return fx.get("%s/%s@%s" % (owner, repo, ver))
    r = api("/repos/%s/%s/releases/tags/%s" % (owner, repo, ver))
    if isinstance(r, dict) and r.get("tag_name"):
        return {"kind": "release", "url": r.get("html_url") or "",
                "commit": (r.get("target_commitish") or "")[:12]}
    t = api("/repos/%s/%s/tags/%s" % (owner, repo, ver))
    if isinstance(t, dict) and t.get("name"):
        return {"kind": "tag", "url": (t.get("commit") or {}).get("url") or "",
                "commit": ((t.get("commit") or {}).get("sha") or "")[:12]}
    return None


# ── 清單的存取 ────────────────────────────────────────────────────────────
def path_of(name):
    return os.path.join(DIR, name + ".json")


def load(name):
    try:
        with open(path_of(name)) as f:
            return json.load(f)
    except Exception:
        return None


def all_manifests():
    try:
        names = [f[:-5] for f in os.listdir(DIR) if f.endswith(".json")]
    except Exception:
        return []
    rows = [load(n) for n in names]
    rows = [m for m in rows if m]
    # 🔴 排序鍵不能只用時間戳：同一分鐘凍兩份清單就會並列，
    #    而並列之後「現在手上的是哪一份」會變成 os.listdir 的順序（等於隨機）。
    #    seq 是 freeze 當下遞增的流水號 ⇒ 先後永遠有唯一解。
    rows.sort(key=lambda m: (m.get("seq", 0), m.get("frozen_at") or ""), reverse=True)
    return rows


def parse_part(s):
    m = PART_RE.match(s.strip())
    if not m:
        die("🔴 寫法是 owner/repo@版本號，你給的是：%s" % s)
    return m.group(1), m.group(2), m.group(3)


def opts(argv, name):
    return [argv[i + 1] for i, a in enumerate(argv) if a == name and i + 1 < len(argv)]


def opt(argv, name, default=None):
    v = opts(argv, name)
    return v[0] if v else default


# ── INDEX.md：leo 打開就看得懂「我現在手上是哪一版」 ──────────────────────
def write_index():
    rows = all_manifests()
    lines = ["# 出貨清單（哪一版由哪幾個 repo 的哪幾版組成）", "",
             "> 這份檔案由 `scripts/release-manifest` 自動重寫，**不要手改**"
             "（手改的那一份會安靜地騙人）。", "",
             "- 每一列 ＝ 一次交貨。**最上面那一份就是現在手上的這一版。**"]
    if not rows:
        lines.append("\t- 目前還沒有任何凍結過的清單。")
    for i, m in enumerate(rows):
        head = "**%s**%s" % (m["name"], "　← 現在手上的這一版" if i == 0 else "")
        lines.append("\t- " + head)
        lines.append("\t\t- 凍結於 %s（UTC）" % m.get("frozen_at", "?"))
        if m.get("note"):
            lines.append("\t\t- 說明：%s" % m["note"])
        lines.append("\t\t- 由這些組成（沒有更動的 repo 不會出現在這裡）")
        for p in m.get("parts", []):
            ev = p.get("verified") or {}
            lines.append("\t\t\t- `%s` @ **%s**　（凍結當下驗過：%s %s）"
                         % (p["repo"], p["version"], ev.get("kind", "?"),
                            ev.get("commit", "")))
        lines.append("\t\t- 要退回這一版：`scripts/release-manifest rollback %s`" % m["name"])
    lines.append("")
    lines.append("- 為什麼要有這份東西（`inkstone/ISEP#84`）")
    lines.append("\t- 退版只退了其中一個 repo，剩下的還停在新版"
                 " ⇒ **變成一組從來沒測過的組合**。")
    lines.append("\t- 所以送測、放行、出貨、退版**全部以一份清單為單位**，"
                 "不以單一 repo 為單位。")
    try:
        os.makedirs(DIR, exist_ok=True)
        with open(os.path.join(DIR, "INDEX.md"), "w") as f:
            f.write("\n".join(lines) + "\n")
    except Exception as e:
        die("🔴 寫不進 %s：%s" % (DIR, e))


# ── freeze ───────────────────────────────────────────────────────────────
def cmd_freeze(argv):
    if not argv:
        die("用法：release-manifest freeze <清單名> --part owner/repo@版本 [--part …] "
            "[--note 說明] [--dry-run]")
    name = argv[0]
    if not NAME_RE.match(name):
        die("🔴 清單名只准英數與 . _ -（例：R-2026.08.30），你給的是：%s" % name)
    raw = opts(argv[1:], "--part")
    if not raw:
        die("🚫 一份清單至少要有一格 --part owner/repo@版本號。\n"
            "   （單一 repo 的交貨也要開清單——它只是退化成一格，規則不變。）")

    if load(name) is not None:
        die("🚫 %s 已經凍結過了，不准再加東西。\n"
            "   【inkstone/ISEP#84】「清單一旦凍結就不再加東西，**新的需求進下一版**。」\n"
            "   要出新的組合 ⇒ 換一個清單名重新 freeze（舊的留著，退版時要用）。" % name)

    seen, parts = set(), []
    for s in raw:
        owner, repo, ver = parse_part(s)
        full = "%s/%s" % (owner, repo)
        if full in seen:
            die("🔴 同一個 repo 在清單裡出現兩次：%s" % full)
        seen.add(full)
        ev = resolve(owner, repo, ver)
        if not ev:
            die("🚫 %s@%s **在 Gitea 上抓不到**——不給凍結。\n"
                "   【inkstone/ISEP#84 驗收 1】「清單上的版本號**真的存在**"
                "（不是寫上去而已，要抓得到那個 release）」。\n"
                "   先把那個 repo 的版本打出來（tag／release），再回來 freeze。"
                % (full, ver))
        parts.append({"repo": full, "version": ver, "verified": ev})

    nxt = max([m.get("seq", 0) for m in all_manifests()] or [0]) + 1
    man = {"name": name, "seq": nxt,
           "frozen_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
           "note": opt(argv[1:], "--note", "") or "",
           "parts": parts}

    if DRY:
        print("[dry-run] 會寫進 %s" % path_of(name))
        print(json.dumps(man, ensure_ascii=False, indent=2))
        print("[dry-run] 一個檔都沒有真的寫出去。")
        return
    os.makedirs(DIR, exist_ok=True)
    with open(path_of(name), "w") as f:
        json.dump(man, f, ensure_ascii=False, indent=2)
    write_index()
    print("✅ 清單 %s 已凍結（%d 個 repo，每一格都驗過真的抓得到）" % (name, len(parts)))
    for p in parts:
        print("   %-34s %s" % (p["repo"], p["version"]))
    print("\n📌 從現在起：送測／放行／出貨／退版都以 %s 為單位，不以單一 repo 為單位。" % name)
    print("   人看的版本：%s" % os.path.join(DIR, "INDEX.md"))


# ── verify / show / list / current / diff ────────────────────────────────
def cmd_verify(argv):
    if not argv:
        die("用法：release-manifest verify <清單名>")
    man = load(argv[0]) or die("🔴 沒有這份清單：%s" % argv[0])
    bad = 0
    for p in man["parts"]:
        owner, repo = p["repo"].split("/", 1)
        ev = resolve(owner, repo, p["version"])
        print("%s %-34s %s" % ("✅" if ev else "❌", p["repo"], p["version"]))
        if not ev:
            bad += 1
    if bad:
        die("\n🔴 %d 格抓不到了——這份清單現在**退不回去**，"
            "要嘛版本被刪了，要嘛當初就沒真的存在。" % bad)
    print("\n✅ %s 全部 %d 格都還抓得到，可以拿它退版。" % (man["name"], len(man["parts"])))


def cmd_show(argv):
    if not argv:
        die("用法：release-manifest show <清單名>")
    man = load(argv[0]) or die("🔴 沒有這份清單：%s" % argv[0])
    print(json.dumps(man, ensure_ascii=False, indent=2))


def cmd_list(argv):
    rows = all_manifests()
    if not rows:
        print("（還沒有任何凍結過的清單。第一份用 release-manifest freeze 開。）")
        return
    for i, m in enumerate(rows):
        print("%s %-18s %s  %d 個 repo：%s"
              % ("👉" if i == 0 else "  ", m["name"], m.get("frozen_at", "?"),
                 len(m.get("parts", [])),
                 " + ".join("%s@%s" % (p["repo"].split("/")[-1], p["version"])
                            for p in m.get("parts", []))))
    print("\n👉 ＝ 現在手上的這一版。人看的版本：%s" % os.path.join(DIR, "INDEX.md"))


def cmd_current(argv):
    rows = all_manifests()
    if not rows:
        die("🔴 一份清單都沒有——「我現在手上是哪一版」目前沒有人答得出來。", 1)
    m = rows[0]
    print("現在手上的這一版：%s（凍結於 %s）" % (m["name"], m.get("frozen_at", "?")))
    for p in m["parts"]:
        print("   %-34s %s" % (p["repo"], p["version"]))


def cmd_diff(argv):
    if len(argv) < 2:
        die("用法：release-manifest diff <舊清單> <新清單>")
    a = load(argv[0]) or die("🔴 沒有這份清單：%s" % argv[0])
    b = load(argv[1]) or die("🔴 沒有這份清單：%s" % argv[1])
    va = {p["repo"]: p["version"] for p in a["parts"]}
    vb = {p["repo"]: p["version"] for p in b["parts"]}
    for repo in sorted(set(va) | set(vb)):
        x, y = va.get(repo), vb.get(repo)
        if x == y:
            print("   = %-34s %s" % (repo, x))
        elif x is None:
            print("   + %-34s %s（新進來的）" % (repo, y))
        elif y is None:
            print("   - %-34s %s（新清單裡沒有了）" % (repo, x))
        else:
            print("   ~ %-34s %s → %s" % (repo, x, y))


# ── rollback：整份一起退，沒有「只退一個」這個選項 ────────────────────────
def cmd_rollback(argv):
    if not argv:
        die("用法：release-manifest rollback <清單名> [--dry-run]")
    name = argv[0]
    man = load(name) or die("🔴 沒有這份清單：%s" % name)

    only = opts(argv[1:], "--only")
    if only:
        die("🚫 **退版沒有「只退一個」這個選項。**（你指名了：%s）\n\n"
            "   【inkstone/ISEP#84】「要退版的時候，只退了其中一個 repo，\n"
            "     剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」\n\n"
            "   %s 這一版由 %d 個 repo 組成：\n%s\n"
            "   ⇒ 拿掉 --only，整份一起退：release-manifest rollback %s\n"
            "   ⇒ 真的只有一個 repo 要動 ⇒ 那就 freeze 一份**新的**清單，"
            "把不動的那幾格照抄過去。"
            % (", ".join(only), name, len(man["parts"]),
               "\n".join("       %s @ %s" % (p["repo"], p["version"])
                         for p in man["parts"]), name))

    cur = all_manifests()
    curv = {p["repo"]: p["version"] for p in cur[0]["parts"]} if cur else {}
    curname = cur[0]["name"] if cur else "（沒有）"

    print("退版目標：%s（現在手上的是 %s）\n" % (name, curname))
    moves, stays = [], []
    for p in man["parts"]:
        (stays if curv.get(p["repo"]) == p["version"] else moves).append(p)

    print("【要動的】")
    if not moves:
        print("   （沒有——現在手上的組合已經等於這份清單）")
    for p in moves:
        owner_repo = p["repo"]
        print("   %-34s %s → %s" % (owner_repo, curv.get(owner_repo, "?"), p["version"]))
        print("       git -C <%s 的工作目錄> fetch --tags && "
              "git -C <同上> checkout %s" % (owner_repo.split("/")[-1], p["version"]))
    if stays:
        print("\n【已經在目標版本、不必動】（列出來是為了讓你確認**沒有一格被漏掉**）")
        for p in stays:
            print("   %-34s %s" % (p["repo"], p["version"]))

    print("\n🔴 這 %d 格是一組，要嘛全部到位，要嘛就不算退版完成。"
          % len(man["parts"]))
    print("   退完跑一次複驗：release-manifest verify %s" % name)


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 = {"freeze": cmd_freeze, "verify": cmd_verify, "show": cmd_show,
             "list": cmd_list, "current": cmd_current, "diff": cmd_diff,
             "rollback": cmd_rollback}
    if cmd not in table:
        print(__doc__)
        die("🔴 沒有這個子指令：%s" % cmd)
    table[cmd](rest)


if __name__ == "__main__":
    main()
