"""시험이 바깥으로 유료 호출을 내보내려 하는지 재는 pytest 붙임말 (2026-08-20).

왜 있나: `tests/services/test_still_jit_verify.py` 두 건이 오래 빨간불이었는데,
「원래 실패하던 것」으로 넘어가 있었다. 열어 보니 실패가 아니라 **돈**이었다 —
시험 하네스가 기계의 `.env`(변환 켜짐)를 물어 실제 이미지 API 를 부르고 있었고,
xAI 는 **거부당한 호출에도 요금을 매긴다**(08-19 실측).

실측(2026-08-20, 이 도구로 잰 값 — 잰 범위를 함께 적는다):
  · tests/services/test_still_jit_verify.py 만    수리 전 24건 → 수리 후 0건
  · tests/unit + tests/core + tests/services 전체  수리 전 168건 → 수리 후 0건
  · tests/ 전체(API·통합 포함)                    566건 → 돈 가드 도입으로 0건
★첫 측정에서 「전수 0건」이라 적었던 것은 **틀린 수**였다 — 그때 그물이 urllib
하나만 잡아 httpx 축(글자 호출·Qwen)이 통째로 안 보였다. 아래 「두 축」 절 참조.

쓰는 법: **평소에는 아무것도 안 해도 된다.** `tests/conftest.py` 가 이 붙임말을
기본으로 등록해(THEROAD_TEST_BLOCK_OUTBOUND=1) 바깥 호출을 막고 센다.
끄려면 `THEROAD_TEST_BLOCK_OUTBOUND=0`, 따로 붙이려면 `-p tests.netprobe`.

끝에 「바깥(유료) 호출 시도 N건」이 찍힌다. 0이 아니면 그 시험은 돌 때마다 돈을
쓰려 한 것이다 — 해당 시험의 설정 기준선(_FLAG_PATCHES 류)에 그 기능을 꺼 두거나
클라이언트를 흉내 내야 한다. 어느 시험이 냈는지도 함께 찍는다.

★새 시험을 쓸 때 기억할 것: 하네스는 본체 `.env` 를 통째로 읽는다(opik pytest
붙임말이 환경에 싣는다). 그래서 시험 기준선은 **환경이 아니라 시험이** 정해야
한다. 안 그러면 기계 설정에 따라 결과가 흔들리고, 최악에는 돈이 나간다.

★그물은 **두 축** 다 덮어야 한다 (2026-08-20 리뷰 지적, 수용):
이 저장소가 바깥으로 나가는 길은 하나가 아니다.
  · `urllib.request.urlopen` — 이미지 클라이언트들(grok·gemini 등 11곳)
  · `httpx` — litellm(`llm_client.py`)과 openai SDK(`qwen_vlm_client.py`)
urllib 만 가로채면 **글자 호출과 Qwen 판정이 통째로 안 보인다**. 그러면
「0건」이 「돈이 안 나간다」가 아니라 「urllib 로는 안 나간다」만 뜻하게 되고,
바로 그 오독이 이 도구를 만든 이유였다.
"""
from __future__ import annotations

import urllib.request

ATTEMPTS: list[tuple[str, str]] = []   # (시험 이름, 주소)
_CURRENT = {"test": "(수집·설정 단계)"}


def _is_local(url: str) -> bool:
    """집 안(사설·로컬) 주소인가 — 자체 호스팅이라 요금이 없다."""
    import ipaddress
    from urllib.parse import urlparse

    host = urlparse(url).hostname or ""
    # 빈 호스트는 **바깥으로 본다** — 갈래 없는 주소가 셈에서 조용히 빠지면
    # 안 잡히는 쪽으로 틀리게 된다(아래 이름 주소 취급과 방향을 맞춘다).
    if host in ("localhost", "testserver"):
        return True
    try:
        ip = ipaddress.ip_address(host)
    except ValueError:
        return False   # 이름으로 된 주소는 바깥으로 본다(보수적)
    return ip.is_private or ip.is_loopback


def _redact(url: str) -> str:
    """물음표 뒤를 지운다 — 제공자 열쇠가 주소에 실려 온다.

    실측(2026-08-20): Gemini 는 `…:generateContent?key=AIza…` 로 부른다.
    지우지 않으면 이 도구의 출력·보고서·화면에 살아 있는 열쇠가 그대로
    남는다. 세는 데는 갈래(물음표 앞)만 있으면 된다.
    """
    return url.split("?", 1)[0]


def _should_block(url: str) -> bool:
    """막을 것인가 — 집 안 주소는 **통과시킨다**.

    ★한 번 틀렸다: 집 안 주소를 셈에서만 빼고 막기는 그대로 막았다. 자체
    호스팅 Opik(기록 서버)이 통째로 죽어 시험마다 긴 오류 더미가 찍혔다.
    무료인 것을 막아 놓고 세지만 않은 셈이라, 세는 기준과 막는 기준은
    **같아야 한다**.
    """
    return not _is_local(url)


def _record(url: str):
    safe = _redact(url)
    ATTEMPTS.append((_CURRENT["test"], safe))
    return RuntimeError(f"netprobe: 바깥 호출을 막았다 — {safe}")


def pytest_runtest_protocol(item, nextitem) -> None:  # noqa: ARG001
    # 어느 시험이 냈는지 알아야 조치가 된다 — 총계만으로는 못 고친다.
    _CURRENT["test"] = item.nodeid


def pytest_configure(config) -> None:  # noqa: ARG001
    real = urllib.request.urlopen

    def blocked(req, *args, **kwargs):  # noqa: ARG001
        url = getattr(req, "full_url", None) or str(req)
        if not _should_block(url):
            return real(req, *args, **kwargs)
        raise _record(url)

    blocked._netprobe_real = real  # type: ignore[attr-defined]
    urllib.request.urlopen = blocked

    # ★★★**자기 opener 를 만든 자리는 위 문을 안 지난다** (2026-08-30 실측).
    #  `urlopen` 은 전역 opener 를 쓰지만, `build_opener(...)` 로 만든 것은
    #  `OpenerDirector.open` 을 **직접** 부른다 — 그래서 grounding 인용 확인이
    #  시험 중에 실제로 `https://a.org` 를 받아 오고 있었다. 프로덕션에도
    #  같은 자리가 하나 더 있다(`search_grounded_ref`).
    #  ★그래서 **아래층**을 덮는다. 여기 하나면 두 갈래가 다 걸린다.
    real_open = urllib.request.OpenerDirector.open

    def _blocked_open(self, fullurl, *args, **kwargs):
        url = getattr(fullurl, "full_url", None) or str(fullurl)
        if not _should_block(url):
            return real_open(self, fullurl, *args, **kwargs)
        raise _record(url)

    _blocked_open._netprobe_real = real_open  # type: ignore[attr-defined]
    urllib.request.OpenerDirector.open = _blocked_open

    # httpx — litellm·openai SDK 가 타는 길. 둘 다 Client.send 로 모인다.
    try:
        import httpx
    except ImportError:  # httpx 없는 환경이면 urllib 축만 덮는다
        return

    real_send = httpx.Client.send
    real_asend = httpx.AsyncClient.send

    def _in_process(client) -> bool:
        """앱을 프로세스 안에서 부르는 것(TestClient 등)은 바깥이 아니다.

        FastAPI TestClient 도 httpx 를 타는데, 그것은 한 바이트도 안 나가고
        요금도 없다. 막으면 API 층 시험이 무더기로 죽고 「돈을 쓴다」로
        읽힌다 — 도구가 헛울음을 울면 아무도 안 본다.

        판별은 이름 목록으로 하지 않는다 — TestClient 의 전송기는
        `starlette.testclient._TestClientTransport` 라 httpx 의 ASGI/WSGI
        이름으로도 안 맞고, 새 이름이 나올 때마다 조용히 샌다.

        ★방향이 중요하다. 「바깥 전송기면 막는다」로 잡으면 **모르는 전송기가
        통과**한다(fail-open). 실제로 그렇게 짰다가 litellm 의 비동기 전송기
        (`LiteLLMAiohttpTransport`, httpx 계열이 아니다)가 그대로 나가는
        구멍이 생겼다. 그래서 **프로세스 안이라고 증명될 때만 통과**시킨다 —
        앱을 안고 있는 전송기만 `app` 을 가진다(TestClient·ASGI·WSGI 전부
        해당, 진짜 망을 타는 것과 litellm 전송기는 없다). 모르면 막는다.
        """
        t = getattr(client, "_transport", None)
        return hasattr(t, "app")

    def _blocked_send(self, request, *args, **kwargs):
        url = str(getattr(request, "url", request))
        if _in_process(self) or not _should_block(url):
            return real_send(self, request, *args, **kwargs)
        raise _record(url)

    async def _blocked_asend(self, request, *args, **kwargs):
        url = str(getattr(request, "url", request))
        if _in_process(self) or not _should_block(url):
            return await real_asend(self, request, *args, **kwargs)
        raise _record(url)

    httpx.Client.send = _blocked_send  # type: ignore[method-assign]
    httpx.AsyncClient.send = _blocked_asend  # type: ignore[method-assign]


def _restore_opener() -> None:
    """★되돌린다 — 안 되돌리면 같은 프로세스의 다른 것까지 막는다."""
    cur = urllib.request.OpenerDirector.open
    real = getattr(cur, "_netprobe_real", None)
    if real is not None:
        urllib.request.OpenerDirector.open = real


def pytest_unconfigure(config) -> None:  # noqa: ARG001
    from collections import Counter

    _restore_opener()

    # 집 안(사설·로컬) 주소는 자체 호스팅이라 요금이 없다 — 바깥으로 나가는
    # 것만 돈이다. 둘을 섞어 세면 도구가 헛울음을 울어 아무도 안 본다.
    paid = [(t, u) for t, u in ATTEMPTS if not _is_local(u)]
    local = len(ATTEMPTS) - len(paid)

    if paid:
        print(f"\n[netprobe] ★바깥(유료) 호출 시도 {len(paid)}건 — "
              f"이 시험들은 돌 때마다 돈을 쓴다")
        print("[netprobe] 주소별:")
        for url, n in Counter(u for _t, u in paid).most_common(8):
            print(f"[netprobe]   {n}회 {url}")
        print("[netprobe] 시험별:")
        for test, n in Counter(t for t, _u in paid).most_common(12):
            print(f"[netprobe]   {n}회 {test}")
    else:
        print("\n[netprobe] 바깥(유료) 호출 시도 0건")
    if local:
        # 집 안 주소는 막지 않고 통과시키므로 여기 쌓일 일이 거의 없다.
        # (막는 기준과 세는 기준을 같게 맞춘 뒤로는 방어적 표시다.)
        print(f"[netprobe] (집 안 주소 {local}건은 셈에서 뺐다 — "
              f"자체 호스팅이라 요금 없음)")
