"""이미지 bytes 가 실려 나가는 **호출**을 센다 (2026-08-27, #92).

사용자 지시: 「gpt sol vlm 은 성능이 안좋아 … 무조건 gemini 3.1 pro 와 grok
최신 모델 둘을 사용해야해 / 아홉 전부 바꿔」.

Codex 지적 둘을 반영한 판이다.

## ① 「아홉」은 숫자가 아니라 자리로 세야 한다

`space_set_bg_provider.TEXT_MODEL_DEFAULT` 는 이미지가 없는 순수 text 라
대상이 아니고, 기본 OFF 라 빠진 실제 VLM 이 더 있었다. 기준은
**「이미지 bytes 가 request 에 실리는가」**다.

## ② ★함수 안 공존을 한 요청으로 오독하면 안 된다

앞 판은 한 함수 안에 이미지 표식 하나 + 발송 하나 + 모델 이름 하나가
있으면 한 자리로 묶었다. 그래서 `_fitting_refs` 를 Sol VLM 으로 셌는데
**실제로는 GPT 호출이 이미지 없는 검색문 저작**이고 이미지는 같은 함수의
다른 호출(`FITTING_PICK_JUDGE=gemini-pro`)에만 실린다 — **거짓 양성**이다.
상수부터 바꿨으면 text-only 검색 저작까지 바뀌었다.

그래서 이 판은 **발송 호출 하나하나**를 보고, 그 호출의 **인자에서 출발한
지역 dataflow** 안에 이미지가 있는지로 판정한다.

## 재는 도구를 세 번 고칠 때마다 목록이 늘었다 (8 → 12 → 15)

    ① `llm_completion` 이 `completion` 과 글자가 달라 provider 넷이 통째로
       안 잡혔다 (정확 일치 → 부분 일치)
    ② 물리 모델명(`openai/gpt-5.6-sol`)이 alias 목록에 없었다
    ③ ★`X: str = "..."` 는 `Assign` 이 아니라 **`AnnAssign`** 이다 —
       `VISION_MODEL_DEFAULT` 값을 통째로 못 읽어 「못 읽음」이 됐다

**「전부」라고 못박지 않는다.**

## 이 도구가 못 보는 것 (스스로 밝힌다)

**함수 경계를 넘는 흐름을 안 따라간다.** 예: lane 은
`shot_conti_light_step` 이 `outdoor_marker_map` 으로 `map_png` 를 넘기고
거기서 발송한다 — 발송이 있는 쪽에서만 잡힌다. 그래서 두 번째 렌즈
(파일 단위 후보)를 같이 낸다. **두 렌즈를 합쳐도 전수라는 보장은 없다.**

    .venv/bin/python tools/prompt_measure/audit_vlm_sites.py
"""
from __future__ import annotations

import ast
import pathlib
import re
import sys

ROOT = pathlib.Path(__file__).resolve().parents[2] / "app"

_IMAGE_MARKS = ("image_url", "png_part", "ref_parts", "data:image",
                "_png_part", "inline_data", "image/png", "b64encode")
# ★정확히 같은 이름만 보면 놓친다 — provider 넷은 `llm_completion` 을 부른다.
_SEND_MARKS = ("completion", "call_structured", "call_multiturn",
               "call_text", "ask_openrouter_structured",
               "ask_gemini_structured", "generate_content")
_MODEL_CONST = re.compile(r"^[A-Z0-9_]*MODEL[A-Z0-9_]*$")
_SOL = ("gpt-5", "-sol")
_ALIASES = ("gpt", "gemini-pro", "gemini-flash", "grok", "gpt-mini",
            "claude-opus", "qwen-vlm")


def _module_consts(tree) -> dict:
    """모듈 수준 `*_MODEL*` 문자열 상수 — `AnnAssign` 도 본다."""
    out = {}
    for n in tree.body:
        if isinstance(n, ast.AnnAssign):
            tgts, val = [n.target], n.value
        elif isinstance(n, ast.Assign):
            tgts, val = n.targets, n.value
        else:
            continue
        if not (isinstance(val, ast.Constant) and isinstance(val.value, str)):
            continue
        for t in tgts:
            if isinstance(t, ast.Name) and _MODEL_CONST.match(t.id):
                out[t.id] = val.value
    return out


def _local_assigns(fn) -> dict:
    """함수 안 지역 변수 → 마지막으로 대입된 노드.

    ★**인자 기본값도 지역 대입이다** (2026-08-27 실측). provider 들은
     `def f(..., model: str = PROVIDER_MODEL_DEFAULT)` 로 받아 `model=model`
     로 넘긴다 — 대입만 보면 그 이름이 뭘 가리키는지 모르고 「못 읽음」이
     되어 Sol 자리가 통째로 빠진다.
    """
    out = {}
    a = fn.args
    for params, defaults in ((a.posonlyargs + a.args, a.defaults),
                             (a.kwonlyargs, a.kw_defaults)):
        if not defaults:
            continue
        pad = len(params) - len(defaults)
        for i, d in enumerate(defaults):
            if d is not None and 0 <= pad + i < len(params):
                out[params[pad + i].arg] = d
    for n in ast.walk(fn):
        if isinstance(n, ast.Assign):
            for t in n.targets:
                if isinstance(t, ast.Name):
                    out[t.id] = n.value
        elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name):
            if n.value is not None:
                out[n.target.id] = n.value
        elif isinstance(n, (ast.For, ast.comprehension)):
            tgt = getattr(n, "target", None)
            if isinstance(tgt, ast.Name):
                out.setdefault(tgt.id, getattr(n, "iter", None))
    return {k: v for k, v in out.items() if v is not None}


def _expand(nodes, locals_map, depth=3):
    """인자에서 출발해 **지역 대입을 따라** 몇 단 펼친다."""
    seen, out = set(), []
    stack = [(n, depth) for n in nodes if n is not None]
    while stack:
        node, d = stack.pop()
        if id(node) in seen:
            continue
        seen.add(id(node))
        out.append(node)
        if d <= 0:
            continue
        for sub in ast.walk(node):
            if isinstance(sub, ast.Name) and sub.id in locals_map:
                stack.append((locals_map[sub.id], d - 1))
    return out


def _text_of(nodes) -> str:
    parts = []
    for node in nodes:
        for n in ast.walk(node):
            if isinstance(n, ast.Constant) and isinstance(n.value, str):
                parts.append(n.value)
            elif isinstance(n, ast.Name):
                parts.append(n.id)
            elif isinstance(n, ast.Attribute):
                parts.append(n.attr)
    return " ".join(parts)


def _project_config_models(nodes) -> list:
    """`{"<스텝>": {"model": "gpt"}}` 모양에서 모델을 읽는다.

    ★**이 저장소가 실제로 모델을 고르는 주된 방식이다** (2026-08-27 실측).
     `shot_conti_light_step.py:1116` 의
     `geometry_config = {"outdoor_marker_geometry": {"model": "gpt"}}` 가
     lane geometry 를 Sol 로 만드는데, `model=` 인자만 보면 안 보인다 —
     Codex 가 특정해 주지 않았으면 목록에서 빠졌다.
    """
    out = []
    for node in nodes:
        for n in ast.walk(node):
            if not isinstance(n, ast.Dict):
                continue
            for v in n.values:
                if not isinstance(v, ast.Dict):
                    continue
                for k2, v2 in zip(v.keys, v.values):
                    if (isinstance(k2, ast.Constant) and k2.value == "model"
                            and isinstance(v2, ast.Constant)
                            and isinstance(v2.value, str)):
                        out.append(f'project_config→"{v2.value}"')
    return out


def _models_of(call, nodes, consts) -> list:
    """이 **호출**이 쓰는 모델 — 인자에서만 읽는다."""
    hints = _project_config_models(nodes)
    for kw in call.keywords:
        if kw.arg == "model":
            for n in ast.walk(kw.value):
                if isinstance(n, ast.Name) and n.id in consts:
                    hints.append(f"{n.id}={consts[n.id]}")
                elif isinstance(n, ast.Name) and _MODEL_CONST.match(n.id):
                    hints.append(n.id)
                elif isinstance(n, ast.Constant) and isinstance(n.value, str):
                    hints.append(f'"{n.value}"')
    for node in nodes:
        for n in ast.walk(node):
            if isinstance(n, ast.Name) and n.id in consts:
                hints.append(f"{n.id}={consts[n.id]}")
            elif isinstance(n, ast.Constant) and isinstance(n.value, str):
                v = n.value
                if v in _ALIASES or any(k in v.lower() for k in
                                        ("gpt-5", "sol", "gemini-", "grok-",
                                         "qwen")):
                    hints.append(f'"{v}"')
    return sorted(set(hints))


def _is_sol(hints) -> bool:
    return any(('"gpt"' in h or "=gpt" in h
                or any(k in h.lower() for k in _SOL)) for h in hints)


def main() -> None:
    rows, cand_files = [], []
    for py in sorted(ROOT.rglob("*.py")):
        try:
            src = py.read_text(encoding="utf-8")
            tree = ast.parse(src)
        except Exception:
            continue
        consts = _module_consts(tree)
        rel = str(py.relative_to(ROOT.parent))
        found_here = False
        for fn in ast.walk(tree):
            if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
                continue
            locals_map = _local_assigns(fn)
            for call in ast.walk(fn):
                if not isinstance(call, ast.Call):
                    continue
                f = call.func
                name = (f.id if isinstance(f, ast.Name)
                        else f.attr if isinstance(f, ast.Attribute) else "")
                if not any(m in name for m in _SEND_MARKS):
                    continue
                args = [*call.args, *(k.value for k in call.keywords)]
                nodes = _expand(args, locals_map)
                if not any(m in _text_of(nodes) for m in _IMAGE_MARKS):
                    continue  # ★이 **호출**엔 이미지가 안 실린다
                found_here = True
                rows.append({
                    "file": rel, "fn": fn.name, "line": call.lineno,
                    "send": name,
                    "models": _models_of(call, nodes, consts) or ["(못 읽음)"],
                })
        if not found_here and any(m in src for m in _IMAGE_MARKS) and any(
                m in src for m in _SEND_MARKS):
            sols = sorted({ln.strip() for ln in src.splitlines()
                           if any(k in ln.lower() for k in _SOL)
                           and "=" in ln and not ln.strip().startswith("#")})
            if sols:
                cand_files.append((rel, sols))

    print(f"■ 렌즈 1 — 이미지가 실리는 **호출** {len(rows)}건\n")
    by_file: dict = {}
    for r in rows:
        by_file.setdefault(r["file"], []).append(r)
    sol = []
    for f in sorted(by_file):
        print(f"  {f}")
        for r in sorted(by_file[f], key=lambda x: x["line"]):
            mark = "★Sol" if _is_sol(r["models"]) else "    "
            print(f"   {mark} :{r['line']:<5} {r['fn']:<40} "
                  f"[{r['send']}]  {' · '.join(r['models'])}")
            if _is_sol(r["models"]):
                sol.append(f"{f}:{r['line']} {r['fn']}")

    print(f"\n■ 그중 **Sol** 인 호출 {len(sol)}건")
    for s in sol:
        print(f"   · {s}")

    print("\n" + "─" * 62)
    print("■ 렌즈 2 — 이 도구가 **못 따라간** 후보 (함수 경계를 넘는 흐름)")
    for rel, sols in cand_files:
        print(f"  {rel}")
        for s in sols[:3]:
            print(f"    {s[:86]}")
    print(f"\n  후보 파일 {len(cand_files)}개")
    print("\n★이 도구는 **함수 경계를 넘는 흐름을 안 따라간다.** 두 렌즈를"
          " 합쳐도\n 전수라는 보장은 없다 — 「전부」라고 쓰지 말 것.")


if __name__ == "__main__":
    sys.exit(main())
