"""Unit tests for the GPT/Claude provider switch.

The unit CI job installs only pytest, so the heavy runtime deps that
``ai_functions`` imports unguarded (``openai``, ``tiktoken``, ``other_functions``
-> ``discord``) are stubbed *only when genuinely absent*. Locally, where the
real packages exist, the stubs are skipped and the real modules are used.
"""
import sys
import types


def _stub_if_missing(name: str, build):
    if name in sys.modules:
        return
    try:  # real package present (local dev / bot image) -> use it
        __import__(name)
    except ImportError:
        sys.modules[name] = build()


def _build_tiktoken():
    mod = types.ModuleType("tiktoken")

    class _Enc:
        def encode(self, text):
            return list(text)

    mod.encoding_for_model = lambda _model: _Enc()
    return mod


def _build_other_functions():
    mod = types.ModuleType("other_functions")

    async def _noop(*_a, **_k):
        return None

    mod.discord_friendly_send = _noop
    mod.discord_friendly_reply = _noop
    return mod


def _build_openai():
    mod = types.ModuleType("openai")
    for cls_name in (
        "APITimeoutError",
        "APIConnectionError",
        "BadRequestError",
        "APIResponseValidationError",
        "AuthenticationError",
        "PermissionDeniedError",
        "RateLimitError",
        "UnprocessableEntityError",
        "APIError",
        "OpenAIError",
    ):
        setattr(mod, cls_name, type(cls_name, (Exception,), {}))
    return mod


_stub_if_missing("tiktoken", _build_tiktoken)
_stub_if_missing("other_functions", _build_other_functions)
_stub_if_missing("openai", _build_openai)

import ai_functions  # noqa: E402  (import after stubbing)
import openai  # noqa: E402  (real or stub, same object ai_functions uses)


def _reset_active(name="gpt"):
    ai_functions._ACTIVE_CONFIG_NAME = name


def test_split_extracts_system_and_starts_with_user():
    system, convo = ai_functions._to_anthropic_messages(
        [
            {"role": "system", "content": "SYS1"},
            {"role": "system", "content": "SYS2"},
            {"role": "assistant", "content": "leading-assistant-dropped"},
            {"role": "user", "content": "u1"},
            {"role": "assistant", "content": "a1"},
            {"role": "user", "content": "hi"},
        ]
    )
    assert system == "SYS1\n\nSYS2"
    assert convo[0] == {"role": "user", "content": "u1"}
    assert convo == [
        {"role": "user", "content": "u1"},
        {"role": "assistant", "content": "a1"},
        {"role": "user", "content": "hi"},
    ]


def test_split_synthesises_user_turn_when_only_system():
    _system, convo = ai_functions._to_anthropic_messages(
        [{"role": "system", "content": "only"}]
    )
    assert convo == [{"role": "user", "content": " "}]


def test_select_model_gpt_active():
    _reset_active("gpt")
    # legacy default auto-selects; MUSIC is cheap; explicit ids are honoured
    assert ai_functions.select_model("GENERAL", "gpt-4o") == "gpt-4o"
    assert ai_functions.select_model("MUSIC", "gpt-4o") == "gpt-4o-mini"
    assert ai_functions.select_model("MUSIC", "auto") == "gpt-4o-mini"
    assert ai_functions.select_model("GENERAL", "o1-preview") == "o1-preview"


def test_select_model_claude_active_maps_legacy_default():
    _reset_active("claude")
    try:
        # the old gpt-4o default must not leak to Claude - it auto-maps
        assert ai_functions.select_model("GENERAL", "gpt-4o") == "claude-opus-4-8"
        assert ai_functions.select_model("MUSIC", "gpt-4o") == "claude-haiku-4-5"
        # a real, deliberate model id is still honoured verbatim
        assert ai_functions.select_model("GENERAL", "claude-sonnet-5") == "claude-sonnet-5"
    finally:
        _reset_active("gpt")


def test_list_ai_configs_hides_templates():
    names = ai_functions.list_ai_configs()
    assert "_template" not in names
    assert {"gpt", "claude"}.issubset(set(names))


def _bare(cls):
    # Build an instance without invoking __init__ - the real openai SDK
    # exceptions require response/body kwargs, the CI stubs don't. isinstance
    # (all _map_openai_error cares about) works on __new__-created instances.
    return cls.__new__(cls)


def test_map_openai_error_categories():
    assert ai_functions._map_openai_error(_bare(openai.AuthenticationError)).category == "auth"
    assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit"
    assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout"
    assert ai_functions._map_openai_error(ValueError("x")).category == "api"
