"""프로덕션 샷 루프에 capture scope 가 있어야 한다.

2026-08-23 실측: still_recipe_service.py 에 generation_context 가 0건이라
still-recipe 계열 Opik trace 529건 전수에 still_id 가 없었다(100%).
"""
import ast
from pathlib import Path

SRC = (Path(__file__).resolve().parents[2]
       / "app" / "services" / "still_recipe_service.py")


def test_shot_loop_opens_capture_scope():
    """샷 루프 안에서 generation_context 가 열려야 한다.

    ★문자열 검사가 아니라 AST 로 본다 — 주석에 이름만 있어도 통과하면
    안 된다(scene_image_service.py:638 이 그 예다).
    """
    tree = ast.parse(SRC.read_text(encoding="utf-8"))
    opens = [
        n for n in ast.walk(tree)
        if isinstance(n, ast.With)
        for item in n.items
        if isinstance(item.context_expr, ast.Call)
        and getattr(item.context_expr.func, "id", "") == "generation_context"
    ]
    assert opens, "샷 루프에 generation_context 가 없다"


def test_scope_carries_shot_identity():
    """still_id·scene_index·shot_index 를 넘겨야 한다 — 안 넘기면 빈 채로 남는다."""
    src = SRC.read_text(encoding="utf-8")
    tree = ast.parse(src)
    kwsets = []
    for n in ast.walk(tree):
        if isinstance(n, ast.Call) and \
                getattr(n.func, "id", "") == "generation_context":
            kwsets.append({k.arg for k in n.keywords})
    assert kwsets, "generation_context 호출이 없다"
    assert any({"still_id", "scene_index", "shot_index"} <= s for s in kwsets), \
        f"샷 신원을 안 넘긴다: {kwsets}"


def test_scope_does_not_rename_the_step():
    """★stage 는 지금 쓰이는 스텝 이름 그대로여야 한다.

    resolve_step_name(image_tracer.py:316)이 ambient["stage"] 를 1순위로 쓴다.
    여기에 새 이름을 넣으면 이 경로의 llm_call_log.step_name 이 통째로
    바뀐다 — 지금 그 이름('scene_image_pipeline')으로 쌓인 것이 3,707행이라
    신·구 대조가 끊긴다.

    샷 신원은 still_id 가, 세부 단계는 op: 태그가 말한다. stage 로 말하지
    않는다.
    """
    tree = ast.parse(SRC.read_text(encoding="utf-8"))
    stages = []
    for n in ast.walk(tree):
        if isinstance(n, ast.Call) and \
                getattr(n.func, "id", "") == "generation_context":
            for kw in n.keywords:
                if kw.arg == "stage" and isinstance(kw.value, ast.Constant):
                    stages.append(kw.value.value)
    assert stages, "stage 를 상수로 안 넘긴다"
    assert "scene_image_pipeline" in stages, \
        f"stage 가 스텝 이름과 다르다: {stages} — step_name 이 갈린다"


def test_scope_disables_capture():
    """★capture=False 여야 한다 — 안 그러면 중간물 자산이 무더기로 생긴다."""
    tree = ast.parse(SRC.read_text(encoding="utf-8"))
    for n in ast.walk(tree):
        if isinstance(n, ast.Call) and \
                getattr(n.func, "id", "") == "generation_context":
            caps = [kw.value for kw in n.keywords if kw.arg == "capture"]
            assert caps, "capture 를 안 넘긴다 (기본 True = 자산이 생긴다)"
            assert isinstance(caps[0], ast.Constant) and caps[0].value is False


# ── OFF 면 scope 자체가 안 열려야 한다 (Codex BLOCK 1, 2026-08-24) ─────
#
# scope 가 열리면 `ambient_call_meta`(image_tracer.py)가 still_id·scene_index·
# shot_index·stage 를 읽어 **v1** trace metadata 와 DB `llm_call_log.metadata`
# 에 싣는다. 그러면 설정이 꺼져 있어도 Opik payload 가 지금과 달라진다 —
# 계획 Global Constraints 의 「OFF 경로는 바이트 동일」 위반이고, 「되돌리기는
# 한 줄」이라는 안전판이 깨진다.


def test_off_opens_no_scope(monkeypatch):
    """설정 OFF — ambient 가 생기지 않는다(= v1 payload 그대로)."""
    from app.core import config
    from app.services.image_capture.context import current_context
    from app.services.still_recipe_service import _shot_capture_scope
    monkeypatch.setattr(config.settings, "opik_trace_v2_enabled", False)

    with _shot_capture_scope("p", "e", still_id="abc",
                             scene_index=1, shot_index=2):
        assert current_context() is None


def test_off_leaves_ambient_call_meta_empty(monkeypatch):
    """★결함이 드러나던 바로 그 자리로 잰다 — ambient_call_meta 의 출력."""
    from app.core import config
    from app.modules.llm.image_tracer import ambient_call_meta
    from app.services.still_recipe_service import _shot_capture_scope
    monkeypatch.setattr(config.settings, "opik_trace_v2_enabled", False)

    with _shot_capture_scope("p", "e", still_id="abc",
                             scene_index=1, shot_index=2):
        meta = ambient_call_meta()
    for key in ("still_id", "scene_index", "shot_index", "stage"):
        assert key not in meta, f"OFF 인데 {key} 가 실린다 — v1 payload 가 달라진다"


def test_on_opens_scope_with_identity(monkeypatch, tmp_path):
    from app.core import config
    from app.services.image_capture.context import current_context
    from app.services.still_recipe_service import _shot_capture_scope
    monkeypatch.setattr(config.settings, "opik_trace_v2_enabled", True)
    monkeypatch.setenv("PROJECTS_DIR", str(tmp_path))

    with _shot_capture_scope("p", "e", still_id="abc",
                             scene_index=1, shot_index=2):
        ctx = current_context()
        assert ctx is not None
        assert ctx.still_id == "abc"
        assert ctx.scene_index == 1
        assert ctx.shot_index == 2
        assert ctx.stage == "scene_image_pipeline"
        assert ctx.capture_enabled is False


def test_shot_loop_uses_the_flagged_helper():
    """샷 루프가 generation_context 를 **직접** 열면 안 된다 — 플래그를 지나친다."""
    tree = ast.parse(SRC.read_text(encoding="utf-8"))
    for n in ast.walk(tree):
        if not isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        if n.name == "_shot_capture_scope":
            continue
        for c in ast.walk(n):
            if isinstance(c, ast.Call) and \
                    getattr(c.func, "id", "") == "generation_context":
                raise AssertionError(
                    f"{n.name} 이 generation_context 를 직접 연다 "
                    f"(줄 {c.lineno}) — _shot_capture_scope 를 거쳐야 "
                    f"OFF 에서 안 열린다")
