#!/usr/bin/env python3
"""
auto-merge-develop.py — feature/* → develop 的自动合并器 + PR 队列自动 update

判定条件（**全部满足**才合）：
  1. base == develop 且 state == open
  2. mergeable is True 且 base.sha == merge_base（无冲突 + 不 outdated）
     - mergeable=True + base.sha != merge_base → update 分支（outdated）
     - mergeable=False                          → update 分支（真冲突时 409 加 label）
     - mergeable=None                           → skip 等 Gitea 算完
  3. 所有 required status check == success
  4. 最新 AIBot review 评论 verdict 严格匹配 "pass"
     （pass_with_risk / should_fix / needs_fix / block 均不合）
  5. PR 没有未 dismiss 的 REQUEST_CHANGES review
  6. PR 作者 != AIBot（防递归，大小写不敏感比较）
  7. PR 没有 label `do-not-auto-merge`（作者逃生口）

分类执行（**每 tick 最多 1 merge + 1 update**，防 CI 雪崩）：
  - 1-7 全过 + mergeable=True + base.sha == merge_base → 双重 read → 合 → 审计评论
  - 1/3-7 全过 + mergeable=True + base.sha != merge_base → POST /pulls/N/update?style=merge
  - 1/3-7 全过 + mergeable=False                        → POST /pulls/N/update?style=merge（409→加 label）
  - 1/3-7 全过 + mergeable=None                         → skip 等 Gitea 算
  - 其他                                                → skip

⚠️ Gitea `mergeable` 字段语义跟 GitHub `mergeable_state` 不同——只反映「无冲突」，
不反映「base outdated」。`block_on_outdated_branch=True` 时 outdated PR 仍
`mergeable=True`，但 POST /merge 会被 405 拒。所以 dispatch 必须主动用
`base.sha vs merge_base` 自己判 outdated。详见 ERR-20260519-004。

合并方式：squash（develop 历史线干净，与现有规则一致）。
update 方式：merge（保留作者本地 ref，不重写 PR 分支历史）。

依赖：scripts/ops/_gitea_api.py（统一 Api / get_token / detect_repo）

环境变量：
  GITEA_API_TOKEN     必填——AIBot PAT（write:repository scope）
  DRY_RUN             可选——非空则只打印不真合 / 不真 update / 不真评论
  GITEA_REPO          可选——默认从 git remote 解析
  DAEMON_HOST         可选——审计评论用的主机名，默认 socket.gethostname()
  JSONL_LOG_PATH      可选——daemon 模式下 JSONL 日志路径
                              （只在真做事时 append；空扫不写）

退出码：
  0  正常（含 "本轮没有可合的 PR"）
  1  脚本错误 / API 错误
"""

from __future__ import annotations

import datetime as _dt
import json
import os
import re
import socket
import sys
from pathlib import Path

# 让 import 在 cron / systemd timer / 任意 cwd 下都能工作
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _gitea_api import Api, detect_repo, ensure_label, get_token, paginate  # noqa: E402

DRY_RUN = bool(os.environ.get("DRY_RUN"))
AIBOT_LOGIN = "AIBot"  # 与 scripts/ops/ai-review-runner.sh 中的 author 一致
TARGET_BRANCH = "develop"
LABEL_OPT_OUT = "do-not-auto-merge"
LABEL_NEEDS_REBASE = "needs-manual-rebase"
# Gitea label API 对 '#' 前缀的容忍度不稳定（仓内其他 ensure_label 调用都无 '#'：
# sweep-remote-stale.py / weekly-retro-issue.py）。保持一致避免静默 422。
LABEL_NEEDS_REBASE_COLOR = "fbca04"  # 黄，跟 Gitea built-in warning label 一致
LABEL_NEEDS_REBASE_DESC = "auto-merge daemon update_branch 出真 conflict；需人工 rebase"

MAX_MERGE_PER_TICK = 1
MAX_UPDATE_PER_TICK = 1

DAEMON_HOST = os.environ.get("DAEMON_HOST") or socket.gethostname()
JSONL_LOG_PATH = os.environ.get("JSONL_LOG_PATH")

# 5 种 verdict 与 scripts/ops/ai-review-schema.json 对齐
# 注意：长串放前面，避免 "pass" 被 "pass_with_risk" 子串吃掉
VERDICT_RE = re.compile(
    r"AI Review.*?—\s+(pass_with_risk|should_fix|needs_fix|block|pass)\b",
    re.MULTILINE,
)


# ──────────────────── 日志 ────────────────────


def _now_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def log_event(event: str, **fields) -> None:
    """JSONL append——只在真做事 / 真出错时写。空扫不调用。

    路径来自 JSONL_LOG_PATH env；不设则不写文件，stdout 仍正常打印。
    """
    record = {"t": _now_iso(), "host": DAEMON_HOST, "event": event, **fields}
    line = json.dumps(record, ensure_ascii=False)
    if JSONL_LOG_PATH:
        try:
            with open(JSONL_LOG_PATH, "a", encoding="utf-8") as f:
                f.write(line + "\n")
        except OSError as e:
            print(f"WARN: cannot write JSONL log {JSONL_LOG_PATH}: {e}", file=sys.stderr)


# ──────────────────── API helpers ────────────────────


def fetch_json(api: Api, path: str, params: dict | None = None):
    code, txt = api.get(path, params)
    if code != 200:
        raise RuntimeError(f"GET {path} → {code}: {txt[:200]}")
    return json.loads(txt) if txt else None


def list_open_prs(api: Api) -> list[dict]:
    prs = paginate(api, "/pulls", {"state": "open"})
    return [pr for pr in prs if pr.get("base", {}).get("ref") == TARGET_BRANCH]


def get_required_check_names(api: Api, branch: str) -> set[str]:
    bp = fetch_json(api, f"/branch_protections/{branch}")
    return set(bp.get("status_check_contexts") or [])


def get_check_runs(api: Api, pr: dict) -> dict[str, str]:
    """{context_name: status}，同 context 取最新（Gitea 返回 desc 序）"""
    head_sha = pr["head"]["sha"]
    runs = fetch_json(api, f"/commits/{head_sha}/statuses") or []
    out: dict[str, str] = {}
    for r in runs:
        ctx = r.get("context") or ""
        if ctx and ctx not in out:
            out[ctx] = r.get("status", "pending")
    return out


def get_reviews(api: Api, pr: dict) -> list[dict]:
    return paginate(api, f"/pulls/{pr['number']}/reviews")


def get_comments(api: Api, pr: dict) -> list[dict]:
    return paginate(api, f"/issues/{pr['number']}/comments")


def latest_aibot_verdict(comments: list[dict]) -> str | None:
    for c in reversed(comments):
        if (c.get("user", {}).get("login") or "").lower() != AIBOT_LOGIN.lower():
            continue
        body = c.get("body") or ""
        if "AI Review" not in body:
            continue
        m = VERDICT_RE.search(body)
        if m:
            return m.group(1)
    return None


def has_active_request_changes(reviews: list[dict]) -> bool:
    """有任何 state=REQUEST_CHANGES 且未被 dismiss 的 review 即视为存在。

    Gitea Review API：dismiss 后 state 变为 'DISMISSED'，不是布尔字段。
    """
    return any(r.get("state") == "REQUEST_CHANGES" for r in reviews)


def has_label(pr: dict, name: str) -> bool:
    return any((lbl.get("name") == name) for lbl in (pr.get("labels") or []))


def clear_needs_rebase_label(api: Api, pr: dict) -> bool:
    """删 PR 上的 needs-manual-rebase label + 同步更新 pr['labels']。

    用于"人工 rebase + force-push 解了冲突 → mergeable 回 True"后自动清 stale label，
    否则 daemon 永远 skip 这个 PR（见 ERR-20260519-005）。

    成功 True，失败 False（daemon 会 fallback 到原 skip 行为，让人工处理）。
    """
    n = pr["number"]
    label_id = next(
        (lbl.get("id") for lbl in (pr.get("labels") or []) if lbl.get("name") == LABEL_NEEDS_REBASE),
        None,
    )
    if label_id is None:
        return False
    code, txt = api.delete(f"/issues/{n}/labels/{label_id}")
    if code >= 300:
        print(f"  [warn] PR #{n}: 清 stale '{LABEL_NEEDS_REBASE}' 失败: HTTP {code} {txt[:100]}",
              file=sys.stderr)
        return False
    pr["labels"] = [l for l in (pr.get("labels") or []) if l.get("id") != label_id]
    return True


# ──────────────────── 评判 ────────────────────


# 3 个动作分类
ACT_MERGE = "merge"      # 全 7 条件满足，可合
ACT_UPDATE = "update"    # 除 mergeable 外全过，base outdated，应 update
ACT_SKIP = "skip"        # 其他（等 CI / 等 verdict / 用户 opt-out 等）


def evaluate(api: Api, pr: dict, required_checks: set[str]) -> tuple[str, str]:
    """返回 (action, reason)。

    顺序：先排除 "永远不会合" 的（作者 / label / REQUEST_CHANGES），
         再排除 "等 CI / 等 verdict"，最后看 mergeable 决定 merge / update。
    """
    if (pr.get("user", {}).get("login") or "").lower() == AIBOT_LOGIN.lower():
        return ACT_SKIP, "skip: PR 作者是 AIBot"

    if has_label(pr, LABEL_OPT_OUT):
        return ACT_SKIP, f"skip: 含 label '{LABEL_OPT_OUT}'"

    if has_label(pr, LABEL_NEEDS_REBASE):
        # mergeable=True 表示人工 rebase + force-push 已解了 conflict → 自动清 stale label。
        # 不清 → daemon 每 tick 都 skip，等人工去 Gitea web UI 删 label，多一步认知负担。
        # 详见 .learnings/ERRORS/ERR-20260519-005-daemon-stale-needs-rebase-label.md
        if pr.get("mergeable") is True and clear_needs_rebase_label(api, pr):
            log_event("cleared_stale_needs_rebase_label", pr=pr["number"])
            # 继续往下走 evaluate（CI / verdict / mergeable 等检查）
        else:
            return ACT_SKIP, (
                f"skip: 含 label '{LABEL_NEEDS_REBASE}'（mergeable={pr.get('mergeable')!r}，"
                f"等人工处理）"
            )

    # CI 检查
    checks = get_check_runs(api, pr)
    missing = required_checks - set(checks.keys())
    if missing:
        return ACT_SKIP, f"wait: required check 未上报 {sorted(missing)}"
    failed = [c for c, s in checks.items() if c in required_checks and s != "success"]
    if failed:
        return ACT_SKIP, f"wait: required check 未通过 {failed}"

    # Review
    if has_active_request_changes(get_reviews(api, pr)):
        return ACT_SKIP, "skip: 存在 REQUEST_CHANGES review"

    # AI verdict
    verdict = latest_aibot_verdict(get_comments(api, pr))
    if verdict is None:
        return ACT_SKIP, "wait: 还没收到 AIBot 的 AI Review verdict"
    if verdict != "pass":
        return ACT_SKIP, f"skip: ai-review verdict={verdict}（严格模式只接受 pass）"

    # 上面全过了，只剩 mergeable 是 True / False / None
    if pr.get("mergeable") is True:
        # ⚠️ Gitea `mergeable=True` 只代表「无冲突」，**不反映** base outdated。
        # 当 `block_on_outdated_branch=True` 时，outdated PR 调 POST /merge 会被 405 拒
        # （"The head branch is behind the base branch"）。所以必须主动查 outdated。
        # 用 merge_base vs base.sha 判：base 已超前 merge_base（共同祖先）= outdated。
        # 见 .learnings/ERRORS/ERR-20260519-004-daemon-mergeable-not-reflect-outdated.md
        full = re_read_pr(api, pr["number"])
        base_sha = (full.get("base") or {}).get("sha")
        merge_base = full.get("merge_base")
        if base_sha and merge_base and base_sha != merge_base:
            # 让未来挂监控告警有 hook（ERR-20260519-004 「工程化保险 §2」）
            log_event("outdated_detected", pr=pr["number"], base=base_sha[:8], merge_base=merge_base[:8])
            return ACT_UPDATE, f"ready except outdated（base={base_sha[:8]} ≠ merge_base={merge_base[:8]}）: 试 update_branch"
        if not (base_sha and merge_base):
            # Gitea 偶发不返回 merge_base（建议 round 1 提醒）—— fallthrough 到 ACT_MERGE
            # 可能再撞 405，但至少可观测，不静默；下 tick Gitea 应正常返回
            log_event("merge_base_missing", pr=pr["number"], base=base_sha, merge_base=merge_base)
        return ACT_MERGE, "ready: 全检通过 + verdict=pass + mergeable=True + 不 outdated"
    if pr.get("mergeable") is False:
        # mergeable=False 是真冲突（Gitea 算过了，干净 merge 不可能）。
        # 仍尝试 update_branch——409 时加 LABEL_NEEDS_REBASE 跳过。
        return ACT_UPDATE, "ready except mergeable=False: 试 update_branch（真冲突时会 409 加 label）"
    return ACT_SKIP, f"wait: mergeable={pr.get('mergeable')!r}（Gitea 未算完）"


# ──────────────────── 动作 ────────────────────


def re_read_pr(api: Api, pr_number: int) -> dict:
    """合前再读一次——防双 daemon race 或人工先合一步"""
    return fetch_json(api, f"/pulls/{pr_number}")


def merge_pr(api: Api, pr: dict) -> bool:
    """返回 True 表示真合了一个，False 表示因为状态变化跳过"""
    n = pr["number"]
    head_sha = pr["head"]["sha"]

    # 双重 read（race safety）
    cur = re_read_pr(api, n)
    if cur.get("merged"):
        log_event("merge_skip_already_merged", pr=n)
        print(f"  [skip] PR #{n} 已被合（peer daemon 或人工先一步）")
        return False
    if cur.get("state") != "open":
        log_event("merge_skip_state_changed", pr=n, state=cur.get("state"))
        print(f"  [skip] PR #{n} state={cur.get('state')}，状态变了")
        return False
    if cur["head"]["sha"] != head_sha:
        log_event("merge_skip_head_moved", pr=n, old=head_sha[:8], new=cur["head"]["sha"][:8])
        print(f"  [skip] PR #{n} head 移动了（作者推了新 commit）")
        return False

    body = {
        "Do": "squash",
        "MergeTitleField": pr.get("title", ""),
        "MergeMessageField": (pr.get("body") or "").strip()[:4000],
        # 防 evaluate→merge 之间作者新推 commit 绕过 verdict 校验：
        # Gitea 校验 head_commit_id 与当前 head 不一致会拒掉
        "head_commit_id": head_sha,
    }
    if DRY_RUN:
        print(f"  [dry-run] 跳过真合 PR #{n} (head={head_sha[:8]})")
        return True  # dry-run 也算"用掉一次配额"

    code, txt = api.post(f"/pulls/{n}/merge", body)
    if code == 409:
        # peer daemon / 人工抢先一步
        log_event("merge_conflict_409", pr=n, response=txt[:200])
        print(f"  [skip] PR #{n} → 409（已被合），无副作用")
        return False
    if code >= 300:
        log_event("merge_error", pr=n, code=code, response=txt[:200])
        raise RuntimeError(f"merge PR #{n} → {code}: {txt[:200]}")

    print(f"  [merged] PR #{n} squash 合并到 {TARGET_BRANCH}")
    log_event("merged", pr=n, head=head_sha, title=pr.get("title", "")[:100])

    # 审计评论
    try:
        post_merge_audit_comment(api, pr, head_sha)
    except Exception as e:
        # 评论失败不破坏合并结果，仅 log
        log_event("audit_comment_failed", pr=n, error=str(e))
        print(f"  [warn] PR #{n} 合后写评论失败: {e}")
    return True


def post_merge_audit_comment(api: Api, pr: dict, head_sha: str) -> None:
    n = pr["number"]
    body = (
        f"🤖 Auto-merged by `daemon@{DAEMON_HOST}` at {_now_iso()}\n"
        f"head: `{head_sha[:12]}` · verdict: pass · all required checks: success"
    )
    if DRY_RUN:
        print(f"  [dry-run] 跳过审计评论 PR #{n}")
        return
    code, txt = api.post(f"/issues/{n}/comments", {"body": body})
    if code >= 300:
        raise RuntimeError(f"comment PR #{n} → {code}: {txt[:200]}")


def update_pr_branch(api: Api, pr: dict) -> bool:
    """尝试把 base merge 进 PR 分支。

    返回 True 表示用掉一次 update 配额（含 dry-run / 409 真冲突 / 200 成功），
    False 表示 PR 状态变了不算配额。
    """
    n = pr["number"]
    head_sha = pr["head"]["sha"]

    # 双重 read
    cur = re_read_pr(api, n)
    if cur.get("merged") or cur.get("state") != "open":
        log_event("update_skip_state_changed", pr=n, state=cur.get("state"))
        return False
    # race 消解判据：用 outdated（base.sha vs merge_base），不用 mergeable=True
    # 因为 block_on_outdated_branch=True 时 mergeable=True 仍可能 outdated（见 ERR-20260519-004）
    base_sha = (cur.get("base") or {}).get("sha")
    merge_base = cur.get("merge_base")
    if base_sha and merge_base and base_sha == merge_base:
        log_event("update_skip_no_longer_outdated", pr=n)
        print(f"  [skip] PR #{n} base 已跟 merge_base 一致（peer daemon 先 update 了），下 tick 合")
        return False

    if DRY_RUN:
        print(f"  [dry-run] 跳过真 update PR #{n} (head={head_sha[:8]})")
        return True

    # Gitea API: style 是 **query param** 不是 body（Gitea swagger 规约）。
    # 写 body 里 Gitea 默默忽略 → 用默认 style=merge——当前行为是对的，但显式写 query 防未来默认值变。
    code, txt = api.post(f"/pulls/{n}/update?style=merge", {})
    if code in (200, 201):
        print(f"  [updated] PR #{n} base 已 merge 进分支，等 CI 重跑")
        log_event("updated", pr=n, head=head_sha)
        return True
    if code == 409:
        # 真 conflict——加 label 跳过直到人工解决
        log_event("update_conflict", pr=n, response=txt[:200])
        print(f"  [conflict] PR #{n} update 出 409，加 label '{LABEL_NEEDS_REBASE}'")
        # ensure_label 做 name→id + 自动创建（防"label 不存在 422"+"老 Gitea API 要 IDs"两个坑，
        # 见 .learnings/2026-05-14-gitea-label-id-hardcoding-trap.md + CLAUDE.md §11）
        label_id = ensure_label(api, LABEL_NEEDS_REBASE, LABEL_NEEDS_REBASE_COLOR, LABEL_NEEDS_REBASE_DESC)
        if label_id is None:
            # label 创建失败 → 不占 quota（return False），否则下 tick 同 PR 再撞 → hot loop。
            # PR 没加 label，evaluate() 下 tick 仍会归类成 ACT_UPDATE 再试一次——但 quota
            # 还在，至少能让别的 ready PR 被 update 处理掉，整体不卡死。
            log_event("update_label_failed_giving_up_quota", pr=n)
            # 同时打 stderr ERROR：systemd journal priority=err，便于将来挂监控告警 hook
            print(f"ERROR: PR #{n}: ensure_label '{LABEL_NEEDS_REBASE}' 失败，conflict 标记功能"
                  f"静默失效——每 tick 会重撞同 PR。需人工：检查 Gitea label API / repo 权限",
                  file=sys.stderr)
            return False
        lcode, ltxt = api.post(f"/issues/{n}/labels", {"labels": [label_id]})
        if lcode >= 300:
            log_event("update_label_attach_failed", pr=n, code=lcode, response=ltxt[:200])
            print(f"  [warn] PR #{n}: 加 label '{LABEL_NEEDS_REBASE}' 失败: HTTP {lcode} {ltxt[:200]}，放弃配额")
            return False
        return True  # label 加成功，占了一次 update 配额，下 tick evaluate 会因 LABEL_NEEDS_REBASE 而 skip
    log_event("update_error", pr=n, code=code, response=txt[:200])
    raise RuntimeError(f"update PR #{n} → {code}: {txt[:200]}")


# ──────────────────── 主循环 ────────────────────


def main() -> int:
    token = get_token()
    repo = os.environ.get("GITEA_REPO") or detect_repo()
    api = Api(token, repo)

    required = get_required_check_names(api, TARGET_BRANCH)
    if not required:
        print(f"WARN: {TARGET_BRANCH} 分支保护没配 required status check，自动合无意义，退出", file=sys.stderr)
        return 0
    print(f"[info] repo={repo} required_checks={sorted(required)}")

    prs = list_open_prs(api)
    print(f"[info] 发现 {len(prs)} 个 open PR → {TARGET_BRANCH}")

    merged = 0
    updated = 0
    for pr in prs:
        n = pr["number"]
        title = pr.get("title", "")
        author = pr.get("user", {}).get("login", "?")
        action, reason = evaluate(api, pr, required)

        marker = {"merge": "🔀", "update": "⬆️", "skip": "·"}.get(action, "?")
        print(f"  {marker} #{n} [{author}] {title[:60]} — {reason}")

        if action == ACT_MERGE and merged < MAX_MERGE_PER_TICK:
            try:
                if merge_pr(api, pr):
                    merged += 1
            except Exception as e:
                print(f"  [error] 合 #{n} 失败: {e}", file=sys.stderr)
                log_event("merge_exception", pr=n, error=str(e))
        elif action == ACT_UPDATE and updated < MAX_UPDATE_PER_TICK:
            try:
                if update_pr_branch(api, pr):
                    updated += 1
            except Exception as e:
                print(f"  [error] update #{n} 失败: {e}", file=sys.stderr)
                log_event("update_exception", pr=n, error=str(e))

        # 双配额都用完就停（避免无谓 evaluate 后面 PR）
        if merged >= MAX_MERGE_PER_TICK and updated >= MAX_UPDATE_PER_TICK:
            break

    if DRY_RUN:
        print(f"[done] DRY_RUN merged={merged} updated={updated}（未真做）")
    else:
        print(f"[done] merged={merged} updated={updated}")
    return 0


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