"""canary 주행의 **요청 계약을 진짜로 잠근다**. ★문구가 아니라 값이다.

Codex BLOCK (2026-08-31) —

> `PINNED` 은 실제 요청 계약이 아니라 **계산용 dict** 뿐입니다. plan/상한
> 계산에만 넣고 `canary_pipeline` 은 production `run_steps_batch` 를 그대로
> 부릅니다. 실제 `call_structured` 기본은 `enable_fallback=True,
> num_retries=None` 이고 Router 는 `settings.llm_max_retries`(기본 3)로
> 생성됩니다. `canary_text_scope` 는 Router 진입 **전** counted 만 세므로
> **Router 내부 재시도는 안 보입니다.**

## 문 위와 아래

    문 **위**(세어진다)   tier × 키 슬롯 — 매번 `reserve` 한다
    문 **아래**(안 보임)  Router `num_retries` × litellm SDK `max_retries`

그래서 **아래를 잠가야** raw 상한을 말할 수 있다. 이 파일이 잠그는 것은
`settings.llm_max_retries` **하나**다 — Router 가 **지어지기 전에** 걸어야
한다. 이미 지어졌으면 **provider 0 으로 선다**.

★`enable_fallback` 은 **안 잠근다.** tier 가 늘어나는 것은 문 **위**라
counted 상한이 막는다 — 그래서 그것까지 「잠갔다」고 쓰지 않는다.
"""
from __future__ import annotations

import contextlib
from typing import Any, Dict, Iterator, Optional

#: canary 가 강제하는 값. ★Router 가 이 값으로 지어져야 한다.
CANARY_NUM_RETRIES = 0

#: ★★★2026-09-02 (Codex): **문 아래 두 겹을 다 잠근다**.
#:
#:      SDK `max_retries`   litellm 이 제 OpenAI client 에 주는 값.
#:                          기본은 **2** 다 — `openai.py:577` 이
#:                          `inference_params.pop("max_retries", 2)` 로 집는다.
#:                          ★실측(무료 probe): 아무것도 안 주면 client 가
#:                           `max_retries=2` 로 지어지고, `max_retries=0` 을
#:                           주면 **0 으로 지어진다**. 즉 잠글 수 있다.
#:      tier fallback       `call_structured` 의 Tier 2/3. 문 **위**라 counted
#:                          가 막긴 하지만, 켜 두면 상한 70 에서 조기 정지할
#:                          수 있어 **판이 inconclusive 로 끝난다**.
#:
#: ★둘 다 **canary 안에서만** 강제하고 나올 때 되돌린다.
CANARY_SDK_MAX_RETRIES = 0
CANARY_ENABLE_FALLBACK = False

#: 실제 provider client 가 **어떤 값으로 지어졌나** — 문구가 아니라 관측.
OBSERVED_CLIENT_RETRIES: list = []
#: 부르는 쪽이 **실제로 준** 값. ★`'<없음>'` 이면 우리가 채웠다는 뜻이다.
REQUESTED_CLIENT_RETRIES: list = []


class TransportBudgetExceeded(RuntimeError):
    """운반층에서 센 전송이 승인 밖이다. ★네트워크 전에 선다."""


class TransportCounter:
    """**전송마다** 세는 계수기. ★어느 예산이 깔려 있든 이것은 본다.

    ★★★왜 필요한가 (실측 2026-09-02): `grounding_chunk` 이 제
    `research_run_scope(cap=24)` 를 열고, 그것이 `install_budget` 으로 canary
    예산을 **갈아 끼운다**. 그래서 그 스텝의 유료 호출 2건이 canary 장부에
    **counted 0** 으로 적혔다 — 즉 「run 전체 정지선」이 그 스텝을 못 봤다.
    이 계수기는 `reserve_current_research_call` **그 자리**를 감싸므로
    안쪽 scope 가 무엇을 깔든 **전부** 보인다.
    """

    def __init__(self, cap: int, *, name: str = "pipeline",
                 sink: Optional[Any] = None) -> None:
        """
        Args:
            name: 이 계수기가 **무엇의 몫**인가. 부트스트랩과 pipeline 은
                상한도 장부도 따로다 — 한 계수기가 둘을 같이 세면 뜻이 섞인다
                (Codex 2026-09-02).
            sink: `(snapshot) -> None`. **전송마다** 불린다 — 메모리에만 세면
                `os._exit` 로 프로세스가 사라졌을 때 또 0 으로 보이고 다음
                재개가 다시 산다.
        """
        import threading

        self.cap, self.used, self.denied = int(cap), 0, 0
        self.name = str(name)
        self.by_source: Dict[str, int] = {}
        self.history: list = []
        self._sink = sink
        # ★★★fan-out worker 가 **동시에** 부른다 (Codex 2026-09-02).
        #  검사→증가→기록을 한 임계구역으로 묶지 않으면 cap 을 넘겨 나간다.
        self._lock = threading.RLock()

    def adopt(self, used: int, by_source: Optional[Dict[str, int]] = None
              ) -> None:
        """앞 판이 **남긴 수를 이어받는다**. ★0 으로 지우지 않는다.

        ★★★실측 지적 (Codex 2026-09-02): 계수기를 0 에서 시작하고 기록을
        덮으면, 프로세스가 죽어 남은 durable 수를 **다음 판이 지운다**.
        """
        with self._lock:
            self.used = max(self.used, int(used))
            for k, v in (by_source or {}).items():
                self.by_source[k] = max(self.by_source.get(k, 0), int(v))
            self._flush()

    def set_scope(self, name: str, cap: int) -> None:
        """**몫을 바꾼다** — 부트스트랩과 pipeline 은 상한도 장부도 따로다.

        ★앞의 몫은 `history` 에 남는다 — 한 계수기가 둘을 같이 세면 fresh
        run 에서 뜻이 섞인다 (Codex 2026-09-02).
        """
        if self.used or self.denied:
            self.history.append(self.snapshot())
        self.name, self.cap = str(name), int(cap)
        self.used = self.denied = 0
        self.by_source = {}
        self._flush()

    def note(self, source: str) -> None:
        # ★검사→증가→기록이 **한 임계구역**이다
        with self._lock:
            if self.cap >= 0 and self.used >= self.cap:
                self.denied += 1
                self._flush()
                raise TransportBudgetExceeded(
                    f"[{self.name}] 운반층 전송이 승인 {self.cap} 을 넘었다 "
                    f"(쓴 것 {self.used}) — 거절한 자리 {source!r}. "
                    f"네트워크 전에 선다")
            self.used += 1
            self.by_source[source] = self.by_source.get(source, 0) + 1
            # ★★**보내기 직전에** 내려쓴다 — 죽어도 이 수가 남는다
            self._flush()

    def _flush(self) -> None:
        if self._sink is None:
            return
        try:
            self._sink(self.snapshot())
        except Exception as exc:                    # noqa: BLE001
            raise TransportBudgetExceeded(
                f"[{self.name}] 운반 기록을 못 남겼다 ({exc}) — 얼마 썼는지 "
                f"모르는 채로 보내지 않는다") from exc

    def snapshot(self) -> Dict[str, Any]:
        return {"scope": self.name, "cap": self.cap, "used": self.used,
                "denied": self.denied, "earlier_scopes": list(self.history),
                "by_source": dict(sorted(self.by_source.items())),
                "★means": ("운반층에서 **전송마다** 센 수다. 스텝이 제 예산 "
                           "scope 를 열어도 이것은 가려지지 않는다")}


class RequestContractRefused(RuntimeError):
    """요청 계약을 못 잠갔다. ★아무것도 안 사고 선다."""


def _live_router() -> Any:
    """지금 **실제로 쓰이는** Router. ★`_binding` 이 정본이다.

    ★★`_get_router_binding()` 은 `_binding` 캐시를 먼저 본다 — `_router` 만
    비워도 **다시 안 짓고** 옛 Router 를 그대로 돌려준다 (실측 2026-08-31).
    그래서 `_router` 만 보면 「없다」로 잘못 읽는다.
    """
    from app.modules.llm import llm_client as lc

    b = getattr(lc, "_binding", None)
    if b is not None and getattr(b, "router", None) is not None:
        return b.router
    return getattr(lc, "_router", None)


def router_already_built() -> bool:
    """Router 가 이미 있나. ★있으면 `num_retries` 를 못 바꾼다."""
    return _live_router() is not None


@contextlib.contextmanager
def canary_request_lock(*, transport_cap: int = -1,
                        transport_name: str = "pipeline",
                        transport_sink: Optional[Any] = None
                        ) -> Iterator[Dict[str, Any]]:
    """이 안에서 지어지는 Router 는 **재시도 0** 이다.

    Raises:
        RequestContractRefused: Router 가 **이미** 지어져 있다 — 그것은
            옛 `num_retries` 를 들고 있어 잠글 수 없다. provider 앞에서 선다.
    """
    from app.core.config import settings

    if router_already_built():
        raise RequestContractRefused(
            "Router 가 이미 지어져 있다 — 그 안의 `num_retries` 를 못 바꾼다. "
            "canary 는 **새 프로세스**에서 돌아야 한다. provider 를 안 부른다")
    from app.modules.llm import llm_client as lc

    prev = settings.llm_max_retries
    settings.llm_max_retries = CANARY_NUM_RETRIES
    prev_completion = lc._completion
    prev_structured = lc.call_structured
    prev_client_init = _install_client_spy()
    from app.core import research_call_budget as rcb

    counter = TransportCounter(transport_cap, name=transport_name,
                               sink=transport_sink)
    prev_reserve = rcb.reserve_current_research_call

    def _counted_reserve(*, source: str):
        """★어느 예산이 깔려 있든 **여기서** 센다 — 다만 **나갈 것만**.

        ★★★앞 판은 `note()` 를 **먼저** 불렀다 (실측 2026-09-02 재판정 판).
        그러면 안쪽 스텝 예산이 막아 **한 번도 안 나간** 호출이 run 전체
        승인선을 먹는다 — `entity_t2i` 의 거절 16건이 남은 23을 다 써서
        정작 하려던 재판정이 시작도 못 했다. Opik 성공 **0** · 텍스트 예산
        `used 0` 이 그것을 증언한다. **돈이 안 나간 것에 상한을 쓰지 않는다.**

        ★순서가 뒤집혀도 문은 여전히 **네트워크 앞**이다 —
        `reserve_current_research_call` 은 보내기 전에 불리기 때문이다.
        안쪽 scope 가 바깥을 가리는 문제도 그대로 잡힌다: 안쪽이 통과시키면
        여기서 센다.
        """
        got = prev_reserve(source=source)
        counter.note(source)
        return got

    def _completion_zero_retry(binding, model, kwargs):
        """★보내기 **직전**에 SDK 재시도를 0 으로 박는다."""
        kwargs["max_retries"] = CANARY_SDK_MAX_RETRIES
        return prev_completion(binding, model, kwargs)

    def _no_tier_fallback(*a, **k):
        """★Tier 2/3 를 닫는다 — 한 논리 호출이 한 tier 만 연다."""
        k["enable_fallback"] = CANARY_ENABLE_FALLBACK
        return prev_structured(*a, **k)

    lc._completion = _completion_zero_retry
    lc.call_structured = _no_tier_fallback
    rcb.reserve_current_research_call = _counted_reserve
    try:
        yield {"llm_max_retries": CANARY_NUM_RETRIES, "was": prev,
               "sdk_max_retries": CANARY_SDK_MAX_RETRIES,
               "enable_fallback": CANARY_ENABLE_FALLBACK,
               "transport": counter}
    finally:
        settings.llm_max_retries = prev
        lc._completion = prev_completion
        lc.call_structured = prev_structured
        rcb.reserve_current_research_call = prev_reserve
        _remove_client_spy(prev_client_init)


def _install_client_spy():
    """OpenAI client 를 **재시도 0 으로 짓게 하고**, 원래 값도 적는다.

    ★★「0 으로 보냈다」와 「0 으로 지어졌다」는 다르다 — 끝점에서 잰다.

    ★★★앞 판은 **보기만** 했다 (실측 2026-09-02 유료 canary ①). 중앙 조사가
    쓰는 Responses client 는 `max_retries` 를 **안 주고** 지어져서
    `'<없음>'` 으로 관측됐고, 파이프라인이 다 돈 **뒤에** 이 문이 서서
    「raw 상한을 말할 수 없다」로 판이 끝났다. openai SDK 기본값은 2 라
    한 논리 호출이 최대 3회 나갈 수 있었다 — 그래서 **보기 전에 박는다**.
    안 준 것만 채우고, 사람이 명시로 준 값은 안 덮는다(그건 계약 위반이라
    아래 문이 잡아야 한다).
    """
    import openai

    real = openai.OpenAI.__init__

    def _spy(self, *a, **k):
        asked = k.get("max_retries", "<없음>")
        REQUESTED_CLIENT_RETRIES.append(asked)
        if "max_retries" not in k:
            k["max_retries"] = CANARY_SDK_MAX_RETRIES
        OBSERVED_CLIENT_RETRIES.append(k["max_retries"])
        return real(self, *a, **k)

    openai.OpenAI.__init__ = _spy
    return real


def _remove_client_spy(real) -> None:
    import openai

    openai.OpenAI.__init__ = real


def assert_client_retries_observed(*, strict: bool = True) -> Dict[str, Any]:
    """지어진 client 들이 **정말 0** 이었나. ★관측이 없으면 「모른다」다.

    Args:
        strict: 0 이 아닌 값이 하나라도 있으면 **선다**.

    ★관측이 **비어 있는 것은 실패가 아니다** — Gemini 경로는 openai client 를
    안 짓는다. 있는 것만 본다.
    """
    bad = [x for x in OBSERVED_CLIENT_RETRIES
           if x != CANARY_SDK_MAX_RETRIES]
    #: 우리가 **채워 준** 것들 — 그 자리는 잠글 사람이 없었다는 뜻이다.
    forced = sum(1 for x in REQUESTED_CLIENT_RETRIES if x == "<없음>")
    got = {"observed": list(OBSERVED_CLIENT_RETRIES), "bad": bad,
           "requested": list(REQUESTED_CLIENT_RETRIES),
           "forced_to_zero": forced, "locked": not bad,
           "★means": ("`requested` 는 부르는 쪽이 준 값이다. `'<없음>'` 이면 "
                      "우리가 0 으로 박았다 — production 에서는 그 자리가 "
                      "SDK 기본값(2)으로 열려 있다는 뜻이라 따로 고쳐야 한다")}
    if bad and strict:
        raise RequestContractRefused(
            f"provider client 가 재시도 {sorted(set(map(str, bad)))} 로 "
            f"지어졌다 — {CANARY_SDK_MAX_RETRIES} 여야 raw 상한을 말할 수 있다")
    return got


def assert_router_locked(*, strict: bool = True) -> Dict[str, Any]:
    """**지어진 Router** 가 정말 재시도 0 인가. ★보내기 전에 본다.

    Args:
        strict: 못 읽거나 아직 없으면 **선다**. ★기본이 엄격이다 —
            「모른다」로 지나가면 문 아래에서 몇 번 나가는지 모른 채 산다
            (Codex 2026-08-31).

    ★문구를 믿지 않는다 — 실제 객체에서 읽는다.
    """
    r = _live_router()
    if r is None:
        if strict:
            raise RequestContractRefused(
                "Router 가 아직 없다 — 무엇으로 나갈지 모르는 채 살 수 없다. "
                "`prepare_router()` 로 **먼저 짓고** 확인한다")
        return {"router": "아직 안 지어졌다", "locked": None}
    got = getattr(r, "num_retries", None)
    if got is None:
        # litellm 판마다 자리가 다르다 — **못 읽으면 못 산다**
        if strict:
            raise RequestContractRefused(
                "Router 에서 `num_retries` 를 못 읽었다 — 문 **아래**에서 몇 "
                "번 나가는지 모른 채 살 수 없다. provider 앞에서 선다")
        return {"router": "있다", "num_retries": None, "locked": None,
                "why": "Router 에서 `num_retries` 를 못 읽었다 — 모른다"}
    if int(got) != CANARY_NUM_RETRIES:
        raise RequestContractRefused(
            f"Router 의 `num_retries` 가 {got} 다 — {CANARY_NUM_RETRIES} 여야 "
            "한다. 문 **아래**에서 더 나가므로 raw 상한을 말할 수 없다")
    return {"router": "있다", "num_retries": int(got), "locked": True}


def prepare_router() -> Dict[str, Any]:
    """**첫 유료 호출 전에** Router 를 짓고 **즉시** 확인한다.

    ★`_get_router_binding()` 은 production 함수이고 **아무것도 안 보낸다** —
    객체만 짓는다. 그 객체에서 `num_retries` 를 읽어 0 이 아니거나 못 읽으면
    **provider 0 으로 선다** (Codex 2026-08-31).

    ★이 함수는 `canary_request_lock()` **안에서** 불러야 한다.
    """
    from app.core.config import settings
    from app.modules.llm.llm_client import _get_router_binding

    if int(settings.llm_max_retries) != CANARY_NUM_RETRIES:
        raise RequestContractRefused(
            f"`llm_max_retries` 가 {settings.llm_max_retries} 다 — 잠금 "
            "**안에서** 불러야 한다")
    _get_router_binding()                       # ★안 보낸다. 짓기만 한다
    return assert_router_locked(strict=True)
