"""tests/regression/test_lifecycle_reconciliation_manager_2518.py — 14 회귀 + 5 replay fixture.

회장 명시 P0: lifecycle state machine + bot session decoupling + source-of-truth + idempotent reconcile.
"""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import Any

import pytest

# Ensure worktree root on sys.path so utils.* imports resolve
_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.lifecycle_reconciliation_manager import (  # noqa: E402  # pyright: ignore[reportMissingImports]
    LifecycleState,
    StuckReason,
    LifecycleEvidence,
    determine_state,
    detect_stuck_cases,
    reconcile,
    assert_no_manual_done_forgery,
)


# ---------------------------------------------------------------------------
# Helper: evidence builder (default clean baseline)
# ---------------------------------------------------------------------------

def make_evidence(**overrides) -> LifecycleEvidence:
    """Default 'clean baseline' evidence; tests override fields they care about."""
    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,
    )
    base.update(overrides)
    return LifecycleEvidence(**base)


# ===========================================================================
# A. State Enum + Reconciliation (4건)
# ===========================================================================

class TestLifecycleStateEnum:
    """A-1. LifecycleState enum 7종 멤버 검증."""

    def test_lifecycle_state_enum_seven_members(self):
        """회장 명시: LifecycleState가 정확히 7종 멤버."""
        expected = {
            "RUNNING",
            "PR_OPEN",
            "MERGED_PENDING_RECONCILE",
            "RECONCILING",
            "FINALIZED",
            "STUCK_NEEDS_RECONCILE",
            "ESCALATED",
        }
        actual = {member.value for member in LifecycleState}
        assert actual == expected, (
            f"LifecycleState enum 멤버 불일치: expected={expected}, actual={actual}"
        )
        assert len(list(LifecycleState)) == 7


class TestSourceOfTruth:
    """A-2~4. source-of-truth evidence → state 결정."""

    def test_source_of_truth_merged_smoke_pass_finalize(self):
        """A-2: PR merged + ci PASS + smoke PASS + has_done_acked + has_merge_done + timer completed → FINALIZED."""
        evidence = make_evidence(
            pr_state="MERGED",
            merge_commit="4d45947eaabbccdd",
            merged_into_main=True,
            ci_status="SUCCESS",
            smoke_status="PASS",
            timer_status="completed",
            has_done=True,
            has_done_acked=True,
            has_merge_done=True,
        )
        state, stuck_cases = determine_state(evidence)
        assert state == LifecycleState.FINALIZED, (
            f"FINALIZED 기대, 실제: {state}"
        )
        assert stuck_cases == [], f"FINALIZED에는 stuck_cases 없어야 함: {stuck_cases}"

    def test_source_of_truth_timer_running_pr_open_running(self):
        """A-3: timer running + PR OPEN → RUNNING 유지 (정상)."""
        evidence = make_evidence(
            pr_state="OPEN",
            pr_number=42,
            timer_status="running",
        )
        state, _stuck = determine_state(evidence)
        assert state == LifecycleState.RUNNING, (
            f"RUNNING 기대 (PR OPEN + timer running), 실제: {state}"
        )

    def test_evidence_conflict_github_over_timer(self):
        """A-4: timer running인데 PR MERGED → GitHub 우선, state ∈ {STUCK_NEEDS_RECONCILE, MERGED_PENDING_RECONCILE}.

        timer는 stuck signal 또는 backfill 대상.
        """
        evidence = make_evidence(
            pr_state="MERGED",
            merge_commit="11223344aabbccdd",
            merged_into_main=True,
            timer_status="running",
            # has_done_acked/has_merge_done 누락 → MERGED_PENDING_RECONCILE
        )
        state, stuck_cases = determine_state(evidence)
        assert state in {
            LifecycleState.STUCK_NEEDS_RECONCILE,
            LifecycleState.MERGED_PENDING_RECONCILE,
        }, (
            f"GitHub PR MERGED > timer running 우선순위 반영 필요, 실제 state: {state}"
        )
        # timer running + PR merged → TIMER_RUNNING_BUT_PR_MERGED stuck case 확인
        stuck_reasons = {sc.reason for sc in stuck_cases}
        assert StuckReason.TIMER_RUNNING_BUT_PR_MERGED in stuck_reasons, (
            f"TIMER_RUNNING_BUT_PR_MERGED stuck case 기대, actual reasons: {stuck_reasons}"
        )


# ===========================================================================
# B. Stuck Detection 8 케이스 (8건)
# ===========================================================================

class TestStuckDetection:
    """B. stuck detection 8 케이스 — 회장 §1~7 + Telegram cut-off."""

    def test_stuck_timer_running_but_pr_merged(self):
        """B-5: timer running + PR merged → TIMER_RUNNING_BUT_PR_MERGED 감지 (회장 §2, task-2517 dev2 사례)."""
        evidence = make_evidence(
            pr_state="MERGED",
            pr_number=100,
            merge_commit="abcdef99",
            merged_into_main=False,
            timer_status="running",
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.TIMER_RUNNING_BUT_PR_MERGED in reasons, (
            f"TIMER_RUNNING_BUT_PR_MERGED 기대, actual: {reasons}"
        )

    def test_stuck_pr_merged_but_done_missing(self):
        """B-6: PR merged + .done 없음 → PR_MERGED_BUT_DONE_MISSING 감지 (회장 §1)."""
        evidence = make_evidence(
            pr_state="MERGED",
            pr_number=200,
            merge_commit="abcdef12",
            merged_into_main=True,
            has_done=False,
            has_done_acked=False,
            has_merge_done=False,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.PR_MERGED_BUT_DONE_MISSING in reasons, (
            f"PR_MERGED_BUT_DONE_MISSING 기대, actual: {reasons}"
        )

    def test_stuck_merge_commit_but_merge_done_missing(self):
        """B-7: mergeCommit 존재 + .merge-done 없음 → MERGE_COMMIT_BUT_MERGE_DONE_MISSING 감지 (회장 §1)."""
        evidence = make_evidence(
            pr_state="MERGED",
            merge_commit="deadbeef12345678",
            merged_into_main=True,
            has_merge_done=False,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.MERGE_COMMIT_BUT_MERGE_DONE_MISSING in reasons, (
            f"MERGE_COMMIT_BUT_MERGE_DONE_MISSING 기대, actual: {reasons}"
        )

    def test_stuck_ci_pass_but_not_finalized(self):
        """B-8: PR merged + CI PASS + smoke PASS + .done.acked/.merge-done 누락 → CI_PASS_BUT_NOT_FINALIZED."""
        evidence = make_evidence(
            pr_state="MERGED",
            merge_commit="cafebabe12341234",
            merged_into_main=True,
            ci_status="SUCCESS",
            smoke_status="PASS",
            has_done_acked=False,
            has_merge_done=False,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.CI_PASS_BUT_NOT_FINALIZED in reasons, (
            f"CI_PASS_BUT_NOT_FINALIZED 기대, actual: {reasons}"
        )

    def test_stuck_telegram_reply_cut_off(self):
        """B-9: cron history 마지막 라인 truncated → TELEGRAM_REPLY_CUT_OFF 감지 (task-2517 dev2 cron 2BAB8982 패턴)."""
        evidence = make_evidence(
            task_id="task-2517",
            pr_state="MERGED",
            merge_commit="4d45947e",
            merged_into_main=True,
            ci_status="SUCCESS",
            smoke_status="PASS",
            timer_status="completed",
            has_done=True,
            has_merge_done=True,
            telegram_reply_truncated=True,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.TELEGRAM_REPLY_CUT_OFF in reasons, (
            f"TELEGRAM_REPLY_CUT_OFF 기대, actual: {reasons}"
        )

    def test_stuck_bot_session_ended_but_task_ok(self):
        """B-10: cron status=cancelled/error + task evidence 정상(merged_into_main=True) → BOT_SESSION_ENDED_BUT_TASK_OK."""
        for status in ("cancelled", "error"):
            evidence = make_evidence(
                pr_state="MERGED",
                merge_commit="aabbccdd11223344",
                merged_into_main=True,
                bot_session_status=status,
            )
            cases = detect_stuck_cases(evidence)
            reasons = {sc.reason for sc in cases}
            assert StuckReason.BOT_SESSION_ENDED_BUT_TASK_OK in reasons, (
                f"BOT_SESSION_ENDED_BUT_TASK_OK 기대 (bot_session_status={status}), actual: {reasons}"
            )

    def test_stuck_finish_task_interrupted(self):
        """B-11: worktree 존재 + branch_pushed_to_remote + PR 미생성 → FINISH_TASK_INTERRUPTED."""
        evidence = make_evidence(
            worktree_exists=True,
            branch_pushed_to_remote=True,
            pr_number=None,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.FINISH_TASK_INTERRUPTED in reasons, (
            f"FINISH_TASK_INTERRUPTED 기대, actual: {reasons}"
        )

    def test_stuck_stale_escalate_marker(self):
        """B-12: has_escalate_marker + age > 30분 + Critical 7종 매칭 evidence 없음 → STALE_ESCALATE_MARKER."""
        evidence = make_evidence(
            has_escalate_marker=True,
            escalate_marker_age_minutes=45.0,
            # No active critical evidence: ci_status not FAILURE, pr_state not MERGED-without-main
            ci_status="SUCCESS",
            pr_state="MERGED",
            merged_into_main=True,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.STALE_ESCALATE_MARKER in reasons, (
            f"STALE_ESCALATE_MARKER 기대 (age=45min, no active critical), actual: {reasons}"
        )


# ===========================================================================
# C. Idempotent + Manual .done 차단 (2건)
# ===========================================================================

class TestIdempotentAndForgeryBlock:
    """C. idempotent reconcile + manual .done 위장 차단."""

    def test_repeated_reconcile_idempotent(self, tmp_path):
        """C-13: 동일 reconcile 3회 호출 (apply=False) → state 동일, actions_taken 누적 없음, no-op."""

        def fake_pr_lookup(_task_id: str) -> dict:
            return {
                "number": 999,
                "state": "MERGED",
                "mergeCommit": {"oid": "deadbeef00000001"},
            }

        def fake_timer_loader(_task_id: str) -> dict:
            return {"status": "completed", "end_time": "2026-05-09T00:00:00Z"}

        results = []
        for _ in range(3):
            report = reconcile(
                "task-9999",
                apply=False,
                workspace_root=tmp_path,
                pr_lookup=fake_pr_lookup,
                timer_loader=fake_timer_loader,
            )
            results.append(report)

        # state must be consistent across all 3 calls
        states = {r.state for r in results}
        assert len(states) == 1, f"3회 호출 state 불일치: {[r.state for r in results]}"

        # apply=False → actions_taken은 항상 빈 리스트
        for i, r in enumerate(results):
            assert r.actions_taken == [], (
                f"call {i+1}: apply=False인데 actions_taken 비어있어야 함, got: {r.actions_taken}"
            )
        assert results[0].dry_run is True

    def test_manual_done_forgery_blocked(self, tmp_path):
        """C-14: evidence 부족 상태에서 assert_no_manual_done_forgery 호출 시 RuntimeError(MANUAL_DONE_FORGERY_BLOCKED)."""
        evidence = make_evidence(
            # 모든 필드 default: pr_state=None, merge_commit=None, merged_into_main=False
        )
        with pytest.raises(RuntimeError, match="MANUAL_DONE_FORGERY_BLOCKED"):
            assert_no_manual_done_forgery(
                "task-9999",
                evidence,
                workspace_root=tmp_path,
            )

    def test_manual_done_forgery_blocked_partial_evidence(self, tmp_path):
        """C-14 변형: merge_commit 있지만 merged_into_main=False → 차단."""
        evidence = make_evidence(
            merge_commit="cafecafe12345678",
            merged_into_main=False,
            ci_status="SUCCESS",
        )
        with pytest.raises(RuntimeError, match="MANUAL_DONE_FORGERY_BLOCKED"):
            assert_no_manual_done_forgery(
                "task-9999",
                evidence,
                workspace_root=tmp_path,
            )


# ===========================================================================
# Replay Fixtures (5건 — 회장 명시 사례 통합)
# ===========================================================================

class TestReplayFixtures:
    """5 실제 사례 replay fixture."""

    def test_replay_fixture_task2517_telegram_cut_off(self):
        """Fixture 1: task-2517 dev2 Telegram cut-off (cron 2BAB8982 마지막 'Pr'로 잘림).

        evidence: pr_state=MERGED, merge_commit=4d45947e..., merged_into_main=True,
                  ci_status=SUCCESS, smoke_status=PASS, timer_status=completed,
                  has_done=True, has_merge_done=True, telegram_reply_truncated=True
        기대: TELEGRAM_REPLY_CUT_OFF stuck case 감지
        """
        evidence = make_evidence(
            task_id="task-2517",
            pr_state="MERGED",
            merge_commit="4d45947eaabb1234",
            merged_into_main=True,
            ci_status="SUCCESS",
            smoke_status="PASS",
            timer_status="completed",
            has_done=True,
            has_done_acked=True,
            has_merge_done=True,
            telegram_reply_truncated=True,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.TELEGRAM_REPLY_CUT_OFF in reasons, (
            f"Fixture1: TELEGRAM_REPLY_CUT_OFF 기대, actual: {reasons}"
        )

    def test_replay_fixture_pr_merged_done_missing(self):
        """Fixture 2: PR merged but .done missing (회장 §1).

        evidence: pr_state=MERGED, merge_commit=abcdef12, merged_into_main=True,
                  has_done=False, has_done_acked=False, has_merge_done=False
        기대: PR_MERGED_BUT_DONE_MISSING + MERGE_COMMIT_BUT_MERGE_DONE_MISSING
        """
        evidence = make_evidence(
            pr_state="MERGED",
            pr_number=301,
            merge_commit="abcdef12",
            merged_into_main=True,
            has_done=False,
            has_done_acked=False,
            has_merge_done=False,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.PR_MERGED_BUT_DONE_MISSING in reasons, (
            f"Fixture2: PR_MERGED_BUT_DONE_MISSING 기대, actual: {reasons}"
        )
        assert StuckReason.MERGE_COMMIT_BUT_MERGE_DONE_MISSING in reasons, (
            f"Fixture2: MERGE_COMMIT_BUT_MERGE_DONE_MISSING 기대, actual: {reasons}"
        )

    def test_replay_fixture_merge_commit_timer_running(self):
        """Fixture 3: mergeCommit 존재 + timer running.

        evidence: pr_state=MERGED, merge_commit=11223344, merged_into_main=True, timer_status=running
        기대: TIMER_RUNNING_BUT_PR_MERGED + state ∈ {STUCK_NEEDS_RECONCILE, MERGED_PENDING_RECONCILE}
        """
        evidence = make_evidence(
            pr_state="MERGED",
            pr_number=402,
            merge_commit="11223344aabbccdd",
            merged_into_main=True,
            timer_status="running",
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.TIMER_RUNNING_BUT_PR_MERGED in reasons, (
            f"Fixture3: TIMER_RUNNING_BUT_PR_MERGED 기대, actual: {reasons}"
        )

        state, _ = determine_state(evidence)
        assert state in {
            LifecycleState.STUCK_NEEDS_RECONCILE,
            LifecycleState.MERGED_PENDING_RECONCILE,
        }, (
            f"Fixture3: state ∈ {{STUCK_NEEDS_RECONCILE, MERGED_PENDING_RECONCILE}} 기대, actual: {state}"
        )

    def test_replay_fixture_finish_task_interrupted(self):
        """Fixture 4: finish-task interrupted.

        evidence: worktree_exists=True, branch_pushed_to_remote=True, pr_number=None
        기대: FINISH_TASK_INTERRUPTED
        """
        evidence = make_evidence(
            worktree_exists=True,
            branch_pushed_to_remote=True,
            pr_number=None,
        )
        cases = detect_stuck_cases(evidence)
        reasons = {sc.reason for sc in cases}
        assert StuckReason.FINISH_TASK_INTERRUPTED in reasons, (
            f"Fixture4: FINISH_TASK_INTERRUPTED 기대, actual: {reasons}"
        )

    def test_replay_fixture_repeated_reconcile_idempotency(self, tmp_path):
        """Fixture 5: repeated reconcile idempotency.

        - 동일 evidence로 reconcile 3회 호출 (apply=False) → state 동일
        - apply=True 첫 호출 → 일부 backfill 액션 (planned/taken)
        - evidence(backfill 후) + 두번째 apply=True → 추가 액션 0건 (멱등)
        """
        # Baseline evidence: PR merged + markers missing → MERGED_PENDING_RECONCILE
        def fake_pr_lookup_pending(_task_id: str) -> dict:
            return {
                "number": 501,
                "state": "MERGED",
                "mergeCommit": {"oid": "f1x7ure5abcdef12"},
            }

        def fake_timer_loader_completed(_task_id: str) -> dict:
            return {"status": "completed", "end_time": "2026-05-09T01:00:00Z"}

        captured_writes: list[tuple] = []

        def fake_file_writer(path: Path, content: str) -> None:
            captured_writes.append((path, content))

        def fake_timer_writer(_task_id: str, _timer_data: dict) -> None:
            pass  # no-op

        # 1. apply=False 3회 → state 동일, actions_taken 항상 []
        dry_states = []
        for _ in range(3):
            report = reconcile(
                "task-fixture5",
                apply=False,
                workspace_root=tmp_path,
                pr_lookup=fake_pr_lookup_pending,
                timer_loader=fake_timer_loader_completed,
                file_writer=fake_file_writer,
                timer_writer=fake_timer_writer,
            )
            dry_states.append(report.state)
            assert report.actions_taken == [], (
                f"apply=False인데 actions_taken 비어있어야 함: {report.actions_taken}"
            )

        assert len(set(dry_states)) == 1, f"3회 dry-run state 불일치: {dry_states}"

        # 2. apply=True 첫 호출 — backfill 수행
        # Evidence에 has_done_acked=False, has_merge_done=False → backfill 대상
        # merged_into_main은 git check 없이 직접 모킹 필요 (workspace_root=tmp_path)
        # pr_lookup returns MERGED with mergeCommit → gather_evidence will compute merged_into_main
        # via _check_merged_into_main which calls git; we mock runner to return "found"
        def fake_runner_found(args: list, **_kwargs) -> subprocess.CompletedProcess:
            return subprocess.CompletedProcess(args, 0, stdout="f1x7ure5abcdef12\n", stderr="")

        report_apply1 = reconcile(
            "task-fixture5",
            apply=True,
            workspace_root=tmp_path,
            pr_lookup=fake_pr_lookup_pending,
            timer_loader=fake_timer_loader_completed,
            file_writer=fake_file_writer,
            timer_writer=fake_timer_writer,
            runner=fake_runner_found,
        )

        # 3. "backfill 후" evidence를 가정한 두 번째 apply=True
        # 이미 모든 markers가 있는 경우 → FINALIZED → no-op
        def fake_pr_lookup_finalized(_task_id: str) -> dict:
            return {
                "number": 501,
                "state": "MERGED",
                "mergeCommit": {"oid": "f1x7ure5abcdef12"},
            }

        def fake_timer_loader_finalized(_task_id: str) -> dict:
            return {"status": "completed", "end_time": "2026-05-09T01:00:00Z"}

        # Create marker files to simulate backfill already done
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True, exist_ok=True)
        (events_dir / "task-fixture5.done").write_text("{}")
        (events_dir / "task-fixture5.done.acked").write_text("{}")
        (events_dir / "task-fixture5.merge-done").write_text("{}")

        captured_writes_second: list[tuple] = []

        def fake_file_writer2(path: Path, content: str) -> None:
            captured_writes_second.append((path, content))

        report_apply2 = reconcile(
            "task-fixture5",
            apply=True,
            workspace_root=tmp_path,
            pr_lookup=fake_pr_lookup_finalized,
            timer_loader=fake_timer_loader_finalized,
            file_writer=fake_file_writer2,
            timer_writer=fake_timer_writer,
            runner=fake_runner_found,
        )

        # 두번째 apply=True: FINALIZED or no new writes (멱등)
        # actions_taken should be empty or state should be FINALIZED (no backfill needed)
        assert report_apply2.state == LifecycleState.FINALIZED or report_apply2.actions_taken == [], (
            f"두번째 apply=True: 추가 액션 없거나 FINALIZED 기대, "
            f"state={report_apply2.state}, actions_taken={report_apply2.actions_taken}"
        )


# ===========================================================================
# D. Live Pilot Replay (task-2520 self-host 한계점 박제)
# ===========================================================================

class TestLivePilotReplay:
    """task-2520 live pilot self-host 중 발견된 한계점 박제.

    다그다 hang 사례 (bot_session_status=cancelled + timer running + worktree/branch alive +
    PR 미생성)는 현 본체에서 FINISH_TASK_INTERRUPTED로만 매칭되며,
    bot_session_status=cancelled 단독 case는 어떤 stuck reason으로도 격상되지 않는다.
    operational hardening은 task-2521 영역.
    """

    def test_bot_session_cancelled_with_evidence_intact_replay(self, tmp_path):
        """task-2520: bot 종료 + timer 미정지 + worktree/branch 살아있음 + PR 미생성.

        현 본체 동작 정확 박제 (본체 변경 시 회귀 깨짐):
        1. state == STUCK_NEEDS_RECONCILE
           (PR 없음, escalate 없음, stuck_cases 존재 → fallback)
        2. stuck_cases ⊃ {FINISH_TASK_INTERRUPTED}
           (worktree_exists + branch_pushed_to_remote + pr_number None 매칭)
        3. BOT_SESSION_ENDED_BUT_TASK_OK NOT in stuck reasons
           (merged_into_main=True 조건 매칭 실패 — 본체 한계점, task-2521 영역)
        4. reconcile(apply=False) → actions_planned에 'BLOCKED (insufficient evidence)' 포함
           (assert_no_manual_done_forgery 차단)
        """
        evidence = make_evidence(
            task_id="task-2520",
            pr_number=None,
            pr_state=None,
            merge_commit=None,
            merged_into_main=False,
            timer_status="running",
            has_done=False,
            has_done_acked=False,
            has_merge_done=False,
            bot_session_status="cancelled",
            worktree_exists=True,
            branch_pushed_to_remote=True,
            smoke_status="PASS",  # L1 smoke 정상 가정 (state 결정에는 무영향)
        )

        state, stuck_cases = determine_state(evidence)
        reasons = {sc.reason for sc in stuck_cases}

        assert state == LifecycleState.STUCK_NEEDS_RECONCILE, (
            f"STUCK_NEEDS_RECONCILE 기대 (PR 없음 + stuck_cases 존재), 실제: {state}"
        )

        assert StuckReason.FINISH_TASK_INTERRUPTED in reasons, (
            f"FINISH_TASK_INTERRUPTED 기대 (worktree+branch+no_pr 매칭), actual: {reasons}"
        )

        # 현 본체 한계점 박제: cancelled + merged_into_main=False 조합은
        # 어떤 stuck reason으로도 격상되지 않음. 격상은 task-2521 hardening 영역.
        assert StuckReason.BOT_SESSION_ENDED_BUT_TASK_OK not in reasons, (
            "현 본체 한계점 회귀 박제: bot_session_status=cancelled 단독은 격상 안됨. "
            "격상 시 task-2521에서 신규 stuck reason 추가."
        )

        # reconcile dry-run: actions_planned에 forgery 차단 marker 포함 확인
        def fake_pr_lookup(_task_id: str) -> dict:
            return {}

        def fake_timer_loader(_task_id: str) -> dict:
            return {"status": "running", "end_time": None}

        def fake_runner(args: list, **_kwargs) -> subprocess.CompletedProcess:
            # git ls-remote: 가상의 원격 push 흔적 1건 반환
            if "ls-remote" in args:
                return subprocess.CompletedProcess(
                    args,
                    0,
                    stdout="deadbeefcafe1234\trefs/heads/task/task-2520-dev4\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(args, 1, stdout="", stderr="")

        # worktree 디렉터리 + cron history fixture 준비
        worktree_dir = tmp_path / ".worktrees" / "task-2520-dev4"
        worktree_dir.mkdir(parents=True, exist_ok=True)

        cron_dir = tmp_path / "cron_history"
        cron_dir.mkdir(parents=True, exist_ok=True)
        cron_log = cron_dir / "task-2520.log"
        cron_log.write_text(
            json.dumps(
                {
                    "prompt": "[task-2520] live pilot",
                    "status": "cancelled",
                    "response": "ok",
                }
            )
            + "\n",
            encoding="utf-8",
        )

        report = reconcile(
            "task-2520",
            apply=False,
            workspace_root=tmp_path,
            runner=fake_runner,
            cron_history_dir=cron_dir,
            pr_lookup=fake_pr_lookup,
            timer_loader=fake_timer_loader,
        )

        assert report.dry_run is True
        assert report.state == LifecycleState.STUCK_NEEDS_RECONCILE, (
            f"reconcile state STUCK_NEEDS_RECONCILE 기대, 실제: {report.state}"
        )
        forgery_blocked = any(
            ("BLOCKED" in action and "insufficient evidence" in action)
            or "MANUAL_DONE_FORGERY_BLOCKED" in action
            for action in report.actions_planned
        )
        assert forgery_blocked, (
            "actions_planned에 'BLOCKED (insufficient evidence)' 또는 "
            f"'MANUAL_DONE_FORGERY_BLOCKED' 기대, actual: {report.actions_planned}"
        )
        # forgery 차단 후 다른 backfill 액션은 등장하지 않아야 함
        assert not any(
            action.startswith("created_")
            or action.startswith("wrote_")
            or action == "ended_timer"
            for action in report.actions_planned
        ), (
            f"forgery 차단 후 다른 backfill 액션 기록 금지, actual: {report.actions_planned}"
        )
        assert report.actions_taken == [], (
            f"apply=False인데 actions_taken 비어있어야 함: {report.actions_taken}"
        )


# ===========================================================================
# Additional: StuckReason enum 8종 검증
# ===========================================================================

class TestStuckReasonEnum:
    """StuckReason enum이 회장 명시 8종(task-2518) 포함 확인.
    task-2521 §3에서 BOT_CANCELLED_* 4종이 추가되어 총 12종이 되었으므로
    superset 검증으로 전환 (task-2518 contract = 8종 모두 present).
    """

    def test_stuck_reason_enum_eight_members(self):
        expected_2518 = {
            "TIMER_RUNNING_BUT_PR_MERGED",
            "PR_MERGED_BUT_DONE_MISSING",
            "MERGE_COMMIT_BUT_MERGE_DONE_MISSING",
            "CI_PASS_BUT_NOT_FINALIZED",
            "TELEGRAM_REPLY_CUT_OFF",
            "BOT_SESSION_ENDED_BUT_TASK_OK",
            "FINISH_TASK_INTERRUPTED",
            "STALE_ESCALATE_MARKER",
        }
        actual = {member.value for member in StuckReason}
        # task-2518 contract: 8종 모두 present (superset OK; task-2521이 4종 추가)
        missing = expected_2518 - actual
        assert not missing, (
            f"task-2518 StuckReason 누락: missing={missing}, actual={actual}"
        )
