"""Block B B8 — `SceneDetailStep.verify_completion()` origin 분류 + card recompute 분기.

Plan v2.1.3 / spec V5 §4.3 / V2 patch P5 (Codex BLOCKING #6):
- aggregation 단일 return 자리에 origin 분류 추가:
  - loader_violations / contract_violations / structural drift (missing/shape/
    validator_type/recompute_failed) → contract_drift
  - sentinel/card hash drift (t2i_prompt_hash / owned_hash /
    camera_direction_hash / hash) → invariant_drift
  - failed_indices only → contract_drift (safe default)
- card recompute 분기 (line 1296-1310): except Exception → except AppError +
  except Exception 분리
  - AppError → loader_violations.append (contract_drift via aggregation)
  - 그 외 Exception → AppError(step.verify_crashed) raise (자동 force 금지)

scope:
- 본 commit 은 origin 분류 + verify_crashed 격상 만. caller (run() / Resume
  Decision) 의 origin 별 정책 분기 (BLOCK 등) 는 후속 task (B11).
- "clean" 케이스 origin 미설정 (default 'artifact_missing') 은 의도 보존 —
  caller 가 is_complete=False 일 때만 origin 디스패치 (Option A 가드).
"""
from __future__ import annotations

from typing import Any, Dict
from unittest.mock import MagicMock, patch

import pytest

from app.core.errors import AppError
from app.core.steps._owned_helpers import build_owned_sentinel
from app.core.steps.detail_steps import (
    SCENE_DETAIL_SCHEMA_VERSION,
    SceneDetailStep,
)


def _absent_usage(owned: list[str]) -> list[dict[str, str]]:
    """C2 v1: owned token 전부 absent echo — coverage(set/cardinality) trivially 충족."""
    return [
        {"owned_token": t, "usage_kind": "absent", "source_phrase": ""}
        for t in owned
    ]


# ---------------------------------------------------------------------------
# fixture helpers — _make_step_with_result 패턴 (test_g3_2_close_skip_sentinel_drift.py)
# 응용. card 경로 (schema_version=7) 용 추가 patch 포함.
# ---------------------------------------------------------------------------


def _make_step_with_result(
    result: Dict[str, Any],
    owned_by_shot: Dict = None,
    staging_map: Dict = None,
):
    """SceneDetailStep instance + _last_execute_result + patched loader stubs."""
    step = SceneDetailStep.__new__(SceneDetailStep)
    step._last_execute_result = result
    step.project_id = "p"
    step.episode_id = "e"
    step.db = None
    step.project_config = {}

    owned_by_shot = owned_by_shot or {}
    staging_map = staging_map or {}

    def fake_load_owned(self):
        return owned_by_shot

    def fake_load_staging(self):
        return staging_map

    return step, fake_load_owned, fake_load_staging


def _exercise_verify(step, fake_load, fake_staging):
    """기본 verify — bg-off 또는 schema_version=0 이면 card 경로 미진입."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    with patch.object(SceneContextLoader, "_load_chain_bg_owned_by_shot", fake_load), \
            patch.object(SceneContextLoader, "_load_staging_map", fake_staging):
        return step.verify_completion()


def _make_scene(
    *,
    si: int,
    shi: int,
    t2i_prompt: str,
    sentinel: Dict[str, Any] | None,
    status: str | None = None,
    render_prompt_card: Dict[str, Any] | None = None,
    render_prompt_card_hash: str | None = None,
) -> Dict[str, Any]:
    var: Dict[str, Any] = {
        "variant_label": "var_1",
        "camera_effect": "wide",
        "t2i_prompt": t2i_prompt,
        "outfit_assignments": [],
        "source_facts": ["sf"],
        "visual_inferences": ["vi"],
        "creative_decisions": ["cd"],
        "confidence": "high",
    }
    if sentinel is not None:
        var["owned_validation"] = sentinel
    scene: Dict[str, Any] = {
        "scene_index": si,
        "_shot_index": shi,
        "t2i_variations": [var],
    }
    # G4.1 Phase 5 Task 17: render_prompt_card 는 scene-level top field
    # (NOT per-variation — detail_steps.py:2283-2284).
    if render_prompt_card is not None:
        scene["render_prompt_card"] = render_prompt_card
    if render_prompt_card_hash is not None:
        scene["render_prompt_card_hash"] = render_prompt_card_hash
    if status is not None:
        scene["status"] = status
    return scene


def _build_clean_baseline_result() -> Dict[str, Any]:
    """schema_version=0 (card check bypass) baseline — sentinel-only path."""
    owned = ["door"]
    cam_dir = "wide eye-level"
    prompt = "scene"
    sentinel = build_owned_sentinel(
        owned=owned, camera_direction=cam_dir, t2i_prompt=prompt,
        is_close_framing=False, violations=[],
        owned_object_usage=_absent_usage(owned),
    )
    return {
        "data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel),
        ]},
    }


# ---------------------------------------------------------------------------
# Tests — sentinel-only path (no card check)
# ---------------------------------------------------------------------------


class TestLoaderViolationContractDrift:
    def test_bg_on_loader_apperror_returns_contract_drift(self, monkeypatch):
        """V5 S4 branch 1: bg-on + SceneContextLoader._load_chain_bg_owned_by_shot
        AppError → loader_violations 누적 → origin='contract_drift'.
        """
        from app.core.steps.scene_context_loader import SceneContextLoader

        monkeypatch.setattr(
            "app.core.config.settings.background_mode", "on",
        )

        result = _build_clean_baseline_result()
        step, _, fake_staging = _make_step_with_result(
            result, owned_by_shot={(1, 1): ["door"]},
            staging_map={"1_1": {"camera_direction": "wide eye-level", "framing_scale": "medium"}},
        )

        def fake_loader_raise(self):
            raise AppError(code="step.contract_violation", message="bg loader failed")

        monkeypatch.setattr(
            SceneContextLoader, "_load_chain_bg_owned_by_shot", fake_loader_raise,
        )
        monkeypatch.setattr(
            SceneContextLoader, "_load_staging_map", fake_staging,
        )

        report = step.verify_completion()

        assert report.is_complete is False
        assert report.origin == "contract_drift"
        assert any(
            "loader contract violation" in m or "bg loader" in m
            for m in report.missing
        )


class TestSentinelMissingContractDrift:
    def test_owned_validation_missing_returns_contract_drift(self):
        """V5 S4 branch 6: owned_validation key 부재 → sentinel "missing" tag →
        contract_drift (구조 위반).
        """
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt="x", sentinel=None),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): ["door"]},
            staging_map={"1_1": {"camera_direction": "wide", "framing_scale": "medium"}},
        )

        report = _exercise_verify(step, fl, fs)

        assert report.is_complete is False
        assert report.origin == "contract_drift"
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "missing" for d in drifted), drifted


class TestSentinelHashDriftInvariantDrift:
    def test_t2i_prompt_hash_drift_returns_invariant_drift(self):
        """V5 S4 branch 4: stored sentinel.t2i_prompt_hash != computed →
        sentinel_drifted "t2i_prompt_hash" tag → invariant_drift.
        """
        owned = ["door"]
        cam_dir = "wide"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir, t2i_prompt="original",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        # STALE hash override — t2i_prompt_hash drift 강제
        sentinel["t2i_prompt_hash"] = "deadbeef12345678"

        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt="original", sentinel=sentinel),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": cam_dir, "framing_scale": "medium"}},
        )

        report = _exercise_verify(step, fl, fs)

        assert report.is_complete is False
        assert report.origin == "invariant_drift"
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "t2i_prompt_hash" for d in drifted), drifted


class TestPriorityLoaderOverSentinel:
    def test_loader_violation_priority_over_sentinel_drift(self, monkeypatch):
        """V5 S4: loader_violation + sentinel_drift 동시 발생 시 contract_drift 우선.

        (sentinel hash drift 만 있으면 invariant_drift 였을 것).
        """
        from app.core.steps.scene_context_loader import SceneContextLoader

        monkeypatch.setattr(
            "app.core.config.settings.background_mode", "on",
        )

        owned = ["door"]
        cam_dir = "wide"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir, t2i_prompt="x",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        sentinel["t2i_prompt_hash"] = "deadbeef12345678"

        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt="x", sentinel=sentinel),
        ]}}
        step, _, fake_staging = _make_step_with_result(
            result, owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": cam_dir, "framing_scale": "medium"}},
        )

        def fake_loader_raise(self):
            raise AppError(code="step.contract_violation", message="bg loader failed")

        monkeypatch.setattr(
            SceneContextLoader, "_load_chain_bg_owned_by_shot", fake_loader_raise,
        )
        monkeypatch.setattr(
            SceneContextLoader, "_load_staging_map", fake_staging,
        )

        report = step.verify_completion()

        # loader 우선 — sentinel drift 가 있어도 contract_drift
        assert report.origin == "contract_drift"


class TestSeverityClassification:
    def test_partial_drift_severity_partial(self):
        """V5 S4: 일부 variation drift 만 — len(failed_indices) < total 이고
        loader_violations 없음 → severity='partial'.
        """
        owned = ["door"]
        cam_dir = "wide"
        sentinel_clean = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir, t2i_prompt="prompt_a",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        sentinel_drift = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir, t2i_prompt="prompt_b",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        sentinel_drift["t2i_prompt_hash"] = "deadbeef12345678"

        # 2 scene — 1 clean + 1 drift → severity='partial'
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt="prompt_a", sentinel=sentinel_clean),
            _make_scene(si=2, shi=1, t2i_prompt="prompt_b", sentinel=sentinel_drift),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned, (2, 1): owned},
            staging_map={"1_1": {"camera_direction": cam_dir, "framing_scale": "medium"},
                         "2_1": {"camera_direction": cam_dir, "framing_scale": "medium"}},
        )

        report = _exercise_verify(step, fl, fs)

        assert report.is_complete is False
        assert report.severity == "partial"
        assert report.origin == "invariant_drift"  # hash drift only


# ---------------------------------------------------------------------------
# Tests — card path (schema_version=7)
# ---------------------------------------------------------------------------


def _make_card_path_result(
    *,
    render_prompt_card: Dict[str, Any] | None,
    render_prompt_card_hash: str | None = None,
    sentinel_override: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
    """schema_version=7 result — card check 활성화. sentinel 은 valid baseline."""
    owned = ["door"]
    cam_dir = "wide"
    prompt = "scene"
    sentinel = sentinel_override or build_owned_sentinel(
        owned=owned, camera_direction=cam_dir, t2i_prompt=prompt,
        is_close_framing=False, violations=[],
        owned_object_usage=_absent_usage(owned),
    )
    return {
        "schema_version": SCENE_DETAIL_SCHEMA_VERSION,
        "data": {"scenes": [
            _make_scene(
                si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel,
                render_prompt_card=render_prompt_card,
                render_prompt_card_hash=render_prompt_card_hash,
            ),
        ]},
    }


class TestCardPayloadMissingContractDrift:
    def test_render_prompt_card_payload_missing_returns_contract_drift(self):
        """V5 S4 branch 7: schema_version=7 + render_prompt_card 부재 →
        card_drifted "missing" tag → contract_drift (구조 위반).
        """
        result = _make_card_path_result(
            render_prompt_card=None,  # explicitly absent
            render_prompt_card_hash=None,
        )
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): ["door"]},
            staging_map={"1_1": {"camera_direction": "wide", "framing_scale": "medium"}},
        )

        report = _exercise_verify(step, fl, fs)

        assert report.is_complete is False
        assert report.origin == "contract_drift"
        card_drifted = report.metadata.get("card_drifted", [])
        assert any(c[2] == "missing" for c in card_drifted), card_drifted


class TestCardRecomputeFailFast:
    """B7+B8 핵심 통합: card recompute 시 unexpected exception → verify_crashed.

    plan B8 spec line 2074-2089: AppError 는 loader_violations.append (contract_drift),
    그 외 Exception 은 step.verify_crashed raise.
    """

    def _make_card_path_setup(self, monkeypatch):
        """card 검증 path 통과용 minimum setup — assert_card_shape no-op + ctx mock.

        verify_completion (line 1230) 가 함수-내부에서
        ``from app.core.steps.render_prompt_card import assert_card_shape as _g41_assert_card_shape``
        하므로 module-level patch 가 호출에 반영. 본 테스트 scope 는 card recompute 단의
        AppError vs Exception 분기 검증 — shape 통과는 prerequisite.
        """
        from app.core.steps.render_prompt_card import build_empty_card

        # build_empty_card 의 envelope 구조 — shape 통과는 patch 로 우회 (본 테스트의 scope 가 아님)
        empty_card = build_empty_card(scene_index=1, shot_index=1)
        result = _make_card_path_result(
            render_prompt_card=empty_card,
            render_prompt_card_hash="deadbeef12345678",
        )
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): ["door"]},
            staging_map={"1_1": {"camera_direction": "wide", "framing_scale": "medium"}},
        )
        # assert_card_shape no-op — 본 테스트 는 recompute 단 검증
        monkeypatch.setattr(
            "app.core.steps.render_prompt_card.assert_card_shape",
            lambda card, where="": None,
        )
        return step, fl, fs

    def test_card_recompute_unexpected_exception_raises_verify_crashed(self, monkeypatch):
        """V5 S4 branch 3: build_render_prompt_card KeyError → AppError(verify_crashed)."""
        from app.core.steps.scene_context_loader import SceneContextLoader

        step, fl, fs = self._make_card_path_setup(monkeypatch)

        # ctx 부재 시 recompute_failed 로 빠지므로 _ensure_verify_ctx 가
        # non-None 반환하도록 _loader.load_all + _collect_card_inputs 모두 mock.
        monkeypatch.setattr(SceneContextLoader, "_load_chain_bg_owned_by_shot", fl)
        monkeypatch.setattr(SceneContextLoader, "_load_staging_map", fs)
        monkeypatch.setattr(SceneContextLoader, "load_all", lambda self: MagicMock())

        # _collect_card_inputs 의 ctx-derived path 우회 — 빈 dict 반환 (legacy fallback).
        # builder 가 KeyError raise 하면 verify_crashed 격상이 본 테스트의 핵심.
        with patch(
            "app.core.steps.detail_steps._collect_card_inputs",
            return_value={"ctx": None},  # empty inputs (only ctx key, stripped before splat)
        ), patch(
            "app.core.steps.render_prompt_card.build_render_prompt_card",
            side_effect=KeyError("ctx['some_key']"),
        ):
            with pytest.raises(AppError) as exc_info:
                step.verify_completion()

        assert exc_info.value.code == "step.verify_crashed"
        assert "build_render_prompt_card" in exc_info.value.message
        assert "KeyError" in exc_info.value.message

    def test_card_recompute_apperror_marks_recompute_failed_contract_drift(self, monkeypatch):
        """V5 S4 branch 2: build_render_prompt_card AppError → card_drifted
        "recompute_failed" structural tag → origin='contract_drift'. severity
        는 기존 동작 보존 (loader_violations 미오염 → 'partial').
        """
        from app.core.steps.scene_context_loader import SceneContextLoader

        step, fl, fs = self._make_card_path_setup(monkeypatch)

        monkeypatch.setattr(SceneContextLoader, "_load_chain_bg_owned_by_shot", fl)
        monkeypatch.setattr(SceneContextLoader, "_load_staging_map", fs)
        monkeypatch.setattr(SceneContextLoader, "load_all", lambda self: MagicMock())

        with patch(
            "app.core.steps.detail_steps._collect_card_inputs",
            return_value={"ctx": None},
        ), patch(
            "app.core.steps.render_prompt_card.build_render_prompt_card",
            side_effect=AppError(code="step.contract_violation", message="ctx mismatch"),
        ):
            report = step.verify_completion()

        assert report.is_complete is False
        assert report.origin == "contract_drift"
        card_drifted = report.metadata.get("card_drifted", [])
        assert any(c[2] == "recompute_failed" for c in card_drifted), card_drifted
        # severity 보존 — loader_violations 미오염 (기존 동작 회귀 방지)
        assert report.metadata.get("loader_violations") == []
