#!/usr/bin/env python3
"""ticket — 讓 Gitea 變成「可追蹤的線」，不是「越積越大的池子」。

leo 2026-08-16 三句話，本工具就是它們的機械化：
  「不要每個開新票，現有的票開在它下面的對話裡」
  「我希望你把 gitea 變成可以追蹤，不是變成一個池子」
  「寫開票前先去搜尋要開在哪裡，不然你永遠會亂開新票」

當天實錯（本工具的來由）：總管要派人查一個部署擋路石，**沒有搜尋就直接開新票**
（arcrun-rag#110），而那條線早就有 hub（InkStoneCo#44）。多開一張票 = 池子加大 =
那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條，是 `new` 的**前置條件**。

四個動詞，各有一道閘：

    ticket where <關鍵字...>        搜「這件事該放哪」→ 產生戳記
    ticket say <票> -F <檔>         貼進既有票的對話（**預設路徑**）
    ticket new <repo> -F <檔>       開新票（要戳記＋模板欄位齊全）
    ticket close <票> --deliverable <URL>   關票（要有交付物連結）
    ticket decide <票> -F <答案檔>          記 leo 的裁決＋改狀態（同一個動作）

票的寫法：`owner/repo#N`，例：`inkstone/InkStoneCo#44`
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

# ── 身份欄：與 hooks/lib/dispatch_parse.py 共用同一份定義 ──────────────────
#   leo 2026-08-27：「**subagent 回覆時要表明身份**」
#   實害（同日）：多條線並行，票上的留言看不出是誰寫的，
#   **總管寫的診斷被當成 subagent 的結論，而其中一則是錯的**。
#   規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
    from dispatch_parse import parse_identity, IDENTITY_ROLES
except Exception:      # 拿不到共用零件就不擋（fail-open）——開票的路不該被一個 import 卡死
    parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo")


def check_identity(body, what):
    """貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**，所以離線測得動。"""
    if parse_identity is None:
        return
    ok, detail = parse_identity(body)
    if ok:
        return
    die(f"""🚫 {what}的第一行要表明身份（leo 2026-08-27：「**subagent 回覆時要表明身份**」）

  現在的問題：{detail}

第一行照這個寫（角色三選一：{'／'.join(IDENTITY_ROLES)}）：

    【身份】subagent／inkstone/ISEP／feat/my-branch
    【身份】總管／inkstone/InkStoneCo／-

**這條管所有人，不是只管 subagent。** 總管寫在票上的東西同樣要標。
實害（2026-08-27）：多條線並行時票上看不出誰寫的，
總管寫的診斷被當成 subagent 的結論，而其中一則是錯的。

規約全文：docs/governance/dispatch-and-reply-format.md §3""")


HOST = "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
STAMP_TTL = 30 * 60  # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開

# 模板必填欄位。糊弄的票開不出來；開出來的票就是能用的 spec。
REQUIRED_SECTIONS = ["## 目標", "## 驗收條件", "## deliverable 類型"]
VALID_KINDS = ["code", "research"]


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


def token():
    root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
    # 掃這個 repo 的**所有** remote，找第一個指向本站、且帶憑證的。
    # 原本寫死只認名叫 "gitea" 的 remote —— 2026-08-20 實撞：
    # ISEP 這個新 repo 的 remote 叫 origin，於是這支腳本在那裡整個跑不起來，
    # 「開票前先搜」那道閘在新 repo 等於不存在。閘不該綁在某個 remote 的名字上。
    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 not in line:
            continue
        m = re.search(r"//[^:/]+:([^@]+)@", line)
        if m:
            return m.group(1)
    # 退而求其次：環境變數（雲端／CI 沒有帶憑證的 remote 時走這條）
    for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
        v = os.environ.get(env)
        if v:
            return v
    die(f"""🔴 拿不到 {host} 的 token

這個 repo 的 remote 裡沒有一個帶憑證且指向 {host}：
{out.strip() or "（沒有任何 remote）"}

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


def api(path, payload=None, method=None):
    url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        url, data=data, method=method or ("POST" if data else "GET"),
        headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
    try:
        return json.load(urllib.request.urlopen(req, timeout=40))
    except urllib.error.HTTPError as e:
        die(f"🔴 Gitea {e.code}：{e.read().decode()[:300]}")


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


def stamp_path():
    return os.path.join(STAMP_DIR, ".ticket-where-ok")


# ── where ────────────────────────────────────────────────────────────────
def cmd_where(argv):
    if not argv:
        die("用法：ticket where <關鍵字...>\n（用幾個真的會出現在票裡的詞，中英文都行）")
    kws = argv
    seen, hits = {}, []
    for kw in kws:
        q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 30})
        for it in api(f"/repos/issues/search?{q}") or []:
            ref = it["repository"]["full_name"] + "#" + str(it["number"])
            if ref in seen:
                seen[ref]["score"] += 1
                continue
            seen[ref] = {"score": 1, "it": it}
    hits = sorted(seen.values(), key=lambda x: -x["score"])

    print(f"🔍 搜尋：{' '.join(kws)}　→ 命中 {len(hits)} 張 open 票\n")
    if not hits:
        print("  （沒有命中——換幾個講法再試一次。真的沒有，才輪到開新票）")
    for h in hits[:12]:
        it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])]
        print(f"  [{h['score']}] {it['repository']['full_name']}#{it['number']}  {labels}")
        print(f"       {it['title'][:70]}")

    with open(stamp_path(), "w") as f:
        json.dump({"at": time.time(), "kws": kws, "n": len(hits),
                   "top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"])
                           for h in hits[:12]]}, f)

    print(f"""
── 決定要做什麼 ───────────────────────────────────────────────
  命中了、而且是同一條線  →  **貼進那張票的對話**（預設，也是 leo 要的）
        ticket say <owner/repo#N> -F <內文檔>
  真的是新的一條線        →  ticket new <repo> -F <內文檔>
                            （模板要有：## 目標 / ## 驗收條件 / ## deliverable 類型）

🔴 判準不是「這件事夠不夠大」，是「**它跟現有的哪條線是同一條**」。
   同一條線就進對話——多開一張票只會讓池子變大、線串不起來。
戳記已寫（{STAMP_TTL // 60} 分鐘有效）。""")


# ── say ──────────────────────────────────────────────────────────────────
def cmd_say(argv):
    if len(argv) < 3 or argv[1] not in ("-F", "--file"):
        die("用法：ticket say <owner/repo#N> -F <內文檔>")
    owner, repo, num = parse_ref(argv[0])
    body = open(argv[2]).read()
    check_identity(body, "貼進票的留言")
    c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
    print(f"✅ 已貼進 {owner}/{repo}#{num}")
    print(f"   定址：{owner}/{repo}#{num}#issuecomment-{c['id']}")
    print(f"   {c['html_url']}")
    print(f"\n📌 派工時把上面那行「定址」整串寫進【工單】，那條線才接得起來。")


# ── new ──────────────────────────────────────────────────────────────────
def cmd_new(argv):
    if len(argv) < 3 or argv[1] not in ("-F", "--file"):
        die("用法：ticket new <repo> -F <內文檔> [--title <標題>]")
    repo = argv[0]
    body = open(argv[2]).read()
    title = None
    if "--title" in argv:
        title = argv[argv.index("--title") + 1]

    # 閘一：搜過了沒
    try:
        st = json.load(open(stamp_path()))
    except Exception:
        die("""🚫 開新票前要先搜「這件事該放哪」（leo 2026-08-16）

leo 原話：「**寫開票前先去搜尋要開在哪裡，不然你永遠會亂開新票**」
實錯（同日）：總管沒搜就開 arcrun-rag#110，而那條線早有 hub InkStoneCo#44。

先跑：  ticket where <關鍵字...>
搜完它會告訴你該 `say` 進哪張票，還是真的該 `new`。""")
    if time.time() - st["at"] > STAMP_TTL:
        die(f"🚫 搜尋戳記已過期（超過 {STAMP_TTL // 60} 分鐘）。重跑一次 ticket where")

    # 閘二：搜到了東西，就要說明為什麼不是貼進去
    if st["n"] > 0 and "--not-a-comment" not in argv:
        top = "\n".join("     " + t for t in st["top"][:8])
        die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票，你卻要開新的。

命中的前幾張：
{top}

**先問一次：這件事跟上面哪一條是同一條線？**
  是  → `ticket say <那張票> -F <檔>`（這是預設路徑）
  不是 → 重下一次指令，帶上理由：
         ticket new {repo} -F <檔> --not-a-comment "為什麼它是獨立的一條線"

理由會被寫進票的內文，往後任何人都看得到你當時怎麼判的。""")

    # 閘三：模板欄位非空
    missing = [s for s in REQUIRED_SECTIONS if s not in body]
    if missing:
        die(f"""🚫 票的模板缺欄位：{'、'.join(missing)}

**糊弄的票開不出來，開得出來的票就是能用的 spec。** 必填：
  ## 目標          要達成什麼（不是要改哪個檔）
  ## 驗收條件      做完要能證明什麼、怎麼驗
  ## deliverable 類型   code（→ PR）或 research（→ 貼在票上的結論）""")

    m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
    kind_line = (m.group(1) if m else "").lower()
    if not any(k in kind_line for k in VALID_KINDS):
        die(f"🚫 `## deliverable 類型` 底下要明寫 `code` 或 `research`（現在是：{kind_line.strip() or '空的'}）\n"
            "   關票時會驗這個型別對應的交付物有沒有連上，所以不能含糊。")

    if "--not-a-comment" in argv:
        why = argv[argv.index("--not-a-comment") + 1]
        body += (f"\n\n---\n> 🔎 **為什麼另開一張票而不是貼進既有的**（開票時聲明）：{why}\n"
                 f"> 當時搜尋：`{' '.join(st['kws'])}` → 命中 {st['n']} 張。")

    if not title:
        die("🚫 缺 --title")
    check_title(title)
    d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
    os.remove(stamp_path())  # 戳記用掉就沒了，一次只開一張
    print(f"✅ {ORG}/{repo}#{d['number']} 已開：{d['html_url']}")



# ── 標題規約閘（leo 2026-08-19：「票的寫法不受控制嗎？沒有辦法規範？」）─────────
#
# 實錯（本閘的來由）：2026-08-19 一個 session 造了 17 張 `👤 裁決題：…` 與
# 1 張 `【版本】…`。兩種前綴都是 AI 自己發明的分類，都不是 User Story，
# 也都不該是票——**裁決在對話裡講，版本用里程碑**。leo：「亂搞一通」。
#
# 判準跟 empty-handed-stop-guard 同一個哲學：**封形狀，不封措辭**。
# User Story 的形狀是可枚舉的（身為…我要…我才…），自創前綴也是可枚舉的（開頭的方括號／
# 冒號式分類詞）。不做語意判斷，只認形狀。
USER_STORY_RE = re.compile(r"^\s*身為.{2,}?，\s*我(要|想要).{2,}?，\s*我才.{2,}")
BANNED_PREFIX_RE = re.compile(r"^\s*(?:[\U0001F300-\U0001FAFF\u2600-\u27BF]\s*)*"
                              r"(?:[【\[(（][^】\])）]{1,12}[】\])）]|[^\s：:]{2,10}題)\s*[：:]")


def check_title(title):
    if BANNED_PREFIX_RE.match(title):
        die("🚫 標題不准自創分類前綴（leo 2026-08-19：「亂搞一通」）\n"
            f"   你寫的：{title[:60]}\n\n"
            "   2026-08-19 實錯：AI 造了『👤 裁決題：』17 張、『【版本】』1 張，\n"
            "   兩種都不是 User Story，也都不該是票：\n"
            "     · 要 leo 裁決  → **在對話裡講**，不要開票\n"
            "     · 一個版本／sprint → **建里程碑**，把既有 issues 拉進去\n"
            "     · 真的是一條待辦 → 用 User Story 寫標題（見下）")
    if not USER_STORY_RE.match(title):
        die("🚫 票名一律 User Story（leo 2026-08-17；規約在 CLAUDE.md）\n"
            f"   你寫的：{title[:60]}\n\n"
            "   格式：身為<誰>，我要<什麼>，我才<為什麼>\n"
            "   例：  身為把整台電腦交給 AI 的人，我要它指得出出處，我才敢相信它讀懂了我的東西\n\n"
            "   🔴 不要照抄現場的多數——2026-08-17 實查 39 張 open 票只有 6 張合規，\n"
            "      照多數抄就會抄到錯的那邊。\n"
            "   真的不是一條待辦？那它就不該是票（裁決→對話；版本→里程碑）。")


# ── close ────────────────────────────────────────────────────────────────
def cmd_close(argv):
    if not argv:
        die("用法：ticket close <owner/repo#N> --deliverable <URL>")
    owner, repo, num = parse_ref(argv[0])
    issue = api(f"/repos/{owner}/{repo}/issues/{num}")
    body = issue.get("body") or ""
    comments = api(f"/repos/{owner}/{repo}/issues/{num}/comments") or []
    blob = body + "\n" + "\n".join(c.get("body") or "" for c in comments)

    deliv = None
    if "--deliverable" in argv:
        deliv = argv[argv.index("--deliverable") + 1]

    m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
    kind = "code" if m and "code" in m.group(1).lower() else (
        "research" if m and "research" in m.group(1).lower() else "unknown")

    has_pr = bool(re.search(r"/pulls?/\d+", blob))
    has_report = len([c for c in comments if len(c.get("body") or "") > 200]) > 0

    ok = bool(deliv) or (has_pr if kind == "code" else has_report if kind == "research"
                         else (has_pr or has_report))
    if not ok:
        die(f"""🚫 這張票關不掉——找不到交付物。

  票的 deliverable 類型：{kind}
  票上有 PR 連結：{'有' if has_pr else '沒有'}
  票上有實質回報（>200 字的 comment）：{'有' if has_report else '沒有'}

**沒有交付物的票是關不掉的票**——它會一直掛在看板上刺眼，那正是設計意圖。
  真的有交付物 → 先貼上去：ticket say {owner}/{repo}#{num} -F <檔>
  交付物在別處 → ticket close {owner}/{repo}#{num} --deliverable <URL>""")

    if deliv:
        api(f"/repos/{owner}/{repo}/issues/{num}/comments",
            {"body": f"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管／{owner}/{repo}／-'}\n\n✅ 結案。交付物：{deliv}"})
    api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
    print(f"✅ {owner}/{repo}#{num} 已關（交付物：{deliv or ('PR' if has_pr else '票上回報')}）")



# ── decide ───────────────────────────────────────────────────────────────
def cmd_decide(argv):
    """記錄 leo 的裁決＋改狀態，**一個動作**。

    leo 2026-08-16：「回覆過很多次了，**回覆過的就要記錄下來**」
                    「這些我答了，來自各地，**問題是你怎麼追蹤**？」

    病灶：leo 從對話／手機／Gitea 各處答覆 ⇒ 總管照著做了但沒落到票上
         ⇒ 下一輪（或下個 session）又問一次同一題。B 題就是實例。
    ⇒ 所以「寫下答案」與「拿掉 Human」必須是**同一個動作**，不能只做一半。

    🔄 2026-08-16 改版（leo）：`s/leo` 併入 `Human`，且 **Human 與 s/* 正交**——
       「要不要人批」跟「它在流程哪一格」是兩個獨立的軸。
       ⇒ decide **只拿掉 Human，不動 s/* 狀態**（除非呼叫端明給 --next）。
       舊做法把 s/leo 換成 s/todo 會把票的真實流程位置抹掉。
    """
    if len(argv) < 3 or argv[1] not in ("-F", "--file"):
        die("用法：ticket decide <owner/repo#N> -F <答案檔> [--next <狀態標籤>]\n"
            "（預設只拿掉 Human、保留原本的 s/* 狀態；要同時改狀態才加 --next）\n"
            "答案檔要寫「leo 原話」與「所以要做什麼」")
    owner, repo, num = parse_ref(argv[0])
    body = open(argv[2]).read()
    nxt = argv[argv.index("--next") + 1] if "--next" in argv else None

    check_identity(body, "裁決紀錄")

    if "leo" not in body.lower() and "原話" not in body:
        die("🚫 答案檔裡看不到 leo 的原話。\n"
            "   **裁決要記原話，不是記你的轉述**——轉述會漂，原話不會。\n"
            "   （今天 `Arcrun#132` 就是把 leo 的「確認」套到錯的提案上，同一張票誤讀兩次。）")

    api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})

    ids = {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=60")}
    issue = api(f"/repos/{owner}/{repo}/issues/{num}")
    cur = [l["name"] for l in issue.get("labels") or []]

    # 預設：只拿掉 Human（那是「還在等人批」的標記），流程位置維持不動
    keep = [n for n in cur if n != "Human"]
    if "--next" in argv:
        keep = [n for n in keep if not n.startswith("s/")] + [nxt]
        if nxt not in ids:
            die(f"🚫 這個 repo 沒有 `{nxt}` 標籤。現有：{[n for n in ids if n.startswith('s/')]}")
    api(f"/repos/{owner}/{repo}/issues/{num}/labels",
        {"labels": [ids[n] for n in keep if n in ids]}, method="PUT")

    # 批完了就不該還掛在 leo 名下——指派給他的清單裡每一張都要是真的在等他
    api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": []}, method="PATCH")
    print(f"   已拿掉 Human 並取消指派——「指派給 Leo」那份清單保持誠實。")

    print(f"✅ {owner}/{repo}#{num}：答案已記進票，狀態 → {nxt}")
    print("   兩件事是同一個動作——不會只改標籤而忘了記，也不會記了而看板還在說『等 leo』。")


CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide}

if __name__ == "__main__":
    if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
        print(__doc__)
        sys.exit(0 if len(sys.argv) < 2 else 2)
    CMDS[sys.argv[1]](sys.argv[2:])
