"""G3.2 Phase 5 Task 19: scene_detail verify_completion sentinel drift detection.

Tests for round 4 BLOCKING 2 + round 5 BLOCKING 3, 4:
- close_skip vs full validator type drift
- t2i_prompt_hash drift (round 5 BLOCKING 3)
- camera_direction_hash drift (round 4 BLOCKING 2)
- owned_hash drift
- missing sentinel
- shape violation
- contract_violation status mark

verify_completion() 가 framing_scale enum SOT helper (`get_framing_scale_or_raise`)
를 사용하는지 (framing_scale enum SOT v1, 2026-05-15 — round 5 BLOCKING 4 의
regex-based 검증 supersede) 도 확인.
"""
from __future__ import annotations

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

import pytest

from app.core.steps._owned_helpers import (
    OWNED_VALIDATOR_CLOSE_SKIP,
    OWNED_VALIDATOR_FULL,
    build_owned_sentinel,
    compute_camera_direction_hash,
    compute_owned_hash,
    compute_t2i_prompt_hash,
)


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
    ]


# ---------------------------------------------------------------------------
# helper: minimal SceneDetailStep instance for verify_completion exercise
# ---------------------------------------------------------------------------


def _make_step_with_result(result: Dict[str, Any], owned_by_shot=None,
                           staging_map=None):
    """SceneDetailStep instance + _last_execute_result + patched loader."""
    from app.core.steps.detail_steps import SceneDetailStep

    step = SceneDetailStep.__new__(SceneDetailStep)
    step._last_execute_result = result
    step.project_id = "p"
    step.episode_id = "e"
    step.db = None
    step.project_config = {}

    # patch loader so verify_completion 가 호출하는 reload 가 deterministic
    owned_by_shot = owned_by_shot or {}
    staging_map = staging_map or {}

    def fake_load(self):
        return owned_by_shot

    def fake_staging(self):
        return staging_map

    return step, fake_load, fake_staging


def _exercise_verify(step, fake_load, fake_staging):
    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) -> Dict[str, Any]:
    var = {
        "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],
    }
    if status is not None:
        scene["status"] = status
    return scene


# ---------------------------------------------------------------------------
# Task 19: drift checks
# ---------------------------------------------------------------------------


class TestVerifyCompletionOwnedDrift:
    def test_clean_when_sentinel_matches_ground_truth(self):
        owned = ["door", "window"]
        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),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, 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, report.missing

    def test_close_skip_sentinel_matches_close_framing(self):
        # round 5 BLOCKING 4: 생성 path 와 동일한 module-level regex 재사용 →
        # 한국어 클로즈업 / 손가락이 같은 키워드도 일치.
        owned = ["door"]
        cam_dir = "extreme close-up on the hand"
        prompt = "the door from the reference"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir, t2i_prompt=prompt,
            is_close_framing=True, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, 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": "close"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert report.is_complete, report.missing

    def test_missing_sentinel_marks_drift(self):
        owned = ["door"]
        cam_dir = "wide"
        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): owned},
            staging_map={"1_1": {"camera_direction": cam_dir, "framing_scale": "medium"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "missing" for d in drifted), drifted

    def test_verify_completion_validator_type_drift(self):
        """current state = close framing 인데 sentinel.validator = full → drift."""
        owned = ["door"]
        cam_dir_now = "extreme close-up"
        prompt = "x"
        # sentinel 은 옛날 wide 시점에 만들어졌다고 가정 → full validator
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam_dir_now,
            t2i_prompt=prompt,
            is_close_framing=False,  # WRONG 의도적
            violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": cam_dir_now, "framing_scale": "close"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "validator_type" for d in drifted), drifted

    def test_verify_completion_camera_direction_hash_drift(self):
        """sentinel 은 옛 camera_direction 기준 — 현재 staging 와 다르면 drift."""
        owned = ["door"]
        old_cam = "wide eye-level"
        new_cam = "low angle medium"
        prompt = "x"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=old_cam,  # stale
            t2i_prompt=prompt, is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": new_cam, "framing_scale": "medium"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "camera_direction_hash" for d in drifted), drifted

    def test_verify_completion_t2i_prompt_hash_drift(self):
        """round 5 BLOCKING 3: t2i_prompt 가 t2i_review 등으로 수정됐는데 sentinel
        미갱신 → t2i_prompt_hash drift 검출."""
        owned = ["door"]
        cam = "wide"
        old_prompt = "the door from the reference"
        new_prompt = "the door, lit warmly, from the reference"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam, t2i_prompt=old_prompt,
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(owned),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=new_prompt, sentinel=sentinel),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": cam, "framing_scale": "medium"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "t2i_prompt_hash" for d in drifted), drifted

    def test_verify_completion_owned_hash_drift(self):
        """현재 owned 가 변했는데 sentinel.owned_hash 가 stale → drift."""
        cam = "wide"
        prompt = "x"
        # sentinel 은 옛 owned ["door"] 기준
        sentinel = build_owned_sentinel(
            owned=["door"], camera_direction=cam, t2i_prompt=prompt,
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(["door"]),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel),
        ]}}
        # 현재 owned 가 ["door","window"]
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): ["door", "window"]},
            staging_map={"1_1": {"camera_direction": cam, "framing_scale": "medium"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        drifted = report.metadata.get("sentinel_drifted", [])
        assert any(d[2] == "owned_hash" for d in drifted), drifted

    def test_contract_violation_status_marked_partial(self):
        owned = ["door"]
        cam = "wide"
        prompt = "x"
        sentinel = build_owned_sentinel(
            owned=owned, camera_direction=cam, t2i_prompt=prompt,
            is_close_framing=False,
            violations=[{"owned_object": "door", "violating_phrase": "a new door",
                         "reason": "redraw"}],
            owned_object_usage=_absent_usage(owned),
        )
        result = {"data": {"scenes": [
            _make_scene(si=1, shi=1, t2i_prompt=prompt, sentinel=sentinel,
                        status="contract_violation"),
        ]}}
        step, fl, fs = _make_step_with_result(
            result,
            owned_by_shot={(1, 1): owned},
            staging_map={"1_1": {"camera_direction": cam, "framing_scale": "medium"}},
        )
        report = _exercise_verify(step, fl, fs)
        assert not report.is_complete
        assert report.severity == "partial"
        cv = report.metadata.get("contract_violations", [])
        assert (1, 1) in cv

    def test_verify_uses_framing_scale_enum_helper(self):
        """framing_scale enum SOT v1 (2026-05-15): verify_completion 는 module-
        level helper `get_framing_scale_or_raise` 를 사용 — local regex 정의
        안 함 + 폐기된 `_CLOSE_FRAMING_RE` reference 0."""
        import inspect
        from app.core.steps.detail_steps import SceneDetailStep
        src = inspect.getsource(SceneDetailStep.verify_completion)
        assert "re.compile" not in src, (
            "verify_completion 는 local regex 정의 금지"
        )
        assert "get_framing_scale_or_raise" in src, (
            "verify_completion 는 framing_scale enum helper 사용 의무 "
            "(framing_scale enum SOT v1, regex-based 검증 supersede)"
        )
        assert "_CLOSE_FRAMING_RE" not in src, (
            "_CLOSE_FRAMING_RE 폐기 — framing_scale enum SOT v1 후 사용 금지"
        )


# ---------------------------------------------------------------------------
# Task 19.5: _user_edited owned drift helper (round 4 IMPORTANT 4)
# ---------------------------------------------------------------------------


def _make_user_edited_variation(*, owned_hash="abc", camera_direction_hash="def",
                                t2i_prompt_hash="ghi",
                                owned_usage_hash="0123456789abcdef",
                                validator=OWNED_VALIDATOR_FULL,
                                t2i_prompt="the door from the reference"):
    return {
        "variant_label": "var_1",
        "camera_effect": "wide eye-level",
        "t2i_prompt": t2i_prompt,
        "outfit_assignments": [],
        "source_facts": ["fact"],
        "visual_inferences": ["inf"],
        "creative_decisions": ["dec"],
        "confidence": "high",
        "owned_validation": {
            "schema_version": 2,
            "owned_hash": owned_hash,
            "camera_direction_hash": camera_direction_hash,
            "t2i_prompt_hash": t2i_prompt_hash,
            "owned_usage_hash": owned_usage_hash,
            "validator": validator,
            "violations": [],
        },
    }


class TestUserEditedOwnedDrift:
    def test_owned_hash_mismatch_rejects_reuse(self):
        from app.core.steps.detail_steps import _user_edited_owned_contract_violated
        var = _make_user_edited_variation(owned_hash="stale")
        assert _user_edited_owned_contract_violated(
            var, ["door", "window"], "wide eye-level", is_close=False,
        )

    def test_match_passes_reuse(self):
        from app.core.steps.detail_steps import _user_edited_owned_contract_violated
        owned = ["door", "window"]
        cam = "wide eye-level"
        prompt = "the door from the reference"
        var = _make_user_edited_variation(
            owned_hash=compute_owned_hash(owned),
            camera_direction_hash=compute_camera_direction_hash(cam),
            t2i_prompt_hash=compute_t2i_prompt_hash(prompt),
            validator=OWNED_VALIDATOR_FULL,
            t2i_prompt=prompt,
        )
        assert not _user_edited_owned_contract_violated(
            var, owned, cam, is_close=False,
        )

    def test_validator_type_drift_rejects_reuse(self):
        from app.core.steps.detail_steps import _user_edited_owned_contract_violated
        owned = ["door"]
        cam = "extreme close-up"
        prompt = "x"
        var = _make_user_edited_variation(
            owned_hash=compute_owned_hash(owned),
            camera_direction_hash=compute_camera_direction_hash(cam),
            t2i_prompt_hash=compute_t2i_prompt_hash(prompt),
            validator=OWNED_VALIDATOR_FULL,  # WRONG: 현재 close 인데 full
            t2i_prompt=prompt,
        )
        assert _user_edited_owned_contract_violated(
            var, owned, cam, is_close=True,
        )

    def test_missing_owned_validation_rejects_reuse(self):
        from app.core.steps.detail_steps import _user_edited_owned_contract_violated
        var = _make_user_edited_variation()
        del var["owned_validation"]
        assert _user_edited_owned_contract_violated(
            var, ["door"], "wide", is_close=False,
        )

    def test_t2i_prompt_hash_drift_rejects_reuse(self):
        """round 5 BLOCKING 3: user 가 cp 직접 편집해서 t2i_prompt 바꾸면 hash 불일치 → reuse 거부."""
        from app.core.steps.detail_steps import _user_edited_owned_contract_violated
        owned = ["door"]
        cam = "wide"
        var = _make_user_edited_variation(
            owned_hash=compute_owned_hash(owned),
            camera_direction_hash=compute_camera_direction_hash(cam),
            t2i_prompt_hash=compute_t2i_prompt_hash("OLD_PROMPT"),
            validator=OWNED_VALIDATOR_FULL,
            t2i_prompt="NEW_PROMPT",  # mismatch
        )
        assert _user_edited_owned_contract_violated(
            var, owned, cam, is_close=False,
        )
