"""litellm 우회 호출의 기록에 **프로젝트·에피소드 신원이 붙어야 한다**.

2026-08-07 사용자 지시의 남은 조각. 우회 호출을 Opik·DB 에 남기는 일 자체는
`record_provider_call` 로 끝났는데, **검색 호출은 신원 칸이 빈 채로 남았다.**
`ambient_call_meta()` 가 이미지 포착 scope 하나만 읽기 때문이다 — 검색은 이미지를
포착하지 않으므로 그 scope 를 열 이유가 없고, 그래서
`outdoor_structure_form_reference_step.py` 의 세 호출 자리(814·896·1021)는
scope 밖에서 돈다.

결과: DB 의 `project_id`/`episode_id` 칸이 비어 **"이 프로젝트의 검색이 살아
있나"를 물을 수 없다.** 실제로 그 스텝이 87분 도는 동안 기록으로 확인할 방법이
없었다.

고치는 자리는 호출 사이트가 아니라 **읽는 쪽**이다. 호출자마다 ID 를 넘기게
만들면 배선을 빠뜨린 사이트가 조용히 기록 없이 돈다 — gpt-image 경로 전체가
그래서 한 줄도 안 남았던 전례가 있다. StepRunner 는 **모든 스텝**에서
`set_opik_context` 를 부르므로, 그 신원을 fallback 으로 읽으면 배선 누락이
구조적으로 생기지 않는다.
"""
from __future__ import annotations

import pytest

from app.modules.llm.image_tracer import ambient_call_meta
from app.modules.llm.llm_client import set_opik_context
from app.services.image_capture.context import GenerationContext, bind_context
from app.services.image_capture.context import _gen_ctx  # noqa: PLC2701 — 정리용

PROJECT = "e716bafb-24bb-42b7-aea0-fdb383844ee8"
EPISODE = "d6a9aa85-b75e-400c-980c-4ee7e876a15b"


@pytest.fixture(autouse=True)
def _clean_scopes():
    """thread-local 과 contextvar 는 시험 사이에 새면 안 된다."""
    set_opik_context(None)
    token = _gen_ctx.set(None)
    yield
    _gen_ctx.reset(token)
    set_opik_context(None)


def _step_context(**extra):
    """StepRunner 가 설정하는 모양의 thread-local 메타."""
    meta = {
        "tags": ["outdoor_structure_form_reference"],
        "session_id": "run-tag-1",
        "trace_name": "프로젝트 > 에피소드 > outdoor_structure_form_reference",
        "project_id": PROJECT,
        "episode_id": EPISODE,
    }
    meta.update(extra)
    return meta


def test_scope_가_전혀_없으면_빈_메타():
    """근거가 없으면 지어내지 않는다."""
    assert ambient_call_meta() == {}


def test_스텝_컨텍스트만_있어도_신원이_붙는다():
    """검색 호출의 자리 — 이미지 포착 scope 없이 스텝 컨텍스트만 있다."""
    set_opik_context(_step_context())

    meta = ambient_call_meta()

    assert meta.get("project_id") == PROJECT
    assert meta.get("episode_id") == EPISODE


def test_스텝_컨텍스트의_기록용_키는_새지_않는다():
    """`tags`/`session_id`/`trace_name` 은 Opik 묶음용이지 호출 메타가 아니다.

    `record_provider_call` 은 신원 두 칸을 뺀 나머지를 그대로 DB metadata 로
    넣으므로, 통째로 병합하면 스텝마다 같은 값이 쌓인다.
    """
    set_opik_context(_step_context())

    meta = ambient_call_meta()

    assert set(meta) == {"project_id", "episode_id"}


def test_포착_scope_가_스텝_컨텍스트를_이긴다():
    """포착 scope 는 still_id·shot_index 까지 아는 더 정확한 근거다."""
    set_opik_context(_step_context(project_id="틀린-프로젝트"))
    bind_context(GenerationContext(
        project_id=PROJECT, episode_id=EPISODE,
        stage="outdoor_structure_seed", queue=None,  # type: ignore[arg-type]
        still_id="still-9", shot_index=3,
    ))

    meta = ambient_call_meta()

    assert meta["project_id"] == PROJECT
    assert meta["still_id"] == "still-9"
    assert meta["shot_index"] == 3
    assert meta["stage"] == "outdoor_structure_seed"


def test_포착_scope_에_신원이_비어도_스텝_컨텍스트가_메운다():
    """scope 는 열렸는데 episode 를 모르는 경우 — 아는 쪽이 채운다."""
    set_opik_context(_step_context())
    bind_context(GenerationContext(
        project_id=PROJECT, episode_id=None,
        stage="entity_t2i", queue=None,  # type: ignore[arg-type]
    ))

    meta = ambient_call_meta()

    assert meta["episode_id"] == EPISODE
    assert meta["stage"] == "entity_t2i"


def test_StepRunner_가_신원을_컨텍스트에_싣는다():
    """읽는 쪽만 고쳐도 싣는 쪽이 안 넣으면 여전히 빈다."""
    from app.core.step_runner import StepRunner

    class _Runner(StepRunner):
        def _execute(self, mode="run"):  # pragma: no cover — 부르지 않는다
            return {}

    runner = _Runner(
        step_id="outdoor_structure_form_reference",
        project_id=PROJECT, episode_id=EPISODE, db=None,  # type: ignore[arg-type]
    )

    meta = runner.build_opik_metadata()

    assert meta["project_id"] == PROJECT
    assert meta["episode_id"] == EPISODE
