"""shot_run_uid — Opik·records.json·image_asset 세 곳에 같은 값."""
import ast
from pathlib import Path

from app.modules.llm.opik_trace import current_shot_uid

SRC = (Path(__file__).resolve().parents[2] / "app" / "modules"
       / "pipeline" / "multiroll_select.py")


def test_none_outside_scope():
    assert current_shot_uid() is None


def test_matches_open_trace_uid(monkeypatch):
    from app.core import config
    from app.modules.llm import opik_trace
    monkeypatch.setattr(config.settings, "opik_trace_v2_enabled", True)

    handle = opik_trace.TraceHandle(uid="0190-shot-1", name="still:x")
    token = opik_trace.bind_trace(handle)
    try:
        assert current_shot_uid() == "0190-shot-1"
    finally:
        opik_trace.reset_trace(token)


def test_disabled_gives_none(monkeypatch):
    from app.core import config
    from app.modules.llm import opik_trace
    monkeypatch.setattr(config.settings, "opik_trace_v2_enabled", False)
    token = opik_trace.bind_trace(
        opik_trace.TraceHandle(uid="0190-x", name="n"))
    try:
        assert current_shot_uid() is None
    finally:
        opik_trace.reset_trace(token)


# ── 모든 반환이 stamp 를 지나는가 (Task 12 Step 7) ─────────────────────

def _outer_nodes(fn):
    """바깥 함수 본문의 노드만 — **중첩 함수·lambda 는 건너뛴다**.

    ★`ast.walk` 를 그냥 쓰면 안쪽 헬퍼의 `return` 까지 「stamp 없는 반환」으로
    잡혀 시험이 구현을 막는다. 그 반환들은 이 계약의 대상이 아니다.
    """
    out = []

    def _rec(node):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef,
                                  ast.Lambda)):
                continue          # 중첩 정의는 통째로 건너뛴다
            out.append(child)
            _rec(child)

    _rec(fn)
    return out


def _shot_fn():
    """샷 함수 = `run_multiroll_select`.

    ★이름으로 고정한다. 「2-튜플을 반환하는 함수」로 찾으면 `gemini_select`
    같은 다른 함수가 먼저 잡혀 엉뚱한 반환을 센다(실제로 그랬다).
    """
    tree = ast.parse(SRC.read_text(encoding="utf-8"))
    return next(
        n for n in ast.walk(tree)
        if isinstance(n, ast.FunctionDef) and n.name == "run_multiroll_select"
    )


def test_every_outer_return_is_stamped():
    """★바깥 반환이 전부 _stamp_shot_run 을 지나야 한다.

    한 곳만 빠져도 그 갈래에서 세 곳의 uid 가 갈린다. 사람 눈으로 세면
    빠뜨리므로 기계로 센다.
    """
    target = _shot_fn()
    nodes = _outer_nodes(target)
    returns = [n for n in nodes
               if isinstance(n, ast.Return) and n.value is not None]
    stamps = [n.lineno for n in nodes
              if isinstance(n, ast.Call)
              and getattr(n.func, "id", "") == "_stamp_shot_run"]

    plain = [r.lineno for r in returns
             if not any(r.lineno - 6 <= s < r.lineno for s in stamps)]
    assert not plain, f"stamp 없이 나가는 바깥 반환: {plain}줄"
    assert len(returns) == 5, f"바깥 반환이 {len(returns)}개 — 5개여야 한다"


def test_nested_helper_returns_are_ignored():
    """중첩 헬퍼의 반환은 이 계약 밖이다 — 시험이 구현을 막으면 안 된다."""
    fn = ast.parse(
        "def outer():\n"
        "    def inner():\n"
        "        return 1\n"
        "    _stamp_shot_run(r, produced=True)\n"
        "    return 2\n"
    ).body[0]
    rets = [n for n in _outer_nodes(fn) if isinstance(n, ast.Return)]
    assert len(rets) == 1, "중첩 함수의 return 이 새어 들어왔다"


def test_reused_visit_does_not_claim_authorship():
    """산출 재사용 방문의 uid 로 자산 계보를 덮지 않는다."""
    rec = {"shot_run_uid": "0190-visit-2", "shot_run_produced": False}
    stamped = (rec.get("shot_run_uid")
               if rec.get("shot_run_produced") else None)
    assert stamped is None

    rec2 = {"shot_run_uid": "0190-visit-3", "shot_run_produced": True}
    assert (rec2.get("shot_run_uid")
            if rec2.get("shot_run_produced") else None) == "0190-visit-3"


def test_stale_uid_is_cleared_when_no_trace():
    """★v2 OFF·scope 누락이면 옛 uid 를 지운다 — 옛 trace 저자로 안 남긴다."""
    rec = {"shot_run_uid": "0190-old", "shot_run_produced": True}
    rec["shot_run_produced"] = False        # 항상 현재 값
    uid = None
    if uid:
        rec["shot_run_uid"] = uid
    else:
        rec.pop("shot_run_uid", None)
    assert "shot_run_uid" not in rec
    assert rec["shot_run_produced"] is False
    assert (rec.get("shot_run_uid") if rec.get("shot_run_produced") else None) \
        is None


def test_spend_is_marked_before_the_call_not_after():
    """★★과금 후 실패해도 지출 시도가 남아야 한다.

    예외는 still_recipe_service 의 except 로 빠져 JIT 전후 비교를 통째로
    건너뛴다. 반환 직전에 세면 「성공한 지출」만 잡히고, 다음 resume 이
    같은 돈을 다시 쓴다.
    """
    tree = ast.parse(SRC.read_text(encoding="utf-8"))

    marker = next(
        (n for n in ast.walk(tree)
         if isinstance(n, ast.FunctionDef)
         and n.name == "_mark_spend_attempt_once"), None)
    assert marker is not None, \
        "_mark_spend_attempt_once 가 없다 — 지출을 반환 직전에 센다"

    body = ast.dump(marker)
    assert "shot_run_spend_attempt_count" in body
    assert "_persist" in body, "durable 하게 안 적는다 — 죽으면 시도가 사라진다"

    stamp = next(n for n in ast.walk(tree)
                 if isinstance(n, ast.FunctionDef)
                 and n.name == "_stamp_shot_run")
    assert "shot_run_spend_attempt_count" not in ast.dump(stamp), \
        "_stamp_shot_run 이 아직 계수를 올린다 — 실패한 시도를 놓친다"


def test_spend_marker_is_called_before_generation():
    """생성·판정·critique **앞**에서 불러야 한다 — 정의 1 + 호출 4."""
    import re
    src = SRC.read_text(encoding="utf-8")
    calls = [m.start() for m in re.finditer(r"_mark_spend_attempt_once\(\)", src)]
    assert len(calls) >= 5, f"_mark_spend_attempt_once 호출이 {len(calls) - 1}곳"
