"""Phase 4 iter 7 Stabilization Patch — W1 LVM + W3 observability.

regression matrix:

W1 — _call_gpt_lvm 에 temperature 명시 전달 금지 (gpt-5* family 거부) +
     scene/ref pipeline 의 LVM 실패 silent OK fallback 제거 (default
     fail-fast, ALLOW_LVM_VALIDATION_UNAVAILABLE override 만 partial 허용).
W3 — generate_and_validate_scene / generate_and_validate_reference 가
     trace_meta 인자를 받고 set_context 에 forward (PID/EID/scene/shot 추적).
W7 — (Area D-next 폐기: keep_elements regex 차단 → producer SOT kind enum
     으로 격상) — 옛 _assert_keep_elements_are_environment_only +
     _KEEP_ELEMENT_PERSON_TOKENS regex 영구 폐기. 검증은
     `app.core.keep_elements.validate_keep_elements` enum SOT 로 이관.

References:
- session_20260507_g4_6_phase4_iter3_6.md (Phase 4 iter 3-6 history)
- feedback_no_silent_fallback.md
- session_20260514_area_d_min_required_refs_sot.md (Area D-next carry)
"""
from __future__ import annotations

import inspect
import re
from contextlib import contextmanager

import pytest

from app.core.errors import AppError


@contextmanager
def expect_apperror(code_substr: str):
    with pytest.raises(AppError) as excinfo:
        yield excinfo
    assert code_substr in excinfo.value.code, (
        f"expected AppError.code containing '{code_substr}', "
        f"got code='{excinfo.value.code}'"
    )


# ─────────────────────────────────────────────
# W1 — _call_gpt_lvm temperature drop
# ─────────────────────────────────────────────


def test_w1_call_gpt_lvm_does_not_pass_temperature():
    """gpt-5* family 가 temperature=0.2 거부 (BadRequestError) → 호출 측에서
    명시 전달 안 해야 함. 모델 호출 코드에 `temperature=` 없음.

    [2026-08-01 A5 마무리] 호출 형태가 `router.completion(...)` 에서
    `router_completion(...)` 으로 바뀌었다 — 원시 Router 를 꺼내 부르면 키 슬롯
    전환이 일어나지 않아서다. 이 테스트의 의도(temperature 미전달)는 그대로이고
    찾는 형태만 옮긴다. shape 단언이 살아 있어 이 변경을 잡아냈다.
    """
    from app.modules.pipeline import ref_image_pipeline as ref_mod
    src = inspect.getsource(ref_mod._call_gpt_lvm)
    # 모델 호출 블록 안에 temperature= 키워드 없어야 함
    completion_block_re = re.compile(
        r"router_completion\([^)]*\)", re.DOTALL,
    )
    blocks = completion_block_re.findall(src)
    assert blocks, (
        "_call_gpt_lvm 모델 호출이 발견되지 않음 — module shape 변경"
    )
    for b in blocks:
        assert "temperature=" not in b, (
            f"_call_gpt_lvm 의 모델 호출에 'temperature=' 가 포함됨. "
            f"gpt-5* family BadRequestError 재발 위험. block={b[:200]}"
        )


# ─────────────────────────────────────────────
# W1 — scene/ref pipeline LVM silent OK fallback 제거
# ─────────────────────────────────────────────


def test_w1_scene_pipeline_no_silent_ok_severity_fallback():
    """scene_image_pipeline 의 LVM 호출 try/except 가 `severity="ok"` 자동
    덮어쓰기 패턴을 가지면 안 됨 (silent absorb 차단)."""
    from app.modules.pipeline import scene_image_pipeline as scene_mod
    src = inspect.getsource(scene_mod)
    # 옛 패턴: matches_prompt=True ... severity="ok" 자동 fallback (운영자 override 없음)
    # 새 패턴: AppError fail-fast OR settings.allow_lvm_validation_unavailable 분기
    # → silent fallback 없는지 검사: severity=\"ok\" 가 except branch 에 그대로 박혀있지 않아야.
    bad_pattern = re.compile(
        r"except\s+Exception[^:]*:[^}]*severity\"\s*:\s*\"ok\"",
        re.DOTALL,
    )
    if bad_pattern.search(src):
        # 새 패턴은 AppError 또는 settings.allow_lvm_validation_unavailable 가 같이 등장
        assert "allow_lvm_validation_unavailable" in src, (
            "scene_image_pipeline 의 LVM except branch 가 severity=\"ok\" "
            "silent fallback 으로 보이며 ALLOW_LVM_VALIDATION_UNAVAILABLE "
            "override 분기 없음. silent absorb 위반."
        )
        assert "AppError" in src, (
            "scene_image_pipeline 의 LVM except branch 가 fail-fast AppError 없음"
        )


def test_w1_ref_pipeline_no_silent_ok_severity_fallback():
    """ref_image_pipeline 의 LVM 호출 try/except 도 silent OK fallback 제거."""
    from app.modules.pipeline import ref_image_pipeline as ref_mod
    src = inspect.getsource(ref_mod)
    bad_pattern = re.compile(
        r"except\s+Exception[^:]*:[^}]*severity\"\s*:\s*\"ok\"",
        re.DOTALL,
    )
    if bad_pattern.search(src):
        assert "allow_lvm_validation_unavailable" in src
        assert "AppError" in src


# ─────────────────────────────────────────────
# W3 — pipeline functions accept trace_meta
# ─────────────────────────────────────────────


def test_w3_scene_pipeline_accepts_trace_meta():
    """generate_and_validate_scene 의 signature 에 trace_meta 가 있어야 한다."""
    from app.modules.pipeline.scene_image_pipeline import generate_and_validate_scene
    sig = inspect.signature(generate_and_validate_scene)
    assert "trace_meta" in sig.parameters, (
        "generate_and_validate_scene signature 에 trace_meta 인자 missing — "
        "W3 observability 차단"
    )


def test_w3_ref_pipeline_accepts_trace_meta():
    """generate_and_validate_reference 의 signature 에 trace_meta 가 있어야 한다."""
    from app.modules.pipeline.ref_image_pipeline import generate_and_validate_reference
    sig = inspect.signature(generate_and_validate_reference)
    assert "trace_meta" in sig.parameters, (
        "generate_and_validate_reference signature 에 trace_meta 인자 missing"
    )


# ─────────────────────────────────────────────
# W7 — (Area D-next 폐기, 2026-05-14)
#
# 옛 _assert_keep_elements_are_environment_only + _KEEP_ELEMENT_PERSON_TOKENS
# regex 가 시각 SOT 였을 때의 10 test 가 여기 있었음. Area D-next Task 3 에서
# regex/helper 전체 폐기 + producer schema v6 kind enum SOT 격상 (cf.
# `app.core.keep_elements.validate_keep_elements`). 대체 test = D1-D6 + D5b
# (test_scene_reference_service.py 파일 끝).
# ─────────────────────────────────────────────


# ─────────────────────────────────────────────
# Scene LVM cost-policy mode (iter 7 follow-up)
# ─────────────────────────────────────────────
#
# Spec:
#   full      — 모든 scene LVM 실행 (옛 default).
#   targeted  — SCENE_LVM_TARGETED_SHOT_IDS 의 (scene_index)_(shot_index) 만.
#   sample    — SCENE_LVM_SAMPLE_RATE (0.0~1.0) 확률.
#   off       — scene LVM 모두 skip (default — 비용 절감).
#   ref_only  — 의미적으로 off 와 동일 (ref pipeline 만 LVM).
#
# Skip 시 silent success 금지 — severity="not_run_cost_policy" + matches_prompt=False
# + _scene_lvm_skipped=True marker 동반 (feedback_no_silent_fallback).


def _make_settings_stub(mode, targets="", rate=0.0):
    from types import SimpleNamespace
    return SimpleNamespace(
        scene_lvm_validation_mode=mode,
        scene_lvm_targeted_shot_ids=targets,
        scene_lvm_sample_rate=rate,
    )


def test_scene_lvm_default_mode_is_off():
    """default mode = "off" — 운영 비용 절감 (사용자 정책)."""
    from app.core.config import settings
    assert settings.scene_lvm_validation_mode == "off", (
        f"default scene_lvm_validation_mode 가 'off' 이어야 함. got={settings.scene_lvm_validation_mode!r}"
    )


def test_scene_lvm_decide_full_always_runs():
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("full")
    assert _decide_scene_lvm(None, s) == (True, None)
    assert _decide_scene_lvm({}, s) == (True, None)
    assert _decide_scene_lvm({"scene_index": 1, "shot_index": 1}, s) == (True, None)


def test_scene_lvm_decide_off_always_skips():
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("off")
    assert _decide_scene_lvm({"scene_index": 1, "shot_index": 1}, s) == (False, "off")
    assert _decide_scene_lvm({}, s) == (False, "off")
    assert _decide_scene_lvm(None, s) == (False, "off")


def test_scene_lvm_decide_ref_only_skips_scene():
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("ref_only")
    assert _decide_scene_lvm({"scene_index": 1, "shot_index": 1}, s) == (False, "ref_only")


def test_scene_lvm_decide_targeted_in_list_runs():
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("targeted", targets="5_2,6_2,15_3")
    assert _decide_scene_lvm({"scene_index": 5, "shot_index": 2}, s) == (True, None)
    assert _decide_scene_lvm({"scene_index": 15, "shot_index": 3}, s) == (True, None)


def test_scene_lvm_decide_targeted_not_in_list_skips():
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("targeted", targets="5_2,6_2")
    assert _decide_scene_lvm({"scene_index": 7, "shot_index": 1}, s) == (False, "targeted_not_in_list")


def test_scene_lvm_decide_targeted_no_meta_skips():
    """trace_meta 누락 시 targeted 가 silent run 으로 빠지면 안 됨 — fail-safe skip + marker."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("targeted", targets="5_2")
    assert _decide_scene_lvm(None, s) == (False, "targeted_no_meta")
    assert _decide_scene_lvm({}, s) == (False, "targeted_no_meta")
    assert _decide_scene_lvm({"scene_index": 5}, s) == (False, "targeted_no_meta")


def test_scene_lvm_decide_sample_runs_when_rng_below_rate(monkeypatch):
    """trace_meta 가 비어있어 still_id / (pid|scene|shot) seed 모두 누락 →
    fallback random.random() path 진입. monkeypatch 으로 결정값 주입.
    M1 deterministic seed 가 적용된 후에도 fallback 경로는 그대로 random."""
    from app.modules.pipeline import scene_image_pipeline as scene_mod
    monkeypatch.setattr(scene_mod.random, "random", lambda: 0.3)
    s = _make_settings_stub("sample", rate=0.5)
    assert scene_mod._decide_scene_lvm({}, s) == (True, None)


def test_scene_lvm_decide_sample_skips_when_rng_above_rate(monkeypatch):
    """fallback random path — trace_meta 비어 → random.random() 사용."""
    from app.modules.pipeline import scene_image_pipeline as scene_mod
    monkeypatch.setattr(scene_mod.random, "random", lambda: 0.7)
    s = _make_settings_stub("sample", rate=0.5)
    assert scene_mod._decide_scene_lvm({}, s) == (False, "sample_excluded")


def test_scene_lvm_decide_sample_deterministic_with_still_id():
    """M1: 같은 still_id 두 번 → 같은 결과 (재현성 보장)."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("sample", rate=0.5)
    meta = {"still_id": "abc-123-def-456"}
    first = _decide_scene_lvm(meta, s)
    second = _decide_scene_lvm(meta, s)
    assert first == second  # idempotent


def test_scene_lvm_decide_sample_deterministic_distinct_still_ids():
    """M1: 다른 still_id 는 독립 bucket — 분포 검증 (rate=0.5, 100 샘플 중
    절반 근처 — sha256 균일 분포 가정. 정확한 boundary 검증보다는 양극단
    sanity 만)."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("sample", rate=0.5)
    runs = sum(
        1 for i in range(200)
        if _decide_scene_lvm({"still_id": f"still-{i}"}, s)[0]
    )
    # rate=0.5 → 200 샘플 중 통상 80~120 (대략 ±20% 허용 — sha256 균일성)
    assert 60 <= runs <= 140, f"sha256 분포 비정상: {runs}/200 (기대 100±40)"


def test_scene_lvm_decide_sample_seed_fallback_to_pid_scene_shot():
    """M1: still_id 누락 시 (project_id|scene|shot) seed 로 fallback —
    여전히 deterministic. 같은 (pid, scene, shot) 두 번 → 같은 결과."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("sample", rate=0.5)
    meta = {"project_id": "pid-1", "scene_index": 5, "shot_index": 2}
    assert _decide_scene_lvm(meta, s) == _decide_scene_lvm(meta, s)


def test_scene_lvm_decide_sample_rate_one_always_runs():
    """Claude NIT #2: rate=1.0 boundary — bucket 항상 < 1.0 (sha256 hex 가
    1<<64 정확히 나오는 일은 없음). 모든 입력에서 True."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("sample", rate=1.0)
    assert _decide_scene_lvm({"still_id": "x"}, s) == (True, None)
    assert _decide_scene_lvm({"still_id": "y"}, s) == (True, None)
    assert _decide_scene_lvm({"still_id": "z"}, s) == (True, None)


def test_scene_lvm_decide_sample_rate_zero_always_skips():
    """rate=0.0 — bucket >= 0.0 항상 → 모든 입력 False."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("sample", rate=0.0)
    assert _decide_scene_lvm({"still_id": "x"}, s) == (False, "sample_excluded")
    assert _decide_scene_lvm({"still_id": "y"}, s) == (False, "sample_excluded")


def test_scene_lvm_decide_sample_rate_out_of_range_raises():
    """sample_rate 범위 밖 → fail-fast (config.py field_validator 가 startup
    에서 미리 catch — 여기 도달은 stub 만)."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    for bad_rate in (-0.1, 1.5, 2.0):
        s = _make_settings_stub("sample", rate=bad_rate)
        with pytest.raises(ValueError, match="SCENE_LVM_SAMPLE_RATE out of range"):
            _decide_scene_lvm({"still_id": "x"}, s)


def test_scene_lvm_decide_targeted_int_normalize_str_meta():
    """M2: trace_meta 가 (5, 2) int 가 아니라 ('5', '2') str 로 들어와도
    매칭 — int cast 후 키 비교."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("targeted", targets="5_2")
    assert _decide_scene_lvm({"scene_index": "5", "shot_index": "2"}, s) == (True, None)


def test_scene_lvm_decide_targeted_int_normalize_invalid_meta_skips():
    """M2: trace 가 정수 변환 불가 → targeted_no_meta (silent run 차단)."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("targeted", targets="5_2")
    assert _decide_scene_lvm({"scene_index": "abc", "shot_index": "2"}, s) == (False, "targeted_no_meta")


def test_scene_lvm_decide_unknown_mode_raises():
    """알 수 없는 mode → fail-fast (silent default 금지)."""
    from app.modules.pipeline.scene_image_pipeline import _decide_scene_lvm
    s = _make_settings_stub("garbage")
    with pytest.raises(ValueError, match="Unknown scene_lvm_validation_mode"):
        _decide_scene_lvm({}, s)


def test_scene_lvm_skip_marker_signals_no_validation():
    """Skip marker — silent success 차단 5요건:
    1) matches_prompt=False (downstream 오판 차단)
    2) severity="not_run_cost_policy" (운영자 의도 skip vs unavailable 분리)
    3) _scene_lvm_skipped=True (downstream 분기 marker)
    4) _scene_lvm_skip_reason 보존 (관측)
    5) issues 메시지 비어있지 않음
    """
    from app.modules.pipeline.scene_image_pipeline import _build_scene_lvm_skip_validation
    v = _build_scene_lvm_skip_validation("off", "off")
    assert v["matches_prompt"] is False
    assert v["severity"] == "not_run_cost_policy"
    assert v["_scene_lvm_skipped"] is True
    assert v["_scene_lvm_skip_reason"] == "off"
    assert v["_scene_lvm_mode"] == "off"
    assert v["score"] == 0
    assert v["issues"] and "off" in v["issues"][0]


def test_scene_lvm_severe_regen_does_not_trigger_on_skip_marker():
    """Skip marker 의 severity="not_run_cost_policy" 가 severe regenerate 분기를
    유발하지 않는지 module source 검사 (severity=='severe' 비교 단일 지점).
    """
    from app.modules.pipeline import scene_image_pipeline as scene_mod
    src = inspect.getsource(scene_mod.generate_and_validate_scene)
    # severe regenerate 분기는 'severity") == "severe"' 정확 매치 — not_run_cost_policy
    # 는 그 분기 진입 못 함 (자동 안전).
    assert 'severity") == "severe"' in src or "severity'] == 'severe'" in src, (
        "severe regenerate 분기 패턴이 사라짐 — 회귀 가능"
    )
    # 옛 silent OK 패턴 재발 가드는 별도 test (test_w1_scene_pipeline_no_silent_ok_severity_fallback)


def test_scene_lvm_settings_targeted_shot_ids_str_default_empty():
    """default targeted_shot_ids = "" (env 미설정 시 안전)."""
    from app.core.config import settings
    assert settings.scene_lvm_targeted_shot_ids == ""
    assert settings.scene_lvm_sample_rate == 0.0


# ─────────────────────────────────────────────
# B1 — legacy validator (LVM 2) cost policy 통합
# ─────────────────────────────────────────────


def test_b1_legacy_validator_disabled_when_mode_is_off(monkeypatch):
    """B1 (Codex BLOCKING): scene_lvm_validation_mode="off" 일 때
    SceneValidationService.create_validator() 는 None 반환 — LVM 2 가
    cost policy 와 무관하게 OpenAI vision 호출 유지하던 누수 차단."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "off")
    monkeypatch.setattr(app_settings, "openai_api_key", "fake-key-just-to-pass-other-guard")
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    assert svc.create_validator() is None


def test_b1_legacy_validator_disabled_when_mode_is_targeted(monkeypatch):
    """B1: targeted 도 cost-controlled — full 외 모든 모드 legacy off."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "targeted")
    monkeypatch.setattr(app_settings, "openai_api_key", "fake-key")
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    assert svc.create_validator() is None


def test_b1_legacy_validator_disabled_when_mode_is_sample(monkeypatch):
    """B1: sample 도 cost-controlled — legacy off."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "sample")
    monkeypatch.setattr(app_settings, "openai_api_key", "fake-key")
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    assert svc.create_validator() is None


def test_b1_legacy_validator_disabled_when_mode_is_ref_only(monkeypatch):
    """B1: ref_only 는 의미상 scene LVM off — legacy 도 off."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "ref_only")
    monkeypatch.setattr(app_settings, "openai_api_key", "fake-key")
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    assert svc.create_validator() is None


def test_b1_legacy_validator_enabled_only_when_mode_is_full(monkeypatch):
    """B1: full 모드만 legacy validator 활성. openai_api_key 필요."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    from app.modules.image_validator import ImageValidator
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "full")
    monkeypatch.setattr(app_settings, "openai_api_key", "fake-key")
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    v = svc.create_validator()
    assert isinstance(v, ImageValidator)


def test_b1_legacy_validator_returns_none_when_no_api_key_even_in_full(monkeypatch):
    """B1: full 모드라도 openai_api_key 없으면 None — 기존 가드 보존."""
    from app.core.config import settings as app_settings
    from app.services.scene_validation_service import SceneValidationService
    monkeypatch.setattr(app_settings, "scene_lvm_validation_mode", "full")
    monkeypatch.setattr(app_settings, "openai_api_key", "")
    # [2026-08-01] 키 유무의 권위가 브로커로 옮겼다 — 모듈 지역 settings 만
    # 비우면 보조 슬롯이 살아 있어 '있음'이 된다. 브로커의 시야를 비운다.
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    monkeypatch.setattr(
        "app.core.config.settings.openai_api_key", "", raising=False)
    monkeypatch.setattr(
        "app.core.config.settings.openai_api_key_secondary", "",
        raising=False)
    svc = SceneValidationService(db=None, project_id="p1")  # type: ignore[arg-type]
    assert svc.create_validator() is None


# ─────────────────────────────────────────────
# I2 — config field_validator (sample_rate / targeted_shot_ids)
# ─────────────────────────────────────────────


def test_i2_config_sample_rate_field_validator_rejects_out_of_range():
    """I2 (Codex IMPORTANT): SCENE_LVM_SAMPLE_RATE 가 startup 에서 fail-fast.
    잘못된 설정 하나로 모든 shot 이 이미지 생성/재시도까지 갔다 죽는 것을 차단."""
    import os
    from app.core.config import Settings
    # Pydantic ValidationError 는 ValueError 의 subclass
    with pytest.raises(Exception, match="SCENE_LVM_SAMPLE_RATE out of range"):
        Settings(scene_lvm_sample_rate=1.5)
    with pytest.raises(Exception, match="SCENE_LVM_SAMPLE_RATE out of range"):
        Settings(scene_lvm_sample_rate=-0.1)


def test_i2_config_sample_rate_boundary_accepts_zero_and_one():
    """boundary value 0.0 / 1.0 둘 다 valid (>= 0.0 and <= 1.0)."""
    from app.core.config import Settings
    s0 = Settings(scene_lvm_sample_rate=0.0)
    assert s0.scene_lvm_sample_rate == 0.0
    s1 = Settings(scene_lvm_sample_rate=1.0)
    assert s1.scene_lvm_sample_rate == 1.0


def test_m2_config_targeted_shot_ids_normalizes_zero_padding():
    """M2 (Codex MINOR): env "05_02,06_01" 가 trace (5,2)/(6,1) 와 매칭되도록
    parse 시점에 int normalize → "5_2,6_1"."""
    from app.core.config import Settings
    s = Settings(scene_lvm_targeted_shot_ids="05_02, 06_01")
    assert s.scene_lvm_targeted_shot_ids == "5_2,6_1"


def test_m2_config_targeted_shot_ids_rejects_invalid_token():
    """M2: malformed token (정수 변환 실패) → fail-fast."""
    from app.core.config import Settings
    with pytest.raises(Exception, match="SCENE_LVM_TARGETED_SHOT_IDS"):
        Settings(scene_lvm_targeted_shot_ids="abc_def")
    with pytest.raises(Exception, match="SCENE_LVM_TARGETED_SHOT_IDS"):
        Settings(scene_lvm_targeted_shot_ids="5_2_3")  # 3 parts


def test_m2_config_targeted_shot_ids_empty_passes():
    """default empty 는 OK (env 미설정 case)."""
    from app.core.config import Settings
    s = Settings(scene_lvm_targeted_shot_ids="")
    assert s.scene_lvm_targeted_shot_ids == ""


def test_iter2_m1_config_targeted_shot_ids_rejects_zero_and_negative():
    """iter 2 review M1 (Codex MINOR): scene_index/shot_index 는 1-based
    (production DB MIN=1). "0_0" / "-1_2" / "5_0" 등 non-positive 토큰은
    운영자 오입력 가능성 — fail-fast 로 startup 차단 (silent
    targeted_not_in_list 늦은 발견 방지)."""
    from app.core.config import Settings
    for bad in ("0_0", "0_5", "5_0", "-1_2", "5_-2", "-3_-4"):
        with pytest.raises(Exception, match="non-positive"):
            Settings(scene_lvm_targeted_shot_ids=bad)


def test_iter2_m1_config_targeted_shot_ids_accepts_one_based():
    """1_1 같은 1-based MIN 은 valid (production DB MIN scene_index=1).
    실제 production range (scene 1~30, shot 1~22) 도 통과."""
    from app.core.config import Settings
    s = Settings(scene_lvm_targeted_shot_ids="1_1,1_22,30_1")
    assert s.scene_lvm_targeted_shot_ids == "1_1,1_22,30_1"


# ─────────────────────────────────────────────
# I1 — single-scene path scene_index/shot_index forward
# ─────────────────────────────────────────────


def test_i1_single_scene_path_signature_accepts_scene_shot_index():
    """I1: _single_scene_generate_and_build_result 에 scene_index/shot_index
    인자가 추가됨. signature 검사 (실제 호출은 통합 — 본 unit 은 contract 만)."""
    import inspect as _inspect
    from app.services.scene_image_service import SceneImageService
    sig = _inspect.signature(SceneImageService._single_scene_generate_and_build_result)
    params = sig.parameters
    assert "scene_index" in params, "scene_index 인자 누락 — targeted mode silent skip 위험"
    assert "shot_index" in params, "shot_index 인자 누락 — targeted mode silent skip 위험"
    # default None 으로 backward-compat 유지
    assert params["scene_index"].default is None
    assert params["shot_index"].default is None


def test_i1_single_scene_callsite_forwards_still_attributes():
    """I1: generate_single_scene_image 의 두 호출 site (custom_prompt 분기 +
    _build_final_scene_prompt 분기) 가 still.scene_index / still.shot_index
    를 forward 하는지 source 검사."""
    import inspect as _inspect
    from app.services.scene_image_service import SceneImageService
    src = _inspect.getsource(SceneImageService.generate_single_scene_image)
    # 두 분기 모두 still.scene_index / still.shot_index 를 명시 forward
    assert src.count("still.scene_index") >= 2, (
        "generate_single_scene_image 의 두 호출 site 모두 still.scene_index forward 필요 (I1)"
    )
    assert src.count("still.shot_index") >= 2, (
        "generate_single_scene_image 의 두 호출 site 모두 still.shot_index forward 필요 (I1)"
    )


# ─────────────────────────────────────────────
# matches_prompt 의미 docstring (Claude NIT #1)
# ─────────────────────────────────────────────


def test_skip_marker_docstring_clarifies_matches_prompt_meaning():
    """matches_prompt=False 가 'failed' 가 아닌 'not evaluated' 임을
    docstring 으로 명시 — downstream consumer 가 단순 bool 검사로 false
    negative 처리하는 것을 방지하는 인지 가드."""
    import inspect as _inspect
    from app.modules.pipeline.scene_image_pipeline import _build_scene_lvm_skip_validation
    doc = _inspect.getdoc(_build_scene_lvm_skip_validation) or ""
    assert "not evaluated" in doc.lower() or "판단 불가" in doc, (
        "_build_scene_lvm_skip_validation docstring 에 matches_prompt 의미 명시 필요"
    )


# ─────────────────────────────────────────────
# entity_t2i unsourced trait 차단 (trait provenance fix)
# ─────────────────────────────────────────────
#
# 결함 패턴: entity_t2i step 이 t2i prompt 외에 description / short_description
# / visual_traits 까지 LLM 으로 재생성 — 보조 모델이 의미 layer 에서 unsourced
# visual trait (source detail / scenario fulltext 에 근거 없는 trait) 를
# 추가하던 path. 2-단계 fix (CLAUDE.md 정책 정합):
#   1) default_model 격상 — 의미 생성은 pro 모델만.
#   2) source detail (entity_detail 결과) 에서 description / visual_traits
#      forward, LLM detail 로 silent fallback 0 (feedback_no_silent_fallback)


def test_entity_t2i_default_model_is_gemini_pro():
    """STEP_MANIFEST 의 entity_t2i.default_model 이 gemini-pro 여야 함.
    원 취지: mini 급 모델의 hallucination 차단. 2026-07-11 Gemini 원복
    (사용자 goal — 시각 저작 열화 실측)으로 이관 前 값 복원."""
    from app.core.step_manifest import STEP_MANIFEST
    info = STEP_MANIFEST.get("entity_t2i")
    assert info is not None, "entity_t2i step 누락"
    assert info["default_model"] == "gemini-pro", (
        f"entity_t2i.default_model 이 'gemini-pro' 여야 함. got={info['default_model']!r}"
    )
    assert info["provider"] == "gemini", (
        f"entity_t2i.provider 도 'gemini'. got={info['provider']!r}"
    )


def test_entity_t2i_schema_version_bumped_to_two():
    """Codex review iter 1 IMP 1: entity_t2i.schema_version >= 2 — source-forward
    정책 + short_description 제거가 적용된 manifest 변경 표시. step_runner 의
    _check_cp_mismatch 가 cp.schema_version 을 manifest 의 current_schema 와 직접
    비교 (등호 비교, hash 포함 X) — schema_version 이 기록된 기존 checkpoint 가
    stale 처리되어 옛 hallucinated 결과 통과 path 봉쇄. legacy no-schema 체크포인트
    는 호환 위해 invalidation 대상 아님."""
    from app.core.step_manifest import STEP_MANIFEST
    info = STEP_MANIFEST.get("entity_t2i")
    assert info is not None
    assert info.get("schema_version", 1) >= 2, (
        f"entity_t2i.schema_version 이 2 이상이어야 함. "
        f"got={info.get('schema_version')!r}. 미설정 시 기존 PID resume 가 옛 "
        f"hallucinated checkpoint 그대로 통과."
    )


def test_entity_t2i_resolves_to_gemini_pro_in_router():
    """_resolve_model('entity_t2i') 가 gemini-pro 반환 (2026-07-11 원복)."""
    from app.modules.llm.llm_client import _resolve_model, PIPELINE_STEPS
    assert PIPELINE_STEPS.get("entity_t2i", {}).get("default") == "gemini-pro"
    assert _resolve_model("entity_t2i", None) == "gemini-pro"


def test_entity_t2i_v4_step_source_forward_pattern():
    """entity_steps.EntityT2iStep 가 source detail (ent_detail) 에서 forward
    하는 패턴 (source 검사). model swap 만으로는 hallucination 가능성 0 아님 —
    LLM detail (call_structured 결과) 의 description / visual_traits 사용 자체
    를 봉쇄."""
    import inspect as _inspect
    import re
    from app.core.steps import entity_steps
    src = _inspect.getsource(entity_steps.EntityT2iStep)
    # source detail 정규화 패턴 (`src = ent_detail or {}`) + src.get(...) 로 통일
    assert "src = ent_detail or {}" in src, (
        "EntityT2iStep 의 source detail 정규화 패턴 누락 — LLM fallback 위험"
    )
    # LLM 결과 변수 (`detail`) 의 description / visual_traits 사용 0
    # word boundary — `ent_detail.get` 와 구별 (\b 또는 lookbehind).
    bad_description = re.search(r'(?<![_\w])detail\.get\("description"', src)
    bad_visual = re.search(r'(?<![_\w])detail\.get\("visual_traits"', src)
    assert bad_description is None, (
        "EntityT2iStep 가 LLM detail 의 description 사용 — unsourced trait 재발 path"
    )
    assert bad_visual is None, (
        "EntityT2iStep 가 LLM detail 의 visual_traits 사용 — unsourced trait 재발 path"
    )
    detail_uses = re.findall(r'(?<![_\w])detail\.get\("(\w+)"', src)
    # D6 T2 (B4): metadata_json 도 LLM 출력에서 take 허용. entity_detail batch 가
    # 만들지 않는 field 라 source-forward 정책 외 — location 의 space_profile
    # 은 entity_t2i system prompt 의 schema 가이드대로 LLM 이 분류 출력. 따라서
    # whitelist 에 추가. unsourced trait 회귀 가드는 description / visual_traits
    # 명시적 negative assertion (위 line 739~745) 으로 별도 보장.
    _ALLOWED_LLM_TAKE = {"t2i_prompt", "metadata_json"}
    assert all(k in _ALLOWED_LLM_TAKE for k in detail_uses), (
        f"`detail.get(...)` (LLM 결과 변수) 가 허용 외 field 사용: "
        f"{[k for k in detail_uses if k not in _ALLOWED_LLM_TAKE]}. "
        f"허용: {sorted(_ALLOWED_LLM_TAKE)}"
    )


def test_entity_t2i_legacy_step_source_forward_pattern():
    """analysis_steps_legacy.EntityT2iStep (legacy path) 도 동일 정책 — 일관성 가드."""
    import inspect as _inspect
    import re
    from app.core.steps import analysis_steps_legacy
    src = _inspect.getsource(analysis_steps_legacy.EntityT2iStep)
    assert "src = gpt_detail or {}" in src, (
        "analysis_steps_legacy.EntityT2iStep 의 source detail 정규화 패턴 누락"
    )
    bad_description = re.search(r'(?<![_\w])detail\.get\("description"', src)
    bad_visual = re.search(r'(?<![_\w])detail\.get\("visual_traits"', src)
    assert bad_description is None, (
        "analysis_steps_legacy.EntityT2iStep 가 LLM detail 의 description 사용"
    )
    assert bad_visual is None, (
        "analysis_steps_legacy.EntityT2iStep 가 LLM detail 의 visual_traits 사용"
    )
    detail_uses = re.findall(r'(?<![_\w])detail\.get\("(\w+)"', src)
    # legacy 는 detail.get("name", ename) 도 허용 (t2i_prompt + name)
    assert all(k in ("t2i_prompt", "name") for k in detail_uses), (
        f"`detail.get(...)` (LLM 결과 변수) 가 t2i_prompt/name 외 field 사용: {detail_uses}"
    )


def test_entity_t2i_short_description_removed_from_schema():
    """Codex review iter 1 MIN 1: ENTITY_DETAIL_SCHEMA 에서 short_description
    제거. 옛 forward 정책상 항상 빈 문자열로 고정 → consumer 0 → schema field
    정리. LLM 토큰 절약 (output 강제 생성 0)."""
    from app.modules.pipeline.entity_extractor_v3 import ENTITY_DETAIL_SCHEMA
    assert "short_description" not in ENTITY_DETAIL_SCHEMA["properties"], (
        "ENTITY_DETAIL_SCHEMA.properties 에 short_description 잔존 — schema 정리 미완"
    )
    assert "short_description" not in ENTITY_DETAIL_SCHEMA["required"], (
        "ENTITY_DETAIL_SCHEMA.required 에 short_description 잔존"
    )


def test_entity_t2i_short_description_removed_from_step_output():
    """entity_t2i 의 출력 dict 에서 short_description 제거 — schema 정합성."""
    import inspect as _inspect
    from app.core.steps import entity_steps
    src = _inspect.getsource(entity_steps.EntityT2iStep)
    assert '"short_description"' not in src, (
        "EntityT2iStep 출력 dict 에 short_description 잔존 — Codex iter 1 MIN 1 미반영"
    )


# ─────────────────────────────────────────────
# Codex review iter 1 추가 항목 (I3 / I5 / I6)
# ─────────────────────────────────────────────


def test_entity_t2i_project_config_override_takes_precedence():
    """Codex review I3 — ProjectSettings.llm_config_json override 가 manifest
    default 보다 우선. 운영자 의도 override 는 존중하되, test 가 정책 reminder.
    production audit 시 실수로 mini override 박혀 있으면 unsourced trait 재발
    위험 — config audit 책임은 운영자."""
    from app.modules.llm.llm_client import _resolve_model
    # override 가 있으면 그 모델 사용 (의도된 override 시나리오)
    assert _resolve_model("entity_t2i", {"entity_t2i": {"model": "gpt"}}) == "gpt"
    assert _resolve_model(
        "entity_t2i", {"entity_t2i": {"model": "gpt-mini"}}
    ) == "gpt-mini"
    # override 없을 때 manifest default 적용 (2026-07-11 Gemini 원복)
    assert _resolve_model("entity_t2i", {}) == "gemini-pro"
    assert _resolve_model("entity_t2i", None) == "gemini-pro"


def test_entity_t2i_behavioral_ignores_llm_semantic_fields():
    """Codex review I5 — 행위 기반 test. LLM 이 unsourced trait 반환해도
    결과는 source detail 만 사용해야 함 (forward logic 시뮬레이션 — production
    code path 와 동일 로직). short_description 은 후속 fix (iter 2 MIN 1) 로
    schema 및 출력 dict 에서 제거되어 본 시뮬레이션도 미포함."""
    fake_llm_output = {
        "description": "unsourced trait description",
        "visual_traits": ["legitimate trait", "unsourced trait"],
        "t2i_prompt": "valid t2i prompt",
    }
    ent_detail = {
        "description": "source description",
        "visual_traits": ["legitimate trait"],
    }
    src_dict = ent_detail or {}
    forwarded = {
        "description": src_dict.get("description", ""),
        "visual_traits": src_dict.get("visual_traits", []) if isinstance(src_dict.get("visual_traits"), list) else [],
        "t2i_prompt": fake_llm_output.get("t2i_prompt", ""),
    }
    assert "unsourced" not in forwarded["description"]
    assert all("unsourced" not in t for t in forwarded["visual_traits"])
    assert forwarded["description"] == ent_detail["description"]
    assert forwarded["visual_traits"] == ent_detail["visual_traits"]
    assert forwarded["t2i_prompt"] == fake_llm_output["t2i_prompt"]


def test_entity_t2i_behavioral_empty_source_does_not_fallback_to_llm():
    """Codex review B1 (behavioral) — ent_detail 빈 dict / None 시 LLM detail
    silent fallback 0. forward logic 시뮬레이션. short_description 은 iter 2
    MIN 1 fix 로 제거 — 본 시뮬레이션도 미포함."""
    detail = {
        "description": "unsourced description",
        "visual_traits": ["unsourced trait"],
        "t2i_prompt": "valid t2i",
    }
    for empty_source in ({}, None):
        src_dict = empty_source or {}
        forwarded = {
            "description": src_dict.get("description", ""),
            "visual_traits": src_dict.get("visual_traits", []) if isinstance(src_dict.get("visual_traits"), list) else [],
            "t2i_prompt": detail.get("t2i_prompt", ""),
        }
        assert forwarded["description"] == ""
        assert forwarded["visual_traits"] == []
        assert forwarded["t2i_prompt"] == "valid t2i"


def test_entity_extractor_v3_extract_entities_marked_deprecated():
    """Codex review I2 — entity_extractor_v3.extract_entities() v4 이전 후
    production 호출자 0. legacy public function 이라 module 삭제 안 함.
    docstring 에 deprecated 표시."""
    import inspect as _inspect
    from app.modules.pipeline import entity_extractor_v3
    doc = _inspect.getdoc(entity_extractor_v3.extract_entities) or ""
    assert "deprecated" in doc.lower(), (
        "entity_extractor_v3.extract_entities() 가 dead code 인데 deprecated 표시 누락"
    )
