"""Opik usage 에 캐시 항목 싣기 — 설치본 실물 대상 시험.

가짜 `create_usage_object` 를 만들어 시험하면 래핑이 실제 호출 경로에
걸리는지 알 수 없다. 그래서 litellm 설치본의 진짜 함수를 감싸고, Opik SDK
의 진짜 변환 함수로 끝까지 흘려 본다. 시험이 끝나면 설치본 모듈을 원래
함수로 되돌린다.
"""
import logging
from datetime import datetime

import litellm
import pytest
from litellm.integrations.opik import utils as opik_utils
from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage

from app.modules.llm import llm_client
from app.modules.llm.llm_client import (
    _OPIK_CACHE_WRAP_MARK,
    _wrap_opik_usage_object,
)


@pytest.fixture
def pristine_opik_utils():
    """litellm 의 진짜 함수에서 시작하고, 끝나면 있던 그대로 되돌린다.

    같은 프로세스의 앞선 시험이 이미 앱을 띄워 래핑을 걸어 뒀을 수 있다
    (실제로 그렇다 — 전체 묶음으로 돌리면 걸려 있다). 그 상태를 "감기 전"
    으로 착각하면 시험이 순서에 따라 갈리므로, `__wrapped__` 로 한 겹
    벗겨 내고 시작한다.
    """
    installed = opik_utils.create_usage_object
    original = getattr(installed, "__wrapped__", installed)
    opik_utils.create_usage_object = original
    try:
        yield original
    finally:
        opik_utils.create_usage_object = installed


@pytest.fixture
def opik_env(monkeypatch):
    """닿지 않는 주소로 Opik 설정 — 시험이 밖으로 나가지 않게 한다."""
    monkeypatch.setenv("OPIK_URL_OVERRIDE", "http://127.0.0.1:9/api")
    monkeypatch.setenv("OPIK_API_KEY", "test-key")
    monkeypatch.setenv("OPIK_WORKSPACE", "default")
    monkeypatch.setenv("OPIK_PROJECT_NAME", "theroad-test")


def _usage(**kwargs) -> Usage:
    base = {"prompt_tokens": 9642, "completion_tokens": 4848, "total_tokens": 14490}
    base.update(kwargs)
    return Usage(**base)


def test_cache_fields_are_added_when_provider_reports_them(pristine_opik_utils):
    original = pristine_opik_utils
    usage = _usage(
        prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=7168))

    assert "cached_tokens" not in original(usage)  # 원 함수는 버린다

    _wrap_opik_usage_object()
    got = opik_utils.create_usage_object(usage)

    assert got["cached_tokens"] == 7168
    # 원래 세 값은 그대로다.
    assert got["prompt_tokens"] == 9642
    assert got["completion_tokens"] == 4848
    assert got["total_tokens"] == 14490


def test_anthropic_style_cache_read_field(pristine_opik_utils):
    _wrap_opik_usage_object()
    got = opik_utils.create_usage_object(_usage(cache_read_input_tokens=5120))
    assert got["cache_read_input_tokens"] == 5120


def test_no_cache_field_means_no_key(pristine_opik_utils):
    """캐시 항목이 없으면 원 결과와 같다 — 0 을 지어내지 않는다."""
    original = pristine_opik_utils
    usage = _usage()
    before = original(usage)

    _wrap_opik_usage_object()
    after = opik_utils.create_usage_object(usage)

    assert after == before
    assert "cached_tokens" not in after
    assert "cache_read_input_tokens" not in after


def test_zero_is_recorded_as_zero(pristine_opik_utils):
    """0 을 보고한 호출은 0 으로 남긴다 — '미보고'와 구분해야 한다."""
    _wrap_opik_usage_object()
    got = opik_utils.create_usage_object(
        _usage(prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=0)))
    assert got["cached_tokens"] == 0


def test_double_init_wraps_only_once(pristine_opik_utils):
    original = pristine_opik_utils

    _wrap_opik_usage_object()
    first = opik_utils.create_usage_object
    _wrap_opik_usage_object()
    second = opik_utils.create_usage_object

    assert first is second
    assert getattr(second, _OPIK_CACHE_WRAP_MARK, False) is True
    assert second.__wrapped__ is original          # 한 겹만 덮였다
    assert not getattr(original, _OPIK_CACHE_WRAP_MARK, False)


def _success_event():
    """캐시가 걸린 호출 하나 — 콜백에 먹일 성공 이벤트 (kwargs, response_obj)."""
    response = ModelResponse(
        model="gemini/gemini-3.1-pro-preview",
        choices=[{"message": {"role": "assistant", "content": "ok"},
                  "finish_reason": "stop", "index": 0}])
    response_obj = dict(response.model_dump())
    response_obj["usage"] = _usage(
        prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=7168))
    kwargs = {
        "standard_logging_object": {
            "metadata": {}, "messages": [{"role": "user", "content": "hi"}],
            "response": {}},
        "litellm_params": {"metadata": {"opik": {"tags": ["entity_t2i"]}}},
        "custom_llm_provider": "gemini",
        "response_cost": 0.01,
        "model": "gemini/gemini-3.1-pro-preview",
    }
    return kwargs, response_obj


def test_callback_payload_builder_uses_the_wrapped_function(pristine_opik_utils):
    """콜백이 실제로 지나는 경로에 래핑이 걸리는가 — 설치본 실물로 확인.

    `opik_payload_builder/api.py` 가 `utils.create_usage_object` 를 어떻게
    부르는지는 litellm 버전에 달렸다(모듈 속성으로 매번 찾는지, 이미 import
    해 둔 이름을 쓰는지). 후자면 속성을 바꿔도 안 걸린다. 그래서 감싼 뒤
    payload 를 실제로 지어 캐시 항목이 span 에 실리는지 본다.
    """
    from litellm.integrations.opik.opik_payload_builder.api import (
        build_opik_payload,
    )

    _wrap_opik_usage_object()
    kwargs, response_obj = _success_event()

    _trace, span = build_opik_payload(
        kwargs, response_obj, datetime.now(), datetime.now(), "theroad-test")

    assert span.usage["cached_tokens"] == 7168
    assert span.usage["prompt_tokens"] == 9642


@pytest.mark.filterwarnings(
    # litellm 이 콜백을 이벤트 루프 밖에서 지을 때 내는 경고 — 저쪽이 이미
    # 잡아서 처리한다(periodic_flush 를 걸지 못하고 넘어간다).
    "ignore:coroutine 'CustomBatchLogger.periodic_flush' was never awaited")
def test_active_callback_chain_carries_cache_field(
        pristine_opik_utils, opik_env, monkeypatch):
    """litellm 이 `callbacks=["opik"]` 로 세우는 **그 콜백**을 태워 본다.

    앞의 시험은 payload builder 를 직접 부른다. 그래서 다음 litellm 판이
    콜백을 다른 조립 경로로 옮기고 옛 함수를 호환용으로 남겨 두면, 래퍼가
    죽은 함수만 감싼 채로도 통과한다(캐시 기록은 전부 비고). 이 시험은
    litellm 이 "opik" 이라는 이름으로 실제로 세우는 콜백 객체를 받아
    성공 이벤트를 먹이고, **Opik SDK 로 나가는 경계**에서 payload 를
    잡는다 — 전송만 막고 조립은 전부 실물이다. 활성 경로가 바뀌면 여기서
    깨진다.
    """
    from litellm.litellm_core_utils.litellm_logging import (
        _init_custom_logger_compatible_class,
    )

    _wrap_opik_usage_object()
    monkeypatch.setattr(litellm, "callbacks", ["opik"])

    callback = _init_custom_logger_compatible_class(
        "opik", internal_usage_cache=None, llm_router=None)
    assert callback is not None, "litellm 이 'opik' 이름의 콜백을 못 세운다"
    assert callback._opik_client is not None, (
        "Opik SDK 가 없으면 이 경로 자체가 없다 — 설치본 확인")

    captured = []
    monkeypatch.setattr(
        callback._opik_client._streamer, "put", captured.append)

    kwargs, response_obj = _success_event()
    callback.log_success_event(
        kwargs, response_obj, datetime.now(), datetime.now())

    spans = [m for m in captured if type(m).__name__ == "CreateSpanMessage"]
    assert len(spans) == 1, f"span 메시지가 1건이 아니다 — {captured}"
    usage = spans[0].usage
    assert usage["prompt_tokens"] == 9642
    cached = [v for k, v in usage.items() if k.endswith("cached_tokens")]
    assert cached == [7168], f"활성 콜백 경로에서 캐시 항목이 사라졌다 — {usage}"


def test_wiring_failure_is_loud(pristine_opik_utils, monkeypatch, caplog):
    """배선이 무력화되면 ERROR 로 남고 상태 표시가 False 가 된다.

    조용히 지나가면 토큰 기록은 그대로라 겉보기엔 멀쩡하고, 캐시 칸만 비어
    "캐시가 안 걸린 주행"과 구분되지 않는다.
    """
    monkeypatch.delattr(opik_utils, "create_usage_object")

    with caplog.at_level(logging.ERROR, logger="app.modules.llm.llm_client"):
        _wrap_opik_usage_object()

    assert llm_client._opik_cache_wiring_ok is False
    errors = [r.getMessage() for r in caplog.records
              if r.levelno >= logging.ERROR]
    assert any("캐시 항목 배선 실패" in m for m in errors), errors


@pytest.mark.parametrize("break_wiring,expected", [
    (False, "cache_wiring=ok"),
    (True, "cache_wiring=failed"),
])
def test_enabled_log_reports_cache_wiring(
        pristine_opik_utils, opik_env, monkeypatch, caplog,
        break_wiring, expected):
    """콜백 켬 로그에 배선 상태가 함께 찍힌다 — 운영 로그에서 보이게."""
    from app.core.config import settings

    monkeypatch.setattr(settings, "opik_url_override", "http://127.0.0.1:9/api")
    monkeypatch.setattr(settings, "opik_api_key", "")
    monkeypatch.setattr(litellm, "callbacks", [])
    monkeypatch.setattr(llm_client, "_opik_initialized", False)
    if break_wiring:
        monkeypatch.delattr(opik_utils, "create_usage_object")

    with caplog.at_level(logging.INFO, logger="app.modules.llm.llm_client"):
        llm_client._init_opik()

    enabled = [m for m in (r.getMessage() for r in caplog.records)
               if "Opik callback enabled" in m]
    assert len(enabled) == 1, enabled
    assert expected in enabled[0]
    assert llm_client._opik_cache_wiring_ok is (not break_wiring)


def test_cache_field_survives_opik_sdk_conversion(pristine_opik_utils):
    """Opik SDK 의 실제 변환을 통과해 backend 형식까지 남는가.

    SDK 가 usage 를 제공사 형식으로 검사하다 실패하면 usage 전체를 버릴 수
    있다. 키를 더하는 것이 그 검사를 깨지 않는지 실물로 확인한다.
    """
    import logging

    from opik.api_objects.validation_helpers import validate_and_parse_usage

    _wrap_opik_usage_object()
    usage_dict = opik_utils.create_usage_object(
        _usage(prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=7168)))

    for provider in ("google_ai", "openai", None):
        parsed = validate_and_parse_usage(
            usage=usage_dict, logger=logging.getLogger(__name__),
            provider=provider)
        assert parsed is not None, f"{provider}: usage 가 통째로 버려졌다"
        assert parsed["prompt_tokens"] == 9642
        cached = [v for k, v in parsed.items() if k.endswith("cached_tokens")]
        assert cached == [7168], f"{provider}: 캐시 항목이 사라졌다 — {parsed}"
