"""StepRunner resume 무결성 framework 통합 테스트.

Task 5: verify_completion / cleanup_artifacts default 메서드 + _safe_verify_completion.
21개 step 누구도 영향받지 않도록 베이스는 default-safe (clean / noop / catch-all).

Task 6: recovery counter 메서드 4종 (record/reset/get/get_reason).
verify entry 실패 시 recovery_count++ + last_recovery_reason 영속화로 auto-rerun
가시성을 확보한다. 정상 진행 시 reset해 다음 sweep을 깨끗한 상태로 시작.
"""
import pytest
from sqlalchemy import text

from app.core.integrity_report import CompletionReport, CleanupReport


def test_base_verify_completion_returns_clean(make_step_runner):
    """베이스 verify_completion은 항상 is_complete=True (override 안 한 step 보호)."""
    runner = make_step_runner("text_cleanup")
    report = runner.verify_completion()
    assert isinstance(report, CompletionReport)
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.missing == []


def test_base_cleanup_artifacts_returns_noop(make_step_runner):
    """베이스 cleanup_artifacts는 항상 deleted=0 (이미지 step 보호)."""
    runner = make_step_runner("text_cleanup")
    report = runner.cleanup_artifacts()
    assert isinstance(report, CleanupReport)
    assert report.deleted_db_rows == 0
    assert report.deleted_files == 0
    assert report.targets == []


def test_safe_verify_completion_raises_on_unexpected_exception(make_step_runner):
    """Block B B7 (plan v2.1.3 / spec V5 §4.3): unexpected exception (RuntimeError 등)
    은 fail-fast — `AppError(step.verify_crashed)` raise. 자동 force 격상 금지.

    이전 동작 (모든 exception → is_complete=False 흡수) 은 silent recovery 사고
    패턴 — feedback_no_silent_fallback.md.
    """
    from app.core.errors import AppError

    runner = make_step_runner("text_cleanup")

    def crash(self):
        raise RuntimeError("boom")
    runner.verify_completion = crash.__get__(runner, type(runner))

    with pytest.raises(AppError) as exc_info:
        runner._safe_verify_completion()

    assert exc_info.value.code == "step.verify_crashed"
    assert "RuntimeError" in exc_info.value.message
    assert "boom" in exc_info.value.message


# ── Task 6: recovery counter ──


def test_record_recovery_increments_count(make_step_runner):
    """_record_recovery()는 recovery_count++ + last_recovery_reason 기록."""
    runner = make_step_runner("text_cleanup")
    # ★실제 순서 그대로 — 카운터는 claim 뒤(status='running')에만 오른다.
    #  밖에서 status 를 갈아 끼운 줄에는 안 올라야 맞다(2026-08-26).
    runner._update_step_run("running")

    new_count = runner._record_recovery("verify failed: 3 missing")

    assert new_count == 1
    assert runner._get_recovery_count() == 1
    assert runner._get_last_recovery_reason() == "verify failed: 3 missing"


def test_record_recovery_repeats(make_step_runner):
    runner = make_step_runner("text_cleanup")
    runner._update_step_run("running")
    runner._record_recovery("first")
    runner._record_recovery("second")
    assert runner._get_recovery_count() == 2
    assert runner._get_last_recovery_reason() == "second"


def test_reset_recovery_counter(make_step_runner):
    runner = make_step_runner("text_cleanup")
    runner._update_step_run("running")
    runner._record_recovery("oops")
    assert runner._get_recovery_count() == 1
    # 초기화는 완료로 닫은 **뒤**에 온다 — 프로덕션 순서 그대로.
    runner._update_step_run("completed")
    runner._reset_recovery_counter()
    assert runner._get_recovery_count() == 0
    assert runner._get_last_recovery_reason() == "(없음)"


def test_get_recovery_count_no_row_returns_zero(make_step_runner):
    runner = make_step_runner("text_cleanup")
    # step_run row 미존재
    assert runner._get_recovery_count() == 0
    assert runner._get_last_recovery_reason() == "(없음)"


# ── Task 7: _mask_sensitive_keys ──


def test_mask_sensitive_keys_basic(make_step_runner):
    runner = make_step_runner("text_cleanup")
    cfg = {
        "model": "gpt-5.5",
        "openai_api_key": "sk-xyz",
        "anthropic_api_key": "sk-ant-aaa",
        "password": "p@ss",
        "user_token": "abc",
        "secret_value": "shh",
        "credential": "raw",
        "nested": {"api_key": "deep"},
    }
    masked = runner._mask_sensitive_keys(cfg)
    assert masked["model"] == "gpt-5.5"
    assert masked["openai_api_key"] == "***"
    assert masked["anthropic_api_key"] == "***"
    assert masked["password"] == "***"
    assert masked["user_token"] == "***"
    assert masked["secret_value"] == "***"
    assert masked["credential"] == "***"
    assert masked["nested"]["api_key"] == "***"


def test_mask_sensitive_keys_empty_input(make_step_runner):
    runner = make_step_runner("text_cleanup")
    assert runner._mask_sensitive_keys({}) == {}
    assert runner._mask_sensitive_keys(None) == {}


# ── Task 8: save_checkpoint이 project_config_snapshot 기록 (D2) ──


def test_save_checkpoint_records_project_config_snapshot(make_step_runner, tmp_path):
    """D2 fix: save_checkpoint이 project_config_snapshot을 기록 (마스킹 포함).

    이전엔 schema_version + config_hash만 기록하여 _diff_project_config가
    None reference로 폴백 → 매번 stale auto-rerun trigger되는 패턴을 종결.
    """
    runner = make_step_runner("text_cleanup")
    # tmp_path로 cp_dir 격리 — 실 disk(projects_dir) 오염 방지.
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner.project_config = {"model": "gpt-5.5", "openai_api_key": "sk-xyz"}

    runner.save_checkpoint({"status": "completed", "data": {"foo": "bar"}})

    cp = runner.load_checkpoint()
    assert cp is not None
    assert "project_config_snapshot" in cp
    assert cp["project_config_snapshot"]["model"] == "gpt-5.5"
    assert cp["project_config_snapshot"]["openai_api_key"] == "***"


def test_save_checkpoint_preserves_explicit_snapshot(make_step_runner, tmp_path):
    """step이 _execute()에서 명시적으로 project_config_snapshot을 반환했다면 보존.

    Phase 5.1 패턴(step이 schema_version/config_hash 명시 시 보존)과 일관.
    """
    runner = make_step_runner("text_cleanup")
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner.project_config = {"model": "gpt-5.5"}

    explicit = {"model": "different-from-runtime", "extra": "step_provided"}
    runner.save_checkpoint({
        "status": "completed",
        "data": {"foo": "bar"},
        "project_config_snapshot": explicit,
    })

    cp = runner.load_checkpoint()
    assert cp is not None
    assert cp["project_config_snapshot"] == explicit


# ── Task 10: run() resume entry verify_completion + cp=None handling (D1) ──


def test_resume_with_cp_and_verify_pass_skips(make_step_runner, tmp_path):
    """status=completed + cp 있음 + verify pass → skipped 반환 (정상 case 보존)."""
    runner = make_step_runner("text_cleanup")
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner._update_step_run("completed")
    runner.save_checkpoint({"status": "completed", "data": {}})

    # _execute가 호출되면 안 됨 — skipped 경로 검증.
    executed = []

    def stub_execute(self, mode):
        executed.append(mode)
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    result = runner.run(mode="resume")
    assert result["status"] == "skipped"
    assert executed == [], "verify pass + cp 정합 시 _execute 호출되면 안 됨"


def test_resume_with_cp_none_triggers_force(make_step_runner, tmp_path):
    """D1 핵심: status=completed + cp=None (manifest 폐기) → force 격상.

    PID 0bb48ebf 사고 패턴(manifest 폐기 + step_run.completed)에서 silent
    skip 되던 동작을 force 재실행으로 격상한다.

    Task 13 이후: 정상 완료 시 recovery counter reset되므로, _record_recovery
    시점은 _execute 호출 직전(force 격상 시)에서만 관측 가능. 따라서 stub_execute에서
    snapshot을 캡처해 검증한다.
    """
    runner = make_step_runner("text_cleanup")
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner._update_step_run("completed")
    # 의도적으로 cp 미저장 (.force_cleared marker 작성됨)
    runner.clear_checkpoint()

    executed = []
    snapshot = {}

    def stub_execute(self, mode):
        executed.append(mode)
        # _record_recovery 직후, _reset_recovery_counter 전 시점 snapshot.
        snapshot["count"] = self._get_recovery_count()
        snapshot["reason"] = self._get_last_recovery_reason()
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="resume")
    # B12 (plan v2.1.3 §S2): cp=None artifact_missing → RERUN_SELF (not force).
    # _execute_rerun_self 는 _execute(mode='resume') 호출 — cleanup/invalidate X.
    assert executed == ["resume"], "cp=None artifact_missing → rerun_self (B12 정책)"
    assert snapshot["count"] == 1
    assert "checkpoint missing" in snapshot["reason"]
    # 정상 완료 후 reset 확인 (Task 13 계약)
    assert runner._get_recovery_count() == 0


def test_resume_with_verify_fail_triggers_rerun_self(make_step_runner, tmp_path):
    """B12 (plan v2.1.3 §S2): status=completed + cp 있음 + verify 실패 (origin
    default=artifact_missing) → RERUN_SELF + recovery_count 기록.

    이전 동작: mode='force' 격상 → cleanup + invalidate + execute(mode='force').
    B12 후: _execute_rerun_self → cleanup/invalidate X, execute(mode='resume').
    """
    runner = make_step_runner("text_cleanup")
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner._update_step_run("completed")
    runner.save_checkpoint({"status": "completed", "data": {}})

    from app.core.integrity_report import CompletionReport

    def stub_verify(self):
        return CompletionReport(
            is_complete=False, missing=["X"], severity="missing", metadata={},
        )
    runner.verify_completion = stub_verify.__get__(runner, type(runner))

    cleanup_calls = []

    def stub_cleanup(self):
        cleanup_calls.append(True)
        return CleanupReport(deleted_db_rows=0, deleted_files=0, targets=[], skipped=[])
    runner.cleanup_artifacts = stub_cleanup.__get__(runner, type(runner))

    executed = []

    def stub_execute(self, mode):
        executed.append(mode)
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="resume")

    # B12: rerun_self → mode='resume' 로 _execute 호출, cleanup_artifacts 호출 안 함
    assert executed == ["resume"], "auto-recovery RERUN_SELF → execute(mode='resume')"
    assert cleanup_calls == [], "RERUN_SELF 는 cleanup_artifacts 호출 안 함 (downstream 보존)"
    # recovery_count 는 helper 디스패치 단에서 영속 (가시성 유지)
    assert runner._get_recovery_count() == 1
    assert "verify failed" in runner._get_last_recovery_reason()


# ── Task 11: status별 force-like 분기 (추천 1) ──


@pytest.mark.parametrize("status", ["failed", "partial", "stale", "pending"])
def test_resume_non_completed_status_rerun_self(make_step_runner, status):
    """B12 (plan v2.1.3 §S2): status in {failed/partial/stale/pending} →
    RERUN_SELF + origin='prior_state' → `_execute_rerun_self()`. cleanup/invalidate
    호출 안 함 (downstream cp 보존, auto-recovery 가 다른 step 영향 X).

    이전 동작 (T1~T3): mode='force' 격상 → cleanup + invalidate + execute(force).
    이후 (B12): execute(mode='resume'), cleanup/invalidate skip.

    NOTE: 'running' 은 B4 에서 분리 — timeout 기반 BLOCK / STALE_RUNNING_RECOVERY.

    시나리오 (running 제외):
      - failed: 이전 실행 실패
      - partial: 일부만 성공 (failed_count > 0)
      - stale: downstream invalidate 결과 (이전 force가 마킹)
      - pending: step_run row만 있고 실제 실행 안 됨
    """
    runner = make_step_runner("text_cleanup")
    runner._update_step_run(status)

    cleanup_called = []

    def stub_cleanup(self):
        cleanup_called.append(True)
        return CleanupReport(deleted_db_rows=0, deleted_files=0, targets=[], skipped=[])
    runner.cleanup_artifacts = stub_cleanup.__get__(runner, type(runner))

    invalidate_called = []

    def stub_invalidate(*args, **kwargs):
        invalidate_called.append(True)
    runner.invalidate_downstream = stub_invalidate

    executed = []

    def stub_execute(self, mode):
        executed.append(mode)
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="resume")

    assert executed == ["resume"], (
        f"status={status} RERUN_SELF — execute(mode='resume') 의무 (B12)"
    )
    assert cleanup_called == [], (
        f"status={status} RERUN_SELF — cleanup_artifacts 호출 X (B12 정책)"
    )
    assert invalidate_called == [], (
        f"status={status} RERUN_SELF — invalidate_downstream 호출 X (B12 정책)"
    )


# ── Task 12: cleanup_artifacts 호출 + 예외 처리 (force path) ──


def test_force_calls_cleanup_artifacts(make_step_runner):
    """force 분기는 _execute 직전 cleanup_artifacts를 한 번 호출해야 한다."""
    runner = make_step_runner("text_cleanup")
    runner._update_step_run("completed")
    runner.save_checkpoint({"status": "completed", "data": {}})

    cleanup_calls = []

    def stub_cleanup(self):
        cleanup_calls.append(True)
        return CleanupReport(
            deleted_db_rows=5, deleted_files=2, targets=["x"], skipped=[],
        )
    runner.cleanup_artifacts = stub_cleanup.__get__(runner, type(runner))

    def stub_execute(self, mode):
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="force")
    assert cleanup_calls == [True]


def test_cleanup_artifacts_exception_marks_failed(make_step_runner):
    """cleanup_artifacts 예외 시 즉시 raise + step_run='failed' 마킹.

    spec §6 Error handling: cleanup 자체 예외는 DB inconsistent 위험.
    silent fallback 금지 — 사용자에게 노출 + step_run에 failed 영속.
    """
    runner = make_step_runner("text_cleanup")
    runner._update_step_run("completed")

    def stub_cleanup(self):
        raise RuntimeError("cleanup boom")
    runner.cleanup_artifacts = stub_cleanup.__get__(runner, type(runner))

    with pytest.raises(RuntimeError, match="cleanup boom"):
        runner.run(mode="force")

    row = runner._get_step_run("text_cleanup")
    assert row is not None
    assert row["status"] == "failed"


# ── Task 13: exit verify + recovery counter reset ──


def test_exit_verify_pass_completes(make_step_runner):
    """exit verify pass → final_status='completed' 유지 (정상 case 보존)."""
    runner = make_step_runner("text_cleanup")

    def stub_execute(self, mode):
        return {"completed_count": 5, "applicable_count": 5, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    result = runner.run(mode="force")
    assert result["status"] == "completed"


def test_exit_verify_fail_marks_partial(make_step_runner):
    """exit verify 실패 → final_status='partial' 마킹."""
    runner = make_step_runner("text_cleanup")

    def stub_execute(self, mode):
        return {"completed_count": 5, "applicable_count": 5, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    def stub_verify(self):
        return CompletionReport(is_complete=False, missing=["x"], severity="partial", metadata={})
    runner.verify_completion = stub_verify.__get__(runner, type(runner))

    result = runner.run(mode="force")
    assert result["status"] == "partial"


def test_completed_resets_recovery_counter(make_step_runner):
    """status=completed 정상 종료 시 recovery_count → 0 reset."""
    runner = make_step_runner("text_cleanup")
    runner._update_step_run("running")
    runner._record_recovery("prior")
    assert runner._get_recovery_count() == 1
    runner._update_step_run("completed")   # 이 판이 끝난 상태로 되돌린다

    def stub_execute(self, mode):
        return {"completed_count": 5, "applicable_count": 5, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="force")
    assert runner._get_recovery_count() == 0
    assert runner._get_last_recovery_reason() == "(없음)"


# ── Task 14: recovery_exhausted 차단 (3회 후) ──


def test_recovery_exhausted_after_3_attempts(make_step_runner):
    """recovery_count >= 3 + mode=force → step.recovery_exhausted AppError."""
    import pytest
    from app.core.errors import AppError

    runner = make_step_runner("text_cleanup")
    runner._update_step_run("running")
    runner._record_recovery("a")
    runner._record_recovery("b")
    runner._record_recovery("c")
    assert runner._get_recovery_count() == 3
    runner._update_step_run("completed")   # 이 판이 끝난 상태로 되돌린다

    # cp=None + status=completed → resume 분기에서 force 격상 → recovery_exhausted
    runner.clear_checkpoint()
    with pytest.raises(AppError) as exc:
        runner.run(mode="resume")
    assert exc.value.code == "step.recovery_exhausted"
    assert "3" in exc.value.message


# ── Task 15: integration — entry/exit 양방향 cycle ──


def test_full_cycle_entry_exit_recovery(make_step_runner, tmp_path):
    """End-to-end: 정상 → entry verify fail → force → execute → exit verify pass → recovery reset.

    A5 hygiene: tmp_path로 cp_dir 격리 — 실 disk(projects_dir) 오염 방지.
    """
    runner = make_step_runner("text_cleanup")
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner._update_step_run("completed")
    runner.save_checkpoint({"status": "completed", "data": {}})

    # cycle 1 — entry verify 실패하도록 stub
    fail_count = [0]

    def stub_verify(self):
        fail_count[0] += 1
        if fail_count[0] == 1:
            return CompletionReport(is_complete=False, missing=["x"], severity="missing", metadata={})
        return CompletionReport(is_complete=True, missing=[], severity="clean", metadata={})
    runner.verify_completion = stub_verify.__get__(runner, type(runner))

    def stub_execute(self, mode):
        return {"completed_count": 5, "applicable_count": 5, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    result = runner.run(mode="resume")
    assert result["status"] == "completed"
    assert runner._get_recovery_count() == 0  # exit pass로 reset
    # entry 1회 + exit 1회 = 2회 호출
    assert fail_count[0] == 2


# ── Task 16: integration — .force_cleared marker 상호작용 ──


def test_force_cleared_marker_with_auto_rerun(make_step_runner, tmp_path):
    """auto-rerun으로 force 격상 → clear_checkpoint이 marker 작성 → 정상 save 시 marker 제거.

    spec §6: clear_checkpoint이 .force_cleared marker를 작성하고, save_checkpoint이
    status=completed/partial/not_applicable 저장 시 marker를 제거하는 양쪽 흐름을
    end-to-end로 검증한다. 흐름:
      entry verify fail → mode=force 격상 → cleanup_artifacts → invalidate_downstream
        → clear_checkpoint(marker 작성) → _execute → exit verify pass
        → save_checkpoint(status=completed, marker 제거)
    """
    runner = make_step_runner("text_cleanup")
    # tmp_path로 cp_dir 격리 — 실 disk(projects_dir) 오염 방지 + 다른 테스트 영향 없도록.
    runner._cp_dir = tmp_path / "cp"
    runner._cp_dir.mkdir(parents=True, exist_ok=True)
    runner._update_step_run("completed")
    runner.save_checkpoint({"status": "completed", "data": {}})

    def stub_verify(self):
        # 1회 entry fail → force 후 exit pass 만들기 위해 호출 카운트 분기.
        if not hasattr(self, '_v_call_count'):
            self._v_call_count = 0
        self._v_call_count += 1
        if self._v_call_count == 1:
            return CompletionReport(is_complete=False, missing=["x"], severity="missing", metadata={})
        return CompletionReport(is_complete=True, missing=[], severity="clean", metadata={})
    runner.verify_completion = stub_verify.__get__(runner, type(runner))

    def stub_execute(self, mode):
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0, "data": {}}
    runner._execute = stub_execute.__get__(runner, type(runner))

    runner.run(mode="resume")
    # 정상 save 후 marker 제거 확인
    marker = runner._cp_dir / runner._FORCE_CLEARED_MARKER
    assert not marker.exists(), "정상 save 후 .force_cleared marker는 제거되어야 함"


# ── 2026-08-26 Codex PR#4 리뷰 BLOCK-1·2 ─────────────────────────────


def test_진행률이_끝에_닿아도_성공_주행이_예외로_끝나지_않는다(make_step_runner):
    """★fan-out 이 마지막 조각을 끝내면 `update_progress(total, total)` 가
     불린다. 그때 DB 를 곧바로 'completed' 로 바꿔 버리면, 그 뒤 마무리가
     상태를 적으려 할 때 「내 줄이 아니다」로 읽혀 **성공한 주행이 예외로
     끝난다**.

     터미널 상태는 마무리 한 곳만 소유해야 한다.
    """
    runner = make_step_runner("text_cleanup")

    def stub_execute(self, mode):
        # 실제 fan-out 스텝이 하는 그대로 — 끝에 닿은 진행률을 적고 반환한다.
        self.update_progress(3, 3)
        return {"completed_count": 3, "applicable_count": 3,
                "failed_count": 0, "data": {}}

    runner._execute = stub_execute.__get__(runner, type(runner))
    out = runner.run(mode="force")          # 예외 없이 끝나야 한다
    assert out["status"] == "completed"
    assert runner._get_recovery_count() == 0


def test_체크포인트_저장이_실패하면_completed_로_남지_않는다(make_step_runner):
    """★마무리가 completed 를 먼저 적고 체크포인트를 저장한다. 저장이
     실패하면 failed 로 되돌려야 하는데, 그 되돌리기가 막히면 **DB 는
     성공인데 실제로는 실패**인 채 남아 다음 재개가 건너뛴다.
    """
    from app.core.errors import AppError

    runner = make_step_runner("text_cleanup")

    def stub_execute(self, mode):
        return {"completed_count": 1, "applicable_count": 1,
                "failed_count": 0, "data": {}}

    def boom(self, payload):
        raise OSError("디스크가 꽉 찼다")

    runner._execute = stub_execute.__get__(runner, type(runner))
    runner.save_checkpoint = boom.__get__(runner, type(runner))

    with pytest.raises((OSError, AppError)):
        runner.run(mode="force")

    row = runner.db.execute(text(
        "SELECT status FROM step_run WHERE project_id = :pid "
        "AND episode_id = :eid AND step_id = :sid"
    ), {"pid": runner.project_id, "eid": runner.episode_id,
        "sid": runner.step_id}).fetchone()
    assert row is not None
    assert row[0] != "completed", (
        f"체크포인트를 못 남겼는데 DB 는 completed 다 — 다음 재개가 "
        f"이 스텝을 건너뛴다 (status={row[0]})")
