"""task-2509 회귀 테스트 — merge_queue_executor 12 케이스 + 보너스 2.

QA 담당: 모리건(Morrigan)
대상: utils/merge_queue_executor.py

케이스 목록:
  TC-01  queue head 아닌 경우 → WAITING_FOR_PREDECESSOR
  TC-02  queue head + 10조건 PASS → AUTO_MERGE_ALLOWED
  TC-03  BEHIND 상태 → merge sync (rebase X)
  TC-04  HEAD SHA 변경 → HEAD_SHA_LOCK_BROKEN
  TC-05  effective diff 오염 → DIFF_CONTAMINATION_REPLACEMENT + replacement_pr_runner
  TC-06  forbidden path → BLOCKED_WITH_REASON + CRITICAL_FORBIDDEN_PATH
  TC-07  CI failure → CI_FAILURE_BLOCK
  TC-08a Gemini auto_triage_candidate → GEMINI_UNRESOLVED_BLOCK
  TC-08b Gemini critical_scope_expansion → BLOCKED_WITH_REASON + CRITICAL_GEMINI_SCOPE_EXPANSION
  TC-09a mergeStateStatus=DIRTY → MERGE_STATE_NOT_CLEAN
  TC-09b mergeStateStatus=BLOCKED → BLOCKED_WITH_REASON + CRITICAL_BLOCK_OVERRIDE
  TC-10  post-merge smoke FAIL → BLOCKED_WITH_REASON + CRITICAL_POST_MERGE_SMOKE
  TC-11  post-merge smoke PASS → AUTO_MERGE_SUCCESS
  TC-12  후행 PR stale 재검증 state machine
  BONUS-1  assert_no_forbidden_git_flags: --admin → RuntimeError
  BONUS-2  assert_no_forbidden_git_flags: rebase → RuntimeError
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path
import pytest

# workspace root → sys.path (force position 0 to shadow /home/jay/workspace/utils)
WORKSPACE = Path(__file__).resolve().parent.parent.parent
if str(WORKSPACE) in sys.path:
    sys.path.remove(str(WORKSPACE))
sys.path.insert(0, str(WORKSPACE))

from utils.merge_queue_executor import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    AUTO_MERGE_ALLOWED,
    AUTO_MERGE_SUCCESS,
    BLOCKED_WITH_REASON,
    CI_FAILURE_BLOCK,
    CRITICAL_BLOCK_OVERRIDE,
    CRITICAL_FORBIDDEN_PATH,
    CRITICAL_GEMINI_SCOPE_EXPANSION,
    CRITICAL_POST_MERGE_SMOKE,
    DIFF_CONTAMINATION_REPLACEMENT,
    GEMINI_UNRESOLVED_BLOCK,
    HEAD_SHA_LOCK_BROKEN,
    MERGE_STATE_NOT_CLEAN,
    REPLACEMENT_PR_RUNNER_HOOK,
    WAITING_FOR_PREDECESSOR,
    ExecutorContext,
    QueueDecision,
    TaskSpec,
    assert_no_forbidden_git_flags,
    evaluate_pr,
    recheck_following_prs,
    verify_head_lock_then_merge,
)


# ─── helpers ──────────────────────────────────────────────────────────────────

def cp(returncode: int = 0, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
    """CompletedProcess 생성 헬퍼."""
    return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)


def make_runner(returns_by_args: dict):
    """args 패턴별 CompletedProcess 반환 fake runner.

    key = frozenset of arg tokens to match,
    value = CompletedProcess to return.
    Unmatched → returncode=0, empty stdout/stderr.
    """
    calls: list[dict] = []

    def runner(args, cwd=None, timeout=60):
        calls.append({"args": list(args), "cwd": cwd, "timeout": timeout})
        for pattern, completed in returns_by_args.items():
            if all(p in args for p in pattern):
                return completed
        return cp()

    runner.calls = calls  # type: ignore[attr-defined]  # pyright: ignore[reportFunctionMemberAccess]
    return runner


# ─── fixtures ─────────────────────────────────────────────────────────────────

EXPECTED_FILES = [
    "utils/merge_queue_executor.py",
    "tests/regression/test_merge_queue_executor_2509.py",
]

CLEAN_MERGE_STATE = {
    "mergeStateStatus": "CLEAN",
    "headRefOid": "sha-clean-001",
    "baseRefName": "main",
}

CI_OK = {"status": "SUCCESS", "details": ["SUCCESS"], "raw": []}
GEMINI_OK = {"status": "ok", "unresolved": [], "hook": None}


@pytest.fixture
def serial_task_spec() -> TaskSpec:
    return TaskSpec(
        task_id="task-2509",
        expected_files=list(EXPECTED_FILES),
        risk_area="merge_queue/auto_merge/conflict_recovery",
        dependency=[],
        parallel_policy="serial_only",
        merge_queue_position=4,
        stale_recheck_required=True,
        cherry_pick_allowed=False,
        smoke_command=None,
    )


def _make_ctx(
    runner=None,
    pr_workdir: str | None = None,
    smoke_command=None,
    fixture_main_sha: str = "main-sha-fixture-001",
    main_log_grep=None,
) -> ExecutorContext:
    """기본 ExecutorContext 생성 헬퍼."""
    if runner is None:
        runner = make_runner({})
    return ExecutorContext(
        runner=runner,
        pr_workdir=pr_workdir,
        smoke_command=smoke_command,
        no_audit=True,
        fixture_main_sha=fixture_main_sha,
        main_log_grep=main_log_grep,
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-01: queue head 아닌 경우 → WAITING_FOR_PREDECESSOR
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc01_waiting_for_predecessor(serial_task_spec: TaskSpec) -> None:
    """dependency=["task-9999.merged"]이고 main_log_grep이 False 반환하면
    evaluate_pr이 WAITING_FOR_PREDECESSOR를 결정해야 한다."""
    spec = serial_task_spec
    spec.dependency = ["task-9999.merged"]

    # main_log_grep: task-9999 → False (미머지)
    def _not_merged(_task_id: str) -> bool:
        return False

    ctx = _make_ctx(main_log_grep=_not_merged)

    decision = evaluate_pr(
        pr_number=99,
        task_spec=spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == WAITING_FOR_PREDECESSOR, (
        f"expected WAITING_FOR_PREDECESSOR, got {decision.decision}"
    )
    assert "task-9999" in decision.reason, f"reason must mention task-9999: {decision.reason}"


# ═══════════════════════════════════════════════════════════════════════════════
# TC-02: queue head + 10조건 PASS → AUTO_MERGE_ALLOWED
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc02_all_gates_pass_auto_merge_allowed(serial_task_spec: TaskSpec) -> None:
    """모든 조건 정상 → evaluate_pr이 AUTO_MERGE_ALLOWED를 반환해야 한다."""
    ctx = _make_ctx()

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == AUTO_MERGE_ALLOWED, (
        f"expected AUTO_MERGE_ALLOWED, got {decision.decision} / reason={decision.reason}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-03: BEHIND 상태 → merge sync (rebase 미호출)
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc03_behind_state_uses_merge_not_rebase(serial_task_spec: TaskSpec) -> None:
    """mergeStateStatus=BEHIND + pr_workdir 설정 시
    runner가 ['git','merge','origin/main','--no-edit']로 호출돼야 하고
    'rebase' 명령은 호출되지 않아야 한다."""
    pr_workdir = "/tmp/fake-pr-workdir"
    runner = make_runner({
        ("git", "merge", "origin/main"): cp(returncode=0, stdout="Merge made by ort."),
    })
    ctx = _make_ctx(runner=runner, pr_workdir=pr_workdir)

    behind_merge_state = {
        "mergeStateStatus": "BEHIND",
        "headRefOid": "sha-pr-behind",
        "baseRefName": "main",
    }

    evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-behind",
        effective_files=list(EXPECTED_FILES),
        merge_state=behind_merge_state,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    # merge 호출 확인
    merge_calls = [c for c in runner.calls if "merge" in c["args"] and "git" in c["args"]]  # pyright: ignore[reportFunctionMemberAccess]
    assert merge_calls, "git merge origin/main --no-edit must have been called"

    merge_args = merge_calls[0]["args"]
    assert "origin/main" in merge_args, f"expected 'origin/main' in merge args: {merge_args}"
    assert "--no-edit" in merge_args, f"expected '--no-edit' in merge args: {merge_args}"

    # rebase 호출 없음 확인
    rebase_calls = [c for c in runner.calls if "rebase" in c["args"]]  # pyright: ignore[reportFunctionMemberAccess]
    assert not rebase_calls, f"rebase must NOT be called, but got: {rebase_calls}"


# ═══════════════════════════════════════════════════════════════════════════════
# TC-04: HEAD SHA 변경 → HEAD_SHA_LOCK_BROKEN
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc04_head_sha_changed_breaks_lock(serial_task_spec: TaskSpec) -> None:  # noqa: ARG001
    """pr_head_sha_start와 fetch_pr_head_at_merge가 다른 값 반환 시
    verify_head_lock_then_merge가 HEAD_SHA_LOCK_BROKEN을 반환해야 한다."""
    ctx = _make_ctx()

    # 먼저 AUTO_MERGE_ALLOWED 결정 획득
    decision = QueueDecision(
        decision=AUTO_MERGE_ALLOWED,
        pr_number=57,
        task_id="task-2509",
        pr_head_sha_start="sha-start-111",
    )

    # merge 직전에 SHA가 바뀐 상황 시뮬레이션
    def _changed_sha(_pr_number: int) -> str:
        return "sha-changed-999"  # 다른 SHA

    result = verify_head_lock_then_merge(
        decision=decision,
        pr_number=57,
        ctx=ctx,
        fetch_pr_head_at_merge=_changed_sha,
        dry_run=True,
    )

    assert result.decision == HEAD_SHA_LOCK_BROKEN, (
        f"expected HEAD_SHA_LOCK_BROKEN, got {result.decision}"
    )
    assert "sha-start-111" in result.reason, f"reason must contain start sha: {result.reason}"
    assert "sha-changed-999" in result.reason, f"reason must contain new sha: {result.reason}"


# ═══════════════════════════════════════════════════════════════════════════════
# TC-05: effective diff 오염 → DIFF_CONTAMINATION_REPLACEMENT + replacement_pr_runner
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc05_diff_contamination_replacement(serial_task_spec: TaskSpec) -> None:
    """effective_files에 expected에 없는 추가 파일 → DIFF_CONTAMINATION_REPLACEMENT
    + reason에 'replacement_pr_runner' 포함."""
    ctx = _make_ctx()

    contaminated_files = list(EXPECTED_FILES) + ["utils/rogue_extra_file.py"]

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=contaminated_files,
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == DIFF_CONTAMINATION_REPLACEMENT, (
        f"expected DIFF_CONTAMINATION_REPLACEMENT, got {decision.decision}"
    )
    assert REPLACEMENT_PR_RUNNER_HOOK in decision.reason, (
        f"reason must mention '{REPLACEMENT_PR_RUNNER_HOOK}': {decision.reason}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-06: forbidden path → BLOCKED_WITH_REASON + CRITICAL_FORBIDDEN_PATH
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc06_forbidden_path_invasion(serial_task_spec: TaskSpec) -> None:
    """effective_files에 '.github/workflows/ci.yml' 포함 →
    BLOCKED_WITH_REASON + critical_code=CRITICAL_FORBIDDEN_PATH."""
    ctx = _make_ctx()

    files_with_forbidden = list(EXPECTED_FILES) + [".github/workflows/ci.yml"]

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=files_with_forbidden,
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == BLOCKED_WITH_REASON, (
        f"expected BLOCKED_WITH_REASON, got {decision.decision}"
    )
    assert decision.critical_code == CRITICAL_FORBIDDEN_PATH, (
        f"expected critical_code=CRITICAL_FORBIDDEN_PATH, got {decision.critical_code}"
    )
    assert ".github/workflows/ci.yml" in decision.forbidden_paths, (
        f"forbidden_paths must contain the invaded file: {decision.forbidden_paths}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-07: CI failure → CI_FAILURE_BLOCK
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc07_ci_failure_block(serial_task_spec: TaskSpec) -> None:
    """ci_state.status=CI_FAILURE_BLOCK 시 결정이 CI_FAILURE_BLOCK이어야 한다."""
    ctx = _make_ctx()

    ci_fail = {"status": CI_FAILURE_BLOCK, "details": ["FAILURE"], "raw": []}

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=CLEAN_MERGE_STATE,
        ci_state=ci_fail,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == CI_FAILURE_BLOCK, (
        f"expected CI_FAILURE_BLOCK, got {decision.decision}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-08a: Gemini auto_triage_candidate → GEMINI_UNRESOLVED_BLOCK
# TC-08b: Gemini critical_scope_expansion → BLOCKED_WITH_REASON + CRITICAL_GEMINI_SCOPE_EXPANSION
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc08a_gemini_auto_triage_candidate(serial_task_spec: TaskSpec) -> None:
    """gemini_state.status=auto_triage_candidate → GEMINI_UNRESOLVED_BLOCK."""
    ctx = _make_ctx()

    gemini_auto = {
        "status": "auto_triage_candidate",
        "unresolved": [{"path": "utils/merge_queue_executor.py", "body": "style nit"}],
        "inside": [{"path": "utils/merge_queue_executor.py", "body": "style nit"}],
        "hook": "auto_gemini_triage",
    }

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=gemini_auto,
        ctx=ctx,
    )

    assert decision.decision == GEMINI_UNRESOLVED_BLOCK, (
        f"expected GEMINI_UNRESOLVED_BLOCK, got {decision.decision}"
    )


def test_tc08b_gemini_critical_scope_expansion(serial_task_spec: TaskSpec) -> None:
    """gemini_state.status=critical_scope_expansion →
    BLOCKED_WITH_REASON + critical_code=CRITICAL_GEMINI_SCOPE_EXPANSION."""
    ctx = _make_ctx()

    gemini_critical = {
        "status": "critical_scope_expansion",
        "unresolved": [{"path": "some/external/file.py", "body": "real bug outside expected"}],
        "outside": [{"path": "some/external/file.py", "body": "real bug outside expected"}],
        "hook": "critical_escalation_reporter",
        "critical_code": CRITICAL_GEMINI_SCOPE_EXPANSION,
    }

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=CLEAN_MERGE_STATE,
        ci_state=CI_OK,
        gemini_state=gemini_critical,
        ctx=ctx,
    )

    assert decision.decision == BLOCKED_WITH_REASON, (
        f"expected BLOCKED_WITH_REASON, got {decision.decision}"
    )
    assert decision.critical_code == CRITICAL_GEMINI_SCOPE_EXPANSION, (
        f"expected critical_code=CRITICAL_GEMINI_SCOPE_EXPANSION, got {decision.critical_code}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-09a: mergeStateStatus=DIRTY → MERGE_STATE_NOT_CLEAN
# TC-09b: mergeStateStatus=BLOCKED → BLOCKED_WITH_REASON + CRITICAL_BLOCK_OVERRIDE
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc09a_merge_state_dirty(serial_task_spec: TaskSpec) -> None:
    """mergeStateStatus=DIRTY → MERGE_STATE_NOT_CLEAN 또는 BLOCKED_WITH_REASON."""
    ctx = _make_ctx()

    dirty_state = {
        "mergeStateStatus": "DIRTY",
        "headRefOid": "sha-pr-001",
        "baseRefName": "main",
    }

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=dirty_state,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision in (MERGE_STATE_NOT_CLEAN, BLOCKED_WITH_REASON), (
        f"expected MERGE_STATE_NOT_CLEAN or BLOCKED_WITH_REASON, got {decision.decision}"
    )


def test_tc09b_merge_state_blocked(serial_task_spec: TaskSpec) -> None:
    """mergeStateStatus=BLOCKED → BLOCKED_WITH_REASON + critical_code=CRITICAL_BLOCK_OVERRIDE."""
    ctx = _make_ctx()

    blocked_state = {
        "mergeStateStatus": "BLOCKED",
        "headRefOid": "sha-pr-001",
        "baseRefName": "main",
    }

    decision = evaluate_pr(
        pr_number=57,
        task_spec=serial_task_spec,
        pr_head_sha="sha-pr-001",
        effective_files=list(EXPECTED_FILES),
        merge_state=blocked_state,
        ci_state=CI_OK,
        gemini_state=GEMINI_OK,
        ctx=ctx,
    )

    assert decision.decision == BLOCKED_WITH_REASON, (
        f"expected BLOCKED_WITH_REASON, got {decision.decision}"
    )
    assert decision.critical_code == CRITICAL_BLOCK_OVERRIDE, (
        f"expected critical_code=CRITICAL_BLOCK_OVERRIDE, got {decision.critical_code}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-10: post-merge smoke FAIL → BLOCKED_WITH_REASON + CRITICAL_POST_MERGE_SMOKE
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc10_post_merge_smoke_failure(serial_task_spec: TaskSpec) -> None:
    """squash merge 성공(returncode=0) 후 smoke 실패(returncode=1) →
    BLOCKED_WITH_REASON + critical_code=CRITICAL_POST_MERGE_SMOKE."""
    smoke_cmd = ["python3", "-m", "pytest", "tests/smoke"]
    serial_task_spec.smoke_command = smoke_cmd

    runner = make_runner({
        # squash merge → 성공
        ("gh", "pr", "merge", "57", "--squash"): cp(returncode=0, stdout="PR merged"),
        # smoke command → 실패
        tuple(smoke_cmd): cp(returncode=1, stderr="SMOKE FAILED: 3 tests failed"),
    })

    ctx = ExecutorContext(
        runner=runner,
        pr_workdir=None,
        smoke_command=smoke_cmd,
        no_audit=True,
        fixture_main_sha="main-sha-fixture-001",
    )

    # AUTO_MERGE_ALLOWED 결정 생성
    allowed_decision = QueueDecision(
        decision=AUTO_MERGE_ALLOWED,
        pr_number=57,
        task_id="task-2509",
        pr_head_sha_start="sha-pr-smoke-001",
    )

    # SHA 변경 없음 (lock 유지)
    def _same_sha(_pr_number: int) -> str:
        return "sha-pr-smoke-001"

    result = verify_head_lock_then_merge(
        decision=allowed_decision,
        pr_number=57,
        ctx=ctx,
        fetch_pr_head_at_merge=_same_sha,
        dry_run=False,  # 실제 머지 수행
    )

    assert result.decision == BLOCKED_WITH_REASON, (
        f"expected BLOCKED_WITH_REASON (smoke fail), got {result.decision}"
    )
    assert result.critical_code == CRITICAL_POST_MERGE_SMOKE, (
        f"expected critical_code=CRITICAL_POST_MERGE_SMOKE, got {result.critical_code}"
    )
    assert result.smoke_status == "FAIL", (
        f"expected smoke_status=FAIL, got {result.smoke_status}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-11: post-merge smoke PASS → AUTO_MERGE_SUCCESS
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc11_post_merge_smoke_pass(serial_task_spec: TaskSpec) -> None:
    """squash merge + smoke 모두 PASS → verify_head_lock_then_merge가 AUTO_MERGE_SUCCESS 반환."""
    smoke_cmd = ["python3", "-m", "pytest", "tests/smoke"]
    serial_task_spec.smoke_command = smoke_cmd

    runner = make_runner({
        # squash merge → 성공
        ("gh", "pr", "merge", "57", "--squash"): cp(returncode=0, stdout="PR merged"),
        # smoke → PASS
        tuple(smoke_cmd): cp(returncode=0, stdout="5 passed, 0 failed"),
    })

    ctx = ExecutorContext(
        runner=runner,
        pr_workdir=None,
        smoke_command=smoke_cmd,
        no_audit=True,
        fixture_main_sha="main-sha-fixture-001",
    )

    allowed_decision = QueueDecision(
        decision=AUTO_MERGE_ALLOWED,
        pr_number=57,
        task_id="task-2509",
        pr_head_sha_start="sha-pr-pass-001",
    )

    def _same_sha(_pr_number: int) -> str:
        return "sha-pr-pass-001"

    result = verify_head_lock_then_merge(
        decision=allowed_decision,
        pr_number=57,
        ctx=ctx,
        fetch_pr_head_at_merge=_same_sha,
        dry_run=False,
    )

    assert result.decision == AUTO_MERGE_SUCCESS, (
        f"expected AUTO_MERGE_SUCCESS, got {result.decision} / reason={result.reason}"
    )
    assert result.smoke_status == "PASS", (
        f"expected smoke_status=PASS, got {result.smoke_status}"
    )


# ═══════════════════════════════════════════════════════════════════════════════
# TC-12: 후행 PR stale 재검증 state machine
# ═══════════════════════════════════════════════════════════════════════════════
def test_tc12_recheck_following_prs_state_machine() -> None:
    """recheck_following_prs 호출 시 mergeStateStatus에 따라
    needs_recheck/behind/conflict/blocked 필드가 올바르게 설정돼야 한다.

    케이스:
      pr_number=55 : BEHIND  → needs_recheck=True, behind=True
      pr_number=56 : DIRTY   → conflict=True
      pr_number=77 : BLOCKED → blocked=True
      pr_number=88 : CLEAN   → needs_recheck=False
    """
    import json

    def _make_gh_response(status: str) -> subprocess.CompletedProcess:
        payload = {"mergeStateStatus": status, "headRefOid": "sha-stub", "baseRefName": "main"}
        return cp(returncode=0, stdout=json.dumps(payload))

    runner = make_runner({
        ("55",): _make_gh_response("BEHIND"),
        ("56",): _make_gh_response("DIRTY"),
        ("77",): _make_gh_response("BLOCKED"),
        ("88",): _make_gh_response("CLEAN"),
    })

    queue = [
        {"pr_number": 55},
        {"pr_number": 56},
        {"pr_number": 77},
        {"pr_number": 88},
    ]

    states = recheck_following_prs(queue=queue, runner=runner)

    by_pr = {s["pr_number"]: s for s in states}

    # BEHIND → needs_recheck=True, behind=True
    s55 = by_pr[55]
    assert s55["needs_recheck"] is True, f"PR#55 BEHIND: needs_recheck must be True: {s55}"
    assert s55["behind"] is True, f"PR#55 BEHIND: behind must be True: {s55}"
    assert s55["conflict"] is False, f"PR#55 BEHIND: conflict must be False: {s55}"

    # DIRTY → conflict=True, needs_recheck=True
    s56 = by_pr[56]
    assert s56["conflict"] is True, f"PR#56 DIRTY: conflict must be True: {s56}"
    assert s56["needs_recheck"] is True, f"PR#56 DIRTY: needs_recheck must be True: {s56}"

    # BLOCKED → blocked=True, needs_recheck=True
    s77 = by_pr[77]
    assert s77["blocked"] is True, f"PR#77 BLOCKED: blocked must be True: {s77}"
    assert s77["needs_recheck"] is True, f"PR#77 BLOCKED: needs_recheck must be True: {s77}"

    # CLEAN → needs_recheck=False
    s88 = by_pr[88]
    assert s88["needs_recheck"] is False, f"PR#88 CLEAN: needs_recheck must be False: {s88}"
    assert s88["behind"] is False, f"PR#88 CLEAN: behind must be False: {s88}"
    assert s88["conflict"] is False, f"PR#88 CLEAN: conflict must be False: {s88}"
    assert s88["blocked"] is False, f"PR#88 CLEAN: blocked must be False: {s88}"


# ═══════════════════════════════════════════════════════════════════════════════
# BONUS-1: assert_no_forbidden_git_flags → --admin RuntimeError
# ═══════════════════════════════════════════════════════════════════════════════
def test_bonus1_admin_flag_raises() -> None:
    """assert_no_forbidden_git_flags(['gh','pr','merge','--admin']) → RuntimeError."""
    with pytest.raises(RuntimeError, match="FORBIDDEN_GIT_FLAGS"):
        assert_no_forbidden_git_flags(["gh", "pr", "merge", "--admin"])


# ═══════════════════════════════════════════════════════════════════════════════
# BONUS-2: assert_no_forbidden_git_flags → rebase RuntimeError
# ═══════════════════════════════════════════════════════════════════════════════
def test_bonus2_rebase_raises() -> None:
    """assert_no_forbidden_git_flags(['git','rebase','main']) → RuntimeError."""
    with pytest.raises(RuntimeError, match="REBASE_FORBIDDEN"):
        assert_no_forbidden_git_flags(["git", "rebase", "main"])
