#!/usr/bin/env python3
"""Unit tests for cmd_promote invariants — mock Api, no network.

Covers AI review sustained suggestion (PR #384): T13-T16 in gitea.test.py
hit real API with --dry-run + UP_TO_DATE state, but never exercise the
error paths (E_NOT_FF_ABLE / E_CI_NOT_GREEN / E_POST_MERGE_DRIFT) because
real API can't be driven into those states reliably.

Run: python3 testing/ops/gitea_promote_unit.test.py

Loads `scripts/ops/gitea` as a module via importlib (no .py extension,
no sys.path side-effect on production import order).
"""
from __future__ import annotations

import importlib.util
import io
import json
import sys
from argparse import Namespace
from contextlib import redirect_stderr, redirect_stdout
from importlib.machinery import SourceFileLoader
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / "scripts" / "ops" / "gitea"
sys.path.insert(0, str(REPO / "scripts" / "ops"))
# scripts/ops/gitea 无 .py 后缀 — importlib.spec_from_file_location 无法
# 自动推断 loader（返回 None）。显式传 SourceFileLoader 让 importlib 把
# shebang 文件当 Python 模块加载。
loader = SourceFileLoader("gitea_cli", str(SCRIPT))
spec = importlib.util.spec_from_loader("gitea_cli", loader)
gitea = importlib.util.module_from_spec(spec)
loader.exec_module(gitea)


class FakeApi:
    """Canned response by (method, path) — first prefix match wins."""

    def __init__(self, responses: dict):
        self.responses = responses
        self.calls: list = []

    def _dispatch(self, method: str, path: str):
        self.calls.append((method, path))
        for (m, p), resp in self.responses.items():
            if m == method and path.startswith(p):
                return resp
        return (500, json.dumps({"error": f"no fake for {method} {path}"}))

    def get(self, path, params=None):
        return self._dispatch("GET", path)

    def post(self, path, body=None):
        return self._dispatch("POST", path)


def make_args(target="uat", dry_run=False, force=False, verbose=0, json_mode=True):
    return Namespace(target=target, dry_run=dry_run, force_anyway=force,
                     verbose=verbose, json=json_mode)


passed = 0
failed = 0


def check(label: str, cond: bool, detail: str = ""):
    global passed, failed
    if cond:
        passed += 1
        print(f"  ✓ {label}")
    else:
        failed += 1
        print(f"  ✗ {label}{(': ' + detail) if detail else ''}")


# ---- T1: E_NOT_FF_ABLE — base 比 head 多 commit ----
print("[T1] E_NOT_FF_ABLE: behind_by > 0 → exit 1")
api = FakeApi({
    ("GET", "/branches/develop"): (200, json.dumps({"commit": {"id": "a" * 40}})),
    ("GET", "/branches/staging"): (200, json.dumps({"commit": {"id": "b" * 40}})),
    ("GET", "/compare/staging...develop"): (200, json.dumps(
        {"behind_by": 3, "ahead_by": 5, "commits": []})),
})
err = io.StringIO()
out = io.StringIO()
with redirect_stderr(err), redirect_stdout(out):
    rc = gitea.cmd_promote(make_args(), api)
check("exit 1 (user error)", rc == 1, f"got {rc}")
check("stderr contains E_NOT_FF_ABLE", "E_NOT_FF_ABLE" in err.getvalue())
check("no POST calls (never reached merge)",
      all(m == "GET" for m, _ in api.calls))


# ---- T2: E_CI_NOT_GREEN — CI state != success → exit 1 ----
print("[T2] E_CI_NOT_GREEN: state=failure → exit 1")
api = FakeApi({
    ("GET", "/branches/develop"): (200, json.dumps({"commit": {"id": "c" * 40}})),
    ("GET", "/branches/staging"): (200, json.dumps({"commit": {"id": "d" * 40}})),
    ("GET", "/compare/staging...develop"): (200, json.dumps(
        {"behind_by": 0, "ahead_by": 5, "commits": []})),
    ("GET", f"/commits/{'c' * 40}/status"): (200, json.dumps({
        "state": "failure",
        "statuses": [
            {"status": "failure", "context": "quality-gates / build-check"},
            {"status": "skipped", "context": "quality-gates / migration-file-count"},
        ],
    })),
})
err = io.StringIO()
with redirect_stderr(err), redirect_stdout(io.StringIO()):
    rc = gitea.cmd_promote(make_args(), api)
check("exit 1 (user error)", rc == 1, f"got {rc}")
check("stderr contains E_CI_NOT_GREEN", "E_CI_NOT_GREEN" in err.getvalue())
check("skipped status not listed as failing",
      "migration-file-count" not in err.getvalue(),
      f"skipped 误列：{err.getvalue()[:200]}")


# ---- T3: --force-anyway 绕过 CI 红 ----
print("[T3] --force-anyway 绕过 CI 红，进入后续路径")
api = FakeApi({
    ("GET", "/branches/develop"): (200, json.dumps({"commit": {"id": "e" * 40}})),
    ("GET", "/branches/staging"): (200, json.dumps({"commit": {"id": "f" * 40}})),
    ("GET", "/compare/staging...develop"): (200, json.dumps(
        {"behind_by": 0, "ahead_by": 1, "commits": []})),
    ("GET", f"/commits/{'e' * 40}/status"): (200, json.dumps({
        "state": "failure", "statuses": []})),
})
# dry-run + force-anyway：应在 PR 查找/创建之前因 dry-run 退出
with redirect_stderr(io.StringIO()), redirect_stdout(io.StringIO()):
    rc = gitea.cmd_promote(make_args(dry_run=True, force=True), api)
check("--force-anyway + dry-run 不被 CI 拒", rc == 0, f"got {rc}")


# ---- T4: E_POST_MERGE_DRIFT — merge 后 base tip != head sha ----
print("[T4] E_POST_MERGE_DRIFT: post-merge base 不等于 head → exit 2")
HEAD_SHA = "1" * 40
BASE_SHA = "2" * 40
DRIFTED_SHA = "9" * 40  # post-merge tip 跟 head 不等
api = FakeApi({
    ("GET", "/branches/develop"): [
        (200, json.dumps({"commit": {"id": HEAD_SHA}})),
        (200, json.dumps({"commit": {"id": HEAD_SHA}})),  # 兜底
    ],
    ("GET", "/branches/staging"): (200, json.dumps({"commit": {"id": BASE_SHA}})),
    ("GET", "/compare/staging...develop"): (200, json.dumps(
        {"behind_by": 0, "ahead_by": 1, "commits": []})),
    ("GET", f"/commits/{HEAD_SHA}/status"): (200, json.dumps(
        {"state": "success", "statuses": []})),
    ("GET", "/pulls"): (200, json.dumps([{
        "number": 999, "head": {"ref": "develop"}, "base": {"ref": "staging"},
    }])),
    ("POST", "/pulls/999/reviews"): (200, json.dumps({"id": 1})),
    ("POST", "/pulls/999/merge"): (200, ""),
})
# post-merge 校验时再读 /branches/staging — 让它返回 drift 值
# FakeApi 简化版每个 key 单一响应，要让第二次读 staging 返回 drift，patch _branch_tip
original_branch_tip = gitea._branch_tip
calls = {"count": 0}


def fake_branch_tip(api_, branch):
    if branch == "staging":
        calls["count"] += 1
        # 第一次（pre-merge）返回 BASE_SHA；第二次（post-merge）返回 drift
        return BASE_SHA if calls["count"] == 1 else DRIFTED_SHA
    return HEAD_SHA


gitea._branch_tip = fake_branch_tip
try:
    err = io.StringIO()
    with redirect_stderr(err), redirect_stdout(io.StringIO()):
        rc = gitea.cmd_promote(make_args(), api)
    check("exit 2 (sys error)", rc == 2, f"got {rc}")
    check("stderr contains E_POST_MERGE_DRIFT",
          "E_POST_MERGE_DRIFT" in err.getvalue())
finally:
    gitea._branch_tip = original_branch_tip


# ---- T5: 成功路径 happy case ----
print("[T5] 成功路径：FF-able + CI 绿 + merge OK + post-merge 对齐")
HEAD_SHA = "abc" * 13 + "a"  # 40 chars
BASE_PRE_SHA = "def" * 13 + "d"
api = FakeApi({
    ("GET", "/compare/staging...develop"): (200, json.dumps(
        {"behind_by": 0, "ahead_by": 2, "commits": []})),
    ("GET", f"/commits/{HEAD_SHA}/status"): (200, json.dumps(
        {"state": "success", "statuses": []})),
    ("GET", "/pulls"): (200, json.dumps([{
        "number": 777, "head": {"ref": "develop"}, "base": {"ref": "staging"},
    }])),
    ("POST", "/pulls/777/reviews"): (200, json.dumps({"id": 1})),
    ("POST", "/pulls/777/merge"): (200, ""),
})
staging_calls = {"count": 0}


def fake_branch_tip_happy(api_, branch):
    # head 永远返回 HEAD_SHA；staging 第一次（pre-merge）返回 base，
    # 第二次（post-merge）返回 HEAD_SHA（对齐 = 成功）
    if branch == "staging":
        staging_calls["count"] += 1
        return BASE_PRE_SHA if staging_calls["count"] == 1 else HEAD_SHA
    return HEAD_SHA


gitea._branch_tip = fake_branch_tip_happy
try:
    out = io.StringIO()
    with redirect_stdout(out), redirect_stderr(io.StringIO()):
        rc = gitea.cmd_promote(make_args(), api)
    check("exit 0", rc == 0, f"got {rc}")
    try:
        result = json.loads(out.getvalue())
        check("output JSON has promoted=true", result.get("promoted") is True)
        check("output JSON has pr=777", result.get("pr") == 777)
    except json.JSONDecodeError:
        failed += 1
        print(f"  ✗ stdout not JSON: {out.getvalue()[:200]}")
finally:
    gitea._branch_tip = original_branch_tip


# ---- summary ----
print(f"\n[summary] {passed} passed, {failed} failed")
sys.exit(0 if failed == 0 else 1)
