# #region Test.LLMAsyncHttpClient [C:3] [TYPE Module] [SEMANTICS test,llm,http,async]
# @BRIEF Tests for _llm_async_http.py — async HTTP LLM client.
# @RELATION BINDS_TO -> [LLMAsyncHttpClient]
# @TEST_EDGE: empty_base_url -> ValueError
# @TEST_EDGE: no_choices -> ValueError
# @TEST_EDGE: empty_content -> ValueError

import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))

import pytest
from unittest.mock import AsyncMock, MagicMock, patch

import httpx

from src.plugins.translate._llm_async_http import (
    _get_verify,
    _get_http_client,
    call_openai_compatible,
    _do_http_request,
    _handle_response_format_fallback,
)


class TestGetVerify:
    """_get_verify — SSL verification context."""

    def test_verify_true_default(self):
        """Default (true) returns SSLContext."""
        with patch.dict(os.environ, {"LLM_SSL_VERIFY": "true"}, clear=True):
            result = _get_verify()
            assert isinstance(result, bool) is False  # It's an SSLContext

    def test_verify_false(self):
        """False returns False."""
        with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}, clear=True):
            result = _get_verify()
            assert result is False

    def test_verify_off(self):
        """'off' returns False."""
        with patch.dict(os.environ, {"LLM_SSL_VERIFY": "off"}, clear=True):
            result = _get_verify()
            assert result is False

    def test_verify_0(self):
        """'0' returns False."""
        with patch.dict(os.environ, {"LLM_SSL_VERIFY": "0"}, clear=True):
            result = _get_verify()
            assert result is False

    def test_verify_no(self):
        """'no' returns False."""
        with patch.dict(os.environ, {"LLM_SSL_VERIFY": "no"}, clear=True):
            result = _get_verify()
            assert result is False


class TestGetHttpClient:
    """_get_http_client — module-level singleton."""

    @pytest.mark.asyncio
    async def test_creates_client(self):
        """Creates client on first call."""
        client = await _get_http_client()
        assert isinstance(client, httpx.AsyncClient)

    @pytest.mark.asyncio
    async def test_reuses_client(self):
        """Returns same client on second call."""
        client1 = await _get_http_client()
        client2 = await _get_http_client()
        assert client1 is client2


class TestCallOpenaiCompatible:
    """call_openai_compatible — main LLM call function."""

    @pytest.mark.asyncio
    async def test_empty_base_url(self):
        """Empty base_url raises ValueError."""
        with pytest.raises(ValueError, match="no base_url"):
            await call_openai_compatible("", "key", "model", "prompt")

    @pytest.mark.asyncio
    async def test_successful_call(self):
        """Successful LLM call returns content and finish_reason."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [
                {
                    "finish_reason": "stop",
                    "message": {"content": "Hello, world!"},
                }
            ],
            "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
        }
        mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "Hello, world!"}}]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                content, finish_reason = await call_openai_compatible(
                    "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                )
                assert content == "Hello, world!"
                assert finish_reason == "stop"

    @pytest.mark.asyncio
    async def test_no_choices_raises(self):
        """No choices in response raises ValueError."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {"choices": []}
        mock_response.text = '{"choices": []}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                with pytest.raises(ValueError, match="no choices"):
                    await call_openai_compatible(
                        "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                    )

    @pytest.mark.asyncio
    async def test_empty_content_raises(self):
        """Empty content raises ValueError."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [
                {
                    "finish_reason": "stop",
                    "message": {"content": ""},
                }
            ],
        }
        mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": ""}}]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                with pytest.raises(ValueError, match="empty content"):
                    await call_openai_compatible(
                        "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                    )

    @pytest.mark.asyncio
    async def test_type_error_processing_choices(self):
        """TypeError processing choices raises ValueError."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [None],  # choices[0] is None -> TypeError
        }
        mock_response.text = '{"choices": [null]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                with pytest.raises(ValueError, match="LLM response processing failed"):
                    await call_openai_compatible(
                        "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                    )

    @pytest.mark.asyncio
    async def test_refusal_raises(self):
        """LLM refusal raises ValueError."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [
                {
                    "finish_reason": "stop",
                    "message": {"refusal": "I cannot answer that", "content": None},
                }
            ],
        }
        mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": null}}]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                with pytest.raises(ValueError, match="refused"):
                    await call_openai_compatible(
                        "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                    )

    @pytest.mark.asyncio
    async def test_unsuccessful_response(self):
        """Non-success response raises HTTP status error."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = False
        mock_response.status_code = 500
        mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
            "Server error", request=MagicMock(), response=mock_response,
        )
        mock_response.text = "Internal Server Error"

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                with pytest.raises(httpx.HTTPStatusError):
                    await call_openai_compatible(
                        "https://api.openai.com", "sk-test", "gpt-4o", "translate this",
                    )

    @pytest.mark.asyncio
    async def test_response_format_for_provider_types(self):
        """response_format is set for openai-like providers."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
            "usage": {},
        }
        mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                # disable_reasoning=False, so response_format should be set
                content, _ = await call_openai_compatible(
                    "https://api.openai.com", "sk-test", "gpt-4o", "test",
                    provider_type="openai", disable_reasoning=False,
                )
                assert content == "hi"

    @pytest.mark.asyncio
    async def test_disable_reasoning(self):
        """disable_reasoning=True adjusts payload."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.is_success = True
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
            "usage": {},
        }
        mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'

        with patch("src.plugins.translate._llm_async_http._do_http_request",
                   AsyncMock(return_value=(mock_response, mock_response.text))):
            with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
                       AsyncMock()):
                content, _ = await call_openai_compatible(
                    "https://api.openai.com", "sk-test", "gpt-4o", "test",
                    disable_reasoning=True,
                )
                assert content == "hi"


class TestDoHttpRequest:
    """_do_http_request — async HTTP POST with rate-limit retry."""

    @pytest.mark.asyncio
    async def test_success(self):
        """Single successful request."""
        mock_response = MagicMock(spec=httpx.Response)
        mock_response.status_code = 200
        mock_response.text = "OK"

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.return_value = mock_response

            response, text = await _do_http_request(
                "https://api.openai.com/chat/completions",
                {"Authorization": "Bearer test"},
                {"model": "gpt-4o"},
            )
            assert response.status_code == 200
            assert text == "OK"

    @pytest.mark.asyncio
    async def test_rate_limit_retry(self):
        """429 triggers retry."""
        mock_429 = MagicMock(spec=httpx.Response)
        mock_429.status_code = 429
        mock_429.headers = {}
        mock_429.text = "Rate limited"

        mock_200 = MagicMock(spec=httpx.Response)
        mock_200.status_code = 200
        mock_200.text = "OK"

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.side_effect = [mock_429, mock_200]

            with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
                       AsyncMock()):
                response, text = await _do_http_request(
                    "https://api.openai.com/chat/completions",
                    {"Authorization": "Bearer test"},
                    {"model": "gpt-4o"},
                )
                assert response.status_code == 200
                assert text == "OK"
                assert mock_client.post.call_count == 2

    @pytest.mark.asyncio
    async def test_rate_limit_with_retry_after(self):
        """Retry-After header is respected."""
        mock_429 = MagicMock(spec=httpx.Response)
        mock_429.status_code = 429
        mock_429.headers = {"Retry-After": "2"}
        mock_429.text = "Rate limited"

        mock_200 = MagicMock(spec=httpx.Response)
        mock_200.status_code = 200
        mock_200.text = "OK"

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.side_effect = [mock_429, mock_200]

            with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
                       AsyncMock()) as mock_sleep:
                response, _ = await _do_http_request(
                    "https://api.openai.com/chat/completions",
                    {"Authorization": "Bearer test"},
                    {"model": "gpt-4o"},
                )
                assert response.status_code == 200
                # Verify sleep was called with retry-after value
                mock_sleep.assert_called_once_with(2)

    @pytest.mark.asyncio
    async def test_rate_limit_exhausted(self):
        """All 429 retries exhausted, returns last 429."""
        mock_429 = MagicMock(spec=httpx.Response)
        mock_429.status_code = 429
        mock_429.headers = {}
        mock_429.text = "Rate limited"

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.return_value = mock_429  # Always 429

            with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
                       AsyncMock()):
                response, _ = await _do_http_request(
                    "https://api.openai.com/chat/completions",
                    {"Authorization": "Bearer test"},
                    {"model": "gpt-4o"},
                )
                assert response.status_code == 429
                assert mock_client.post.call_count == 3  # max 3 retries

    @pytest.mark.asyncio
    async def test_rate_limit_invalid_retry_after(self):
        """Retry-After with non-int value falls back to exponential backoff (lines 215-216)."""
        mock_429 = MagicMock(spec=httpx.Response)
        mock_429.status_code = 429
        mock_429.headers = {"Retry-After": "abc"}
        mock_429.text = "Rate limited"

        mock_200 = MagicMock(spec=httpx.Response)
        mock_200.status_code = 200
        mock_200.text = "OK"

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.side_effect = [mock_429, mock_200]

            with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
                       AsyncMock()) as mock_sleep:
                response, _ = await _do_http_request(
                    "https://api.openai.com/chat/completions",
                    {"Authorization": "Bearer test"},
                    {"model": "gpt-4o"},
                )
                assert response.status_code == 200
                # Should fall back to exponential backoff: 2^1 = 2
                mock_sleep.assert_called_once_with(2)


class TestHandleResponseFormatFallback:
    """_handle_response_format_fallback — structured output fallback."""

    @pytest.mark.asyncio
    async def test_400_response_format_error(self):
        """400 error with response_format pattern triggers retry."""
        mock_400 = MagicMock(spec=httpx.Response)
        mock_400.status_code = 400
        mock_400.is_success = False
        mock_400.text = "response_format is not supported"

        mock_200 = MagicMock(spec=httpx.Response)
        mock_200.status_code = 200
        mock_200.is_success = True
        mock_200.content = b'{"ok": true}'
        mock_200.encoding = "utf-8"
        mock_200.headers = {}

        payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client
            mock_client.post.return_value = mock_200

            await _handle_response_format_fallback(
                mock_400, mock_400.text, payload,
                "https://api.openai.com", {},
            )
            # Verify response_format was popped
            assert "response_format" not in payload
            # Verify status_code was updated
            assert mock_400.status_code == 200

    @pytest.mark.asyncio
    async def test_non_400_noop(self):
        """Non-400 error does nothing."""
        mock_500 = MagicMock(spec=httpx.Response)
        mock_500.status_code = 500
        mock_500.is_success = False
        mock_500.text = "Server error"

        payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client

            await _handle_response_format_fallback(
                mock_500, mock_500.text, payload,
                "https://api.openai.com", {},
            )
            # Payload unchanged
            assert "response_format" in payload
            # No retry call
            mock_client.post.assert_not_called()

    @pytest.mark.asyncio
    async def test_400_no_pattern_noop(self):
        """400 error without response_format pattern does nothing."""
        mock_400 = MagicMock(spec=httpx.Response)
        mock_400.status_code = 400
        mock_400.is_success = False
        mock_400.text = "Some other error"

        payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}

        with patch("src.plugins.translate._llm_async_http._get_http_client",
                   AsyncMock()) as mock_get:
            mock_client = AsyncMock()
            mock_get.return_value = mock_client

            await _handle_response_format_fallback(
                mock_400, mock_400.text, payload,
                "https://api.openai.com", {},
            )
            assert "response_format" in payload
            mock_client.post.assert_not_called()
# #endregion Test.LLMAsyncHttpClient
