"""
Tests for Phase 7 AI provider refactor.

Wave 2 (Plan 07-02) promotes: test_get_provider_typed, test_client_singleton,
test_generic_openai_json_mode, test_context_chars_truncation, test_smart_truncation,
test_gemini_fallback_to_parse_classification.

Wave 3 (Plan 07-03) promotes: test_anthropic_structured_output.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from ai.anthropic_provider import AnthropicProvider, _CLASSIFICATION_SCHEMA
from ai.generic_openai_provider import GenericOpenAIProvider
from ai.openai_provider import OpenAIProvider
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
from ai.utils import parse_classification


# ---------------------------------------------------------------------------
# Task 3: Registry-based get_provider(config: ProviderConfig) — D-06
# ---------------------------------------------------------------------------

def test_get_provider_typed():
    """get_provider() accepts a ProviderConfig and returns the correct provider class."""
    from ai import get_provider

    # Groq → GenericOpenAIProvider with supports_json_mode=True
    config = ProviderConfig(provider_id="groq")
    result = get_provider(config)
    assert isinstance(result, GenericOpenAIProvider)
    assert result.supports_json_mode is True
    assert result._context_chars == PROVIDER_DEFAULTS["groq"]["context_chars"]

    # Gemini → GenericOpenAIProvider with supports_json_mode=False (D-02)
    config_gemini = ProviderConfig(provider_id="gemini")
    result_gemini = get_provider(config_gemini)
    assert isinstance(result_gemini, GenericOpenAIProvider)
    assert result_gemini.supports_json_mode is False

    # Unknown provider → ValueError
    config_bogus = ProviderConfig(provider_id="bogus")
    with pytest.raises(ValueError, match="Unknown AI provider"):
        get_provider(config_bogus)


# ---------------------------------------------------------------------------
# Task 4: Singleton client lifecycle — D-07
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_client_singleton():
    """AsyncOpenAI is instantiated exactly once per OpenAIProvider instance (D-07)."""
    with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
        # mock_cls() returns a mock instance — configure chat.completions.create
        mock_instance = MagicMock()
        mock_instance.chat = MagicMock()
        mock_instance.chat.completions = MagicMock()
        mock_instance.chat.completions.create = AsyncMock(
            return_value=MagicMock(
                choices=[MagicMock(message=MagicMock(content='{"assigned_topics":[],"new_topic_suggestions":[]}'))]
            )
        )
        mock_cls.return_value = mock_instance

        provider = OpenAIProvider(api_key="test-key", model="gpt-4o", base_url=None, context_chars=1000)

        # Call classify twice on the same instance
        await provider.classify("doc text", [], "sys")
        await provider.classify("doc text", [], "sys")

        # AsyncOpenAI class should have been called exactly once (in __init__)
        assert mock_cls.call_count == 1


# ---------------------------------------------------------------------------
# Task 4: JSON-mode conditional — D-01
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_generic_openai_json_mode():
    """GenericOpenAIProvider passes response_format only when supports_json_mode=True."""
    synthetic_response = MagicMock(
        choices=[MagicMock(message=MagicMock(
            content='{"assigned_topics":["finance"],"new_topic_suggestions":[]}'
        ))]
    )

    # supports_json_mode=True → response_format present in call kwargs
    with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
        mock_instance = MagicMock()
        mock_instance.chat.completions.create = AsyncMock(return_value=synthetic_response)
        mock_cls.return_value = mock_instance

        provider_json = GenericOpenAIProvider(
            api_key="key", model="gpt-4o", base_url=None,
            context_chars=1000, supports_json_mode=True
        )
        await provider_json.classify("text", [], "sys")
        call_kwargs = mock_instance.chat.completions.create.call_args.kwargs
        assert "response_format" in call_kwargs
        assert call_kwargs["response_format"] == {"type": "json_object"}

    # supports_json_mode=False → response_format absent from call kwargs (Gemini preset path)
    with patch("ai.openai_provider.AsyncOpenAI") as mock_cls2:
        mock_instance2 = MagicMock()
        mock_instance2.chat.completions.create = AsyncMock(return_value=synthetic_response)
        mock_cls2.return_value = mock_instance2

        provider_no_json = GenericOpenAIProvider(
            api_key="key", model="gemini-2.0-flash",
            base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
            context_chars=1000, supports_json_mode=False
        )
        await provider_no_json.classify("text", [], "sys")
        call_kwargs2 = mock_instance2.chat.completions.create.call_args.kwargs
        assert "response_format" not in call_kwargs2


# ---------------------------------------------------------------------------
# Task 4: Smart truncation — D-12/D-13
# ---------------------------------------------------------------------------

def test_context_chars_truncation():
    """Provider with context_chars=100 truncates a 500-char input."""
    provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=100)
    long_text = "a" * 500
    result = provider._truncate(long_text)
    assert len(result) < 500
    assert "[...truncated...]" in result


def test_smart_truncation():
    """_truncate uses 60% head + 40% tail of context_chars."""
    provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=1000)
    # Build a distinguishable input where head and tail chars differ
    input_text = "H" * 5000 + "T" * 5000  # 10000 chars total
    result = provider._truncate(input_text)
    # head = int(1000 * 0.6) = 600, tail = 1000 - 600 = 400
    assert result.startswith("H" * 600)
    assert result.endswith("T" * 400)
    assert "[...truncated...]" in result


# ---------------------------------------------------------------------------
# Task 4: Gemini fallback to parse_classification (D-02 contract enforcement)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_gemini_fallback_to_parse_classification():
    """D-02: GenericOpenAIProvider(supports_json_mode=False) calls parse_classification()
    and does NOT send response_format to the API.
    """
    raw_content = '{"assigned_topics":["x"],"new_topic_suggestions":[],"reasoning":"r"}'

    with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
        mock_create = AsyncMock(
            return_value=MagicMock(
                choices=[MagicMock(message=MagicMock(content=raw_content))]
            )
        )
        mock_instance = MagicMock()
        mock_instance.chat.completions.create = mock_create
        mock_cls.return_value = mock_instance

        # Wrap parse_classification so we can assert it was called
        with patch(
            "ai.generic_openai_provider.parse_classification",
            wraps=parse_classification,
        ) as mock_parse:
            provider = GenericOpenAIProvider(
                api_key="",
                model="gemini-2.0-flash",
                base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
                context_chars=8000,
                supports_json_mode=False,
            )
            result = await provider.classify("doc text", [], "sys")

    # Result must be a valid ClassificationResult
    assert result.topics == ["x"]

    # parse_classification was called with the raw content (D-02 contract)
    assert mock_parse.called
    mock_parse.assert_called_once_with(raw_content)

    # response_format must NOT have been sent to the API (Gemini preset path)
    call_kwargs = mock_create.call_args.kwargs
    assert "response_format" not in call_kwargs


# ---------------------------------------------------------------------------
# Task 1 (Plan 07-03): AnthropicProvider output_config structured output — D-03
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_anthropic_structured_output():
    """AnthropicProvider.classify() passes output_config with the classification schema (D-03).

    Verifies:
    - output_config kwarg is present in the messages.create call
    - output_config value matches _CLASSIFICATION_SCHEMA exactly
    - Provider correctly reads response.content[0].text when stop_reason == "end_turn"
    - Singleton _client is reused (AsyncAnthropic constructed once per provider instance)
    """
    provider = AnthropicProvider(api_key="test-key", model="claude-sonnet-4-6", context_chars=100)

    # Build a stub response that models a successful end_turn response
    stub_content = MagicMock()
    stub_content.text = '{"assigned_topics":[],"new_topic_suggestions":[]}'
    stub_response = MagicMock()
    stub_response.content = [stub_content]
    stub_response.stop_reason = "end_turn"

    mock_create = AsyncMock(return_value=stub_response)

    with patch("ai.anthropic_provider.anthropic.AsyncAnthropic") as mock_cls:
        mock_client = MagicMock()
        mock_client.messages = MagicMock()
        mock_client.messages.create = mock_create
        mock_cls.return_value = mock_client

        # Re-create provider inside the patch so self._client uses the mock
        provider = AnthropicProvider(
            api_key="test-key", model="claude-sonnet-4-6", context_chars=100
        )
        result = await provider.classify("short doc text", [], "sys prompt")

    # output_config must be present and match the schema (D-03)
    call_kwargs = mock_create.await_args.kwargs
    assert "output_config" in call_kwargs, "output_config must be passed to messages.create()"
    assert call_kwargs["output_config"] == {
        "format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}
    }, "output_config must use _CLASSIFICATION_SCHEMA"

    # AsyncAnthropic must have been constructed exactly once (D-07 singleton)
    assert mock_cls.call_count == 1, "AsyncAnthropic must be constructed once (singleton)"

    # Result must be a valid ClassificationResult
    assert result.topics == []
    assert result.suggested_new_topics == []


@pytest.mark.asyncio
async def test_anthropic_stop_reason_fallback():
    """When stop_reason is not 'end_turn', AnthropicProvider falls back to empty ClassificationResult.

    T-07-08: refusal or max_tokens stop_reason must not crash — parse_classification("") returns
    an empty result rather than raising an exception.
    """
    stub_content = MagicMock()
    stub_content.text = '{"assigned_topics":["should","be","ignored"]}'
    stub_response = MagicMock()
    stub_response.content = [stub_content]
    stub_response.stop_reason = "max_tokens"  # simulated refusal / truncation

    mock_create = AsyncMock(return_value=stub_response)

    with patch("ai.anthropic_provider.anthropic.AsyncAnthropic") as mock_cls:
        mock_client = MagicMock()
        mock_client.messages = MagicMock()
        mock_client.messages.create = mock_create
        mock_cls.return_value = mock_client

        provider = AnthropicProvider(api_key="k", model="claude-sonnet-4-6", context_chars=1000)
        result = await provider.classify("doc text", [], "sys")

    # stop_reason != "end_turn" → raw == "" → empty ClassificationResult, no crash
    assert result.topics == []
    assert result.suggested_new_topics == []
