"""Block B B4 — `StepRunner._evaluate_running_state()` running timeout 정책.

Plan v2.1.3 / spec V5 §4.6 (AC-B5):
- started_at NULL → BLOCK (자동 rerun 금지 — 정상적인 running row 는 항상
  started_at 보유)
- started_at parse 실패 → BLOCK (corrupt timestamp = manual investigation)
- elapsed < timeout → BLOCK ("healthy" — 다른 worker 가 정상 진행 중일 가능성)
- elapsed >= timeout → STALE_RUNNING_RECOVERY (expected_started_at +
  expected_run_id 동반 — Block C atomic steal 입력)

scope:
- 본 commit 은 helper 도입 + `_evaluate_resume_decision` 의 status='running'
  분기 wiring + run() BLOCK/STALE_RUNNING_RECOVERY 디스패치 (Block C 까지
  STALE 도 임시 BLOCK 처리).
- atomic claim steal 자체는 Block C follow-up.

핵심 보안 가드:
- 이전 동작: status='running' → RERUN_SELF + force-like (cleanup + invalidate +
  execute). 다른 worker 와 동시 실행 / DB row 덮어쓰기 / lock 사고 위험.
- 본 commit 후: 모든 running row 가 BLOCK — 자동 force 차단. 사용자 명시 force
  로만 진행.
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock

import pytest

from app.core.errors import AppError
from app.core.step_runner import ResumeAction, StepRunner


def _make_runner(*, project_config: dict | None = None):
    runner = StepRunner.__new__(StepRunner)
    runner.step_id = "test_step"
    runner.project_id = "p-test"
    runner.episode_id = "e-test"
    runner.project_config = project_config or {}
    return runner


# ---------------------------------------------------------------------------
# `_evaluate_running_state()` 단독 검증
# ---------------------------------------------------------------------------


class TestEvaluateRunningStateNullStartedAt:
    def test_started_at_null_returns_block(self):
        """started_at = None → BLOCK (정상 running row 에서 발생할 수 없는 상태)."""
        runner = _make_runner()

        decision = runner._evaluate_running_state({
            "started_at": None,
            "run_id": "r1",
        })

        assert decision.action == ResumeAction.BLOCK
        assert "NULL" in decision.reason or "None" in decision.reason


class TestEvaluateRunningStateParseFailure:
    def test_started_at_parse_failed_returns_block(self):
        """non-ISO timestamp → BLOCK (corrupt row, manual investigation)."""
        runner = _make_runner()

        decision = runner._evaluate_running_state({
            "started_at": "not-a-timestamp",
            "run_id": "r1",
        })

        assert decision.action == ResumeAction.BLOCK
        assert "parse" in decision.reason.lower()


class TestEvaluateRunningStateWithinTimeout:
    def test_recent_started_at_returns_block_healthy(self):
        """elapsed < timeout → BLOCK (다른 worker 정상 진행 중일 가능성).

        sweep / dispatcher 가 healthy running row 를 자동 force 로 가로채는
        패턴 차단 (운영 중복 실행 / lock 사고 root cause).
        """
        runner = _make_runner()
        recent = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()

        decision = runner._evaluate_running_state({
            "started_at": recent,
            "run_id": "r1",
        })

        assert decision.action == ResumeAction.BLOCK
        assert "healthy" in decision.reason.lower()


class TestEvaluateRunningStateOverTimeout:
    def test_old_started_at_returns_stale_recovery_with_expected_fields(self):
        """elapsed > timeout → STALE_RUNNING_RECOVERY + expected_started_at +
        expected_run_id (Block C atomic steal 입력 — text 비교 만으로 race-safe).
        """
        runner = _make_runner()
        long_ago = (datetime.now(timezone.utc) - timedelta(seconds=5000)).isoformat()

        decision = runner._evaluate_running_state({
            "started_at": long_ago,
            "run_id": "r-stale",
        })

        assert decision.action == ResumeAction.STALE_RUNNING_RECOVERY
        assert decision.expected_started_at == long_ago
        assert decision.expected_run_id == "r-stale"
        assert "stale" in decision.reason.lower() or "elapsed" in decision.reason.lower()


class TestEvaluateRunningStateConfigOverride:
    def test_custom_timeout_via_settings(self, monkeypatch):
        """settings.step_running_timeout_seconds 가 임계값 반영 (env override 검증)."""
        runner = _make_runner()
        # 30초 timeout 으로 override
        monkeypatch.setattr(
            "app.core.config.settings.step_running_timeout_seconds", 30,
        )
        # 60초 전 → 30s 초과 → stale
        elapsed_60 = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()

        decision = runner._evaluate_running_state({
            "started_at": elapsed_60,
            "run_id": "r1",
        })

        assert decision.action == ResumeAction.STALE_RUNNING_RECOVERY


# ---------------------------------------------------------------------------
# `_evaluate_resume_decision()` 의 status='running' 분기 통합
# ---------------------------------------------------------------------------


class TestResumeDecisionRunningIntegration:
    def _make_runner_with_existing(self, existing: dict, monkeypatch):
        """fixture: helper 의 상위 분기 보존 + _safe_verify_completion 미호출 보장."""
        runner = StepRunner.__new__(StepRunner)
        runner.step_id = "test_step"
        runner.project_id = "p-test"
        runner.episode_id = "e-test"
        runner.project_config = {}
        runner._get_step_run = lambda sid: existing
        # cp / verify 는 호출되면 안 됨 — running 분기 가 가장 먼저 분기되는지 검증
        runner.load_checkpoint = lambda: pytest.fail("must not be called for running status")
        runner._check_cp_mismatch = lambda c: pytest.fail("must not be called for running status")
        runner._safe_verify_completion = lambda: pytest.fail("must not be called for running status")
        return runner

    def test_running_within_timeout_returns_block(self, monkeypatch):
        recent = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
        existing = {
            "status": "running", "run_id": "r1",
            "started_at": recent,
            "completed_count": 0, "applicable_count": 5,
            "recovery_count": 0, "updated_at": recent,
        }
        runner = self._make_runner_with_existing(existing, monkeypatch)

        decision = runner._evaluate_resume_decision(mode="resume")

        assert decision.action == ResumeAction.BLOCK
        # B11 origin marker 와 다르게 running 전용 marker
        assert decision.origin in ("running_healthy", "running_invalid")
        # "prior_state" 또는 "artifact_missing" origin 으로 가지 않아야 함 — auto-rerun 차단
        assert decision.origin != "prior_state"

    def test_running_over_timeout_returns_stale_recovery(self, monkeypatch):
        long_ago = (datetime.now(timezone.utc) - timedelta(seconds=5000)).isoformat()
        existing = {
            "status": "running", "run_id": "r-stale",
            "started_at": long_ago,
            "completed_count": 0, "applicable_count": 5,
            "recovery_count": 0, "updated_at": long_ago,
        }
        runner = self._make_runner_with_existing(existing, monkeypatch)

        decision = runner._evaluate_resume_decision(mode="resume")

        assert decision.action == ResumeAction.STALE_RUNNING_RECOVERY
        assert decision.expected_started_at == long_ago
        assert decision.expected_run_id == "r-stale"

    def test_running_null_started_at_returns_block(self, monkeypatch):
        existing = {
            "status": "running", "run_id": "r1",
            "started_at": None,
            "completed_count": 0, "applicable_count": 0,
            "recovery_count": 0, "updated_at": None,
        }
        runner = self._make_runner_with_existing(existing, monkeypatch)

        decision = runner._evaluate_resume_decision(mode="resume")

        assert decision.action == ResumeAction.BLOCK


# ---------------------------------------------------------------------------
# run() 통합 — running 자동 force 차단 (B4 핵심 운영 가드)
# ---------------------------------------------------------------------------


class TestRunRunningAutoForceBlocked:
    """B4 핵심 가드: 이전에는 status='running' → mode='force' 자동 격상 →
    cleanup_artifacts + invalidate_downstream + execute 자동 진행. 본 commit
    후 running 은 모두 BLOCK (AppError) — 사용자 명시 force 만 허용.
    """

    def _make_runner(self, monkeypatch, existing: dict):
        from app.core.step_runner import StepRunner

        inst = StepRunner.__new__(StepRunner)
        inst.step_id = "text_cleanup"
        inst.project_id = "p"
        inst.episode_id = "e"
        inst.run_id = "r-new"
        inst.project_config = {}
        inst.opik_context = {}
        inst.manifest = {"schema_version": 1}

        monkeypatch.setattr(inst, "check_gate", lambda: None)
        monkeypatch.setattr(inst, "check_applicability", lambda: True)
        monkeypatch.setattr(inst, "_get_step_run", lambda sid: existing)
        # Block C: bool 반환 + claim/strict mock (owner check success path).
        monkeypatch.setattr(inst, "_update_step_run", lambda *a, **kw: True)
        monkeypatch.setattr(inst, "_update_step_run_strict", lambda *a, **kw: None)
        monkeypatch.setattr(inst, "_try_claim_running", lambda *a, **kw: True)

        # 호출되어선 안 되는 부수효과들 — running auto-force 차단 가드
        inst._cleanup_called = False
        inst._invalidate_called = False
        inst._executed = False

        def _fail_cleanup(self_unused=None):
            inst._cleanup_called = True
            pytest.fail("cleanup_artifacts must NOT be called for running status (B4)")

        def _fail_invalidate(self_unused=None):
            inst._invalidate_called = True
            pytest.fail("invalidate_downstream must NOT be called for running status (B4)")

        def _fail_execute(*a, **kw):
            inst._executed = True
            pytest.fail("_execute must NOT be called for running status (B4)")

        monkeypatch.setattr(inst, "cleanup_artifacts", _fail_cleanup)
        monkeypatch.setattr(inst, "invalidate_downstream", _fail_invalidate)
        monkeypatch.setattr(inst, "_execute", _fail_execute)

        return inst

    def test_running_within_timeout_raises_appError_no_auto_force(self, monkeypatch):
        recent = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
        existing = {
            "status": "running", "run_id": "r1",
            "started_at": recent,
            "completed_count": 0, "applicable_count": 5,
            "recovery_count": 0, "updated_at": recent,
        }
        inst = self._make_runner(monkeypatch, existing)

        with pytest.raises(AppError) as exc_info:
            inst.run(mode="resume")

        assert exc_info.value.code == "step.resume_invalid"
        # message 에 running healthy / stale 신호
        assert "running" in exc_info.value.message.lower()
        # 자동 force 차단 가드 — pytest.fail 들이 trip 안 됐는지
        assert not inst._cleanup_called
        assert not inst._invalidate_called
        assert not inst._executed

    def test_running_over_timeout_attempts_atomic_claim_block_c(self, monkeypatch):
        """Block C 구현 (Block C C1+C2): STALE_RUNNING_RECOVERY → atomic claim
        with allow_stale_steal=True + expected_started_at + expected_run_id. claim
        성공 시 _execute_rerun_self (cleanup/invalidate 호출 X — downstream 보존).
        """
        long_ago = (datetime.now(timezone.utc) - timedelta(seconds=5000)).isoformat()
        existing = {
            "status": "running", "run_id": "r-stale",
            "started_at": long_ago,
            "completed_count": 0, "applicable_count": 5,
            "recovery_count": 0, "updated_at": long_ago,
        }
        inst = self._make_runner(monkeypatch, existing)

        # claim spy + _execute_rerun_self mock (cleanup/invalidate 가드는 보존)
        claim_calls: list = []

        def _spy_claim(**kw):
            claim_calls.append(kw)
            return True

        monkeypatch.setattr(inst, "_try_claim_running", _spy_claim)
        monkeypatch.setattr(
            inst, "_execute_rerun_self", lambda: {"status": "completed"}
        )

        result = inst.run(mode="resume")

        assert result["status"] == "completed"
        # AC-C8: claim 시 expected-match steal — text 비교 만으로 race-safe.
        assert len(claim_calls) == 1
        assert claim_calls[0]["allow_stale_steal"] is True
        assert claim_calls[0]["expected_started_at"] == long_ago
        assert claim_calls[0]["expected_run_id"] == "r-stale"
        # cleanup/invalidate guard 보존 — STALE_RUNNING_RECOVERY 도 _execute_rerun_self
        # 경로라 cleanup 호출 0 (B12 정책).
        assert not inst._cleanup_called
        assert not inst._invalidate_called
