"""v1 산문 소비·검수 끊기 1단계 — **끝점에서** 잰다.

감사 3-C 의 품질 갈래는 닫혔다(6샷 5축 유실 0). 살아 있는 문제는 비용이다.

★이 판이 하는 것은 「산문을 안 만든다」가 **아니다.** `scene_detail` 의
 저작은 그대로 돈다 — 이 판은 그 산문의 **소비(dependency 입력)와 검수
 (t2i_review 씬 갈래)를 v1 에서 끊는다.** 실제 lean 저작은 다음 판이다.

★시험은 **나가는 것**을 잰다. 앞 판은 소스 문자열을 봤는데 그건 주석만
 있어도 초록이다(2026-08-27 Codex BLOCK-4).
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

from app.core.errors import AppError


# ─────────────────────────── 하네스 ───────────────────────────


def _cps(*, framing_ok: bool = True) -> Dict[str, Any]:
    """두 장소 × 두 샷 — LLM 이 돌 최소 구성."""
    shots = [
        {"scene_index": 1, "shot_index": 1, "description": "정비소 안, 작업대 곁"},
        {"scene_index": 1, "shot_index": 2, "description": "정비소 문간"},
    ]
    staging = [
        {"scene_index": 1, "shot_index": 1, "framing_scale": "medium",
         "camera_direction": "허리 높이에서 작업대 옆, 살짝 올려본다",
         "perspective": "observer"},
        {"scene_index": 1, "shot_index": 2,
         "framing_scale": "medium" if framing_ok else "",
         "camera_direction": "가슴 높이에서 문간 안쪽" if framing_ok else "",
         "perspective": "observer"},
    ]
    return {
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 1, "shots": shots}]}},
        "shot_selection": {"data": {"scenes": [
            {"scene_index": 1, "selected_shot_indices": [1, 2]}]}},
        "scene_director": {"data": {"scenes": [
            {"scene_index": 1, "primary_location": "L01",
             "present_entity_ids": ["C01"]}]}},
        "entity_merge": {"data": {"locations": [
            {"short_id": "L01", "name": "좁은 정비소"}], "characters": [
            {"short_id": "C01", "name": "민수"}]}},
        "shot_staging": {"data": {"shots": staging}},
        "scene_detail": {"data": {"scenes": [
            {"scene_index": 1, "_shot_index": 1, "t2i_variations": [
                {"t2i_prompt": "옛 산문 A — 아주 긴 T2I 프롬프트"}]},
            {"scene_index": 1, "_shot_index": 2, "t2i_variations": [
                {"t2i_prompt": "옛 산문 B — 아주 긴 T2I 프롬프트"}]},
        ]}},
    }


def _run(monkeypatch, *, mode: str, framing_ok: bool = True) -> List[str]:
    """프로덕션 `_execute` 를 태우고 **call_structured 가 받은 user_prompt** 를 모은다."""
    from app.core.config import settings
    from app.core.steps import shot_dependency_t2i_step as m

    monkeypatch.setattr(settings, "still_recipe_mode", mode, raising=False)
    cps = _cps(framing_ok=framing_ok)
    sent: List[str] = []

    def fake_call(**kw):
        sent.append(kw.get("user_prompt", ""))
        return {"shots": [
            {"scene_index": 1, "shot_index": 2,
             "location_refs": [{"scene_index": 1, "shot_index": 1,
                                "reason": "같은 공간"}]}]}

    monkeypatch.setattr(m, "call_structured", fake_call)
    monkeypatch.setattr(m, "load_prompt", lambda *a, **k: "SYS")
    monkeypatch.setattr(m, "load_schema", lambda *a, **k: {"type": "object"})

    step = m.ShotDependencyT2iStep.__new__(m.ShotDependencyT2iStep)
    step.project_config = {}
    step.project_id = "P"
    step.episode_id = "E"
    step.opik_context = {}
    step.step_id = "shot_dependency_t2i"
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    step._save_checkpoint_data = lambda *a, **k: None
    step._execute(mode="resume")
    return sent


# ═══════ ① v1 은 typed 프레이밍을, legacy 는 t2i 산문을 보낸다 ═══════


def test_v1_sends_typed_framing_and_no_t2i_prose(monkeypatch):
    """★**나가는 프롬프트**를 본다 — 소스 문자열이 아니라."""
    sent = _run(monkeypatch, mode="v1")
    assert sent, "LLM 호출이 아예 안 나갔다"
    p = sent[0]
    assert "Framing: medium / 허리 높이에서 작업대 옆" in p, (
        f"typed 프레이밍이 안 실렸다\n{p}")
    assert "T2I:" not in p, f"v1 인데 t2i 산문이 실렸다\n{p}"
    assert "옛 산문" not in p, "scene_detail 산문이 새어 들어왔다"
    assert "observer" not in p, "필요 없는 perspective 까지 실렸다"


def test_legacy_is_untouched(monkeypatch):
    """★legacy 는 **종전 그대로** — 그 경로는 t2i_variations 를 이미지에 쓴다.

    v1 6샷 실측으로 legacy 입력까지 바꿀 근거가 없다 (Codex BLOCK-1).
    """
    sent = _run(monkeypatch, mode="off")
    p = sent[0]
    assert "T2I: 옛 산문 A" in p, f"legacy 입력이 바뀌었다\n{p}"
    assert "Framing:" not in p, "legacy 에 새 칸이 샜다"


# ═══════ ② 프레이밍이 없으면 **유료 호출 전에** 선다 ═══════


def test_missing_framing_stops_before_paying(monkeypatch):
    """★이 판은 프레이밍을 핵심 대체 재료로 삼는다.

    비었는데 그대로 부르면 1순위 기준(카메라 이동/확대 연속)을 **재료
    없이** 판단하게 된다 — 조용히 나빠지고 돈은 나간다. 종전 enum 검증은
    **호출 뒤** close 정책에만 있었다 (Codex BLOCK-4).
    """
    from app.core.config import settings
    from app.core.steps import shot_dependency_t2i_step as m

    monkeypatch.setattr(settings, "still_recipe_mode", "v1", raising=False)
    cps = _cps(framing_ok=False)
    calls: List[str] = []
    monkeypatch.setattr(m, "call_structured",
                        lambda **kw: calls.append("paid") or {})
    monkeypatch.setattr(m, "load_prompt", lambda *a, **k: "SYS")
    monkeypatch.setattr(m, "load_schema", lambda *a, **k: {"type": "object"})
    step = m.ShotDependencyT2iStep.__new__(m.ShotDependencyT2iStep)
    step.project_config = {}
    step.project_id = "P"
    step.episode_id = "E"
    step.opik_context = {}
    step.step_id = "shot_dependency_t2i"
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    step._save_checkpoint_data = lambda *a, **k: None

    with pytest.raises(AppError) as ei:
        step._execute(mode="resume")
    assert "framing_scale" in str(ei.value) or "camera_direction" in str(ei.value)
    assert calls == [], f"결손인데 유료 호출이 {len(calls)}번 나갔다"


# ═══════ ③ t2i_review — 끝점(step 반환)에서 잰다 ═══════


def _review_step(monkeypatch, *, mode: str, skip: bool = True) -> Dict[str, Any]:
    from app.core.config import settings
    from app.core.steps import t2i_review_step as ts
    from app.modules.pipeline import t2i_review as tr

    monkeypatch.setattr(settings, "still_recipe_mode", mode, raising=False)
    monkeypatch.setattr(settings, "t2i_review_skip_scene_on_v1", skip,
                        raising=False)
    calls: List[str] = []
    monkeypatch.setattr(tr, "_review_entity_t2i",
                        lambda *a, **k: (calls.append("entity"), [])[1])
    monkeypatch.setattr(tr, "_review_scene_detail",
                        lambda *a, **k: (calls.append("scene"), [{"x": 1}])[1])
    out = tr.run_t2i_review(
        entity_t2i_data={}, scene_detail_data={}, vwr_data={},
        entity_merge_data={"characters": []}, entity_detail_data={},
        shot_extract_data={}, shot_staging_data={},
    )
    # ★step 이 그 값을 **버리지 않는지**를 본다 — 모듈 반환이 아니라
    #  체크포인트에 실리는 모양이 끝점이다 (Codex BLOCK-2).
    summary = {
        "entity_detected": out["entity_fixes"],
        "scene_detected": out["scene_fixes"],
        "entity_applied": out["entity_applied"],
        "scene_applied": out["scene_applied"],
        "regeneration_required": {"entity": out["entity_fixes"],
                                  "scene": out["scene_fixes"]},
        "scene_review_status": out["scene_review_status"],
    }
    import inspect
    src = inspect.getsource(ts.T2iReviewStep._execute)
    assert '"scene_review_status": review_result["scene_review_status"]' in src, (
        "step 반환이 건너뜀 신원을 버린다 — 체크포인트에 안 남는다")
    return {"summary": summary, "calls": calls}


def test_v1_skips_scene_and_the_checkpoint_says_so(monkeypatch):
    """★★`scene_detected=0` 만 남기면 「검사했는데 깨끗하다」로 오독된다."""
    r = _review_step(monkeypatch, mode="v1")
    assert "scene" not in r["calls"], "v1 인데 씬 갈래가 돌았다"
    assert "entity" in r["calls"], "엔티티 갈래까지 껐다"
    assert r["summary"]["scene_detected"] == 0
    assert r["summary"]["scene_review_status"] == "skipped_v1_typed_sot"


def test_legacy_review_still_runs_the_scene_branch(monkeypatch):
    r = _review_step(monkeypatch, mode="off")
    assert "scene" in r["calls"]
    assert r["summary"]["scene_review_status"] == "ran"


def test_the_skip_can_be_turned_off(monkeypatch):
    r = _review_step(monkeypatch, mode="v1", skip=False)
    assert "scene" in r["calls"]
    assert r["summary"]["scene_review_status"] == "ran"


# ═══════ ④ 계약이 바뀌면 지문이 움직인다 ═══════


def test_both_fingerprints_split_v1_from_legacy(monkeypatch):
    """★안 움직이면 옛 CP 가 **새 계약인 것처럼** current 로 읽힌다."""
    from app.core.config import settings
    from app.core.steps.shot_dependency_t2i_step import ShotDependencyT2iStep
    from app.core.steps.t2i_review_step import T2iReviewStep

    for cls in (ShotDependencyT2iStep, T2iReviewStep):
        st = cls.__new__(cls)
        st.project_config = {}
        monkeypatch.setattr(settings, "still_recipe_mode", "v1", raising=False)
        a = st._config_hash()
        monkeypatch.setattr(settings, "still_recipe_mode", "off", raising=False)
        b = st._config_hash()
        assert a != b, f"{cls.__name__}: v1 과 legacy 지문이 같다"


def test_dependency_declares_the_staging_dependency():
    """★**읽으면 선언한다.** LLM 입력으로 쓰는 첫 판이다."""
    from app.core.step_manifest import get_depends_on

    assert "shot_staging" in set(get_depends_on("shot_dependency_t2i"))


# ═══════ ⑤ 저장값과 재개 기대값이 같아야 한다 ═══════


@pytest.mark.parametrize("mode", ["v1", "off"])
def test_a_fresh_save_does_not_block_the_next_resume(monkeypatch, mode):
    """★★**저장한 지문으로 재개가 서면 안 된다.**

    `_config_hash` 를 새로 만들었는데 `_execute` 반환 최상위에 안 실으면
    `save_checkpoint` 가 `project_config` fallback 을 저장한다 — 저장
    `99914b93…` vs 다음 재개 기대 `0fe556f8…` 라 **첫 완료 뒤 재개가
    항상 contract drift BLOCK** 이다 (2026-08-27 Codex 재리뷰).

    그래서 「반환에 칸이 있다」가 아니라 **저장된 것을 재개 판정에 그대로
    넣어 `None`(일치)이 나오는지**를 잰다.
    """
    from app.core.config import settings
    from app.core.step_manifest import get_manifest_dict
    from app.core.step_runner import compute_config_hash
    from app.core.steps import t2i_review_step as ts
    from app.modules.pipeline import t2i_review as tr

    monkeypatch.setattr(settings, "still_recipe_mode", mode, raising=False)
    monkeypatch.setattr(tr, "_review_entity_t2i", lambda *a, **k: [])
    monkeypatch.setattr(tr, "_review_scene_detail", lambda *a, **k: [])

    cps = {
        "entity_t2i": {"data": {}},
        "scene_detail": {"data": {"scenes": []}},
        "entity_merge": {"data": {"characters": []}},
        "entity_detail": {"data": {}},
        "shot_validator": {"data": {"scenes": []}},
        "shot_staging": {"data": {"shots": []}},
        "visual_world_rules": {"data": {}},
    }
    step = ts.T2iReviewStep.__new__(ts.T2iReviewStep)
    step.project_config = {}
    step.step_id = "t2i_review"
    step.project_id = "P"
    step.episode_id = "E"
    step.opik_context = {}
    step.manifest = get_manifest_dict("t2i_review") or {}
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    step._save_checkpoint_data = lambda *a, **k: None

    # ★**실제 `_execute` 반환**을 쓴다 — 손으로 흉내 내면 반환에서 칸을
    #  빼도 시험이 초록이다(첫 판이 그랬다).
    result = step._execute(mode="resume")

    # save_checkpoint 계약: 반환에 config_hash 가 있으면 그것을, 없으면
    # compute_config_hash(project_config) 를 저장한다.
    saved_cp = {
        "schema_version": step.manifest.get("schema_version", 1),
        "config_hash": result.get("config_hash")
                       or compute_config_hash(step.project_config),
    }
    assert step._check_cp_mismatch(saved_cp) is None, (
        f"{mode}: 방금 저장한 CP 로 재개가 선다 — "
        f"저장 {saved_cp['config_hash']} vs 기대 {step._config_hash()}")


def test_the_step_return_actually_carries_the_hash():
    """★반환에서 빼면 위 시험이 잡는지 — 계약이 코드에 있는지도 본다."""
    import inspect

    from app.core.steps.t2i_review_step import T2iReviewStep

    src = inspect.getsource(T2iReviewStep._execute)
    assert '"config_hash": self._config_hash()' in src, (
        "반환 최상위에 config_hash 가 없다 — fallback 이 저장된다")
