"""tests/regression/test_automation_autonomy_hardening_2521.py

회귀 테스트 — task-2521 automation autonomy hardening
영역 1: bot identity merge capability (#1)
영역 2: Gemini async race (#2)
영역 3: bot session stuck signal (#3)

회장 명시 본질:
  task-2518 PR#70 / task-2519 PR#69 / task-2520 PR#71 모두 mergedBy=JonghyukJeon
  → autonomy 7/10. 본 task 회귀 테스트는 위 3건의 audit fixture replay 필수.

gh api 실호출 금지 — 모두 runner mock / dataclass 직접 주입.
"""
from __future__ import annotations

import subprocess
import sys
from dataclasses import fields
from pathlib import Path
from typing import Any, Dict, List

import pytest

# ---------------------------------------------------------------------------
# Worktree root → sys.path (force position 0 to shadow /home/jay/workspace/utils)
# ---------------------------------------------------------------------------
_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.repository_policy_adapter import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    BlockedReason,
    BotMergeIdentity,
    MergeIdentityRecord,
    MergePathPlan,
    RepositoryCapability,
    classify_capability_gap,
    infer_token_source_from_actor,
    probe_bot_merge_identity,
    select_merge_path,
)

from utils.merge_queue_executor import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    GEMINI_REVIEW_WAIT_BUDGET_SECONDS_DEFAULT,
    WAITING_FOR_GEMINI_REVIEW,
    ExecutorContext,
    TaskSpec,
    evaluate_gemini_async_race,
    evaluate_pr,
)

from utils.lifecycle_reconciliation_manager import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    LifecycleEvidence,
    StuckReason,
    detect_stuck_cases,
)


# ===========================================================================
# Helper builders
# ===========================================================================

def cp(returncode: int = 0, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
    """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 number,mergedBy 호출에 응답하는 runner factory.

    pr_payloads: {pr_number: dict | None}. None이면 returncode=1.
    """
    import json as _json

    def runner(args: List[str], cwd: Any = None) -> subprocess.CompletedProcess:
        del cwd
        # args = ["gh", "pr", "view", "<N>", "--json", ..., ("--repo", "owner/repo")?]
        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


def make_lifecycle_evidence(**overrides) -> LifecycleEvidence:
    """LifecycleEvidence default builder (모든 task-2521 §3 신규 필드 포함)."""
    base: dict[str, Any] = dict(
        task_id="task-9999",
        pr_number=None,
        pr_state=None,
        merge_commit=None,
        merged_into_main=False,
        ci_status=None,
        smoke_status=None,
        timer_status=None,
        timer_end_time=None,
        has_done=False,
        has_done_acked=False,
        has_merge_done=False,
        has_qc_result=False,
        has_followup=False,
        has_escalate_marker=False,
        escalate_marker_age_minutes=None,
        telegram_reply_truncated=False,
        bot_session_status=None,
        worktree_exists=False,
        branch_pushed_to_remote=False,
        # task-2521 §3 신규 필드
        worktree_mtime_seconds_ago=None,
        has_pushed_commits=False,
        report_artifact_present=False,
        process_alive=None,
    )
    base.update(overrides)
    return LifecycleEvidence(**base)


def make_task_spec(
    *,
    task_id: str = "task-9999",
    expected_files: list[str] | None = None,
    dependency: list[str] | None = None,
) -> TaskSpec:
    return TaskSpec(
        task_id=task_id,
        expected_files=expected_files or ["utils/foo.py"],
        risk_area="test",
        dependency=dependency or [],
        parallel_policy="serial_only",
        merge_queue_position=1,
        stale_recheck_required=False,
        cherry_pick_allowed=False,
        smoke_command=None,
    )


def make_executor_ctx(**overrides) -> ExecutorContext:
    """ExecutorContext default — 회귀 테스트용 (task-2521 §2 fields 포함)."""
    base: dict[str, Any] = dict(
        runner=lambda args, cwd=None, timeout=60: cp(0, "", ""),
        pr_workdir=None,
        smoke_command=None,
        no_audit=True,
        main_log_grep=lambda _t: True,
        fixture_main_sha="aaaa1111",
        sleeper=lambda _s: None,
        # task-2521 §2 race fields는 caller가 명시
    )
    base.update(overrides)
    return ExecutorContext(**base)


# ---------------------------------------------------------------------------
# task-2518 PR #70 / task-2519 PR #69 / task-2520 PR #71 fixture
# 회장 명시 §replay fixture 필수 — mergedBy=JonghyukJeon 박제.
# ---------------------------------------------------------------------------

PR_70_FIXTURE: Dict[str, Any] = {
    "number": 70,
    "mergedBy": {
        "login": "JonghyukJeon",
        "type": "User",
    },
    "_meta": {
        "task_id": "task-2518",
        "mergedAt": "2026-05-09T04:50:46Z",
        "mergeCommit": "257fd5259e8e17d2b02a9cc5aa73f4d3670a3bdb",
    },
}

PR_69_FIXTURE: Dict[str, Any] = {
    "number": 69,
    "mergedBy": {
        "login": "JonghyukJeon",
        "type": "User",
    },
    "_meta": {
        "task_id": "task-2519",
        "mergedAt": "2026-05-09T02:04:50Z",
    },
}

PR_71_FIXTURE: Dict[str, Any] = {
    "number": 71,
    "mergedBy": {
        "login": "JonghyukJeon",
        "type": "User",
    },
    "_meta": {
        "task_id": "task-2520",
        "mergedAt": "2026-05-09T06:31:34Z",
        "mergeCommit": "69b4d14ba33b4f7616870bce5ec329585eafe08b",
    },
}


# ===========================================================================
# 영역 1 — Bot identity audit (#1)  6 tests
# ===========================================================================


def test_1_1_bot_merge_identity_six_field_dataclass():
    """(1) BotMergeIdentity dataclass에 6 field 모두 존재."""
    field_names = {f.name for f in fields(BotMergeIdentity)}
    expected = {
        "bot_can_merge_as_app",
        "merge_actor_login",
        "merge_actor_is_bot",
        "token_source",
        "fallback_to_owner_token_detected",
        "merge_identity_audit",
    }
    assert expected.issubset(field_names), (
        f"6 field 누락: missing={expected - field_names}"
    )


def test_1_2_pr_70_replay_fallback_detected():
    """(2) task-2518 PR #70 replay — mergedBy=JonghyukJeon → fallback_to_owner_token=True."""
    runner = make_pr_view_runner({70: PR_70_FIXTURE})
    identity = probe_bot_merge_identity("Jeon-Jonghyuk", "dev_workspace", [70], runner=runner)
    assert identity.fallback_to_owner_token_detected is True
    assert identity.merge_actor_login == "JonghyukJeon"
    assert identity.merge_actor_is_bot is False
    assert identity.token_source == "owner_pat"
    assert identity.bot_can_merge_as_app is False
    # audit에 PR #70 record가 남아 있어야 한다
    assert len(identity.merge_identity_audit) == 1
    rec = identity.merge_identity_audit[0]
    assert rec.pr_number == 70
    assert rec.merged_by_login == "JonghyukJeon"
    assert rec.fallback_to_owner_token is True


def test_1_3_pr_69_replay_fallback_detected():
    """(3) task-2519 PR #69 replay — 동일 owner_pat fallback 패턴."""
    runner = make_pr_view_runner({69: PR_69_FIXTURE})
    identity = probe_bot_merge_identity("Jeon-Jonghyuk", "dev_workspace", [69], runner=runner)
    assert identity.fallback_to_owner_token_detected is True
    assert identity.token_source == "owner_pat"
    assert identity.merge_identity_audit[0].pr_number == 69


def test_1_4_pr_71_replay_fallback_detected():
    """(4) task-2520 PR #71 replay — 동일 owner_pat fallback 패턴."""
    runner = make_pr_view_runner({71: PR_71_FIXTURE})
    identity = probe_bot_merge_identity("Jeon-Jonghyuk", "dev_workspace", [71], runner=runner)
    assert identity.fallback_to_owner_token_detected is True
    assert identity.token_source == "owner_pat"
    assert identity.merge_identity_audit[0].pr_number == 71


def test_1_5_owner_token_fallback_classified_as_capability_gap():
    """(5) owner_pat fallback identity → AUTOMATION_CAPABILITY_GAP 분류 + select_merge_path
    가 escalate_capability_gap path 선택."""
    # PR #70/#69/#71 동시 replay → 3 연속 owner_pat (회장 §본질)
    runner = make_pr_view_runner({
        70: PR_70_FIXTURE,
        69: PR_69_FIXTURE,
        71: PR_71_FIXTURE,
    })
    identity = probe_bot_merge_identity(
        "Jeon-Jonghyuk", "dev_workspace", [70, 69, 71], runner=runner,
    )
    assert identity.fallback_to_owner_token_detected is True
    assert identity.bot_can_merge_as_app is False
    # 3 sample 모두 owner_pat
    assert len(identity.merge_identity_audit) == 3
    assert all(r.fallback_to_owner_token for r in identity.merge_identity_audit)

    blocked = classify_capability_gap(identity)
    assert blocked == BlockedReason.AUTOMATION_CAPABILITY_GAP

    # select_merge_path → escalate_capability_gap
    cap = RepositoryCapability(
        can_squash_merge=True,
        requires_approval=False,
        requires_thread_resolution=False,
        auto_merge_enabled=True,
        bot_can_merge=True,   # github-actions[bot]은 write 권한 있음
        admin_override_required=False,
    )
    plan: MergePathPlan = select_merge_path({"number": 71}, cap, blocked)
    assert plan.action == "escalate_capability_gap"
    assert plan.capability_gap is True
    assert plan.requires_chair is False  # ops 채널만 (회장 직접 머지 X)
    assert plan.reason == BlockedReason.AUTOMATION_CAPABILITY_GAP


def test_1_6_bot_app_identity_merge_treated_as_success():
    """(6) bot/app identity로 머지된 fixture (github-actions[bot]) → success 간주
    (capability_gap=None / classify_capability_gap returns None)."""
    bot_pr_fixture = {
        "number": 999,
        "mergedBy": {"login": "github-actions[bot]", "type": "Bot"},
    }
    runner = make_pr_view_runner({999: bot_pr_fixture})
    identity = probe_bot_merge_identity(
        "Jeon-Jonghyuk", "dev_workspace", [999], runner=runner,
    )
    assert identity.bot_can_merge_as_app is True
    assert identity.fallback_to_owner_token_detected is False
    assert identity.merge_actor_is_bot is True
    assert identity.token_source == "installation_app"

    blocked = classify_capability_gap(identity)
    assert blocked is None  # success


# ===========================================================================
# 영역 2 — Gemini async race (#2)  5 tests
# ===========================================================================


def test_2_1_submitted_before_pushed_classified_as_async_pending():
    """(7) submitted_at < pushed_at → status='async_pending', premature_gate_fail_detected=True."""
    race = evaluate_gemini_async_race(
        pushed_at="2026-05-09T05:00:00Z",
        gemini_submitted_at="2026-05-09T04:55:00Z",  # 5분 전 (stale)
        ci_complete_at="2026-05-09T05:01:30Z",
        gemini_status="GEMINI_COMPLETED",
        now_epoch=1778306400.0,  # 2026-05-09T06:00:00Z
        wait_budget_seconds=360.0,
    )
    assert race["status"] == "async_pending"
    assert race["premature_gate_fail_detected"] is True
    assert race["should_block_with_waiting_marker"] is True
    # metric 5종이 모두 채워져야 한다
    assert race["push_to_ci_complete_seconds"] == pytest.approx(90.0, rel=1e-3)
    assert race["push_to_gemini_review_seconds"] == pytest.approx(-300.0, rel=1e-3)
    assert race["gemini_gate_wait_seconds"] is not None


def test_2_2_within_budget_arrival_passes():
    """(8) submitted_at > pushed_at AND within budget → status='ok' (premature=False)."""
    race = evaluate_gemini_async_race(
        pushed_at="2026-05-09T05:00:00Z",
        gemini_submitted_at="2026-05-09T05:03:00Z",   # 3분 후 도착
        ci_complete_at="2026-05-09T05:01:30Z",
        gemini_status="GEMINI_COMPLETED",
        now_epoch=1778303220.0,  # 2026-05-09T05:07:00Z
        wait_budget_seconds=360.0,
    )
    assert race["status"] == "ok"
    assert race["premature_gate_fail_detected"] is False
    assert race["should_block_with_waiting_marker"] is False
    assert race["push_to_gemini_review_seconds"] == pytest.approx(180.0, rel=1e-3)


def test_2_3_budget_exhausted_distinguished_from_quota_timeout():
    """(9) wait budget 초과 + gemini_status가 quota/timeout이면 'wait_budget_exhausted'.
    quota/timeout 신호 없으면 보수적으로 async_pending 유지."""
    # case A: budget 초과 + GEMINI_UNAVAILABLE_QUOTA
    race_a = evaluate_gemini_async_race(
        pushed_at="2026-05-09T05:00:00Z",
        gemini_submitted_at=None,
        ci_complete_at="2026-05-09T05:01:30Z",
        gemini_status="GEMINI_UNAVAILABLE_QUOTA",
        now_epoch=1778304300.0,  # 2026-05-09T05:25:00Z (25분 경과)
        wait_budget_seconds=360.0,
    )
    assert race_a["status"] == "wait_budget_exhausted"
    # case B: budget 초과 + 신호 없음 (quota/timeout 아님) → 보수적으로 async_pending
    race_b = evaluate_gemini_async_race(
        pushed_at="2026-05-09T05:00:00Z",
        gemini_submitted_at=None,
        ci_complete_at="2026-05-09T05:01:30Z",
        gemini_status="GEMINI_COMPLETED",
        now_epoch=1778304300.0,
        wait_budget_seconds=360.0,
    )
    assert race_b["status"] == "async_pending"
    assert race_b["premature_gate_fail_detected"] is True


def test_2_4_metrics_five_keys_populated():
    """(10) 5 metric (push_to_ci_complete_seconds 등) 정상 수집."""
    race = evaluate_gemini_async_race(
        pushed_at="2026-05-09T05:00:00Z",
        gemini_submitted_at="2026-05-09T05:03:30Z",
        ci_complete_at="2026-05-09T05:01:00Z",
        gemini_status="GEMINI_COMPLETED",
        now_epoch=1778303100.0,  # 2026-05-09T05:05:00Z
        wait_budget_seconds=360.0,
    )
    expected_keys = {
        "push_to_ci_complete_seconds",
        "push_to_gemini_review_seconds",
        "gemini_gate_wait_seconds",
        "premature_gate_fail_detected",
        "should_block_with_waiting_marker",
    }
    assert expected_keys.issubset(set(race.keys()))
    assert race["push_to_ci_complete_seconds"] == pytest.approx(60.0, rel=1e-3)
    assert race["push_to_gemini_review_seconds"] == pytest.approx(210.0, rel=1e-3)


def test_2_5_pr_70_71_race_replay_premature_gate_fail_detected():
    """(11) PR #70/#71 race fixture replay — pushed_at 직후 stale Gemini review 도착 →
    evaluate_pr가 WAITING_FOR_GEMINI_REVIEW 분류 + premature_gate_fail_detected=True."""
    # PR #70 시나리오: pushed_at=04:50:00, gemini submitted_at=04:48:00 (이전 head SHA 리뷰)
    fixture_pr70 = {
        "pushed_at": "2026-05-09T04:50:00Z",
        "gemini_submitted_at": "2026-05-09T04:48:00Z",  # stale (2분 전 — head SHA 변경 전 리뷰)
        "ci_complete_at": "2026-05-09T04:51:30Z",
        "now_epoch": 1778302320.0,  # 2026-05-09T04:52:00Z (즉시 평가)
    }
    fixture_pr71 = {
        "pushed_at": "2026-05-09T06:30:00Z",
        "gemini_submitted_at": "2026-05-09T06:28:00Z",  # stale
        "ci_complete_at": "2026-05-09T06:31:00Z",
        "now_epoch": 1778308380.0,  # 2026-05-09T06:33:00Z
    }

    spec = make_task_spec(task_id="task-2518", expected_files=["utils/lifecycle_reconciliation_manager.py"])

    for label, fix in [("PR#70", fixture_pr70), ("PR#71", fixture_pr71)]:
        ctx = make_executor_ctx(
            pushed_at=fix["pushed_at"],
            gemini_submitted_at=fix["gemini_submitted_at"],
            ci_complete_at=fix["ci_complete_at"],
            now_provider=lambda v=fix["now_epoch"]: v,
        )
        decision = evaluate_pr(
            pr_number=70 if label == "PR#70" else 71,
            task_spec=spec,
            pr_head_sha="dead" + ("70" if label == "PR#70" else "71"),
            effective_files=["utils/lifecycle_reconciliation_manager.py"],
            merge_state={"mergeStateStatus": "CLEAN", "baseRefName": "main"},
            ci_state={"status": "SUCCESS", "details": ["SUCCESS"]},
            gemini_state={
                "status": "ok",
                "unresolved": [],
                "submitted_at": fix["gemini_submitted_at"],
            },
            ctx=ctx,
        )
        assert decision.decision == WAITING_FOR_GEMINI_REVIEW, (
            f"{label}: 기대 WAITING_FOR_GEMINI_REVIEW, 실제 {decision.decision} "
            f"(reason={decision.reason!r})"
        )
        assert decision.premature_gate_fail_detected is True
        # 5 metric 박제 검증
        assert decision.push_to_gemini_review_seconds is not None
        assert decision.push_to_gemini_review_seconds < 0  # stale (submitted < pushed)
        assert decision.gemini_gate_wait_seconds is not None


# ===========================================================================
# 영역 3 — Bot session stuck (#3)  6 tests
# ===========================================================================


def test_3_1_bot_cancelled_timer_running_pr_missing():
    """(12) bot_session=cancelled + timer_running + pr_missing → BOT_CANCELLED_TIMER_RUNNING_PR_MISSING."""
    evidence = make_lifecycle_evidence(
        bot_session_status="cancelled",
        timer_status="running",
        pr_number=None,
        pr_state=None,
        worktree_exists=True,
    )
    cases = detect_stuck_cases(evidence)
    reasons = {c.reason for c in cases}
    assert StuckReason.BOT_CANCELLED_TIMER_RUNNING_PR_MISSING in reasons, (
        f"BOT_CANCELLED_TIMER_RUNNING_PR_MISSING 누락. cases={[c.reason.value for c in cases]}"
    )


def test_3_2_bot_cancelled_with_active_worktree():
    """(13) bot_session=cancelled + worktree_active(mtime<5min) →
    BOT_CANCELLED_WITH_ACTIVE_WORKTREE."""
    evidence = make_lifecycle_evidence(
        bot_session_status="cancelled",
        timer_status="completed",   # timer는 종료됨
        pr_number=None,             # PR 없음
        worktree_exists=True,
        worktree_mtime_seconds_ago=120.0,  # 2분 전 — active
    )
    cases = detect_stuck_cases(evidence)
    reasons = {c.reason for c in cases}
    assert StuckReason.BOT_CANCELLED_WITH_ACTIVE_WORKTREE in reasons, (
        f"BOT_CANCELLED_WITH_ACTIVE_WORKTREE 누락. cases={[c.reason.value for c in cases]}"
    )


def test_3_3_bot_cancelled_after_commit_before_pr():
    """(14) bot_session=cancelled + commits_pushed + pr_missing →
    BOT_CANCELLED_AFTER_COMMIT_BEFORE_PR."""
    evidence = make_lifecycle_evidence(
        bot_session_status="cancelled",
        timer_status="completed",
        pr_number=None,
        has_pushed_commits=True,
        branch_pushed_to_remote=True,
        worktree_exists=True,
    )
    cases = detect_stuck_cases(evidence)
    reasons = {c.reason for c in cases}
    assert StuckReason.BOT_CANCELLED_AFTER_COMMIT_BEFORE_PR in reasons, (
        f"BOT_CANCELLED_AFTER_COMMIT_BEFORE_PR 누락. cases={[c.reason.value for c in cases]}"
    )


def test_3_4_bot_cancelled_after_pr_before_finalize():
    """(15) bot_session=cancelled + pr_open + finalize 미완 →
    BOT_CANCELLED_AFTER_PR_BEFORE_FINALIZE."""
    evidence = make_lifecycle_evidence(
        bot_session_status="cancelled",
        timer_status="completed",
        pr_number=42,
        pr_state="OPEN",
        has_done_acked=False,
        has_merge_done=False,
        worktree_exists=True,
        has_pushed_commits=True,
    )
    cases = detect_stuck_cases(evidence)
    reasons = {c.reason for c in cases}
    assert StuckReason.BOT_CANCELLED_AFTER_PR_BEFORE_FINALIZE in reasons, (
        f"BOT_CANCELLED_AFTER_PR_BEFORE_FINALIZE 누락. cases={[c.reason.value for c in cases]}"
    )


def test_3_5_dagda_hang_fixture_replay_2518_classification():
    """(16) 다그다 hang fixture (task-2518) replay — bot session ended + worktree active +
    pr 미생성 + commit 미발생 + timer 살아있음 → BOT_CANCELLED_TIMER_RUNNING_PR_MISSING.

    회장 §명시 7번 완료조건: 4 신규 case 중 정확히 어디로 분류되는지 박제."""
    dagda_evidence = make_lifecycle_evidence(
        task_id="task-2518",
        bot_session_status="cancelled",
        timer_status="running",      # task-timer는 살아있음 (다그다 다운 시점)
        pr_number=None,              # PR 미생성
        pr_state=None,
        has_pushed_commits=False,
        worktree_exists=True,
        worktree_mtime_seconds_ago=240.0,  # 4분 전 — 여전히 active
        telegram_reply_truncated=True,     # bot 응답 끊김 신호
    )
    cases = detect_stuck_cases(dagda_evidence)
    reasons = {c.reason for c in cases}
    # 분류는 우선순위에 따라 TIMER_RUNNING_PR_MISSING로 박제 (timer 살아있음 우선)
    assert StuckReason.BOT_CANCELLED_TIMER_RUNNING_PR_MISSING in reasons
    # ACTIVE_WORKTREE는 fallthrough이므로 동시 분류 X (우선순위 위반 검증)
    assert StuckReason.BOT_CANCELLED_WITH_ACTIVE_WORKTREE not in reasons


def test_3_6_telegram_only_signal_does_not_imply_stuck():
    """(17) Telegram 무응답만 있고 bot_session/PR/CI/worktree/commit 모두 정상 →
    BOT_CANCELLED_* 분류 X (회장 §명시: Telegram 무응답만으로 stuck 판정 절대 X)."""
    # 모든 task-2521 §3 신규 case의 trigger 조건을 만족하지 않는 evidence
    evidence = make_lifecycle_evidence(
        bot_session_status="ok",            # cancelled가 아님 — 가장 중요
        timer_status="running",
        telegram_reply_truncated=True,      # Telegram만 truncated
        pr_number=None,                     # PR 없음
        worktree_exists=True,
        worktree_mtime_seconds_ago=10.0,    # 10초 전 — 매우 active
        has_pushed_commits=False,
    )
    cases = detect_stuck_cases(evidence)
    reasons = {c.reason for c in cases}
    # BOT_CANCELLED_* 4종은 전혀 분류되어선 안 됨
    bot_cancelled_set = {
        StuckReason.BOT_CANCELLED_TIMER_RUNNING_PR_MISSING,
        StuckReason.BOT_CANCELLED_WITH_ACTIVE_WORKTREE,
        StuckReason.BOT_CANCELLED_AFTER_COMMIT_BEFORE_PR,
        StuckReason.BOT_CANCELLED_AFTER_PR_BEFORE_FINALIZE,
    }
    assert not (bot_cancelled_set & reasons), (
        f"Telegram 무응답만으로 BOT_CANCELLED_* stuck 분류 발생: {reasons & bot_cancelled_set}"
    )
    # TELEGRAM_REPLY_CUT_OFF는 별도 분류로 그대로 유지 (기존 task-2518 로직)
    assert StuckReason.TELEGRAM_REPLY_CUT_OFF in reasons


# ===========================================================================
# Sanity — wait budget default constant
# ===========================================================================

def test_wait_budget_default_constant_documented():
    """sanity: GEMINI_REVIEW_WAIT_BUDGET_SECONDS_DEFAULT가 360s (6분)으로 정의."""
    assert GEMINI_REVIEW_WAIT_BUDGET_SECONDS_DEFAULT == 360.0


def test_infer_token_source_known_paths():
    """sanity: infer_token_source_from_actor 4 경로 (installation_app/owner_pat/bot_pat/unknown)."""
    assert infer_token_source_from_actor("github-actions[bot]", "Bot") == "installation_app"
    assert infer_token_source_from_actor("JonghyukJeon", "User") == "owner_pat"
    assert infer_token_source_from_actor("Jeon-Jonghyuk", "User") == "owner_pat"
    assert infer_token_source_from_actor("", "") == "unknown"
    # User type with "bot" substring → bot_pat (휴리스틱)
    assert infer_token_source_from_actor("auto-bot-runner", "User") == "bot_pat"


# ===========================================================================
# Sanity — MergeIdentityRecord 박제
# ===========================================================================

def test_merge_identity_record_fields():
    """sanity: MergeIdentityRecord dataclass에 6 필드."""
    rec = MergeIdentityRecord(
        pr_number=70,
        merged_by_login="JonghyukJeon",
        merged_by_type="User",
        is_bot=False,
        inferred_token_source="owner_pat",
        fallback_to_owner_token=True,
    )
    assert rec.pr_number == 70
    assert rec.merged_by_login == "JonghyukJeon"
    assert rec.fallback_to_owner_token is True
