"""Block B B12 — `_execute_rerun_self` / `_execute_force` 실행 경로 분리.

Plan v2.1.3 / spec V5 §S2 (AC-B6 / AC-B8): auto-recovery (RERUN_SELF) 와 사용자
명시 force (FORCE_EXPLICIT) 의 실행 경로 분리. 이전엔 둘 다 mode='force' 로 격상
되어 cleanup_artifacts + invalidate_downstream 공유 → auto-rerun 이 downstream
까지 invalidate 하는 사고 위험.

본 commit 후:
- `_execute_rerun_self()`: cleanup/invalidate/clear 호출 X — 자기 step 만 rerun.
  downstream cp 보존. allowlist (entity_t2i) / first-run / prior_state 모든
  RERUN_SELF 경로가 사용.
- `_execute_force()`: cleanup_artifacts + invalidate_downstream(delete_cp=True)
  + clear_checkpoint — 사용자 명시 force 만 사용.
- `_execute_and_finalize(execute_mode)`: 두 경로 공통 finalize (running mark +
  _execute + final_status + exit verify + status transition + save_checkpoint
  + recovery counter reset + modifies_checkpoints cascade + 예외 status='failed'
  마킹).

scope:
- B9 (`_update_step_run_strict` / require_owner) follow-up — 본 commit 은
  기존 `_update_step_run` 사용
- exit verify crash status 마킹 (B7 발견 시 status='failed' 영속) — 본 commit
  포함

핵심 운영 가드:
- auto-recovery 시 downstream cp 가 invalidate 안 됨 — verify 에서 sentinel/
  hash drift 로 catch (B7+B8+B11 통합)
- explicit force 만 downstream invalidate — 사용자 의도 반영
"""
from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from app.core.errors import AppError
from app.core.integrity_report import CleanupReport, CompletionReport


def _make_runner_for_finalize_test(monkeypatch):
    """_execute_and_finalize 단위 테스트용 StepRunner — 모든 의존 stub."""
    from app.core.step_runner import StepRunner

    runner = StepRunner.__new__(StepRunner)
    runner.step_id = "test_step"
    runner.run_id = "r1"
    runner.project_id = "p"
    runner.episode_id = "e"
    runner.manifest = {"modifies_checkpoints": [], "invalidate_downstream_on_edit": False}
    runner.opik_context = {}

    runner._update_step_run = MagicMock()
    runner._execute = MagicMock(return_value={
        "completed_count": 1, "applicable_count": 1, "failed_count": 0,
    })
    runner._safe_verify_completion = MagicMock(return_value=CompletionReport(
        is_complete=True, missing=[], severity="clean", metadata={}, origin="clean",
    ))
    runner.save_checkpoint = MagicMock()
    runner._reset_recovery_counter = MagicMock()
    runner.invalidate_downstream = MagicMock()
    runner.build_opik_metadata = lambda **kw: {}
    runner._resolve_model = lambda: "test-model"
    return runner


# ---------------------------------------------------------------------------
# `_execute_rerun_self()` — auto-recovery, NO cleanup/invalidate/clear
# ---------------------------------------------------------------------------


class TestExecuteRerunSelf:
    def test_rerun_self_does_not_call_cleanup_artifacts(self, monkeypatch):
        """AC-B6 핵심: auto-recovery 는 cleanup_artifacts 호출 안 함."""
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))

        runner._execute_rerun_self()

        runner.cleanup_artifacts.assert_not_called()

    def test_rerun_self_does_not_call_invalidate_downstream(self, monkeypatch):
        """AC-B6 핵심: auto-recovery 는 downstream cp 보존."""
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))

        runner._execute_rerun_self()

        runner.invalidate_downstream.assert_not_called()

    def test_rerun_self_does_not_call_clear_checkpoint(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))
        runner.clear_checkpoint = MagicMock()

        runner._execute_rerun_self()

        runner.clear_checkpoint.assert_not_called()

    def test_rerun_self_calls_execute_with_resume_mode(self, monkeypatch):
        """rerun_self 는 _execute(mode='resume') 호출 — force-like 격상 X."""
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock()
        runner.clear_checkpoint = MagicMock()

        runner._execute_rerun_self()

        runner._execute.assert_called_once()
        call_args = runner._execute.call_args
        # mode kwarg or positional
        actual_mode = call_args.kwargs.get("mode") or (call_args.args[0] if call_args.args else None)
        assert actual_mode == "resume", f"_execute mode must be 'resume' (got {actual_mode!r})"


# ---------------------------------------------------------------------------
# `_execute_force()` — explicit force, WITH cleanup/invalidate/clear
# ---------------------------------------------------------------------------


class TestExecuteForce:
    def test_force_calls_cleanup_artifacts(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))
        runner.clear_checkpoint = MagicMock()

        runner._execute_force()

        runner.cleanup_artifacts.assert_called_once()

    def test_force_calls_invalidate_downstream(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))
        runner.clear_checkpoint = MagicMock()

        runner._execute_force()

        runner.invalidate_downstream.assert_called_once()

    def test_force_calls_clear_checkpoint(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))
        runner.clear_checkpoint = MagicMock()

        runner._execute_force()

        runner.clear_checkpoint.assert_called_once()

    def test_force_cleanup_exception_marks_failed(self, monkeypatch):
        """cleanup_artifacts 예외 시 step_run='failed' 마킹 + raise (silent fallback 금지)."""
        runner = _make_runner_for_finalize_test(monkeypatch)

        def _crash():
            raise RuntimeError("cleanup boom")

        runner.cleanup_artifacts = _crash
        runner.clear_checkpoint = MagicMock()

        with pytest.raises(RuntimeError, match="cleanup boom"):
            runner._execute_force()

        # _update_step_run 이 'failed' 로 호출됐는지
        calls = [c for c in runner._update_step_run.call_args_list if c.args and c.args[0] == "failed"]
        assert calls, "_update_step_run('failed', ...) must be called on cleanup crash"
        # error_message 에 cleanup_artifacts 명시
        failed_call = calls[0]
        err_msg = failed_call.kwargs.get("error_message", "")
        assert "cleanup_artifacts" in err_msg or "cleanup" in err_msg

    def test_force_calls_execute_with_force_mode(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner.cleanup_artifacts = MagicMock(return_value=CleanupReport(
            deleted_db_rows=0, deleted_files=0, targets=[], skipped=[],
        ))
        runner.clear_checkpoint = MagicMock()

        runner._execute_force()

        runner._execute.assert_called_once()
        call_args = runner._execute.call_args
        actual_mode = call_args.kwargs.get("mode") or (call_args.args[0] if call_args.args else None)
        assert actual_mode == "force", f"_execute mode must be 'force' (got {actual_mode!r})"


# ---------------------------------------------------------------------------
# `_execute_and_finalize()` — 두 경로 공통 finalize
# ---------------------------------------------------------------------------


class TestExecuteAndFinalizeSteps:
    """V5 S2 + P3 보강: 6 단계 모두 1:1 호출 (running mark / execute / verify /
    transition / save_checkpoint / reset counter / modifies cascade).
    """

    def test_running_status_marked_before_execute(self, monkeypatch):
        """_update_step_run("running") 가 _execute 전 호출."""
        runner = _make_runner_for_finalize_test(monkeypatch)

        runner._execute_and_finalize(execute_mode="resume")

        # _update_step_run("running") 호출 검증 — 첫 호출이 running 마크
        first_call = runner._update_step_run.call_args_list[0]
        assert first_call.args[0] == "running" or first_call.kwargs.get("status") == "running"

    def test_execute_called_with_given_mode(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)

        runner._execute_and_finalize(execute_mode="force")

        runner._execute.assert_called_once()
        call_args = runner._execute.call_args
        actual_mode = call_args.kwargs.get("mode") or (call_args.args[0] if call_args.args else None)
        assert actual_mode == "force"

    def test_completed_status_calls_exit_verify(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)

        runner._execute_and_finalize(execute_mode="resume")

        runner._safe_verify_completion.assert_called_once()

    def test_save_checkpoint_called_with_final_status(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)

        result = runner._execute_and_finalize(execute_mode="resume")

        assert runner.save_checkpoint.called
        cp_data = runner.save_checkpoint.call_args.args[0]
        assert cp_data["status"] == "completed"

    def test_reset_recovery_counter_on_completed(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)

        runner._execute_and_finalize(execute_mode="resume")

        runner._reset_recovery_counter.assert_called_once()

    def test_returns_status_completed_when_all_clean(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)

        result = runner._execute_and_finalize(execute_mode="resume")

        assert result["status"] == "completed"
        assert "result" in result


class TestExecuteAndFinalizeFailureHandling:
    def test_partial_status_when_failed_count_present(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner._execute = MagicMock(return_value={
            "completed_count": 3, "applicable_count": 5, "failed_count": 2,
        })

        result = runner._execute_and_finalize(execute_mode="resume")

        assert result["status"] == "partial"

    def test_failed_status_when_completed_count_zero(self, monkeypatch):
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner._execute = MagicMock(return_value={
            "completed_count": 0, "applicable_count": 5, "failed_count": 5,
        })

        result = runner._execute_and_finalize(execute_mode="resume")

        assert result["status"] == "failed"

    def test_exit_verify_failed_downgrades_to_partial(self, monkeypatch):
        """exit verify is_complete=False → final_status='partial' 격상 (silent miss 차단)."""
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner._safe_verify_completion = MagicMock(return_value=CompletionReport(
            is_complete=False, missing=["x"], severity="partial", metadata={},
            origin="artifact_missing",
        ))

        result = runner._execute_and_finalize(execute_mode="resume")

        assert result["status"] == "partial"

    def test_exit_verify_crashed_marks_failed_and_raises(self, monkeypatch):
        """B7+B12: exit verify 가 AppError(verify_crashed) raise → status='failed'
        영속 + re-raise (status='running' leak 차단).
        """
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner._safe_verify_completion = MagicMock(side_effect=AppError(
            code="step.verify_crashed", message="ctx KeyError",
        ))

        with pytest.raises(AppError) as exc_info:
            runner._execute_and_finalize(execute_mode="resume")

        assert exc_info.value.code == "step.verify_crashed"
        # status='failed' 영속
        failed_calls = [
            c for c in runner._update_step_run.call_args_list
            if c.args and c.args[0] == "failed"
        ]
        assert failed_calls, "exit verify crash 시 status='failed' 영속 의무"

    def test_execute_exception_marks_failed_and_raises(self, monkeypatch):
        """_execute 자체 unexpected exception → status='failed' 영속 (running leak 차단)."""
        runner = _make_runner_for_finalize_test(monkeypatch)
        runner._execute = MagicMock(side_effect=RuntimeError("execute boom"))

        with pytest.raises(RuntimeError):
            runner._execute_and_finalize(execute_mode="resume")

        failed_calls = [
            c for c in runner._update_step_run.call_args_list
            if c.args and c.args[0] == "failed"
        ]
        assert failed_calls
