#!/usr/bin/env python3
"""gitea: 本仓库 Gitea ops 的薄 CLI 出口。

子命令清单（一行一例）：
  gitea issue 331                                  读单个 issue
  gitea issue list --label Kind/Feature --limit 20 列 issue
  gitea issue comment 331 --body "..."             加评论（--body - 从 stdin 读）
  gitea pr 318                                     读单个 PR
  gitea label add 331 "Owner/Chentao"              加 label（按名字）
  gitea label remove 331 "Status/待领取"           移 label（按名字）
  gitea promote uat                                 develop → staging（FF-only）
  gitea promote prod                                staging → production（FF-only）
  gitea promote uat --dry-run                       看会做什么不真合并

全局 flag：--json / --verbose (-v) / --no-color / --dry-run (-n)

Auth: GITEA_API_TOKEN（必填）。详见 CLAUDE.md "Gitea 平台配置" 段。
退出码: 0 成功 / 1 用户错 / 2 系统错 / 3 约定拦截
设计依据: docs/standards/15-cli-design-spec.md
分层: 本 CLI 是 scripts/ops/_gitea_api.py 的薄出口，业务逻辑都在 library 层。
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _gitea_api import Api, detect_repo, get_token  # noqa: E402

MAX_PAGE_SIZE = 50
# 405 covers Gitea PR-merge UX errors (e.g., "head branch is behind",
# "mergeable cache not refreshed") — these are user-fixable, not system errors.
_USER_ERR_HTTP_CODES = frozenset({400, 401, 403, 404, 405, 422})

EXIT_OK = 0
EXIT_USER_ERR = 1
EXIT_SYS_ERR = 2
EXIT_CONVENTION = 3

# ---------- output helpers ----------

def _isatty() -> bool:
    return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None


def _color(s: str, code: str, enabled: bool) -> str:
    return f"\033[{code}m{s}\033[0m" if enabled else s


def emit_data(payload, json_mode: bool) -> None:
    """Write data to stdout. JSON mode = pure JSON. Human mode = formatted."""
    if json_mode:
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return
    if isinstance(payload, list):
        for item in payload:
            _print_human(item)
            print()
    else:
        _print_human(payload)


def _print_body_preview(body: str, max_chars: int = 800) -> None:
    body = (body or "").strip()
    if not body:
        return
    preview = body if len(body) <= max_chars else body[:max_chars] + "\n... [truncated]"
    print("  ---")
    for line in preview.splitlines():
        print(f"  {line}")


def _print_human(item: dict) -> None:
    """Format an issue dict as human-readable block on stdout.

    PR rendering lives in cmd_pr_get since the field layout differs.
    """
    if "number" not in item or "title" not in item:
        print(json.dumps(item, ensure_ascii=False))
        return
    state = item.get("state", "?")
    print(f"#{item['number']} [ISSUE/{state}] {item['title']}")
    labels = ",".join(l["name"] for l in item.get("labels") or [])
    assignees = ",".join(a["login"] for a in (item.get("assignees") or []))
    if labels:
        print(f"  labels:     {labels}")
    if assignees:
        print(f"  assignees:  {assignees}")
    _print_body_preview(item.get("body") or "")


def emit_error(code: str, message: str, hint: str = "", doc_url: str = "",
               json_mode: bool = False) -> None:
    """Write structured error to stderr."""
    if json_mode:
        err = {"code": code, "message": message}
        if hint:
            err["hint"] = hint
        if doc_url:
            err["doc_url"] = doc_url
        print(json.dumps(err, ensure_ascii=False), file=sys.stderr)
    else:
        red = _color(f"ERROR [{code}]: {message}", "31", _isatty())
        print(red, file=sys.stderr)
        if hint:
            print(f"  hint: {hint}", file=sys.stderr)
        if doc_url:
            print(f"  doc:  {doc_url}", file=sys.stderr)


def emit_notice(code: str, message: str, json_mode: bool = False) -> None:
    """Stderr informational notice (NOT an error). Used for dry-run plans
    and scoped-label replacement notes. Spec R2: stderr is the conversation
    channel; reserving ERROR red color for actual failures keeps log scrapers
    honest.
    """
    if json_mode:
        print(json.dumps({"notice": code, "message": message},
                         ensure_ascii=False), file=sys.stderr)
    else:
        print(f"NOTE [{code}]: {message}", file=sys.stderr)


# ---------- API call helpers ----------

def _api(verbose: int, json_mode: bool) -> Api:
    """Get token + repo. Emits structured error and exits 1 if token missing.

    Delegates token resolution to _gitea_api.get_token() and passes a
    JSON-aware emit_error callback. The library owns the env-var precedence;
    we own the error shape. See _gitea_api.get_token docstring.
    """
    def _emit_missing(checked: tuple[str, ...]) -> None:
        emit_error(
            "E_TOKEN_MISSING",
            f"none of {'/'.join(checked)} is set",
            hint=f"export {checked[0]}=<token>",
            doc_url="http://43.130.59.228/FFAIWorkspace/workspace/"
                    "src/branch/develop/CLAUDE.md",
            json_mode=json_mode,
        )
    tok = get_token(on_missing=_emit_missing)
    repo = detect_repo()
    if verbose:
        print(f"[v] using repo={repo} token=****{tok[-4:]}", file=sys.stderr)
    return Api(tok, repo)


def _call(api: Api, method: str, path: str, params=None, body=None,
          verbose: int = 0):
    if verbose:
        print(f"[v] {method} {path} params={params} body={'<set>' if body else None}",
              file=sys.stderr)
    dispatch = {"GET": lambda: api.get(path, params),
                "POST": lambda: api.post(path, body),
                "PATCH": lambda: api.patch(path, body),
                "DELETE": lambda: api.delete(path)}
    return dispatch[method]()


def _http_classify(code: int) -> int:
    """Map HTTP status to our 4-tier exit code. 429 (rate limit) is
    deliberately classified as system error so AI agents retry with backoff
    instead of treating it as a permanent user mistake.
    """
    if 200 <= code < 300:
        return EXIT_OK
    if code in _USER_ERR_HTTP_CODES:
        return EXIT_USER_ERR
    return EXIT_SYS_ERR


# ---------- commands: issue ----------

def cmd_issue_get(args, api: Api) -> int:
    code, txt = _call(api, "GET", f"/issues/{args.num}", verbose=args.verbose)
    if code != 200:
        emit_error("E_API", f"GET /issues/{args.num} → HTTP {code}: {txt[:200]}",
                   json_mode=args.json)
        return _http_classify(code)
    emit_data(json.loads(txt), args.json)
    return EXIT_OK


def cmd_issue_list(args, api: Api) -> int:
    if args.limit > MAX_PAGE_SIZE:
        print(f"WARN: --limit clamped to {MAX_PAGE_SIZE} (v1 single-page; "
              f"TODO(v1.1) --page support).", file=sys.stderr)
    params = {
        "state": args.state,
        "type": "issues",
        "limit": min(args.limit, MAX_PAGE_SIZE),
        "page": 1,
    }
    if args.label:
        params["labels"] = ",".join(args.label)
    if args.assignee:
        params["assigned_by"] = args.assignee
    code, txt = _call(api, "GET", "/issues", params=params, verbose=args.verbose)
    if code != 200:
        emit_error("E_API", f"GET /issues → HTTP {code}: {txt[:200]}",
                   json_mode=args.json)
        return _http_classify(code)
    items = json.loads(txt)
    if args.json:
        emit_data(items, json_mode=True)
    else:
        for it in items:
            num = it["number"]
            state = it.get("state", "?")
            labels = ",".join(l["name"] for l in it.get("labels") or [])
            print(f"#{num} [{state}] {it['title']}  [{labels}]")
    return EXIT_OK


def cmd_issue_comment(args, api: Api) -> int:
    body = args.body
    if body == "-":
        body = sys.stdin.read()
    if not body.strip():
        emit_error("E_EMPTY_BODY", "comment body is empty",
                   hint="pass --body \"...\" or pipe text via --body -",
                   json_mode=args.json)
        return EXIT_USER_ERR
    if args.dry_run:
        emit_notice("DRY_RUN", f"would POST comment to issue #{args.num} "
                    f"({len(body)} chars)", json_mode=args.json)
        return EXIT_OK
    code, txt = _call(api, "POST", f"/issues/{args.num}/comments",
                      body={"body": body}, verbose=args.verbose)
    if code not in (200, 201):
        emit_error("E_API", f"POST comment → HTTP {code}: {txt[:200]}",
                   json_mode=args.json)
        return _http_classify(code)
    emit_data(json.loads(txt), args.json)
    return EXIT_OK


# ---------- commands: pr ----------

def cmd_pr_get(args, api: Api) -> int:
    code, txt = _call(api, "GET", f"/pulls/{args.num}", verbose=args.verbose)
    if code != 200:
        emit_error("E_API", f"GET /pulls/{args.num} → HTTP {code}: {txt[:200]}",
                   json_mode=args.json)
        return _http_classify(code)
    pr = json.loads(txt)
    if args.json:
        emit_data(pr, json_mode=True)
    else:
        print(f"#{pr['number']} [PR/{pr.get('state','?')}] {pr['title']}")
        print(f"  base:       {pr['base']['ref']}")
        print(f"  head:       {pr['head']['ref']}")
        print(f"  author:     {pr['user']['login']}")
        print(f"  mergeable:  {pr.get('mergeable')}")
        labels = ",".join(l["name"] for l in pr.get("labels") or [])
        if labels:
            print(f"  labels:     {labels}")
        _print_body_preview(pr.get("body") or "")
    return EXIT_OK


# ---------- commands: label ----------

def _resolve_label_id(api: Api, name: str, verbose: int) -> int | None:
    """Look up label id by exact name (case-sensitive). Searches first 100
    labels only — TODO(v1.1) paginate when repo grows past that."""
    code, txt = _call(api, "GET", "/labels", params={"limit": 100},
                      verbose=verbose)
    if code != 200:
        return None
    labels = json.loads(txt)
    if len(labels) >= 100:
        print("WARN: hit /labels limit=100; pagination not yet implemented. "
              "If your label is not found but exists, see scripts/ops/gitea "
              "TODO(v1.1).", file=sys.stderr)
    for label in labels:
        if label.get("name") == name:
            return label["id"]
    return None


def _issue_label_names(api: Api, num: int) -> list[str] | None:
    """Fetch current label names on an issue. None on API error so callers
    can distinguish "fetch failed" from "issue has no labels" — critical for
    remove path where empty set would otherwise misclassify a 5xx as
    NOT_ON_ISSUE.
    """
    code, txt = api.get(f"/issues/{num}")
    if code != 200:
        return None
    return [l["name"] for l in json.loads(txt).get("labels") or []]


def _label_mutate(args, api: Api, *, action: str) -> int:
    """Shared body for `gitea label add|remove`. action is 'add' or 'remove'.

    Both paths: resolve name → id, dry-run check, pre-state diff for clear
    feedback (scoped-replacement on add, NOT_ON_ISSUE on remove), HTTP call,
    response.
    """
    label_id = _resolve_label_id(api, args.label, args.verbose)
    if label_id is None:
        emit_error("E_LABEL_NOT_FOUND",
                   f"label '{args.label}' not found in repo "
                   f"(searched first 100 labels)",
                   hint="check spelling, or list labels via Gitea web UI",
                   json_mode=args.json)
        return EXIT_USER_ERR
    verb_http = "POST" if action == "add" else "DELETE"
    if args.dry_run:
        emit_notice("DRY_RUN",
                    f"would {verb_http} label '{args.label}' (id={label_id}) "
                    f"{'to' if action == 'add' else 'from'} issue #{args.num}",
                    json_mode=args.json)
        return EXIT_OK
    before_list = _issue_label_names(api, args.num)
    if before_list is None:
        emit_error("E_API",
                   f"GET /issues/{args.num} failed; cannot read current "
                   f"labels safely",
                   hint="check Gitea reachability and retry",
                   json_mode=args.json)
        return EXIT_SYS_ERR
    before = set(before_list)
    if action == "remove" and args.label not in before:
        emit_error("E_LABEL_NOT_ON_ISSUE",
                   f"label '{args.label}' is not on issue #{args.num}",
                   hint=f"current labels: {', '.join(sorted(before))}",
                   json_mode=args.json)
        return EXIT_USER_ERR
    if action == "add":
        code, txt = _call(api, "POST", f"/issues/{args.num}/labels",
                          body={"labels": [label_id]}, verbose=args.verbose)
        ok_codes = (200, 201)
    else:
        code, txt = _call(api, "DELETE",
                          f"/issues/{args.num}/labels/{label_id}",
                          verbose=args.verbose)
        ok_codes = (200, 204)
    if code not in ok_codes:
        emit_error("E_API", f"{verb_http} label → HTTP {code}: {txt[:200]}",
                   json_mode=args.json)
        return _http_classify(code)
    result: dict = {"issue": args.num, action + "ed": args.label}
    if action == "add":
        # Gitea POST /issues/{n}/labels returns the full resulting label list,
        # so we can compute scoped-replaced siblings from the response without
        # a separate after-GET. DELETE returns 204 (empty), so no diff there.
        after = {l["name"] for l in json.loads(txt)}
        replaced = sorted(before - after)
        if replaced:
            result["replaced"] = replaced
            if not args.json:
                emit_notice("SCOPED_REPLACED",
                            f"scoped-label group replaced: {', '.join(replaced)}",
                            json_mode=False)
    emit_data(result, args.json)
    return EXIT_OK


def cmd_label_add(args, api: Api) -> int:
    return _label_mutate(args, api, action="add")


def cmd_label_remove(args, api: Api) -> int:
    return _label_mutate(args, api, action="remove")


# ---------- commands: promote (env-up FF-only merge) ----------

# 环境升级目标硬编码 — 把 head/base 关系作为 CLI 不变量
# 避免 typo（哪个分支合到哪个分支）+ 给读 CLI 的人一个清单
PROMOTE_TARGETS = {
    "uat":  {"head": "develop", "base": "staging",
             "desc": "把 develop 合到 staging（UAT 升级）"},
    "prod": {"head": "staging", "base": "production",
             "desc": "把 staging 合到 production（生产升级）"},
}


def _branch_tip(api: Api, branch: str) -> str | None:
    code, txt = api.get(f"/branches/{branch}")
    if code != 200:
        return None
    return json.loads(txt).get("commit", {}).get("id")


def _compare(api: Api, base: str, head: str) -> dict | None:
    # Gitea: GET /repos/{}/compare/{base}...{head} → diverged_commits / behind_by / ahead_by 等
    # path 里 ... 不能 urlencode（会被改成 %2E%2E%2E），直接拼字符串
    code, txt = api.get(f"/compare/{base}...{head}")
    if code != 200:
        return None
    return json.loads(txt)


def _ci_status(api: Api, sha: str) -> dict | None:
    # combined status：所有 status check 的总和（success / pending / failure）
    code, txt = api.get(f"/commits/{sha}/status")
    if code != 200:
        return None
    return json.loads(txt)


def _find_open_promotion_pr(api: Api, head: str, base: str) -> dict | None:
    # 实测 Gitea 1.21（本仓库）GET /pulls 的 base=/head= 参数被忽略
    # （传 base=staging 仍返回所有 open PR，不区分 base）→ 客户端 filter 是唯一选项
    # limit=50 足够：repo 同时 open PR 数实测 < 10，不会超
    code, txt = api.get("/pulls", {"state": "open", "limit": 50})
    if code != 200:
        return None
    for pr in json.loads(txt):
        if pr["head"]["ref"] == head and pr["base"]["ref"] == base:
            return pr
    return None


def _build_promote_pr_body(target_key: str, target: dict,
                           head_sha: str, commits: list) -> str:
    """生成 promote PR 的 body — 包含 commits 清单方便 reviewer 看本批包了什么。"""
    lines = [
        f"## {target['desc']}",
        "",
        f"由 `gitea promote {target_key}` 自动创建。",
        f"head: `{target['head']}` ({head_sha[:8]}) → base: `{target['base']}`",
        "",
        "## 本批合并的 commits",
        "",
    ]
    if not commits:
        lines.append("（无）")
    else:
        for c in commits[:50]:  # 防过长
            sha = c.get("sha", "")[:8]
            msg = (c.get("commit", {}).get("message", "") or "").splitlines()[0]
            lines.append(f"- `{sha}` {msg}")
        if len(commits) > 50:
            lines.append(f"- … 还有 {len(commits) - 50} 个 commit（已截断）")
    lines += [
        "",
        "## 合并方式",
        "",
        f"**Fast-forward-only**（FF），由 `gitea promote` CLI 直接合并——"
        f"**禁止 Gitea web UI 手点合并**（policy 见 `CLAUDE.md` Git 规则段 + "
        f"PR #383 事故复盘）。如本 PR 因任何原因 CLI merge 路径中断，"
        f"请重跑 `gitea promote {target_key}`，**不要**通过 web UI 兜底。",
    ]
    return "\n".join(lines)


def cmd_promote(args, api: Api) -> int:
    target = PROMOTE_TARGETS[args.target]
    head, base = target["head"], target["base"]
    v = args.verbose

    # ---- 1. 拿 head/base 分支 tip ----
    head_sha = _branch_tip(api, head)
    base_sha = _branch_tip(api, base)
    if not head_sha or not base_sha:
        emit_error("E_BRANCH_NOT_FOUND",
                   f"无法读取分支 tip（head={head} / base={base}）",
                   json_mode=args.json)
        return EXIT_USER_ERR
    if v:
        print(f"[v] {head}={head_sha[:8]}  {base}={base_sha[:8]}", file=sys.stderr)

    if head_sha == base_sha:
        emit_notice("UP_TO_DATE",
                    f"{base} 已等于 {head}（{head_sha[:8]}），无需 promote",
                    json_mode=args.json)
        emit_data({"promoted": False, "reason": "up_to_date",
                   "head": head, "base": base, "sha": head_sha}, args.json)
        return EXIT_OK

    # ---- 2. FF-able 检查：base 不能有 head 上没有的 commit ----
    cmp_data = _compare(api, base, head)
    if cmp_data is None:
        emit_error("E_COMPARE_FAILED",
                   f"无法 compare {base}...{head}（Gitea API 异常）",
                   json_mode=args.json)
        return EXIT_SYS_ERR
    behind = cmp_data.get("behind_by", 0)
    ahead = cmp_data.get("ahead_by", 0)
    if behind > 0:
        emit_error("E_NOT_FF_ABLE",
                   f"{base} 比 {head} 多 {behind} 个 commit，无法 FF 合并",
                   hint=f"先把 {base} 的差异同步回 {head}（cherry-pick 或 merge），"
                        f"或修正分支保护",
                   json_mode=args.json)
        return EXIT_USER_ERR
    if v:
        print(f"[v] FF-able: behind={behind} ahead={ahead}", file=sys.stderr)

    # ---- 3. CI 硬门槛：head 上所有 status check 必须 success ----
    ci = _ci_status(api, head_sha)
    if ci is None:
        emit_error("E_CI_UNKNOWN",
                   f"无法读取 {head}@{head_sha[:8]} 的 CI status",
                   json_mode=args.json)
        return EXIT_SYS_ERR
    ci_state = ci.get("state", "unknown")  # success / pending / failure
    # 实测 Gitea per-status entry 字段是 'status'，值含 skipped/neutral/success/...
    # skipped/neutral 不是"失败"——计入 failing 会让 --force-anyway 提示充斥 20+ 噪声
    OK_STATUSES = {"success", "skipped", "neutral"}
    failing = [s for s in ci.get("statuses", [])
               if s.get("status") not in OK_STATUSES]
    if ci_state != "success":
        if args.force_anyway:
            emit_notice("CI_NOT_GREEN_FORCED",
                        f"CI state={ci_state}，但 --force-anyway 已绕过",
                        json_mode=args.json)
        else:
            emit_error("E_CI_NOT_GREEN",
                       f"CI 未全绿（state={ci_state}，{len(failing)} 项非 success）",
                       hint=("先把 CI 修绿；确认无误且紧急时 --force-anyway 可绕过。"
                             "失败 check：" +
                             ", ".join(s.get("context", "?") for s in failing[:5])),
                       json_mode=args.json)
            return EXIT_USER_ERR

    # ---- 4. 找已有 PR 或新建 ----
    pr = _find_open_promotion_pr(api, head, base)
    if pr is None:
        # 新建：title 用约定式提交 + 自动 body
        # 拉 commits 列表（compare 已经有 commits 字段，复用）
        commits = cmp_data.get("commits") or []
        title = f"chore({args.target}): promote {head} → {base} ({head_sha[:8]})"
        body = _build_promote_pr_body(args.target, target, head_sha, commits)
        if args.dry_run:
            emit_notice("DRY_RUN",
                        f"would POST /pulls (title='{title}') 创建 PR",
                        json_mode=args.json)
            emit_data({"promoted": False, "reason": "dry_run_pr_create",
                       "title": title, "head_sha": head_sha[:8],
                       "ahead_by": ahead}, args.json)
            return EXIT_OK
        code, txt = api.post("/pulls",
                             {"title": title, "body": body,
                              "head": head, "base": base})
        if code not in (200, 201):
            emit_error("E_PR_CREATE",
                       f"POST /pulls → HTTP {code}: {txt[:300]}",
                       json_mode=args.json)
            return _http_classify(code)
        pr = json.loads(txt)
        if v:
            print(f"[v] 新建 PR #{pr['number']}", file=sys.stderr)
    else:
        if v:
            print(f"[v] 复用已有 PR #{pr['number']}", file=sys.stderr)

    pr_num = pr["number"]

    # ---- 5. self-approve（policy: env-promote PR 允许 self-approve）----
    if args.dry_run:
        emit_notice("DRY_RUN",
                    f"would self-approve PR #{pr_num} + merge with Do=fast-forward-only",
                    json_mode=args.json)
        emit_data({"promoted": False, "reason": "dry_run_merge",
                   "pr": pr_num, "head_sha": head_sha[:8]}, args.json)
        return EXIT_OK

    code, txt = api.post(f"/pulls/{pr_num}/reviews",
                         {"event": "APPROVED",
                          "body": f"gitea promote {args.target}: self-approve "
                                  f"(env-up FF-only via CLI)"})
    if code not in (200, 201):
        # 已经 approve 过 / approve 不允许：不阻断 merge，先 warn 再试 merge
        emit_notice("APPROVE_SKIPPED",
                    f"self-approve 失败（HTTP {code}），继续尝试 merge",
                    json_mode=args.json)

    # ---- 6. merge with Do=fast-forward-only ----
    code, txt = api.post(f"/pulls/{pr_num}/merge",
                         {"Do": "fast-forward-only"})
    if code not in (200, 204):
        emit_error("E_MERGE_FAILED",
                   f"POST /pulls/{pr_num}/merge → HTTP {code}: {txt[:300]}",
                   hint="检查分支保护是否要求其他 check / "
                        "branch 是否仍 outdated（block_on_outdated_branch）",
                   json_mode=args.json)
        return _http_classify(code)

    # ---- 7. post-merge 校验：base tip 应该等于 head_sha（FF 的特征）----
    new_base_sha = _branch_tip(api, base)
    if new_base_sha != head_sha:
        emit_error("E_POST_MERGE_DRIFT",
                   f"预期 {base}={head_sha[:8]}, 实际={new_base_sha[:8] if new_base_sha else 'unknown'}",
                   hint="merge 可能不是 FF（被 Gitea 改成 squash？）— 检查 PR 状态并考虑 force-reset",
                   json_mode=args.json)
        return EXIT_SYS_ERR

    emit_data({"promoted": True, "target": args.target,
               "pr": pr_num, "head": head, "base": base,
               "sha": head_sha,
               "ahead_by": ahead}, args.json)
    return EXIT_OK


# ---------- arg parsing ----------

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="gitea",
        description="Gitea ops CLI for this repo. See "
                    "docs/standards/15-cli-design-spec.md.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Examples:\n"
               "  gitea issue 331\n"
               "  gitea issue list --label Kind/Feature --state open\n"
               "  gitea issue comment 331 --body \"开工了\"\n"
               "  gitea label add 331 Owner/Chentao\n"
               "  gitea pr 318 --json\n"
               "  gitea promote uat              # develop → staging (FF-only)\n"
               "  gitea promote prod --dry-run   # staging → production (preview)\n",
    )
    p.add_argument("--json", action="store_true",
                   help="JSON output to stdout (machine-readable)")
    p.add_argument("-v", "--verbose", action="count", default=0,
                   help="verbose (-vv for debug)")
    p.add_argument("--no-color", action="store_true",
                   help="disable ANSI colors (also honors NO_COLOR env)")
    p.add_argument("-n", "--dry-run", action="store_true",
                   help="show what would happen, don't execute")

    sub = p.add_subparsers(dest="topic", required=True, metavar="TOPIC")

    # issue
    issue = sub.add_parser("issue", help="issue operations")
    issue_sub = issue.add_subparsers(dest="verb", required=True, metavar="VERB")

    issue_get = issue_sub.add_parser("get", help="read single issue")
    issue_get.add_argument("num", type=int)
    issue_get.set_defaults(func=cmd_issue_get)

    issue_list = issue_sub.add_parser("list", help="list issues")
    issue_list.add_argument("--label", action="append", default=[],
                             help="filter by label (repeatable)")
    issue_list.add_argument("--state", default="open",
                             choices=["open", "closed", "all"])
    issue_list.add_argument("--assignee", help="filter by assignee login")
    issue_list.add_argument("--limit", type=int, default=20)
    issue_list.set_defaults(func=cmd_issue_list)

    issue_comment = issue_sub.add_parser("comment", help="post a comment")
    issue_comment.add_argument("num", type=int)
    issue_comment.add_argument("--body", required=True,
                                help="comment body ('-' to read stdin)")
    issue_comment.set_defaults(func=cmd_issue_comment)

    # pr
    pr = sub.add_parser("pr", help="PR operations")
    pr_sub = pr.add_subparsers(dest="verb", required=True, metavar="VERB")
    pr_get = pr_sub.add_parser("get", help="read single PR")
    pr_get.add_argument("num", type=int)
    pr_get.set_defaults(func=cmd_pr_get)

    # label
    label = sub.add_parser("label", help="label operations on issues/PRs")
    label_sub = label.add_subparsers(dest="verb", required=True, metavar="VERB")
    label_add = label_sub.add_parser("add", help="add label to issue/PR")
    label_add.add_argument("num", type=int)
    label_add.add_argument("label", help="label name (exact match)")
    label_add.set_defaults(func=cmd_label_add)
    label_remove = label_sub.add_parser("remove",
                                        help="remove label from issue/PR")
    label_remove.add_argument("num", type=int)
    label_remove.add_argument("label", help="label name (exact match)")
    label_remove.set_defaults(func=cmd_label_remove)

    # promote: env-up FF-only merge (develop→staging / staging→production)
    promote = sub.add_parser(
        "promote",
        help="env-up FF-only merge (uat=develop→staging, prod=staging→production)",
        description="把 develop 合到 staging（uat）或把 staging 合到 production（prod）。"
                    "强制 fast-forward-only；CI 不全绿默认拒绝；自动 self-approve（policy）。"
                    "替代 Gitea web UI 点 merge 按钮——防止误点 squash 破坏 FF 历史。",
    )
    promote.add_argument("target", choices=list(PROMOTE_TARGETS.keys()),
                         help="uat = develop→staging | prod = staging→production")
    promote.add_argument("--force-anyway", action="store_true",
                         help="即使 CI 未全绿也尝试 merge（紧急用，记得在 PR 写理由）")
    promote.set_defaults(func=cmd_promote)

    return p


GLOBAL_BOOL_FLAGS = {"--json", "--no-color", "-n", "--dry-run", "-v", "-vv",
                    "--verbose"}


def _hoist_globals(argv: list[str]) -> list[str]:
    """Move recognized global flags to the very front so argparse sees them
    before the subparser dispatch. argparse's subparsers don't inherit
    top-level optionals — without this, `gitea pr 318 --json` would fail with
    "unrecognized arguments: --json".
    """
    globals_found: list[str] = []
    rest: list[str] = []
    for tok in argv:
        if tok in GLOBAL_BOOL_FLAGS:
            globals_found.append(tok)
        else:
            rest.append(tok)
    return globals_found + rest


def _shorthand(argv: list[str]) -> list[str]:
    """Allow `gitea issue 331` as shorthand for `gitea issue get 331` and
    `gitea pr 318` for `gitea pr get 318`. Detected: token after topic is int.
    Runs after _hoist_globals so all leading tokens are flags we skip over.
    Note: only `issue` and `pr` support int-shorthand; `label` always requires
    an explicit verb (add/remove) since a bare integer would be ambiguous.
    """
    out = list(argv)
    i = 0
    while i < len(out) and out[i].startswith("-"):
        i += 1
    if i + 1 < len(out) and out[i] in ("issue", "pr"):
        try:
            int(out[i + 1])
            out.insert(i + 1, "get")
        except ValueError:
            pass
    return out


def main(argv: list[str] | None = None) -> int:
    raw = sys.argv[1:] if argv is None else argv
    argv = _shorthand(_hoist_globals(raw))
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.no_color:
        os.environ["NO_COLOR"] = "1"

    api = _api(args.verbose, args.json)

    try:
        return args.func(args, api)
    except KeyboardInterrupt:
        emit_error("E_INTERRUPTED", "interrupted by user",
                   json_mode=args.json)
        return EXIT_SYS_ERR


if __name__ == "__main__":
    sys.exit(main())
