"""anu_v2.tests.test_merge_queue_executor_2531 — 9 회귀 (회장 §명시 9 기능 1:1).

회귀 케이스 (회장 §명시):
  1. queue_head_evaluate                      — head task 자동 평가 PASS
  2. expected_files_diff_gate_match           — expected_files == PR diff PASS
  3. expected_files_diff_gate_mismatch        — 불일치 시 BLOCK (비critical)
  4. forbidden_path_gate                      — forbidden_paths hit 시 Critical 7종
  5. ci_gemini_clean_sha_lock                 — 모든 gate + CLEAN + SHA lock 검증
  6. bot_identity_squash_merge                — GH_TOKEN process-local injection (mock gh)
  7. post_merge_smoke                         — 회귀 재실행 PASS
  8. downstream_stale_revalidation            — 다른 OPEN PR evidence 재검증 호출 횟수
  9. critical_7_classification + non_critical_auto
                                              — Critical 7종 분류 + 비critical self-resolve

본 회귀는 anu_v2/* 모듈만 import 한다 (one-way isolation).
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence

import pytest

# workspace root → sys.path (anu_v2 패키지를 절대 import 하기 위함)
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
if str(WORKSPACE_ROOT) not in sys.path:
    sys.path.insert(0, str(WORKSPACE_ROOT))

from anu_v2.merge_queue_executor import (  # noqa: E402
    AUTO_MERGE_ALLOWED,
    AUTO_MERGE_SUCCESS,
    BLOCKED_WITH_REASON,
    CI_FAILURE_BLOCK,
    CRITICAL_BLOCK_OVERRIDE,
    CRITICAL_CODES,
    CRITICAL_FORBIDDEN_PATH,
    CRITICAL_GEMINI_SCOPE_EXPANSION,
    CRITICAL_POST_MERGE_SMOKE,
    DIFF_CONTAMINATION,
    FORBIDDEN_GIT_FLAGS,
    GEMINI_COMPLETED,
    GEMINI_SCOPE_EXPANSION,
    GEMINI_UNRESOLVED,
    GEMINI_UNRESOLVED_BLOCK,
    HEAD_SHA_LOCK_BROKEN,
    MERGE_STATE_NOT_CLEAN,
    NON_CRITICAL_AUTO_RESOLVED,
    WAITING_FOR_PREDECESSOR,
    GateOutcome,
    MergeQueueExecutor,
    PRMeta,
    assert_no_forbidden_git_flags,
    load_task_spec_from_md,
)


# ─── helpers ────────────────────────────────────────────────────────────────
def _cp(returncode: int = 0, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
    return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)


def _make_pr(
    *,
    number: int = 81,
    head_sha: str = "deadbeef",
    head_ref: str = "task/task-2531-dev4",
    base_ref: str = "main",
    changed_files: Sequence[str] = (
        "anu_v2/__init__.py",
        "anu_v2/merge_queue_executor.py",
        "anu_v2/tests/__init__.py",
        "anu_v2/tests/test_merge_queue_executor_2531.py",
    ),
    ci_required_all_success: bool = True,
    gemini_status: str = GEMINI_COMPLETED,
    merge_state_status: str = "CLEAN",
    queue_predecessors_open: int = 0,
) -> PRMeta:
    return PRMeta(
        number=number,
        head_sha=head_sha,
        head_ref=head_ref,
        base_ref=base_ref,
        changed_files=tuple(changed_files),
        ci_required_all_success=ci_required_all_success,
        gemini_status=gemini_status,
        merge_state_status=merge_state_status,
        queue_predecessors_open=queue_predecessors_open,
    )


def _write_task_md(
    tmp_path: Path,
    *,
    task_id: str = "task-2531",
    expected: Sequence[str] = (
        "anu_v2/__init__.py",
        "anu_v2/merge_queue_executor.py",
        "anu_v2/tests/__init__.py",
        "anu_v2/tests/test_merge_queue_executor_2531.py",
    ),
    forbidden: Sequence[str] = (),
    cherry: bool = False,
) -> Path:
    body = ["# " + task_id, "", "```yaml", "expected_files:"]
    for f in expected:
        body.append(f'  - "{f}"')
    if forbidden:
        body.append("forbidden_paths:")
        for f in forbidden:
            body.append(f'  - "{f}"')
    body.append(f"cherry_pick_allowed: {'true' if cherry else 'false'}")
    body.append("```")
    p = tmp_path / f"{task_id}.md"
    p.write_text("\n".join(body), encoding="utf-8")
    return p


@pytest.fixture
def gh_calls() -> list[dict[str, Any]]:
    return []


@pytest.fixture
def pytest_calls() -> list[Sequence[str]]:
    return []


@pytest.fixture
def audit_calls() -> list[Mapping[str, Any]]:
    return []


@pytest.fixture
def executor(tmp_path: Path, gh_calls, pytest_calls, audit_calls) -> MergeQueueExecutor:
    def gh_runner(args, env):
        gh_calls.append({"args": list(args), "env": dict(env or {})})
        return _cp()

    def git_runner(*_args):
        return _cp()

    def pytest_runner(args):
        pytest_calls.append(tuple(args))
        return 0

    def audit_writer(record):
        audit_calls.append(dict(record))

    return MergeQueueExecutor(
        gh_runner=gh_runner,
        git_runner=git_runner,
        pytest_runner=pytest_runner,
        audit_writer=audit_writer,
        task_md_root=tmp_path,
    )


# ─── 1. queue_head_evaluate ─────────────────────────────────────────────────
def test_1_queue_head_evaluate_pass(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(queue_predecessors_open=0)
    out = executor.evaluate_queue_head(pr)
    assert out.passed
    assert out.decision == AUTO_MERGE_ALLOWED
    assert out.reason == "queue_head_confirmed"


def test_1_queue_head_evaluate_waiting(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(queue_predecessors_open=2)
    out = executor.evaluate_queue_head(pr)
    assert not out.passed
    assert out.decision == WAITING_FOR_PREDECESSOR
    assert "2 predecessor" in out.reason


# ─── 2. expected_files_diff_gate (match) ────────────────────────────────────
def test_2_expected_files_diff_gate_match(executor: MergeQueueExecutor, tmp_path: Path) -> None:
    md = _write_task_md(tmp_path)
    spec = load_task_spec_from_md(md)
    pr = _make_pr()
    out = executor.check_expected_files_diff(spec, pr)
    assert out.passed
    assert out.decision == AUTO_MERGE_ALLOWED


# ─── 3. expected_files_diff_gate (mismatch) ─────────────────────────────────
def test_3_expected_files_diff_gate_mismatch(
    executor: MergeQueueExecutor, tmp_path: Path
) -> None:
    md = _write_task_md(tmp_path)
    spec = load_task_spec_from_md(md)
    contaminated = (
        "anu_v2/__init__.py",
        "anu_v2/merge_queue_executor.py",
        "anu_v2/tests/__init__.py",
        "anu_v2/tests/test_merge_queue_executor_2531.py",
        "utils/merge_queue_executor.py",  # 외부 시스템 오염
    )
    pr = _make_pr(changed_files=contaminated)
    out = executor.check_expected_files_diff(spec, pr)
    assert not out.passed
    assert out.decision == DIFF_CONTAMINATION
    assert "utils/merge_queue_executor.py" in out.extra["extra"]


# ─── 4. forbidden_path_gate ─────────────────────────────────────────────────
def test_4_forbidden_path_gate_critical(
    executor: MergeQueueExecutor, tmp_path: Path
) -> None:
    md = _write_task_md(
        tmp_path,
        expected=("anu_v2/merge_queue_executor.py",),
        forbidden=("utils/**", "dispatch/**", "scripts/**"),
    )
    spec = load_task_spec_from_md(md)
    pr = _make_pr(changed_files=("anu_v2/merge_queue_executor.py", "utils/foo.py"))
    out = executor.check_forbidden_paths(spec, pr)
    assert not out.passed
    assert out.decision == BLOCKED_WITH_REASON
    assert out.is_critical
    assert out.critical_code == CRITICAL_FORBIDDEN_PATH
    assert "utils/foo.py" in out.extra["hits"]


def test_4_forbidden_path_gate_clean(executor: MergeQueueExecutor, tmp_path: Path) -> None:
    md = _write_task_md(tmp_path, forbidden=("utils/**",))
    spec = load_task_spec_from_md(md)
    pr = _make_pr()
    out = executor.check_forbidden_paths(spec, pr)
    assert out.passed


# ─── 5. ci_gemini_clean_sha_lock ────────────────────────────────────────────
def test_5_ci_gemini_clean_sha_lock_all_pass(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(head_sha="abc123")
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="abc123")
    assert out.passed


def test_5_ci_gemini_clean_sha_lock_ci_fail(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(ci_required_all_success=False)
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="deadbeef")
    assert out.decision == CI_FAILURE_BLOCK
    assert not out.is_critical


def test_5_ci_gemini_scope_expansion_critical(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(gemini_status=GEMINI_SCOPE_EXPANSION)
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="deadbeef")
    assert out.is_critical
    assert out.critical_code == CRITICAL_GEMINI_SCOPE_EXPANSION


def test_5_ci_gemini_unresolved_blocks_non_critical(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(gemini_status=GEMINI_UNRESOLVED)
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="deadbeef")
    assert out.decision == GEMINI_UNRESOLVED_BLOCK
    assert not out.is_critical


def test_5_ci_gemini_merge_state_blocked_critical(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(merge_state_status="BLOCKED")
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="deadbeef")
    assert out.is_critical
    assert out.critical_code == CRITICAL_BLOCK_OVERRIDE


def test_5_ci_gemini_merge_state_dirty_non_critical(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(merge_state_status="DIRTY")
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="deadbeef")
    assert out.decision == MERGE_STATE_NOT_CLEAN
    assert not out.is_critical


def test_5_ci_gemini_head_sha_lock_broken(executor: MergeQueueExecutor) -> None:
    pr = _make_pr(head_sha="newsha")
    out = executor.check_ci_gemini_clean_sha_lock(pr, head_sha_at_lock="oldsha")
    assert out.decision == HEAD_SHA_LOCK_BROKEN
    assert out.extra["locked"] == "oldsha"
    assert out.extra["current"] == "newsha"


# ─── 6. bot_identity_squash_merge ───────────────────────────────────────────
def test_6_bot_identity_squash_merge_injects_bot_token(
    executor: MergeQueueExecutor, gh_calls, monkeypatch
) -> None:
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT_FAKE_TOKEN")
    monkeypatch.setenv("PATH", "/sentinel/path:/usr/bin")
    # 외부 owner PAT 가 환경에 있더라도 BOT 토큰이 덮어써야 한다
    monkeypatch.setenv("GH_TOKEN", "ghs_OWNER_LEAKED")
    pr = _make_pr(number=81)
    out = executor.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    assert out.decision == AUTO_MERGE_SUCCESS
    assert len(gh_calls) == 1
    call = gh_calls[0]
    assert call["args"][:3] == ["pr", "merge", "81"]
    assert "--squash" in call["args"]
    assert "--match-head-commit" in call["args"]
    # BOT 토큰 명시적 덮어쓰기 (owner PAT 누출 차단)
    assert call["env"]["GH_TOKEN"] == "ghs_BOT_FAKE_TOKEN"
    assert call["env"]["GITHUB_TOKEN"] == "ghs_BOT_FAKE_TOKEN"
    # PATH 등 실행 환경은 보존되어야 gh/git 발견 가능
    assert call["env"]["PATH"] == "/sentinel/path:/usr/bin"
    # 절대 금지 플래그 미포함
    assert not (set(call["args"]) & FORBIDDEN_GIT_FLAGS)


def test_6_bot_identity_blocked_when_token_missing(
    executor: MergeQueueExecutor, monkeypatch
) -> None:
    monkeypatch.delenv("BOT_GITHUB_TOKEN", raising=False)
    pr = _make_pr()
    out = executor.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    assert out.decision == BLOCKED_WITH_REASON
    assert out.is_critical
    assert out.critical_code == CRITICAL_BLOCK_OVERRIDE


def test_6_bot_identity_rejects_admin_force_rebase() -> None:
    for flag in ("--admin", "--force", "--force-with-lease", "-f", "--rebase"):
        with pytest.raises(RuntimeError, match="FORBIDDEN_GIT_FLAG_BLOCKED"):
            assert_no_forbidden_git_flags(["pr", "merge", "81", "--squash", flag])


def test_6_assert_no_forbidden_git_flags_value_form() -> None:
    """Security-High 회귀: `--admin=true`, `--force-with-lease=ref` 등 값 포함형도 차단."""
    for flag in ("--admin=true", "--force=1", "--force-with-lease=refs/x", "--rebase=preserve"):
        with pytest.raises(RuntimeError, match="FORBIDDEN_GIT_FLAG_BLOCKED"):
            assert_no_forbidden_git_flags(["pr", "merge", "81", flag])


def test_6_assert_no_forbidden_git_flags_combined_short() -> None:
    """Security-High 회귀: 단축 결합 (`-af`, `-fa`) 도 `-f` 포함 시 차단."""
    for combined in ("-af", "-fa", "-xfy"):
        with pytest.raises(RuntimeError, match="FORBIDDEN_GIT_FLAG_BLOCKED"):
            assert_no_forbidden_git_flags(["pr", "merge", combined])


def test_6_assert_no_forbidden_git_flags_allows_safe_args() -> None:
    """정상 인자는 통과해야 한다 (false positive 방지)."""
    assert_no_forbidden_git_flags([
        "pr", "merge", "81", "--squash", "--match-head-commit", "abc123",
        "--subject", "[task-2531] something", "-R", "Jeon-Jonghyuk/dev_workspace",
    ])


# ─── 7. post_merge_smoke ────────────────────────────────────────────────────
def test_7_post_merge_smoke_pass(executor: MergeQueueExecutor, pytest_calls) -> None:
    out = executor.run_post_merge_smoke(
        smoke_test_paths=["anu_v2/tests/test_merge_queue_executor_2531.py"]
    )
    assert out.decision == AUTO_MERGE_SUCCESS
    assert pytest_calls == [("anu_v2/tests/test_merge_queue_executor_2531.py",)]


def test_7_post_merge_smoke_fail_is_critical(tmp_path: Path, gh_calls, audit_calls) -> None:
    def failing_pytest(*_args):
        return 1

    def gh_runner(args, env):
        gh_calls.append({"args": list(args), "env": dict(env or {})})
        return _cp()

    def git_runner(*_args):
        return _cp()

    def audit_writer(rec):
        audit_calls.append(dict(rec))

    ex = MergeQueueExecutor(
        gh_runner=gh_runner,
        git_runner=git_runner,
        pytest_runner=failing_pytest,
        audit_writer=audit_writer,
        task_md_root=tmp_path,
    )
    out = ex.run_post_merge_smoke(smoke_test_paths=["anu_v2/tests/x.py"])
    assert out.decision == BLOCKED_WITH_REASON
    assert out.is_critical
    assert out.critical_code == CRITICAL_POST_MERGE_SMOKE
    assert out.extra["pytest_exit_code"] == 1


# ─── 8. downstream_stale_revalidation ───────────────────────────────────────
def test_8_downstream_stale_revalidation_invokes_gh_per_pr(
    executor: MergeQueueExecutor, gh_calls, monkeypatch
) -> None:
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT_DOWNSTREAM")
    monkeypatch.setenv("PATH", "/usr/bin")
    results = executor.revalidate_downstream(
        merged_task_id="task-2531",
        downstream_pr_numbers=[82, 83, 84],
    )
    assert len(results) == 3
    assert all(r["ok"] for r in results)
    # 호출 횟수 == downstream PR 수
    assert len(gh_calls) == 3
    # 각 호출이 pr comment 에 task id 와 revalidation 메시지를 포함하는지
    # + BOT 토큰 process-local injection 검증 (Security-High 회귀 박제)
    for call in gh_calls:
        assert call["args"][0] == "pr"
        assert call["args"][1] == "comment"
        body = call["args"][-1]
        assert "task-2531" in body
        assert "evidence revalidation" in body
        assert call["env"]["GH_TOKEN"] == "ghs_BOT_DOWNSTREAM"
        assert call["env"]["GITHUB_TOKEN"] == "ghs_BOT_DOWNSTREAM"
        assert call["env"]["PATH"] == "/usr/bin"


def test_8_downstream_revalidate_blocks_when_token_missing(
    executor: MergeQueueExecutor, gh_calls, monkeypatch
) -> None:
    """Security-High 회귀: BOT 토큰 미주입 시 owner_pat 사용 차단."""
    monkeypatch.delenv("BOT_GITHUB_TOKEN", raising=False)
    results = executor.revalidate_downstream(
        merged_task_id="task-2531",
        downstream_pr_numbers=[82, 83],
    )
    assert len(results) == 2
    assert all(not r["ok"] for r in results)
    assert all(r["reason"] == "bot_token_unavailable" for r in results)
    # gh 호출 자체가 발생하지 않아야 한다 (token 누설 차단)
    assert len(gh_calls) == 0


# ─── 9. critical_7_classification + non_critical_auto ───────────────────────
def test_9_classify_critical_7_returns_critical_code(executor: MergeQueueExecutor) -> None:
    critical = GateOutcome(
        decision=BLOCKED_WITH_REASON,
        reason="forbidden",
        critical_code=CRITICAL_FORBIDDEN_PATH,
    )
    assert executor.classify_failure(critical) == CRITICAL_FORBIDDEN_PATH
    assert critical.critical_code in CRITICAL_CODES


def test_9_classify_non_critical_returns_auto_resolved(executor: MergeQueueExecutor) -> None:
    non_crit = GateOutcome(decision=DIFF_CONTAMINATION, reason="mismatch")
    assert executor.classify_failure(non_crit) == NON_CRITICAL_AUTO_RESOLVED


def test_9_auto_handle_non_critical_writes_audit_and_self_resolves(
    executor: MergeQueueExecutor, audit_calls
) -> None:
    non_crit = GateOutcome(
        decision=DIFF_CONTAMINATION,
        reason="expected_files_mismatch",
        extra={"missing": ["a.py"], "extra": ["b.py"]},
    )
    out = executor.auto_handle_non_critical(non_crit)
    assert out.decision == NON_CRITICAL_AUTO_RESOLVED
    # audit 만 쌓이고 회장 보고 (escalation) 은 발생하지 않음
    assert len(audit_calls) == 1
    rec = audit_calls[0]
    assert rec["kind"] == "non_critical_auto_resolved"
    assert rec["decision"] == DIFF_CONTAMINATION
    assert rec["reason"] == "expected_files_mismatch"


def test_9_waiting_for_predecessor_skips_audit_log(
    executor: MergeQueueExecutor, audit_calls
) -> None:
    """WAITING_FOR_PREDECESSOR 는 정상 대기 상태 → audit 노이즈 방지 (skip)."""
    waiting = GateOutcome(
        decision=WAITING_FOR_PREDECESSOR,
        reason="2 predecessors still open",
    )
    out = executor.auto_handle_non_critical(waiting)
    assert out.decision == NON_CRITICAL_AUTO_RESOLVED
    # audit 미기록
    assert len(audit_calls) == 0


def test_9_critical_codes_are_exactly_seven() -> None:
    """회장 §명시: Critical 7종 외 분류 일체 금지 → 정확히 7개."""
    assert len(CRITICAL_CODES) == 7


# ─── 통합 파이프라인 보너스: 4 게이트 모두 통과 시 AUTO_MERGE_ALLOWED ───────
def test_integration_full_evaluate_pass(executor: MergeQueueExecutor, tmp_path: Path) -> None:
    _write_task_md(tmp_path)
    pr = _make_pr(head_sha="locked-sha")
    out = executor.evaluate(pr=pr, head_sha_at_lock="locked-sha")
    assert out.passed
    assert out.decision == AUTO_MERGE_ALLOWED
    assert out.reason == "all_4_gates_pass"


# ─── Gemini High/Medium 회귀 박제 (3건) ─────────────────────────────────────
def test_glob_match_double_star_root_file(tmp_path: Path) -> None:
    """`**/foo.py` 패턴이 루트 파일 `foo.py` 도 매칭하는지."""
    from anu_v2.merge_queue_executor import _glob_match

    assert _glob_match("**/foo.py", "foo.py") is True
    assert _glob_match("**/foo.py", "a/foo.py") is True
    assert _glob_match("**/foo.py", "a/b/foo.py") is True
    assert _glob_match("**/foo.py", "foo.txt") is False
    # 단일 segment 글롭은 디렉토리 경계 침범 X
    assert _glob_match("utils/*.py", "utils/x.py") is True
    assert _glob_match("utils/*.py", "utils/sub/x.py") is False
    # `utils/**` 은 utils 하위 모든 파일을 포함
    assert _glob_match("utils/**", "utils/x.py") is True
    assert _glob_match("utils/**", "utils/a/b/x.py") is True


def test_evaluate_path_traversal_branch_rejected(executor: MergeQueueExecutor, tmp_path: Path) -> None:
    """branch 명에 `..` 가 들어와도 task_md_root 외부 파일을 가리키지 않는다."""
    # 외부에 task md 가 있어도 (`tmp_path/../leaked.md`) safe_task_id 가 root 안으로
    # 한정하므로 path traversal 이 차단되어 task_md_missing 으로 떨어진다.
    leaked = tmp_path.parent / "leaked.md"
    leaked.write_text(
        "expected_files:\n  - \"x.py\"\nforbidden_paths:\n  - \"y.py\"\n",
        encoding="utf-8",
    )
    try:
        pr = _make_pr(head_ref="task/task-../../../etc/passwd-dev4")
        out = executor.evaluate(pr=pr, head_sha_at_lock="deadbeef")
        # branch 가 정규식 매칭 실패 → 원본 그대로 반환되지만 Path(...).name 으로 잘림
        # → task_md_root 안에 해당 파일이 없으므로 task_md_missing 으로 BLOCKED.
        assert out.decision == BLOCKED_WITH_REASON
        assert out.is_critical
    finally:
        leaked.unlink(missing_ok=True)


def test_bot_squash_merge_does_not_strip_path(
    executor: MergeQueueExecutor, gh_calls, monkeypatch
) -> None:
    """High Gemini 회귀: env={...} 로 PATH 누락되면 gh 가 없어 fail 했던 패턴 박제."""
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT")
    monkeypatch.setenv("PATH", "/x/y/z")
    monkeypatch.setenv("HOME", "/home/agent")
    pr = _make_pr(number=99)
    executor.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    env_used = gh_calls[-1]["env"]
    # 핵심 실행 환경 보존
    assert env_used["PATH"] == "/x/y/z"
    assert env_used["HOME"] == "/home/agent"
    # BOT 토큰 정확히 주입
    assert env_used["GH_TOKEN"] == "ghs_BOT"
    assert env_used["GITHUB_TOKEN"] == "ghs_BOT"


# ─── Gemini Medium 회귀 추가 (3종) ──────────────────────────────────────────
def test_yaml_parser_allows_hash_in_quoted_filenames(tmp_path: Path) -> None:
    """파일명에 `#` 이 포함된 인용 항목이 주석 시작점으로 오인되지 않는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "expected_files:\n"
        "  - \"src/C#_file.cs\"\n"
        "  - \"docs/note.md\" # 후행 주석\n"
        "  - bare_path.py # 비인용도 주석 절단 동작\n"
        "forbidden_paths:\n"
        "  - \"legacy/#archive/**\"\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert "src/C#_file.cs" in spec.expected_files
    assert "docs/note.md" in spec.expected_files
    assert "bare_path.py" in spec.expected_files
    assert "legacy/#archive/**" in spec.forbidden_paths


def test_bot_squash_merge_handles_none_stderr(tmp_path: Path, gh_calls, audit_calls, monkeypatch) -> None:
    """cp.stderr 가 None 이어도 TypeError 없이 처리하는지."""
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT")

    def gh_runner_none(args, env):
        gh_calls.append({"args": list(args), "env": dict(env or {})})
        # capture_output=False 시 stdout/stderr 는 None 이 된다
        return subprocess.CompletedProcess(args=[], returncode=2, stdout=None, stderr=None)

    def audit_writer(rec):
        audit_calls.append(dict(rec))

    ex = MergeQueueExecutor(
        gh_runner=gh_runner_none,
        git_runner=lambda *_a: _cp(),
        pytest_runner=lambda *_a: 0,
        audit_writer=audit_writer,
        task_md_root=tmp_path,
    )
    pr = _make_pr()
    out = ex.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    # TypeError 없이 BLOCKED_WITH_REASON 으로 떨어져야 함
    assert out.decision == BLOCKED_WITH_REASON
    assert out.extra["stderr"] == ""


def test_bot_squash_merge_handles_none_stdout_on_success(
    tmp_path: Path, gh_calls, audit_calls, monkeypatch
) -> None:
    """cp.stdout 이 None (returncode=0) 인 성공 케이스에서도 TypeError 없는지."""
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT")

    def gh_runner_none(args, env):
        gh_calls.append({"args": list(args), "env": dict(env or {})})
        return subprocess.CompletedProcess(args=[], returncode=0, stdout=None, stderr=None)

    def audit_writer(rec):
        audit_calls.append(dict(rec))

    ex = MergeQueueExecutor(
        gh_runner=gh_runner_none,
        git_runner=lambda *_a: _cp(),
        pytest_runner=lambda *_a: 0,
        audit_writer=audit_writer,
        task_md_root=tmp_path,
    )
    pr = _make_pr()
    out = ex.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    assert out.decision == AUTO_MERGE_SUCCESS
    assert out.extra["stdout"] == ""


def test_evaluate_critical_gates_run_first(executor: MergeQueueExecutor, tmp_path: Path) -> None:
    """Security-High 회귀: forbidden_path (Critical) 가 queue/diff (non-critical) 보다
    먼저 평가되어, 비critical 우선 실패로 보안 검사가 누락되지 않는다."""
    # forbidden_path 침범 + queue 가 head 가 아닌 상황 (predecessors > 0)
    md = tmp_path / "task-2531.md"
    md.write_text(
        "expected_files:\n"
        "  - \"anu_v2/__init__.py\"\n"
        "  - \"anu_v2/merge_queue_executor.py\"\n"
        "  - \"anu_v2/tests/__init__.py\"\n"
        "  - \"anu_v2/tests/test_merge_queue_executor_2531.py\"\n"
        "forbidden_paths:\n"
        "  - \"utils/**\"\n",
        encoding="utf-8",
    )
    # PR 은 forbidden 침범 + 동시에 queue 대기 상태
    pr = _make_pr(
        head_sha="locked-sha",
        queue_predecessors_open=2,
        changed_files=(
            "anu_v2/__init__.py",
            "anu_v2/merge_queue_executor.py",
            "anu_v2/tests/__init__.py",
            "anu_v2/tests/test_merge_queue_executor_2531.py",
            "utils/forbidden.py",
        ),
    )
    out = executor.evaluate(pr=pr, head_sha_at_lock="locked-sha")
    # forbidden_path 가 먼저 잡혀야 한다 (queue 대기 상태로 silent return X)
    assert out.is_critical
    assert out.critical_code == CRITICAL_FORBIDDEN_PATH


def test_yaml_parser_supports_single_quotes(tmp_path: Path) -> None:
    """YAML 작은따옴표 인용 항목도 큰따옴표와 동일하게 추출되는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "expected_files:\n"
        "  - 'src/single quoted.py'\n"
        "  - \"src/double quoted.py\"\n"
        "  - bare/path.py\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert "src/single quoted.py" in spec.expected_files
    assert "src/double quoted.py" in spec.expected_files
    assert "bare/path.py" in spec.expected_files


def test_cherry_pick_allowed_with_trailing_comment(tmp_path: Path) -> None:
    """cherry_pick_allowed 행 끝 주석 (`true # 사유...`) 이 매칭되는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "```yaml\n"
        "expected_files:\n"
        "  - \"a.py\"\n"
        "cherry_pick_allowed: true   # 명시적 허용 사유\n"
        "```\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert spec.cherry_pick_allowed is True


def test_cherry_pick_allowed_ignores_prose_outside_yaml(tmp_path: Path) -> None:
    """본문 prose 에 'cherry_pick_allowed' 단어가 있어도 YAML 값만 신뢰."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "## description\n"
        "이 task 는 절대 cherry_pick_allowed: true 같은 정책을 허용하지 않는다.\n"
        "\n"
        "```yaml\n"
        "expected_files:\n"
        "  - \"a.py\"\n"
        "cherry_pick_allowed: false\n"
        "```\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert spec.cherry_pick_allowed is False


def test_extract_block_indented_yaml_in_fenced_code(tmp_path: Path) -> None:
    """fenced code block 내부 들여쓰기된 expected_files 도 추출되는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "## meta\n"
        "```yaml\n"
        "  expected_files:\n"           # 키 자체가 2칸 들여쓰기됨
        "    - \"a.py\"\n"
        "    - \"b.py\"\n"
        "  forbidden_paths:\n"           # sibling key — 블록 종료점
        "    - \"x/**\"\n"
        "```\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert "a.py" in spec.expected_files
    assert "b.py" in spec.expected_files
    # sibling key (forbidden_paths) 항목이 expected_files 블록에 침범하지 않아야 함
    assert "x/**" not in spec.expected_files
    assert "x/**" in spec.forbidden_paths


def test_extract_block_tolerates_blank_lines_and_comments(tmp_path: Path) -> None:
    """리스트 중간 빈 줄/주석 라인이 있어도 이후 항목이 누락되지 않는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "expected_files:\n"
        "  - \"a.py\"\n"
        "\n"  # 빈 줄
        "  # 중간 주석\n"
        "  - \"b.py\"\n"
        "  - \"c.py\"\n"
        "forbidden_paths:\n"
        "  - \"x/**\"\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert "a.py" in spec.expected_files
    assert "b.py" in spec.expected_files
    assert "c.py" in spec.expected_files
    assert "x/**" in spec.forbidden_paths


def test_cherry_pick_allowed_indented_in_yaml_block(tmp_path: Path) -> None:
    """들여쓰기된 cherry_pick_allowed (YAML 블록 내부) 가 정상 매칭되는지."""
    md = tmp_path / "task-2531.md"
    md.write_text(
        "```yaml\n"
        "expected_files:\n"
        "  - \"a.py\"\n"
        "  cherry_pick_allowed: true\n"   # 들여쓰기 (이전 regex 는 매칭 실패)
        "```\n",
        encoding="utf-8",
    )
    spec = load_task_spec_from_md(md)
    assert spec.cherry_pick_allowed is True


def test_bot_squash_merge_handles_bytes_streams(
    tmp_path: Path, gh_calls, audit_calls, monkeypatch
) -> None:
    """gh_runner 가 bytes stdout/stderr 반환 시 str 로 정규화되는지."""
    monkeypatch.setenv("BOT_GITHUB_TOKEN", "ghs_BOT")

    def gh_runner_bytes(args, env):
        gh_calls.append({"args": list(args), "env": dict(env or {})})
        return subprocess.CompletedProcess(
            args=[], returncode=2,
            stdout=b"ok-bytes", stderr=b"fail-bytes-\xed\x95\x9c",
        )

    def audit_writer(rec):
        audit_calls.append(dict(rec))

    ex = MergeQueueExecutor(
        gh_runner=gh_runner_bytes,
        git_runner=lambda *_a: _cp(),
        pytest_runner=lambda *_a: 0,
        audit_writer=audit_writer,
        task_md_root=tmp_path,
    )
    pr = _make_pr()
    out = ex.execute_bot_squash_merge(pr, head_sha_at_lock="deadbeef")
    assert out.decision == BLOCKED_WITH_REASON
    # bytes 가 그대로 dict 에 박히지 않고 str 로 정규화되어야 한다
    assert isinstance(out.extra["stderr"], str)
    assert "fail-bytes" in out.extra["stderr"]
