"""gpt-image-2 를 부르는 **모든** 자리가 이미지 문(`reserve_current_call`)을 지난다.

★실측 (2026-09-02, 2단계 산정 중): `call_gpt_image_bytes` 호출 자리 22곳 중
`shot_conti_light`(2) · `still_recipe_service`(2) · `gpt_image_gen`(1) · `confined_fp`(1)
여섯 자리에 문이 없었다. Gemini·Grok·Reve 는 client 안에 문이 있는데 gpt-image 는
primitive 에 문이 없고 **부르는 쪽**이 걸어야 한다 — 그래서 자리마다 본다.

판정은 AST 로 한다 — 같은 함수 본문 안에서 `reserve_current_call(...)` 이
`call_gpt_image_bytes(...)` 보다 **앞줄**에 있어야 한다. 주석·docstring 은 안 센다.
"""
from __future__ import annotations

import ast
from pathlib import Path
from typing import Dict, List, Tuple

APP = Path(__file__).resolve().parents[2] / "app"
PRIMITIVE = "call_gpt_image_bytes"
DOOR = "reserve_current_call"


def _name_of(call: ast.Call) -> str:
    f = call.func
    if isinstance(f, ast.Name):
        return f.id
    if isinstance(f, ast.Attribute):
        return f.attr
    return ""


def _own_calls(fn: ast.AST) -> List[ast.Call]:
    """이 함수 **자신의** 본문에 있는 호출 — 안쪽 함수 본문은 그 함수 몫이다."""
    out: List[ast.Call] = []

    def _walk(node: ast.AST) -> None:
        for child in ast.iter_child_nodes(node):
            if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
                continue
            if isinstance(child, ast.Call):
                out.append(child)
            _walk(child)

    _walk(fn)
    return out


def _door_names(tree: ast.AST) -> set:
    """`reserve_current_call` 과, 그것을 **자기 본문에서** 부르는 같은 모듈의 함수들
    (한 겹 wrapper — 예: `space_set_bg_provider._reserve_image_call`)."""
    names = {DOOR}
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if any(_name_of(c) == DOOR for c in _own_calls(node)):
                names.add(node.name)
    return names


def ungated_sites(tree: ast.AST) -> List[Tuple[int, str]]:
    """(줄, 함수 이름) — 문 없이 primitive 를 부르는 자리들."""
    bad: List[Tuple[int, str]] = []
    doors_by_name = _door_names(tree)
    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        calls = _own_calls(node)
        doors = [c.lineno for c in calls if _name_of(c) in doors_by_name]
        for c in calls:
            if _name_of(c) != PRIMITIVE:
                continue
            if not any(d < c.lineno for d in doors):
                bad.append((c.lineno, node.name))
    return bad


def scan_app() -> Dict[str, List[Tuple[int, str]]]:
    out: Dict[str, List[Tuple[int, str]]] = {}
    for p in sorted(APP.rglob("*.py")):
        src = p.read_text(encoding="utf-8")
        if PRIMITIVE not in src or p.name == "gpt_image_primitive.py":
            continue
        bad = ungated_sites(ast.parse(src))
        if bad:
            out[str(p.relative_to(APP.parent))] = bad
    return out


class TestEveryGptImageCallSiteReserves:
    def test_no_ungated_site_in_app(self):
        got = scan_app()
        assert got == {}, f"★문 없는 gpt-image 자리: {got}"

    def test_the_scan_sees_the_sites_it_must(self):
        """★래칫이 살아 있나 — 알려진 자리들이 실제로 훑힌다."""
        seen = 0
        for p in APP.rglob("*.py"):
            src = p.read_text(encoding="utf-8")
            if p.name != "gpt_image_primitive.py" and f"{PRIMITIVE}(" in src:
                seen += 1
        assert seen >= 10, seen

    def test_positive_control_an_ungated_call_is_caught(self):
        """★고친 걸 일부러 다시 빼서 잡히는지 본다."""
        src = (
            "def draw(client):\n"
            "    for attempt in (1, 2):\n"
            "        try:\n"
            "            png = call_gpt_image_bytes(client, mode='generate')\n"
            "        except Exception:\n"
            "            pass\n"
        )
        assert ungated_sites(ast.parse(src)) == [(4, "draw")]

    def test_a_door_after_the_call_does_not_count(self):
        src = (
            "def draw(client):\n"
            "    png = call_gpt_image_bytes(client, mode='generate')\n"
            "    reserve_current_call(source='late')\n"
        )
        assert ungated_sites(ast.parse(src)) == [(2, "draw")]

    def test_a_door_in_the_outer_function_does_not_cover_the_inner(self):
        """안쪽 함수가 따로 불리면 바깥 문은 안 지난다."""
        src = (
            "def outer(client):\n"
            "    reserve_current_call(source='outer')\n"
            "    def inner():\n"
            "        return call_gpt_image_bytes(client, mode='generate')\n"
            "    return inner\n"
        )
        assert ungated_sites(ast.parse(src)) == [(4, "inner")]

    def test_a_same_module_wrapper_counts_as_a_door(self):
        src = (
            "def _gate(source):\n"
            "    reserve_current_call(source=source)\n"
            "def draw(client):\n"
            "    _gate('x')\n"
            "    return call_gpt_image_bytes(client, mode='generate')\n"
        )
        assert ungated_sites(ast.parse(src)) == []

    def test_a_wrapper_that_does_not_reserve_is_not_a_door(self):
        src = (
            "def _gate(source):\n"
            "    return source\n"
            "def draw(client):\n"
            "    _gate('x')\n"
            "    return call_gpt_image_bytes(client, mode='generate')\n"
        )
        assert ungated_sites(ast.parse(src)) == [(5, "draw")]

    def test_a_comment_is_not_a_door(self):
        src = (
            "def draw(client):\n"
            "    # reserve_current_call(source='only a comment')\n"
            "    return call_gpt_image_bytes(client, mode='generate')\n"
        )
        assert ungated_sites(ast.parse(src)) == [(3, "draw")]
