"""EpisodeProjectionService strict mode (problems.md #12).

기존 ``sync_from_checkpoint()`` 가 step_run 의 실제 상태와 무관하게 무조건
``ep.status='analyzed'`` 로 setting 하던 결함 해소. STEP_MANIFEST 의 active
analysis step 들이 모두 completed/not_applicable 일 때만 'analyzed' 로 advance.

검증:
  - strict=True (default): 모든 active step completed → status='analyzed'
  - strict=True: 일부 partial/failed → status 변경 안 함 + analysis_error 기록
  - strict=True: step_run row 자체가 없는 step → 'missing' 으로 incomplete 분류
  - strict=False (옛 동작): 무조건 'analyzed'
  - ENV ``EPISODE_STATUS_STRICT_PROJECTION`` 토글 + Settings fallback
  - logger.warning emit 시 incomplete step 목록 노출
  - return value 의 analysis_complete / incomplete_steps 필드
"""
from __future__ import annotations

import logging
from typing import List, Tuple
from unittest.mock import MagicMock

import pytest


@pytest.fixture
def project_episode():
    return ("test-project", "test-episode")


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
    monkeypatch.delenv("EPISODE_STATUS_STRICT_PROJECTION", raising=False)


@pytest.fixture(autouse=True)
def _stub_applicability(monkeypatch):
    """기본적으로 모든 step 을 'applicable' 로 stub — applicability 시나리오는
    개별 test 에서 override.
    """
    monkeypatch.setattr(
        "app.core.applicability.evaluate_step_applicability",
        lambda sid, pid, eid: "applicable",
    )


def _make_episode_mock(initial_status: str = "analyzing"):
    ep = MagicMock()
    ep.status = initial_status
    ep.analysis_error = None
    ep.updated_at = None
    return ep


def _build_service_with_step_runs(
    project_episode, statuses: List[Tuple[str, str]], episode_mock,
):
    """statuses=[(step_id, status), ...] 형태로 step_run mock 구성."""
    from app.services.checkpoint_sync.episode_projection_service import (
        EpisodeProjectionService,
    )

    pid, eid = project_episode
    db = MagicMock()

    # sync_t2i_appearance_counts 의 db.query().filter().all() — 빈 list 반환
    db.query.return_value.filter.return_value.all.return_value = []
    db.query.return_value.filter.return_value.first.return_value = episode_mock

    # _compute_analysis_completion 가 sql_text 로 직접 execute 호출.
    rows = [MagicMock(step_id=sid, status=st) for sid, st in statuses]
    db.execute.return_value.fetchall.return_value = rows

    return EpisodeProjectionService(db, pid, eid)


# ---------------------------------------------------------------------------
# 1. strict mode (default) — completion 검사
# ---------------------------------------------------------------------------


def test_strict_advances_when_all_active_steps_completed(monkeypatch, project_episode):
    """모든 active analysis step 이 completed → status='analyzed'."""
    from app.core import step_manifest

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
        "step_b": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "completed"), ("step_b", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status == "analyzed"
    assert ep.analysis_error is None
    assert result["analysis_complete"] is True
    assert result["incomplete_steps"] == []


def test_strict_advances_when_step_is_not_applicable(monkeypatch, project_episode):
    """not_applicable 도 completed 로 취급."""
    from app.core import step_manifest

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
        "step_b": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    svc = _build_service_with_step_runs(
        project_episode,
        [("step_a", "completed"), ("step_b", "not_applicable")],
        ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status == "analyzed"
    assert result["analysis_complete"] is True


def test_strict_does_not_advance_on_partial(monkeypatch, project_episode, caplog):
    """partial step 1 개라도 있으면 status 변경 안 함 + analysis_error 기록."""
    from app.core import step_manifest

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
        "step_b": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock(initial_status="analyzing")
    svc = _build_service_with_step_runs(
        project_episode,
        [("step_a", "completed"), ("step_b", "partial")],
        ep,
    )
    with caplog.at_level(
        logging.WARNING,
        logger="app.services.checkpoint_sync.episode_projection_service",
    ):
        result = svc.sync_from_checkpoint()

    assert ep.status == "analyzing"  # 변경 안 됨
    assert ep.analysis_error is not None
    assert "step_b(partial)" in ep.analysis_error
    assert "Analysis incomplete" in ep.analysis_error
    assert result["analysis_complete"] is False
    assert ("step_b", "partial") in result["incomplete_steps"]
    # logger.warning emit
    matching = [r for r in caplog.records if "NOT advanced" in r.message]
    assert matching, "incomplete warning 미emit"


def test_strict_does_not_advance_on_failed(monkeypatch, project_episode):
    from app.core import step_manifest

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock(initial_status="analyzing")
    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "failed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status == "analyzing"
    assert "step_a(failed)" in (ep.analysis_error or "")
    assert result["analysis_complete"] is False


def test_strict_classifies_missing_step_run_as_incomplete(monkeypatch, project_episode):
    """active step 이 manifest 에는 있지만 step_run row 없을 때 'missing'."""
    from app.core import step_manifest

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
        "step_b": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    # step_a 만 row 있음, step_b 는 row 없음.
    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status != "analyzed"
    assert ("step_b", "missing") in result["incomplete_steps"]
    assert "step_b(missing)" in (ep.analysis_error or "")


def test_strict_skips_image_category_steps(monkeypatch, project_episode):
    """category='image' / 'auxiliary' 는 검사 대상 아님 — analysis 만."""
    from app.core import step_manifest

    fake_manifest = {
        "analysis_step": {"category": "analysis", "lifecycle": "active"},
        "image_step":    {"category": "image",    "lifecycle": "active"},
        "aux_step":      {"category": "auxiliary","lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    # image / aux 미실행 상태이지만 analysis_step 완료라 status advance 되어야.
    svc = _build_service_with_step_runs(
        project_episode, [("analysis_step", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status == "analyzed"
    assert result["analysis_complete"] is True


def test_strict_skips_deprecated_lifecycle_steps(monkeypatch, project_episode):
    """lifecycle='deprecated'/'removed' 도 검사 대상 아님."""
    from app.core import step_manifest

    fake_manifest = {
        "active_step":    {"category": "analysis", "lifecycle": "active"},
        "old_step":       {"category": "analysis", "lifecycle": "deprecated"},
        "removed_step":   {"category": "analysis", "lifecycle": "removed"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    svc = _build_service_with_step_runs(
        project_episode, [("active_step", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert ep.status == "analyzed"


def test_strict_truncates_incomplete_sample_in_error(monkeypatch, project_episode):
    """incomplete 가 5개 초과면 sample 5 + '+N more' 표기."""
    from app.core import step_manifest

    fake_manifest = {
        f"step_{i}": {"category": "analysis", "lifecycle": "active"}
        for i in range(10)
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    # 모든 step partial
    svc = _build_service_with_step_runs(
        project_episode,
        [(f"step_{i}", "partial") for i in range(10)],
        ep,
    )
    svc.sync_from_checkpoint()
    assert ep.analysis_error is not None
    assert "+5 more" in ep.analysis_error
    assert "Analysis incomplete: 10 active step(s)" in ep.analysis_error


# ---------------------------------------------------------------------------
# 2. lenient mode (toggle off) — 옛 동작 보존
# ---------------------------------------------------------------------------


def test_lenient_unconditionally_sets_analyzed(monkeypatch, project_episode):
    """ENV/Settings off → 옛 동작 (무조건 analyzed)."""
    from app.core import step_manifest

    monkeypatch.setenv("EPISODE_STATUS_STRICT_PROJECTION", "false")

    fake_manifest = {
        "step_a": {"category": "analysis", "lifecycle": "active"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock(initial_status="analyzing")
    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "partial")], ep,
    )
    result = svc.sync_from_checkpoint()
    # 무조건 analyzed (partial 무시).
    assert ep.status == "analyzed"
    # strict path 가 안 돌았으므로 None.
    assert result["analysis_complete"] is None


# ---------------------------------------------------------------------------
# 3. ENV / Settings 우선순위
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "value,expected",
    [
        ("true", True),
        ("True", True),
        ("1", True),
        ("yes", True),
        ("on", True),
        ("false", False),
        ("0", False),
        ("no", False),
        ("off", False),
        ("", False),
    ],
)
def test_env_truthy_parsing_recognized(monkeypatch, value, expected):
    """인식되는 truthy/falsy 값 파싱."""
    from app.services.checkpoint_sync.episode_projection_service import (
        _is_strict_projection_enabled,
    )

    monkeypatch.setenv("EPISODE_STATUS_STRICT_PROJECTION", value)
    assert _is_strict_projection_enabled() is expected


def test_env_unrecognized_falls_back_to_settings_default(monkeypatch, caplog):
    """ENV typo (review I2) → Settings default (True) 로 fallback + warning."""
    import logging as _lg
    from app.services.checkpoint_sync.episode_projection_service import (
        _is_strict_projection_enabled,
    )

    monkeypatch.setenv("EPISODE_STATUS_STRICT_PROJECTION", "garbage")
    with caplog.at_level(
        _lg.WARNING,
        logger="app.services.checkpoint_sync.episode_projection_service",
    ):
        result = _is_strict_projection_enabled()
    # Settings default = True → fallback 성공.
    assert result is True
    # warning emit 확인.
    matching = [r for r in caplog.records if "unrecognized" in r.message]
    assert matching, "ENV typo warning 미emit"


def test_unset_env_defaults_to_settings_default(monkeypatch):
    """ENV 미설정 시 Settings default (True) 사용."""
    from app.services.checkpoint_sync.episode_projection_service import (
        _is_strict_projection_enabled,
    )

    monkeypatch.delenv("EPISODE_STATUS_STRICT_PROJECTION", raising=False)
    assert _is_strict_projection_enabled() is True


# ---------------------------------------------------------------------------
# 4. Codex P1 — applicability not_applicable 시 검사 대상 제외
# ---------------------------------------------------------------------------


def test_applicability_not_applicable_steps_skipped(monkeypatch, project_episode):
    """applicability='if_planning_doc' 이고 planning doc 없으면 검사 대상 제외.

    select_steps_for_category 가 dispatcher 단계에서 step skip → step_run row
    없음 → strict 가 'missing' 으로 잘못 분류하던 회귀 (Codex P1) 가드.
    """
    from app.core import step_manifest
    from app.services.checkpoint_sync import episode_projection_service

    fake_manifest = {
        "always_step":  {"category": "analysis", "lifecycle": "active",
                         "applicability": "always"},
        "planning_step": {"category": "analysis", "lifecycle": "active",
                          "applicability": "if_planning_doc"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    # evaluate_step_applicability stub: planning_step 만 not_applicable.
    def fake_evaluate(sid, pid, eid):
        return "not_applicable" if sid == "planning_step" else "applicable"

    monkeypatch.setattr(
        "app.core.applicability.evaluate_step_applicability",
        fake_evaluate,
    )

    ep = _make_episode_mock()
    # always_step 만 step_run 에 등록 (planning_step 은 dispatcher skip).
    svc = _build_service_with_step_runs(
        project_episode, [("always_step", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    # planning_step 이 검사 대상에서 제외 → all_completed=True.
    assert ep.status == "analyzed"
    assert result["analysis_complete"] is True
    assert result["incomplete_steps"] == []


def test_evaluate_applicability_exception_treated_as_applicable(monkeypatch, project_episode):
    """evaluate_step_applicability 가 raise 시 applicable 로 fallback (보수적)."""
    from app.core import step_manifest

    fake_manifest = {
        "always_step":  {"category": "analysis", "lifecycle": "active",
                         "applicability": "always"},
        "weird_step":   {"category": "analysis", "lifecycle": "active",
                         "applicability": "weird_rule"},
    }
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    def fake_evaluate(sid, pid, eid):
        if sid == "weird_step":
            raise RuntimeError("rule lookup failed")
        return "applicable"

    monkeypatch.setattr(
        "app.core.applicability.evaluate_step_applicability",
        fake_evaluate,
    )

    ep = _make_episode_mock()
    svc = _build_service_with_step_runs(
        project_episode,
        [("always_step", "completed")],  # weird_step 은 row 없음
        ep,
    )
    result = svc.sync_from_checkpoint()
    # weird_step 이 applicable 로 fallback → 'missing' 으로 incomplete.
    assert ep.status != "analyzed"
    assert ("weird_step", "missing") in result["incomplete_steps"]


# ---------------------------------------------------------------------------
# 5. Claude I1 — analysis_error 보존 (다른 caller 의 specific 에러)
# ---------------------------------------------------------------------------


def test_strict_preserves_existing_analysis_error_from_other_caller(
    monkeypatch, project_episode, caplog,
):
    """다른 caller (e.g. partial-strict) 가 이미 set 한 specific 에러 보존."""
    import logging as _lg
    from app.core import step_manifest

    fake_manifest = {"step_a": {"category": "analysis", "lifecycle": "active"}}
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock(initial_status="analyzing")
    # 다른 caller 가 미리 specific 에러 기록.
    ep.analysis_error = "Step shot_essence_extraction partial (allow_partial_downstream=False)"

    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "partial")], ep,
    )
    with caplog.at_level(
        _lg.INFO,
        logger="app.services.checkpoint_sync.episode_projection_service",
    ):
        svc.sync_from_checkpoint()

    # 기존 에러 보존 — strict-projection 이 덮어쓰지 않음.
    assert "shot_essence_extraction" in ep.analysis_error
    assert "[strict-projection]" not in ep.analysis_error


def test_strict_overwrites_own_strict_projection_message(monkeypatch, project_episode):
    """이전 strict-projection 호출의 메시지는 덮어쓰기 (자기 prefix 자동 갱신)."""
    from app.core import step_manifest

    fake_manifest = {"step_a": {"category": "analysis", "lifecycle": "active"}}
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    # 이전 strict-projection 호출 흔적.
    ep.analysis_error = "[strict-projection] Analysis incomplete: 5 active step(s)..."

    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "partial")], ep,
    )
    svc.sync_from_checkpoint()

    # 새 strict-projection 메시지로 갱신 + prefix 유지.
    assert ep.analysis_error.startswith("[strict-projection]")
    assert "step_a(partial)" in ep.analysis_error


# ---------------------------------------------------------------------------
# 6. backward-compat — Episode 못 찾을 때 graceful
# ---------------------------------------------------------------------------


def test_returns_safely_when_episode_missing(monkeypatch, project_episode):
    from app.core import step_manifest

    fake_manifest = {"step_a": {"category": "analysis", "lifecycle": "active"}}
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "completed")], episode_mock=None,
    )
    result = svc.sync_from_checkpoint()
    assert result["appearance_updated"] == 0
    assert result["analysis_complete"] is None  # episode 없으면 helper 호출 안 함
    assert result["incomplete_steps"] == []


# ---------------------------------------------------------------------------
# 5. 기존 동작 보존 — appearance_updated 필드 유지
# ---------------------------------------------------------------------------


def test_appearance_updated_field_preserved(monkeypatch, project_episode):
    """기존 caller 의 result['appearance_updated'] 호환."""
    from app.core import step_manifest

    fake_manifest = {"step_a": {"category": "analysis", "lifecycle": "active"}}
    monkeypatch.setattr(step_manifest, "STEP_MANIFEST", fake_manifest)

    ep = _make_episode_mock()
    svc = _build_service_with_step_runs(
        project_episode, [("step_a", "completed")], ep,
    )
    result = svc.sync_from_checkpoint()
    assert "appearance_updated" in result
