"""tests/regression/test_bot_merge_identity_regression_2523.py

회귀 테스트 — task-2523 bot merge identity regression / autonomy proof harness.

회장 §본질:
  task-2522 BOT_MERGE_IDENTITY_SUCCESS path를 **회귀로 고정**한다.
  PR #73 (mergeCommit b7a37521..., mergedBy=app/jeon-jonghyuk-taskctl-bot,
  token_source=GITHUB_APP_INSTALLATION_TOKEN) 가 1회성 우연이 아니라 향후 PR에서도
  반복 가능한 표준 path 임을 박제한다.

회장 §명시 8 검증 (정확히 8개):
  1. GH_TOKEN process-local injection이 owner PAT보다 우선
  2. gh auth=owner여도 merge token_source=GITHUB_APP_INSTALLATION_TOKEN 기록
  3. mergedBy=app/jeon-jonghyuk-taskctl-bot → BOT_MERGE_IDENTITY_SUCCESS 분류
  4. mergedBy=JonghyukJeon → owner_pat fallback 분류
  5. token raw value 로그 0건
  6. branch cleanup도 bot token으로 가능
  7. smoke + reconcile 정상
  8. audit JSONL에 token_source / mergedBy / owner_pat_used / expected_bot_identity 4 필드 존재

replay fixture:
  - PR #73 BOT_MERGE_IDENTITY_SUCCESS (mergedBy=app/jeon-jonghyuk-taskctl-bot,
    mergeCommit=b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa, mergedAt=2026-05-09T09:01:27Z)
  - PR #68/#69/#70/#71/#72 owner_pat fallback 5건 (autonomy capability gap 박제)

회장 §보안:
  - raw token value 절대 출력/저장 X (정적 grep 검증 포함)
  - gh api 실호출 X — 모두 runner mock / dataclass 직접 주입
  - subprocess 실행 X — 모든 경로는 fixture replay
"""
from __future__ import annotations

import json
import re
import subprocess
import sys
from dataclasses import fields as _fields
from pathlib import Path
from typing import Any, Dict, List

# ---------------------------------------------------------------------------
# Worktree root → sys.path (force position 0)
# ---------------------------------------------------------------------------
_WORKTREE_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_WORKTREE_ROOT) in sys.path:
    sys.path.remove(str(_WORKTREE_ROOT))
sys.path.insert(0, str(_WORKTREE_ROOT))

from utils.bot_merge_identity import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    DEFAULT_AUDIT_JSONL_PATH,
    MergeIdentityAuditRecord,
    REQUIRED_AUDIT_FIELDS_2523,
    TOKEN_SOURCE_GITHUB_APP,
    TOKEN_SOURCE_OWNER_PAT,
    TOKEN_SOURCE_UNKNOWN,
    append_audit_jsonl,
    build_audit_record,
    classify_token_source,
    compute_autonomy_score_delta,
    expected_bot_identity_for_actor,
    verify_branch_cleanup_token_inheritance,
)
from utils.merge_queue_executor import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    OWNER_PAT_FALLBACK_BLOCKED,
    execute_squash_merge,
    select_merge_token_decision,
)
from utils.repository_policy_adapter import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    BlockedReason,
    classify_capability_gap,
    probe_bot_merge_identity,
)


# ===========================================================================
# 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_view_runner(pr_payloads: Dict[int, Any]):
    """gh pr view <N> --json ... 호출에 응답하는 runner factory."""

    def runner(args: List[str], cwd: Any = None, **kwargs) -> subprocess.CompletedProcess:
        del cwd, kwargs
        if len(args) < 4 or args[0:3] != ["gh", "pr", "view"]:
            return cp(1, "", "unexpected call")
        try:
            n = int(args[3])
        except ValueError:
            return cp(1, "", "bad pr number")
        payload = pr_payloads.get(n)
        if payload is None:
            return cp(1, "", "Not Found")
        return cp(0, json.dumps(payload), "")

    return runner


# ---------------------------------------------------------------------------
# Fixtures — PR #73 (BOT_MERGE_IDENTITY_SUCCESS) + PR #68~#72 (owner_pat fallback)
#
# 회장 §"replay fixture 필수":
#   - PR #73: mergedBy=app/jeon-jonghyuk-taskctl-bot,
#             mergeCommit=b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa,
#             mergedAt=2026-05-09T09:01:27Z, token_source=GITHUB_APP_INSTALLATION_TOKEN
#   - PR #68/#69/#70/#71/#72: mergedBy=JonghyukJeon (owner_pat fallback)
# raw token 값은 fixture에 절대 포함되지 않는다 (회장 §보안).
# ---------------------------------------------------------------------------
PR_73_FIXTURE: Dict[str, Any] = {
    "number": 73,
    "mergedBy": {"login": "app/jeon-jonghyuk-taskctl-bot", "type": "Bot", "is_bot": True},
    "headRefName": "task/task-2522-dev2",
    "_meta": {
        "task_id": "task-2522",
        "mergedAt": "2026-05-09T09:01:27Z",
        "mergeCommit": "b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa",
        "expected_token_source": TOKEN_SOURCE_GITHUB_APP,
    },
}
PR_68_FIXTURE: Dict[str, Any] = {
    "number": 68,
    "mergedBy": {"login": "JonghyukJeon", "type": "User", "is_bot": False},
    "_meta": {"task_id": "task-2517", "mergedAt": "2026-05-09T00:53:24Z"},
}
PR_69_FIXTURE: Dict[str, Any] = {
    "number": 69,
    "mergedBy": {"login": "JonghyukJeon", "type": "User", "is_bot": False},
    "_meta": {"task_id": "task-2519", "mergedAt": "2026-05-09T02:04:50Z"},
}
PR_70_FIXTURE: Dict[str, Any] = {
    "number": 70,
    "mergedBy": {"login": "JonghyukJeon", "type": "User", "is_bot": False},
    "_meta": {"task_id": "task-2518", "mergedAt": "2026-05-09T04:50:46Z"},
}
PR_71_FIXTURE: Dict[str, Any] = {
    "number": 71,
    "mergedBy": {"login": "JonghyukJeon", "type": "User", "is_bot": False},
    "_meta": {"task_id": "task-2520", "mergedAt": "2026-05-09T06:31:34Z"},
}
PR_72_FIXTURE: Dict[str, Any] = {
    "number": 72,
    "mergedBy": {"login": "JonghyukJeon", "type": "User", "is_bot": False},
    "_meta": {"task_id": "task-2521", "mergedAt": "2026-05-09T13:30:00Z"},
}

# raw token은 prefix 5자만 사용 (회장 §보안 — 실제 token X)
_FIXTURE_BOT_INSTALLATION_PREFIX = "ghs_botFIXTUREsuffix"     # GitHub App installation
_FIXTURE_OWNER_PAT_PREFIX = "ghp_ownerFIXTUREsuffix"           # classic PAT
# secret-suffix substring used to assert no raw value leaked
_RAW_LEAK_NEEDLES = ("botFIXTUREsuffix", "ownerFIXTUREsuffix")


# ===========================================================================
# §검증 1 — GH_TOKEN process-local injection이 owner PAT보다 우선
# ===========================================================================

def test_v1_gh_token_process_local_injection_overrides_owner_pat():
    """(1) env에 owner PAT + GH_TOKEN=$BOT_GITHUB_TOKEN 동시 존재 →
    classify가 GH_TOKEN(ghs_) 값을 우선 분류 = GITHUB_APP_INSTALLATION_TOKEN.

    회장 §본질: process-local GH_TOKEN injection 패턴 (task-2522에서 PR #73 머지 시
    실제로 사용된 패턴) 이 owner PAT 보다 우선 적용되는지 회귀 박제.
    classify_token_source(token_value=...) 에 inject 된 token 값을 직접 전달하면
    그 prefix가 ghs_ 인 경우 GitHub App 으로 분류된다 — env에 owner PAT가
    동시에 존재해도 영향 없음.
    """
    # 시나리오: env에는 owner PAT가 있지만, dispatch가 GH_TOKEN으로 bot token을 주입
    env_with_both = {
        "GITHUB_PAT": _FIXTURE_OWNER_PAT_PREFIX,    # owner PAT 상존
        "GH_TOKEN": _FIXTURE_BOT_INSTALLATION_PREFIX,  # process-local injection
    }
    probe = classify_token_source(
        token_value=_FIXTURE_BOT_INSTALLATION_PREFIX,  # injected value
        env=env_with_both,
    )
    assert probe.token_source == TOKEN_SOURCE_GITHUB_APP, (
        f"GH_TOKEN process-local injection이 우선되지 않음: {probe.token_source}"
    )
    assert probe.installation_signal is True
    # 5자 prefix만 노출 — raw value X
    assert probe.token_prefix_observed == _FIXTURE_BOT_INSTALLATION_PREFIX[:5]
    serialised = json.dumps(probe.to_dict())
    for needle in _RAW_LEAK_NEEDLES:
        assert needle not in serialised, f"raw token leaked: {needle!r}"


# ===========================================================================
# §검증 2 — gh auth=owner여도 merge token_source는 GITHUB_APP_INSTALLATION_TOKEN
# ===========================================================================

def test_v2_gh_auth_owner_but_merge_records_app_installation_token():
    """(2) gh auth status는 JonghyukJeon (owner_pat) 이지만, merge 실행 직전
    GH_TOKEN 으로 installation token이 주입된 상황 → audit record가
    token_source=GITHUB_APP_INSTALLATION_TOKEN 으로 기록되어야 한다.

    회장 §본질: gh CLI의 stored auth는 owner PAT 일 수 있지만, 자동화의 진짜
    실행 토큰은 process-local GH_TOKEN injection이다. audit는 후자를 기록한다.
    """
    # gh auth status가 owner라는 가정은 본 단위테스트 범위 밖이지만,
    # build_audit_record가 받는 token_probe는 process-local 기준이어야 한다.
    probe = classify_token_source(
        token_value=_FIXTURE_BOT_INSTALLATION_PREFIX,
        env={
            "GH_TOKEN": _FIXTURE_BOT_INSTALLATION_PREFIX,  # injected
            # gh auth가 stored-owner 라는 사실은 별도 stored auth file이며,
            # classify는 env+token만 본다 (외부 gh api 호출 X — 회장 §보안).
        },
    )
    assert probe.token_source == TOKEN_SOURCE_GITHUB_APP
    record = build_audit_record(
        pr_number=73,
        token_probe=probe,
        mergedBy_login="app/jeon-jonghyuk-taskctl-bot",
        mergedBy_type="Bot",
        merge_commit_sha="b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa",
        task_id="task-2522",
    )
    assert record.token_source_used == TOKEN_SOURCE_GITHUB_APP
    assert record.owner_pat_used is False
    assert record.autonomy_capability_gap is False


# ===========================================================================
# §검증 3 — mergedBy=app/jeon-jonghyuk-taskctl-bot → BOT_MERGE_IDENTITY_SUCCESS
# ===========================================================================

def test_v3_pr_73_mergedBy_app_bot_classified_as_bot_merge_identity_success():
    """(3) PR #73 fixture replay (mergedBy=app/jeon-jonghyuk-taskctl-bot,
    mergeCommit=b7a37521..., is_bot=true) → autonomy_capability_gap=False,
    expected_bot_identity=True, owner_pat_used=False = BOT_MERGE_IDENTITY_SUCCESS.

    회장 §본질: 본 task의 회귀 핵심 — task-2522 success path 박제.
    """
    # fixture sanity
    assert PR_73_FIXTURE["mergedBy"]["login"] == "app/jeon-jonghyuk-taskctl-bot"
    assert PR_73_FIXTURE["mergedBy"]["is_bot"] is True
    assert PR_73_FIXTURE["_meta"]["mergeCommit"] == (
        "b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa"
    )

    probe = classify_token_source(
        token_value=_FIXTURE_BOT_INSTALLATION_PREFIX,
        env={"GH_TOKEN": _FIXTURE_BOT_INSTALLATION_PREFIX},
    )
    record = build_audit_record(
        pr_number=PR_73_FIXTURE["number"],
        token_probe=probe,
        mergedBy_login=PR_73_FIXTURE["mergedBy"]["login"],
        mergedBy_type=PR_73_FIXTURE["mergedBy"]["type"],
        merge_commit_sha=PR_73_FIXTURE["_meta"]["mergeCommit"],
        task_id=PR_73_FIXTURE["_meta"]["task_id"],
    )
    # BOT_MERGE_IDENTITY_SUCCESS 조건 — 4개 동시 만족
    assert record.token_source_used == TOKEN_SOURCE_GITHUB_APP
    assert record.mergedBy_is_bot is True
    assert record.expected_bot_identity is True
    assert record.autonomy_capability_gap is False
    assert record.owner_pat_used is False
    # autonomy score +1 (8 → 9)
    delta = compute_autonomy_score_delta(previous_score=8, record=record)
    assert delta.delta == 1
    assert delta.new_score == 9


# ===========================================================================
# §검증 4 — mergedBy=JonghyukJeon → owner_pat fallback 분류
# ===========================================================================

def test_v4_pr_68_to_72_mergedBy_owner_classified_as_owner_pat_fallback():
    """(4) PR #68~#72 5건 fixture replay (mergedBy=JonghyukJeon) → 모두
    owner_pat fallback (autonomy_capability_gap=True, owner_pat_used=True).

    회장 §본질: task-2522 직전 5 연속 owner_pat 패턴이 회귀로 고정되어
    "성공 path"로 위장되지 않음을 박제.
    """
    fixtures = [
        (68, PR_68_FIXTURE),
        (69, PR_69_FIXTURE),
        (70, PR_70_FIXTURE),
        (71, PR_71_FIXTURE),
        (72, PR_72_FIXTURE),
    ]
    for pr_num, fix in fixtures:
        probe = classify_token_source(
            token_value=_FIXTURE_OWNER_PAT_PREFIX,
            env={"GITHUB_PAT": _FIXTURE_OWNER_PAT_PREFIX},
        )
        record = build_audit_record(
            pr_number=pr_num,
            token_probe=probe,
            mergedBy_login=fix["mergedBy"]["login"],
            mergedBy_type=fix["mergedBy"]["type"],
            task_id=fix["_meta"]["task_id"],
        )
        assert record.token_source_used == TOKEN_SOURCE_OWNER_PAT, f"PR #{pr_num}"
        assert record.owner_pat_used is True, f"PR #{pr_num}"
        assert record.autonomy_capability_gap is True, f"PR #{pr_num}"
        assert record.mergedBy_is_bot is False, f"PR #{pr_num}"
        assert record.expected_bot_identity is False, f"PR #{pr_num}"

    # 추가: probe_bot_merge_identity 호출 (gh runner mock) → 5건 모두 fallback
    runner = make_pr_view_runner({
        68: PR_68_FIXTURE, 69: PR_69_FIXTURE, 70: PR_70_FIXTURE,
        71: PR_71_FIXTURE, 72: PR_72_FIXTURE,
    })
    identity = probe_bot_merge_identity(
        "Jeon-Jonghyuk", "dev_workspace", [68, 69, 70, 71, 72], runner=runner,
    )
    assert identity.fallback_to_owner_token_detected is True
    assert identity.bot_can_merge_as_app is False
    assert classify_capability_gap(identity) == BlockedReason.AUTOMATION_CAPABILITY_GAP


# ===========================================================================
# §검증 5 — token raw value 로그 0건 (정적 검사)
# ===========================================================================

def test_v5_no_raw_token_value_in_any_audit_or_log_output():
    """(5) audit record + JSONL 라인 + delta 출력 전부에서 raw token suffix
    노출 0건. 정적 grep + JSON 직렬화 검사.

    회장 §보안: token raw value 출력 절대 금지. prefix 5자 + sha256 첫 8 hex만.
    """
    # secret suffix를 명확히 식별 가능한 fixture
    secret_token_app = "ghs_PUBLICprefix" + "ULTRASECRETappBotSuffixDoNotLeak"
    secret_token_pat = "ghp_PUBLICprefix" + "ULTRASECRETownerPatSuffixDoNotLeak"

    for tok in (secret_token_app, secret_token_pat):
        probe = classify_token_source(token_value=tok, env={"GH_TOKEN": tok})
        record = build_audit_record(
            pr_number=999,
            token_probe=probe,
            mergedBy_login="app/jeon-jonghyuk-taskctl-bot",
            mergedBy_type="Bot",
            merge_commit_sha="deadbeef" * 5,
        )
        # 직렬화된 형태에서 secret suffix가 절대 등장 X
        serialised = json.dumps(record.to_dict(), ensure_ascii=False)
        assert "ULTRASECRET" not in serialised, f"raw token leaked into record: {serialised!r}"
        assert "DoNotLeak" not in serialised
        # prefix 5자(ghs_P, ghp_P)는 노출 가능 — token type 식별용
        assert tok[:5] in serialised

        # JSONL 파일에 append 후 읽어서 검증
        tmp = _WORKTREE_ROOT / "memory" / "events" / "test_2523_v5_tmp.jsonl"
        if tmp.exists():
            tmp.unlink()
        append_audit_jsonl(record, audit_path=tmp)
        line = tmp.read_text(encoding="utf-8")
        assert "ULTRASECRET" not in line
        assert "DoNotLeak" not in line
        # delta 직렬화 검사
        delta = compute_autonomy_score_delta(previous_score=8, record=record)
        delta_json = json.dumps(delta.__dict__)
        assert "ULTRASECRET" not in delta_json
        assert "DoNotLeak" not in delta_json
        tmp.unlink(missing_ok=True)

    # 추가: bot_merge_identity 모듈 소스 자체에 secret 패턴이 없는지 정적 검사
    src = (_WORKTREE_ROOT / "utils" / "bot_merge_identity.py").read_text(encoding="utf-8")
    # source code 안에 실제 token이 박혀있지 않은지 (test 픽스쳐 prefix는 OK)
    assert not re.search(r"ghs_[A-Za-z0-9]{30,}", src), "long ghs_ token literal in source"
    assert not re.search(r"ghp_[A-Za-z0-9]{30,}", src), "long ghp_ token literal in source"


# ===========================================================================
# §검증 6 — branch cleanup도 bot token으로 가능
# ===========================================================================

def test_v6_branch_cleanup_inherits_bot_token_in_same_merge_call():
    """(6) `gh pr merge --squash --delete-branch` 한 번의 호출 안에서 머지+삭제가
    수행되므로, GH_TOKEN process-local injection이 branch cleanup에도 그대로
    상속된다. PR #73 branch `task/task-2522-dev2` delete가 이 패턴으로 가능했음을
    정적으로 검증.

    회장 §본질: branch cleanup이 별도 호출 / 별도 token으로 분리되면 fallback
    위험이 생긴다. 동일 호출 안에 묶여 있어야 한다.
    """
    # execute_squash_merge가 실제로 issue하는 args를 capture
    captured: List[List[str]] = []

    def runner(args: List[str], **kwargs) -> subprocess.CompletedProcess:
        del kwargs
        captured.append(list(args))
        return cp(0, "Merged.", "")

    result = execute_squash_merge(73, runner)
    assert result["returncode"] == 0
    assert len(captured) == 1, "merge+cleanup은 정확히 1 호출이어야 함"
    issued = captured[0]

    # 정적 검증: 동일 호출 안에 --delete-branch 묶여 있는지
    cleanup = verify_branch_cleanup_token_inheritance(issued)
    assert cleanup["merge_command_present"] is True
    assert cleanup["delete_branch_flag_present"] is True
    assert cleanup["branch_cleanup_in_same_call"] is True
    assert cleanup["forbidden_flags_detected"] == []
    assert cleanup["token_inherits_process_local_gh_token"] is True

    # 회장 §금지: --admin / --force / --rebase 절대 X
    assert "--admin" not in issued
    assert "--force" not in issued
    assert "rebase" not in issued

    # PR #73 branch (task/task-2522-dev2) 삭제 시나리오 — 동일 패턴 재현
    pr73_args = ["gh", "pr", "merge", "73", "--squash", "--delete-branch"]
    cleanup_73 = verify_branch_cleanup_token_inheritance(pr73_args)
    assert cleanup_73["token_inherits_process_local_gh_token"] is True


# ===========================================================================
# §검증 7 — smoke + reconcile 정상 (bot identity merge 후)
# ===========================================================================

def test_v7_post_merge_smoke_and_reconcile_pass_after_bot_identity_merge():
    """(7) PR #73 (mergedBy=bot) 머지 후 post-merge smoke 와 lifecycle reconcile
    이 정상 수행됨을 fixture replay 로 박제.

    회장 §본질: bot 머지 후에도 standard automation flow (smoke + reconcile)이
    동일하게 작동해야 함 — bot 머지가 후행 단계를 깨뜨리지 않는다.

    실호출 X — runner mock으로 smoke=PASS, reconcile=APPLIED 시뮬레이션.
    """
    from utils.merge_queue_executor import run_post_merge_smoke  # pyright: ignore[reportMissingImports]

    # post-merge smoke — runner mock returns 0
    smoke_calls: List[List[str]] = []

    def smoke_runner(args: List[str], **kwargs) -> subprocess.CompletedProcess:
        del kwargs
        smoke_calls.append(list(args))
        return cp(0, "smoke ok", "")

    smoke_result = run_post_merge_smoke(
        ["python3", "-c", "print('smoke ok')"],
        smoke_runner,
    )
    assert smoke_result["status"] == "PASS"
    assert len(smoke_calls) == 1

    # lifecycle_reconciliation_manager — reconcile() 함수와 핵심 enum 가용 확인
    from utils.lifecycle_reconciliation_manager import (  # pyright: ignore[reportMissingImports]
        LifecycleState,
        StuckReason,
        reconcile,
    )
    # 모듈이 존재하고 핵심 enum/함수가 정상 import 가능 = reconcile 가용
    assert hasattr(LifecycleState, "__members__")
    assert hasattr(StuckReason, "__members__")
    assert callable(reconcile)

    # bot 머지 후 audit record가 정상 생성 + capability gap 없음
    probe = classify_token_source(
        token_value=_FIXTURE_BOT_INSTALLATION_PREFIX,
        env={"GH_TOKEN": _FIXTURE_BOT_INSTALLATION_PREFIX},
    )
    record = build_audit_record(
        pr_number=73,
        token_probe=probe,
        mergedBy_login="app/jeon-jonghyuk-taskctl-bot",
        mergedBy_type="Bot",
        merge_commit_sha="b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa",
        task_id="task-2522",
    )
    assert record.autonomy_capability_gap is False
    # smoke + reconcile 가용 + audit success → 종합 정상
    assert smoke_result["status"] == "PASS" and not record.autonomy_capability_gap


# ===========================================================================
# §검증 8 — audit JSONL 4 필드 존재 (token_source / mergedBy / owner_pat_used / expected_bot_identity)
# ===========================================================================

def test_v8_audit_jsonl_contains_required_4_fields_contract():
    """(8) audit JSONL의 마지막 라인에 4 필드 모두 존재 검증:
    token_source_used / mergedBy_login / owner_pat_used / expected_bot_identity.

    회장 §"audit JSONL에 token_source/mergedBy/owner_pat_used/expected_bot_identity
    4 필드 존재"
    """
    # PR #73 (bot success) record를 임시 JSONL에 append
    tmp = _WORKTREE_ROOT / "memory" / "events" / "test_2523_v8_audit_tmp.jsonl"
    if tmp.exists():
        tmp.unlink()

    probe_app = classify_token_source(
        token_value=_FIXTURE_BOT_INSTALLATION_PREFIX,
        env={"GH_TOKEN": _FIXTURE_BOT_INSTALLATION_PREFIX},
    )
    record_success = build_audit_record(
        pr_number=73,
        token_probe=probe_app,
        mergedBy_login="app/jeon-jonghyuk-taskctl-bot",
        mergedBy_type="Bot",
        merge_commit_sha="b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa",
        task_id="task-2522",
    )
    append_audit_jsonl(record_success, audit_path=tmp)

    # PR #72 (owner_pat fallback) record 도 append
    probe_pat = classify_token_source(
        token_value=_FIXTURE_OWNER_PAT_PREFIX,
        env={"GITHUB_PAT": _FIXTURE_OWNER_PAT_PREFIX},
    )
    record_fallback = build_audit_record(
        pr_number=72,
        token_probe=probe_pat,
        mergedBy_login="JonghyukJeon",
        mergedBy_type="User",
        task_id="task-2521",
    )
    append_audit_jsonl(record_fallback, audit_path=tmp)

    # 두 라인 검증 — 각각 4 필드 모두 존재
    lines = tmp.read_text(encoding="utf-8").strip().splitlines()
    assert len(lines) == 2
    for line in lines:
        payload = json.loads(line)
        for required in REQUIRED_AUDIT_FIELDS_2523:
            assert required in payload, f"missing required field {required!r} in {payload}"
        # 4 필드 contract 명시 검증
        assert "token_source_used" in payload
        assert "mergedBy_login" in payload
        assert "owner_pat_used" in payload
        assert "expected_bot_identity" in payload

    # 마지막 라인 (fallback) — owner_pat_used=True / expected_bot_identity=False
    last = json.loads(lines[-1])
    assert last["token_source_used"] == TOKEN_SOURCE_OWNER_PAT
    assert last["mergedBy_login"] == "JonghyukJeon"
    assert last["owner_pat_used"] is True
    assert last["expected_bot_identity"] is False

    # 첫 라인 (success) — owner_pat_used=False / expected_bot_identity=True
    first = json.loads(lines[0])
    assert first["token_source_used"] == TOKEN_SOURCE_GITHUB_APP
    assert first["mergedBy_login"] == "app/jeon-jonghyuk-taskctl-bot"
    assert first["owner_pat_used"] is False
    assert first["expected_bot_identity"] is True

    # raw token 누설 0건 (재확인)
    full = tmp.read_text(encoding="utf-8")
    for needle in _RAW_LEAK_NEEDLES:
        assert needle not in full

    tmp.unlink(missing_ok=True)


# ===========================================================================
# Sanity & contract — 회장 §명시 자기참조 검증
# ===========================================================================


def test_sanity_required_audit_fields_contract_constant():
    """REQUIRED_AUDIT_FIELDS_2523 는 정확히 4종.
    {token_source_used, mergedBy_login, owner_pat_used, expected_bot_identity}.
    """
    assert set(REQUIRED_AUDIT_FIELDS_2523) == {
        "token_source_used",
        "mergedBy_login",
        "owner_pat_used",
        "expected_bot_identity",
    }
    assert len(REQUIRED_AUDIT_FIELDS_2523) == 4


def test_sanity_owner_pat_used_field_present_in_dataclass():
    """MergeIdentityAuditRecord.owner_pat_used 필드 존재 — task-2523 §검증 8."""
    field_names = {f.name for f in _fields(MergeIdentityAuditRecord)}
    assert "owner_pat_used" in field_names


def test_sanity_select_merge_token_decision_app_token_allows_merge():
    """select_merge_token_decision: GITHUB_APP_INSTALLATION_TOKEN → allow_merge=True.
    회장 §"GH_TOKEN process-local injection이 owner PAT보다 우선" 의 후속 결정.
    """
    decision_app = select_merge_token_decision(TOKEN_SOURCE_GITHUB_APP)
    assert decision_app["allow_merge"] is True
    assert decision_app["decision"] == "APP_TOKEN_OK"
    assert decision_app["capability_gap"] is False

    # owner_pat → blocked
    decision_pat = select_merge_token_decision(TOKEN_SOURCE_OWNER_PAT)
    assert decision_pat["allow_merge"] is False
    assert decision_pat["decision"] == OWNER_PAT_FALLBACK_BLOCKED
    assert decision_pat["capability_gap"] is True


def test_sanity_default_audit_jsonl_path_unchanged():
    """task-2523에서 default audit path 변경 X — task-2522 결정 유지."""
    assert str(DEFAULT_AUDIT_JSONL_PATH).endswith("bot-merge-identity.jsonl")


def test_sanity_pr_73_fixture_immutable_bot_identity_replay():
    """PR #73 fixture는 BOT_MERGE_IDENTITY_SUCCESS 의 immutable replay 입력.
    회장 §"replay fixture 필수 — PR #73 (mergedBy=app/jeon-jonghyuk-taskctl-bot
    mergeCommit=b7a37521)".
    """
    assert PR_73_FIXTURE["number"] == 73
    assert PR_73_FIXTURE["mergedBy"]["login"] == "app/jeon-jonghyuk-taskctl-bot"
    assert PR_73_FIXTURE["mergedBy"]["is_bot"] is True
    assert PR_73_FIXTURE["_meta"]["mergeCommit"] == (
        "b7a37521c8f189bfd98b8ea047ebaa5c5fe684aa"
    )
    assert PR_73_FIXTURE["_meta"]["mergedAt"] == "2026-05-09T09:01:27Z"
    assert PR_73_FIXTURE["_meta"]["expected_token_source"] == TOKEN_SOURCE_GITHUB_APP
    # bot identity 검증 helper도 동일 결론
    assert expected_bot_identity_for_actor(
        PR_73_FIXTURE["mergedBy"]["login"],
        PR_73_FIXTURE["mergedBy"]["type"],
    ) is True


def test_sanity_verify_branch_cleanup_token_inheritance_rejects_admin_force():
    """verify_branch_cleanup_token_inheritance — admin/force 플래그 검출.
    회장 §금지 18종 중 admin override / force 적용 회귀 박제.
    """
    bad_admin = ["gh", "pr", "merge", "73", "--squash", "--delete-branch", "--admin"]
    res = verify_branch_cleanup_token_inheritance(bad_admin)
    assert "--admin" in res["forbidden_flags_detected"]
    assert res["token_inherits_process_local_gh_token"] is False

    bad_force = ["gh", "pr", "merge", "73", "--squash", "--delete-branch", "--force"]
    res = verify_branch_cleanup_token_inheritance(bad_force)
    assert "--force" in res["forbidden_flags_detected"]

    # cleanup 누락 (delete-branch 없음) → branch_cleanup_in_same_call=False
    no_cleanup = ["gh", "pr", "merge", "73", "--squash"]
    res = verify_branch_cleanup_token_inheritance(no_cleanup)
    assert res["branch_cleanup_in_same_call"] is False
    assert res["delete_branch_flag_present"] is False


def test_sanity_unknown_token_source_is_failclosed():
    """token_source=UNKNOWN → fail-closed (allow_merge=False, capability_gap=True).
    process-local injection이 실패한 경우의 안전망.
    """
    decision = select_merge_token_decision(TOKEN_SOURCE_UNKNOWN)
    assert decision["allow_merge"] is False
    assert decision["capability_gap"] is True

    probe = classify_token_source(token_value=None, env={})
    assert probe.token_source == TOKEN_SOURCE_UNKNOWN
    record = build_audit_record(
        pr_number=999,
        token_probe=probe,
        mergedBy_login="",
        mergedBy_type="",
    )
    assert record.autonomy_capability_gap is True
    assert record.owner_pat_used is False  # OWNER_PAT 도 아님
