"""Area #4 (v7): element_scope enum SOT — _detect_framing_conflicts validator 회귀 가드.

v7 (2026-05-18): LLM emit `element_scope: "full" | "close"` enum SOT 직접 비교.
구 v6 의 `_classify_framing` + `_ELEMENT_ID_CLOSE_REGEX` + `_DESCRIPTION_CLOSE_KEYWORDS`
regex/keyword inference 폐기.

scene_consistency v7 system.md "element_scope 결정 (필수 emit)" 룰 deterministic
후처리:
- 같은 character_name 의 character_state element 쌍 중 ``applies_to_shots`` 가
  겹치고 element_scope 가 다르면 위반.
- element_scope 누락 → KeyError (schema violation, call_structured retry).
- enum 외 값 → AppError(step.contract_violation.scene_consistency.element_scope).
- 위반 발견 시 ``status="validator_violations"`` + ``validator_violations`` 메타데이터.
- legacy metadata key ``"framings"`` 보존 (값만 element_scope enum).

시나리오 의존성 0 — 모든 fixture placeholder ID 만 사용.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import patch

import pytest


# ─────────────────────────────────────────────
# 공통 fixture (test_scene_consistency_status.py 와 동일 패턴 — 별도 파일이라 복제)
# ─────────────────────────────────────────────

def _write_cp(tmp_path: Path, project_id: str, episode_id: str, step_id: str, manifest: dict) -> None:
    cp_dir = tmp_path / project_id / "checkpoints" / "episodes" / episode_id / step_id
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")


def _make_step(tmp_path, monkeypatch, project_config=None):
    from app.core.steps.scene_consistency_step import SceneConsistencyStep

    step = SceneConsistencyStep.__new__(SceneConsistencyStep)
    step.project_id = "P1"
    step.episode_id = "E1"
    step.step_id = "scene_consistency"
    step.project_config = project_config or {}
    step.opik_context = {}
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    return step


def _setup_min_dependencies(tmp_path, *, scene_save_segs, shot_extract_scenes, shot_selection_scenes):
    pid, eid = "P1", "E1"
    _write_cp(tmp_path, pid, eid, "scene_save", {"data": {"segments": scene_save_segs}})
    _write_cp(tmp_path, pid, eid, "shot_validator", {"data": {"scenes": shot_extract_scenes}})
    _write_cp(tmp_path, pid, eid, "shot_selection", {"data": {"scenes": shot_selection_scenes}})


def _fe(element_id, character_name, applies_to_shots, element_scope="full",
        element_type="character_state", description="placeholder"):
    """v7 fixed_element fixture helper — element_scope enum required."""
    return {
        "element_id": element_id,
        "element_type": element_type,
        "character_name": character_name,
        "description": description,
        "element_scope": element_scope,
        "applies_to_shots": applies_to_shots,
    }


# ─────────────────────────────────────────────
# _detect_framing_conflicts — element_scope 직접 비교
# ─────────────────────────────────────────────

def test_detect_no_conflict_when_different_shots():
    """full + close 가 다른 샷에 분리되어 있으면 위반 없음 (정상 운용)."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_a", "Victim", [1, 5], element_scope="full"),
        _fe("victim_b", "Victim", [2, 3], element_scope="close"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_conflict_when_full_and_close_share_shot():
    """G2.2 핵심: full + close 같은 샷에 적용 → conflict."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_a", "Victim", [1, 2, 5], element_scope="full"),
        _fe("victim_b", "Victim", [1, 2, 5], element_scope="close"),  # 같은 샷 → 룰 위반
    ]}
    violations = _detect_framing_conflicts(scene)
    assert len(violations) == 3  # 샷 1, 2, 5 각각
    shot_indices = sorted(v["shot_index"] for v in violations)
    assert shot_indices == [1, 2, 5]
    for v in violations:
        assert v["character_name"] == "Victim"
        # legacy key "framings" 보존 (Codex iter 1 I2)
        assert set(v["framings"]) == {"full", "close"}
        assert "victim_a" in v["elements"]
        assert "victim_b" in v["elements"]


def test_detect_no_conflict_when_same_scope():
    """같은 element_scope 끼리 (full vs full) 같은 샷 — conflict 아님."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_a", "Victim", [1, 2], element_scope="full"),
        _fe("victim_b", "Victim", [1, 2], element_scope="full"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_conflict_close_close_same_shot_no_conflict():
    """close + close 같은 샷 도 conflict 아님 (둘 다 close localized detail = 다른 부위 OK)."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_wrist", "Victim", [1, 2], element_scope="close"),
        _fe("victim_feet", "Victim", [1, 2], element_scope="close"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_ignores_non_character_state():
    """environment_state / persistent_prop 는 검증 대상 아님 — element_scope 무관."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("broken_window", "", [1, 2], element_type="environment_state", element_scope="full"),
        _fe("blood_stain", "", [1, 2], element_type="persistent_prop", element_scope="close"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_ignores_empty_character_name():
    """character_name 이 빈 character_state 도 안전하게 skip."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("x_full", "", [1], element_scope="full"),
        _fe("x_close", "", [1], element_scope="close"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_handles_string_shot_indices():
    """LLM 이 가끔 applies_to_shots 를 string 으로 출력 — int 캐스팅."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_a", "Victim", ["1", "2"], element_scope="full"),
        _fe("victim_b", "Victim", [1, 2], element_scope="close"),
    ]}
    violations = _detect_framing_conflicts(scene)
    assert len(violations) == 2  # 샷 1, 2


def test_detect_handles_invalid_shot_indices():
    """applies_to_shots 안의 invalid 값 (None, dict 등) 은 skip."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("victim_a", "Victim", [1, None, "abc", {"x": 1}, 2], element_scope="full"),
        _fe("victim_b", "Victim", [1, 2], element_scope="close"),
    ]}
    violations = _detect_framing_conflicts(scene)
    # int 1, 2 만 valid → 둘 다 겹침 → 2 violation.
    assert len(violations) == 2


def test_detect_separate_characters_no_cross_conflict():
    """다른 character_name 끼리는 비교 안 함."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("alice_a", "Alice", [1], element_scope="full"),
        _fe("bob_a", "Bob", [1], element_scope="close"),
    ]}
    assert _detect_framing_conflicts(scene) == []


def test_detect_three_elements_pairwise():
    """3+ element 가 같은 인물에 있을 때 모든 쌍 비교 (different scope only)."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("v_a", "Victim", [1, 2], element_scope="full"),
        _fe("v_b", "Victim", [1, 3], element_scope="close"),
        _fe("v_c", "Victim", [2, 3], element_scope="close"),
    ]}
    violations = _detect_framing_conflicts(scene)
    # v_a(full) - v_b(close) 겹침 [1]
    # v_a(full) - v_c(close) 겹침 [2]
    # v_b(close) - v_c(close) 겹침 [3] but same scope → no conflict
    shots = sorted(v["shot_index"] for v in violations)
    assert shots == [1, 2]


def test_detect_empty_fixed_elements():
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts
    assert _detect_framing_conflicts({"fixed_elements": []}) == []
    assert _detect_framing_conflicts({}) == []


def test_detect_robust_to_non_dict_elements():
    """fixed_elements 안에 dict 가 아닌 항목이 섞여도 crash 안 함."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        "invalid string",
        None,
        _fe("v_only", "Victim", [1], element_scope="full"),
    ]}
    # 무시하고 진행 — 단일 element 는 self-pair 없으니 빈 결과.
    assert _detect_framing_conflicts(scene) == []


# ─────────────────────────────────────────────
# Area #4 v7 — fail-fast (Codex iter 1 C2 fix)
# ─────────────────────────────────────────────

def test_detect_missing_element_scope_raises_keyerror():
    """element_scope 누락 → KeyError (schema violation, call_structured retry 유도)."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    # element_scope 누락 element 2개 (overlap 필요)
    scene = {"fixed_elements": [
        {"element_id": "v_a", "element_type": "character_state",
         "character_name": "V", "description": "x", "applies_to_shots": [1, 2]},
        {"element_id": "v_b", "element_type": "character_state",
         "character_name": "V", "description": "y", "applies_to_shots": [1, 2]},
    ]}
    with pytest.raises(KeyError):
        _detect_framing_conflicts(scene)


def test_detect_invalid_element_scope_raises_apperror():
    """enum 외 값 → AppError(step.contract_violation.scene_consistency.element_scope)."""
    from app.core.errors import AppError
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("v_a", "V", [1, 2], element_scope="medium"),  # invalid enum
        _fe("v_b", "V", [1, 2], element_scope="close"),
    ]}
    with pytest.raises(AppError) as exc_info:
        _detect_framing_conflicts(scene)
    assert "element_scope" in exc_info.value.code
    assert "contract_violation" in exc_info.value.code


def test_detect_invalid_element_scope_both_invalid_raises():
    """양쪽 모두 invalid enum → AppError raised."""
    from app.core.errors import AppError
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("v_a", "V", [1, 2], element_scope="wide"),
        _fe("v_b", "V", [1, 2], element_scope="tight"),
    ]}
    with pytest.raises(AppError):
        _detect_framing_conflicts(scene)


def test_detect_framings_legacy_metadata_key_preserved():
    """violation dict 의 metadata key "framings" 보존 (Codex iter 1 I2 — rename 0)."""
    from app.core.steps.scene_consistency_step import _detect_framing_conflicts

    scene = {"fixed_elements": [
        _fe("v_a", "V", [1], element_scope="full"),
        _fe("v_b", "V", [1], element_scope="close"),
    ]}
    violations = _detect_framing_conflicts(scene)
    assert len(violations) == 1
    v = violations[0]
    # legacy key "framings" exists
    assert "framings" in v
    # 값은 element_scope enum
    assert sorted(v["framings"]) == ["close", "full"]
    # 신규 key "element_scopes" 없음 (rename 폐기)
    assert "element_scopes" not in v


# ─────────────────────────────────────────────
# _execute 통합: validator post-process 적용
# ─────────────────────────────────────────────

def test_execute_promotes_violation_scene_to_partial_status(tmp_path, monkeypatch):
    """LLM 결과에 framing 충돌 있으면 status='validator_violations' 로 격상."""
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "wide"},
                {"shot_index": 2, "description": "close-up"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    fake_response = {
        "scene_index": 1,
        "analysis_summary": "shared dead body state",
        "fixed_elements": [
            _fe("victim_a", "Victim", [1, 2], element_scope="full"),
            _fe("victim_b", "Victim", [1, 2], element_scope="close"),
        ],
    }
    with patch("app.core.steps.scene_consistency_step.call_structured", return_value=fake_response):
        result = step._execute(mode="force")

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    s1 = by_idx[1]
    assert s1["status"] == "validator_violations"
    assert "validator_violations" in s1
    assert len(s1["validator_violations"]) == 2  # 샷 1, 2 각각
    # fixed_elements 는 보존 (silent strip 안 함).
    assert len(s1["fixed_elements"]) == 2
    assert result["validator_violation_count"] == 1


def test_execute_no_promotion_when_no_violation(tmp_path, monkeypatch):
    """LLM 결과 정상 → status="ok" 유지, violation 메타데이터 없음."""
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"},
                {"shot_index": 2, "description": "y"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    fake_response = {
        "scene_index": 1, "analysis_summary": "ok",
        "fixed_elements": [
            _fe("victim_a", "Victim", [1, 2], element_scope="full"),
        ],
    }
    with patch("app.core.steps.scene_consistency_step.call_structured", return_value=fake_response):
        result = step._execute(mode="force")

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    assert by_idx[1]["status"] == "ok"
    assert "validator_violations" not in by_idx[1]
    assert result["validator_violation_count"] == 0


def test_execute_validator_runs_on_old_cp_no_status(tmp_path, monkeypatch):
    """status 없는 옛 cp 가 existing_ok 로 들어와도 validator 검증 적용."""
    existing_cp = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok",
                 "fixed_elements": [
                     _fe("v_a", "Victim", [1, 2], element_scope="full"),
                     _fe("v_b", "Victim", [1, 2], element_scope="close"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", existing_cp)
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"},
                {"shot_index": 2, "description": "y"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    with patch("app.core.steps.scene_consistency_step.call_structured") as mock_call:
        result = step._execute(mode="resume")
        mock_call.assert_not_called()

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    assert by_idx[1]["status"] == "validator_violations"
    assert len(by_idx[1]["validator_violations"]) == 2


def test_execute_validator_skips_failed_and_blocked(tmp_path, monkeypatch):
    """failed_all_tiers / blocked_no_selection 결과는 validator 적용 안 함 (fixed_elements=[])."""
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[
            {"scene_index": 1, "text": "1"},
            {"scene_index": 2, "text": "2"},
        ],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
            {"scene_index": 2, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
        ],
        # scene 2 만 selection 에 — scene 1 은 blocked.
        shot_selection_scenes=[
            {"scene_index": 2, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    def raise_always(*args, **kwargs):
        raise RuntimeError("LLM all-tier fail")

    with patch("app.core.steps.scene_consistency_step.call_structured", side_effect=raise_always):
        result = step._execute(mode="force")

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    assert by_idx[1]["status"] == "blocked_no_selection"
    assert by_idx[2]["status"] == "failed_all_tiers"
    assert result["validator_violation_count"] == 0


# ─────────────────────────────────────────────
# verify_completion: validator_violations → partial
# ─────────────────────────────────────────────

def test_verify_partial_when_validator_violations(tmp_path, monkeypatch):
    """status='validator_violations' → partial + violation_scenes metadata."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok",
                 "fixed_elements": [{"id": "e"}], "status": "ok"},
                {"scene_index": 5, "analysis_summary": "framing conflict detected",
                 "fixed_elements": [{"id": "f"}],
                 "status": "validator_violations",
                 "validator_violations": [
                     {"shot_index": 1, "character_name": "Victim",
                      "elements": ["a", "b"], "framings": ["full", "close"]},
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)
    step = _make_step(tmp_path, monkeypatch)
    report = step.verify_completion()
    assert report.is_complete is False
    assert report.severity == "partial"
    assert report.metadata["violation_scenes"] == [5]
    assert report.metadata["failed_scenes"] == []
    assert report.metadata["blocked_scenes"] == []
    assert any("framing conflict" in m for m in report.missing)


def test_verify_clean_metadata_includes_violation_scenes_key(tmp_path, monkeypatch):
    """clean 분기에서도 violation_scenes 키 노출 (consumer 보호)."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok",
                 "fixed_elements": [{"id": "e"}], "status": "ok"},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)
    step = _make_step(tmp_path, monkeypatch)
    report = step.verify_completion()
    assert report.is_complete is True
    assert "violation_scenes" in report.metadata
    assert report.metadata["violation_scenes"] == []


def test_verify_combined_failed_blocked_violations(tmp_path, monkeypatch):
    """3 종류 (failed + blocked + violations) 동시 → 모두 metadata 노출."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok",
                 "fixed_elements": [], "status": "ok"},
                {"scene_index": 2, "analysis_summary": "분석 실패",
                 "fixed_elements": [], "status": "failed_all_tiers"},
                {"scene_index": 3, "analysis_summary": "blocked",
                 "fixed_elements": [], "status": "blocked_no_selection"},
                {"scene_index": 4, "analysis_summary": "violations",
                 "fixed_elements": [], "status": "validator_violations"},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)
    step = _make_step(tmp_path, monkeypatch)
    report = step.verify_completion()
    assert report.is_complete is False
    assert report.severity == "partial"
    assert report.metadata["failed_scenes"] == [2]
    assert report.metadata["blocked_scenes"] == [3]
    assert report.metadata["violation_scenes"] == [4]
    assert len(report.missing) == 3


def test_verify_revalidates_ok_status_with_violation_in_fixed_elements(tmp_path, monkeypatch):
    """status='ok' 인 G2.1 시대 cp 가 violation 가지면 verify_completion 이 partial 격상."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok", "status": "ok",
                 "fixed_elements": [
                     _fe("v_a", "Victim", [1, 2], element_scope="full"),
                     _fe("v_b", "Victim", [1, 2], element_scope="close"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)
    step = _make_step(tmp_path, monkeypatch)
    report = step.verify_completion()
    assert report.is_complete is False
    assert report.severity == "partial"
    assert report.metadata["violation_scenes"] == [1]


def test_verify_clean_when_ok_status_no_violation(tmp_path, monkeypatch):
    """status='ok' 인데 violation 없으면 그대로 clean."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok", "status": "ok",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)
    step = _make_step(tmp_path, monkeypatch)
    report = step.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"


def test_resume_retry_still_violation_remains_partial(tmp_path, monkeypatch):
    """LLM retry 가 또 같은 violation 만들면 partial 유지 (silent clean 안 됨)."""
    existing_cp = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "old violations",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                     _fe("v_b", "V", [1, 2], element_scope="close"),
                 ],
                 "status": "validator_violations"},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", existing_cp)
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    bad_resp = {
        "scene_index": 1, "analysis_summary": "still bad",
        "fixed_elements": [
            _fe("v_a", "V", [1, 2], element_scope="full"),
            _fe("v_b", "V", [1, 2], element_scope="close"),
        ],
    }
    with patch("app.core.steps.scene_consistency_step.call_structured", return_value=bad_resp) as mock_call:
        result = step._execute(mode="resume")
        # ★2026-09-18: 다시 묻기 + 위반을 알려 준 재시도 한 번 = 2회 (옛 계약은 1회).
        #  같은 입력으로만 다시 물어 S42 가 두 번 같은 충돌로 섰다.
        assert mock_call.call_count == 2
        assert "[수정 요청]" in mock_call.call_args_list[1].kwargs["user_prompt"]

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    assert by_idx[1]["status"] == "validator_violations"
    assert "validator_violations" in by_idx[1]
    assert result["validator_violation_count"] == 1


# ─────────────────────────────────────────────
# _normalize_applies_to_shots scalar safety
# ─────────────────────────────────────────────

@pytest.mark.parametrize("raw", [
    None,
    "1",
    b"1",
    42,
    {"x": 1},
])
def test_normalize_applies_to_shots_scalar_safe(raw):
    from app.core.steps.scene_consistency_step import _normalize_applies_to_shots
    assert _normalize_applies_to_shots(raw) == set()


def test_normalize_applies_to_shots_valid_array():
    from app.core.steps.scene_consistency_step import _normalize_applies_to_shots
    assert _normalize_applies_to_shots([1, "2", 3.0, None]) == {1, 2, 3}


def test_execute_completed_count_subtracts_validator_violations(tmp_path, monkeypatch):
    """validator 격상 scene 은 completed_count 에서 차감."""
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[
            {"scene_index": 1, "text": "1"},
            {"scene_index": 2, "text": "2"},
        ],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
            {"scene_index": 2, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
            {"scene_index": 2, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    def fake_call(*args, **kwargs):
        si = kwargs.get("opik_metadata", {}).get("scene_index", 0)
        if si == 2 or "scene_consistency_2" in (kwargs.get("schema_name") or ""):
            return {
                "scene_index": 2, "analysis_summary": "shared state",
                "fixed_elements": [
                    _fe("v_a", "V", [1, 2], element_scope="full"),
                    _fe("v_b", "V", [1, 2], element_scope="close"),
                ],
            }
        return {
            "scene_index": 1, "analysis_summary": "ok",
            "fixed_elements": [
                _fe("v_only", "V", [1, 2], element_scope="full"),
            ],
        }

    with patch("app.core.steps.scene_consistency_step.call_structured", side_effect=fake_call):
        result = step._execute(mode="force")

    assert result["validator_violation_count"] == 1
    assert result["completed_count"] == 1  # 2 processed - 1 violation


# ─────────────────────────────────────────────
# Consumer-side defense: _load_fixed_elements + is_scene_result_consumer_safe
# ─────────────────────────────────────────────

def test_load_fixed_elements_skips_legacy_violation_when_status_ok(tmp_path, monkeypatch):
    """status='ok' 인 옛 cp 가 violation 가지면 consumer-side 직접 검증 fallback 차단."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "status": "ok",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                     _fe("v_b", "V", [1, 2], element_scope="close"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)

    from app.core.steps.scene_context_loader import SceneContextLoader
    from app.core.steps.scene_consistency_step import SceneConsistencyStep

    runner = SceneConsistencyStep.__new__(SceneConsistencyStep)
    runner.project_id = "P1"
    runner.episode_id = "E1"
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    loader = SceneContextLoader.__new__(SceneContextLoader)
    loader.runner = runner

    fixed_map = loader._load_fixed_elements()
    assert 1 not in fixed_map


def test_load_fixed_elements_skips_legacy_violation_status_none(tmp_path, monkeypatch):
    """옛 G1.3 시대 cp (status=None) violation 도 consumer 차단."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "ok",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                     _fe("v_b", "V", [1, 2], element_scope="close"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)

    from app.core.steps.scene_context_loader import SceneContextLoader
    from app.core.steps.scene_consistency_step import SceneConsistencyStep

    runner = SceneConsistencyStep.__new__(SceneConsistencyStep)
    runner.project_id = "P1"
    runner.episode_id = "E1"
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    loader = SceneContextLoader.__new__(SceneContextLoader)
    loader.runner = runner

    fixed_map = loader._load_fixed_elements()
    assert 1 not in fixed_map


def test_load_fixed_elements_skips_partial_status_scenes(tmp_path, monkeypatch):
    """consumer 가드: failed/blocked/violation scene 의 fixed_elements 는 prompt 에 안 들어감."""
    cp_data = {
        "data": {
            "scenes": [
                # 정상 — 통과해야 함.
                {"scene_index": 1, "status": "ok",
                 "fixed_elements": [_fe("ok_e", "V", [1, 2], element_scope="full")]},
                # validator_violations — 차단해야 함.
                {"scene_index": 2, "status": "validator_violations",
                 "fixed_elements": [_fe("vio_e", "V", [1, 2], element_scope="full")]},
                # blocked_no_selection — 차단해야 함.
                {"scene_index": 3, "status": "blocked_no_selection",
                 "fixed_elements": []},
                # failed_all_tiers — 차단해야 함.
                {"scene_index": 4, "status": "failed_all_tiers",
                 "fixed_elements": []},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)

    from app.core.steps.scene_context_loader import SceneContextLoader
    from app.core.steps.scene_consistency_step import SceneConsistencyStep

    runner = SceneConsistencyStep.__new__(SceneConsistencyStep)
    runner.project_id = "P1"
    runner.episode_id = "E1"
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    loader = SceneContextLoader.__new__(SceneContextLoader)
    loader.runner = runner

    fixed_map = loader._load_fixed_elements()
    assert 1 in fixed_map
    assert 2 not in fixed_map
    assert 3 not in fixed_map
    assert 4 not in fixed_map


def test_shot_dependency_t2i_skips_violation_in_consistency_cp(tmp_path, monkeypatch):
    """shot_dependency_t2i 도 status='ok' 옛 cp violation skip — single source helper."""
    cp_data = {
        "data": {
            "scenes": [
                {"scene_index": 1, "status": "ok",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                     _fe("v_b", "V", [1, 2], element_scope="close"),
                 ]},
                # 정상 ok scene — 통과 대상.
                {"scene_index": 2, "status": "ok",
                 "fixed_elements": [
                     _fe("v2_only", "V2", [1, 2], element_scope="full"),
                 ]},
                # validator_violations status — 직접 차단.
                {"scene_index": 3, "status": "validator_violations",
                 "fixed_elements": [
                     _fe("v3_only", "V3", [1, 2], element_scope="full"),
                 ]},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", cp_data)

    from app.core.steps.scene_consistency_step import is_scene_result_consumer_safe

    fixed_char_states: Dict[int, List[Dict]] = {}
    for sc in cp_data["data"]["scenes"]:
        si = sc.get("scene_index")
        if not is_scene_result_consumer_safe(sc):
            continue
        for fe in sc.get("fixed_elements", []):
            if fe.get("element_type") == "character_state":
                fixed_char_states.setdefault(si, []).append(fe)

    assert 1 not in fixed_char_states
    assert 2 in fixed_char_states
    assert 3 not in fixed_char_states


# ─────────────────────────────────────────────
# is_scene_result_consumer_safe helper unit
# ─────────────────────────────────────────────

@pytest.mark.parametrize("scene,expected_safe", [
    # 정상 ok — clean fixed_elements.
    ({"status": "ok",
      "fixed_elements": [_fe("v_only", "V", [1, 2], element_scope="full")]}, True),
    # status=None 옛 cp + clean fixed_elements.
    ({"fixed_elements": [_fe("v_only", "V", [1, 2], element_scope="full")]}, True),
    # status='failed_all_tiers' — 직접 차단.
    ({"status": "failed_all_tiers", "fixed_elements": []}, False),
    # status='blocked_no_selection' — 직접 차단.
    ({"status": "blocked_no_selection", "fixed_elements": []}, False),
    # status='validator_violations' — 직접 차단.
    ({"status": "validator_violations",
      "fixed_elements": [_fe("x", "V", [1, 2], element_scope="full")]}, False),
    # status='ok' 인데 violation 가짐 — consumer fallback 차단.
    ({"status": "ok",
      "fixed_elements": [
          _fe("v_a", "V", [1, 2], element_scope="full"),
          _fe("v_b", "V", [1, 2], element_scope="close"),
      ]}, False),
    # status=None 옛 cp + violation — consumer fallback 차단.
    ({"fixed_elements": [
          _fe("v_a", "V", [1, 2], element_scope="full"),
          _fe("v_b", "V", [1, 2], element_scope="close"),
      ]}, False),
    # 빈 fixed_elements (skipped 류) — safe.
    ({"status": "skipped_single_shot", "fixed_elements": []}, True),
    ({"status": "skipped_no_selection", "fixed_elements": []}, True),
])
def test_is_scene_result_consumer_safe(scene, expected_safe):
    from app.core.steps.scene_consistency_step import is_scene_result_consumer_safe
    assert is_scene_result_consumer_safe(scene) is expected_safe


def test_resume_retries_validator_violations_status(tmp_path, monkeypatch):
    """status='validator_violations' scene 은 resume 에서 retry."""
    existing_cp = {
        "data": {
            "scenes": [
                {"scene_index": 1, "analysis_summary": "violations detected",
                 "fixed_elements": [
                     _fe("v_a", "V", [1, 2], element_scope="full"),
                     _fe("v_b", "V", [1, 2], element_scope="close"),
                 ],
                 "status": "validator_violations"},
            ]
        }
    }
    _write_cp(tmp_path, "P1", "E1", "scene_consistency", existing_cp)
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "x"}, {"shot_index": 2, "description": "y"},
            ]},
        ],
        shot_selection_scenes=[
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ],
    )
    step = _make_step(tmp_path, monkeypatch)

    fixed_resp = {
        "scene_index": 1, "analysis_summary": "now clean",
        "fixed_elements": [
            _fe("v_only", "V", [1, 2], element_scope="full"),
        ],
    }
    with patch("app.core.steps.scene_consistency_step.call_structured", return_value=fixed_resp) as mock_call:
        result = step._execute(mode="resume")
        mock_call.assert_called_once()

    by_idx = {s["scene_index"]: s for s in result["data"]["scenes"]}
    assert by_idx[1]["status"] == "ok"
    assert "validator_violations" not in by_idx[1]


# ─────────────────────────────────────────────
# 위반을 알려 주고 한 번 다시 묻는다 (2026-09-18 컨트리로드 실측)
#
# S42 가 resume 두 번 모두 같은 충돌로 섰다 — 재시도가 같은 입력을 그대로 보내서
# 모델이 같은 답을 냈다. 위반 자리를 짚어 다시 물으면 답이 바뀔 수 있다.
# ─────────────────────────────────────────────

def _one_scene_two_shots(tmp_path):
    _setup_min_dependencies(
        tmp_path,
        scene_save_segs=[{"scene_index": 1, "text": "scene"}],
        shot_extract_scenes=[{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "wide"},
            {"shot_index": 2, "description": "close-up"}]}],
        shot_selection_scenes=[{"scene_index": 1, "selected_shot_indices": [1, 2]}],
    )


def _conflicting():
    return {"scene_index": 1, "analysis_summary": "s", "fixed_elements": [
        _fe("victim_a", "Victim", [1, 2], element_scope="full"),
        _fe("victim_b", "Victim", [1, 2], element_scope="close")]}


def test_execute_retries_with_the_violation_and_takes_the_clean_answer(tmp_path, monkeypatch):
    _one_scene_two_shots(tmp_path)
    step = _make_step(tmp_path, monkeypatch)
    clean = {"scene_index": 1, "analysis_summary": "s", "fixed_elements": [
        _fe("victim_a", "Victim", [1, 2], element_scope="full")]}
    with patch("app.core.steps.scene_consistency_step.call_structured",
               side_effect=[_conflicting(), clean]) as mock_call:
        result = step._execute(mode="force")

    assert mock_call.call_count == 2
    retry = mock_call.call_args_list[1].kwargs
    assert retry["schema_name"].endswith("_retry")
    assert "[수정 요청]" in retry["user_prompt"]
    assert "victim_a(full)" in retry["user_prompt"] and "victim_b(close)" in retry["user_prompt"]
    assert "Shot 1" in retry["user_prompt"] and "Shot 2" in retry["user_prompt"]
    s1 = {s["scene_index"]: s for s in result["data"]["scenes"]}[1]
    assert s1["status"] == "ok"
    assert result["validator_violation_count"] == 0
    assert result["completed_count"] == 1


def test_execute_retries_once_and_keeps_partial_when_conflict_remains(tmp_path, monkeypatch):
    _one_scene_two_shots(tmp_path)
    step = _make_step(tmp_path, monkeypatch)
    with patch("app.core.steps.scene_consistency_step.call_structured",
               side_effect=[_conflicting(), _conflicting()]) as mock_call:
        result = step._execute(mode="force")

    assert mock_call.call_count == 2, "한 번만 다시 물어야 한다"
    s1 = {s["scene_index"]: s for s in result["data"]["scenes"]}[1]
    assert s1["status"] == "validator_violations"
    assert len(s1["fixed_elements"]) == 2, "위반 결과를 조용히 지우면 안 된다"
    assert result["validator_violation_count"] == 1
