# #region TestLLMAsyncHttp [C:3] [TYPE Module] [SEMANTICS test, translate, llm, async, httpx, rate-limit]
# @BRIEF Tests for async LLM HTTP client — rate-limit retry, asyncio.sleep backoff, structured output fallback.
# @RELATION BINDS_TO -> [LLMAsyncHttpClient]
# @RELATION BINDS_TO -> [call_openai_compatible]
# @TEST_CONTRACT: 429 retry uses asyncio.sleep -> no event loop block
# @TEST_EDGE: rate_limit_429_retries -> VERIFIED_BY: test_llm_rate_limit_uses_asyncio_sleep
# @TEST_EDGE: empty_choices_raises -> VERIFIED_BY: test_call_openai_compatible_empty_choices
# @TEST_EDGE: missing_base_url_raises -> VERIFIED_BY: test_call_openai_compatible_no_base_url
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest


# #region test_llm_rate_limit_uses_asyncio_sleep [C:2] [TYPE Function]
# @BRIEF Verify 429 backoff doesn't block other requests — mock httpx with 2 failures then success.
# @TEST_EDGE: rate_limit_429_retries
@pytest.mark.asyncio
async def test_llm_rate_limit_uses_asyncio_sleep():
    """Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep."""
    from src.plugins.translate._llm_async_http import call_openai_compatible

    call_count = 0

    def mock_post_side_effect(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count <= 2:
            resp = MagicMock(spec=httpx.Response)
            resp.status_code = 429
            resp.ok = False
            resp.text = "Rate limited"
            resp.headers = {"Retry-After": "1"}
            resp.json.return_value = {}
            return resp
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = 200
        resp.ok = True
        resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}'
        resp.json.return_value = {
            "choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}]
        }
        return resp

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(side_effect=mock_post_side_effect)

    with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
        # Patch asyncio.sleep to verify it's called (not time.sleep)
        sleep_calls = []
        original_sleep = asyncio.sleep

        async def tracking_sleep(seconds):
            sleep_calls.append(seconds)
            await original_sleep(0)  # Don't actually wait

        with patch("src.plugins.translate._llm_async_http.asyncio.sleep", side_effect=tracking_sleep):
            content, finish_reason = await call_openai_compatible(
                base_url="http://fake-llm.local",
                api_key="test-key",
                model="gpt-4",
                prompt="translate: hello",
            )

    assert call_count == 3, f"Expected 3 HTTP calls (2 retries + 1 success), got {call_count}"
    assert len(sleep_calls) == 2, f"Expected 2 sleep calls for 429 retry, got {len(sleep_calls)}"
    assert finish_reason == "stop"
# #endregion test_llm_rate_limit_uses_asyncio_sleep


# #region test_call_openai_compatible_no_base_url [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when base_url is empty.
# @TEST_EDGE: missing_base_url_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_no_base_url():
    """Verify empty base_url raises ValueError immediately."""
    from src.plugins.translate._llm_async_http import call_openai_compatible

    with pytest.raises(ValueError, match="no base_url"):
        await call_openai_compatible(
            base_url="",
            api_key="test-key",
            model="gpt-4",
            prompt="hello",
        )
# #endregion test_call_openai_compatible_no_base_url


# #region test_call_openai_compatible_empty_choices [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns empty choices.
# @TEST_EDGE: empty_choices_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_empty_choices():
    """Verify LLM response with empty choices raises ValueError."""
    from src.plugins.translate._llm_async_http import call_openai_compatible

    mock_response = MagicMock(spec=httpx.Response)
    mock_response.status_code = 200
    mock_response.ok = True
    mock_response.json.return_value = {"choices": []}

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(return_value=mock_response)

    with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
        with pytest.raises(ValueError, match="no choices"):
            await call_openai_compatible(
                base_url="http://fake-llm.local",
                api_key="test-key",
                model="gpt-4",
                prompt="hello",
            )
# #endregion test_call_openai_compatible_empty_choices


# #region test_call_openai_compatible_refusal [C:2] [TYPE Function]
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns a refusal.
# @TEST_EDGE: llm_refusal_raises
@pytest.mark.asyncio
async def test_call_openai_compatible_refusal():
    """Verify LLM refusal response raises ValueError."""
    from src.plugins.translate._llm_async_http import call_openai_compatible

    mock_response = MagicMock(spec=httpx.Response)
    mock_response.status_code = 200
    mock_response.ok = True
    mock_response.json.return_value = {
        "choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]
    }

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(return_value=mock_response)

    with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
        with pytest.raises(ValueError, match="refused"):
            await call_openai_compatible(
                base_url="http://fake-llm.local",
                api_key="test-key",
                model="gpt-4",
                prompt="hello",
            )
# #endregion test_call_openai_compatible_refusal


# #region test_retry_does_not_block_event_loop [C:2] [TYPE Function]
# @BRIEF Verify 429 retry sleep doesn't block concurrent tasks on the same event loop.
# @TEST_INVARIANT: retry_backoff_non_blocking -> VERIFIED_BY: test_retry_does_not_block_event_loop
@pytest.mark.asyncio
async def test_retry_does_not_block_event_loop():
    """Verify that 429 retry backoff allows other coroutines to run."""
    from src.plugins.translate._llm_async_http import _do_http_request

    concurrent_ran = False

    async def concurrent_task():
        nonlocal concurrent_ran
        await asyncio.sleep(0.01)
        concurrent_ran = True

    call_count = 0

    def mock_post(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            resp = MagicMock(spec=httpx.Response)
            resp.status_code = 429
            resp.ok = False
            resp.text = "Rate limited"
            resp.headers = {"Retry-After": "1"}
            return resp
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = 200
        resp.ok = True
        resp.text = "ok"
        return resp

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(side_effect=mock_post)

    with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
        with patch("src.plugins.translate._llm_async_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            # Run the HTTP request alongside a concurrent task
            http_task = asyncio.create_task(_do_http_request("http://fake", {}, {}))
            conc_task = asyncio.create_task(concurrent_task())
            await asyncio.gather(http_task, conc_task)

    assert concurrent_ran, "Concurrent task was blocked by 429 retry"
    assert mock_sleep.called, "asyncio.sleep was not called during 429 retry"
# #endregion test_retry_does_not_block_event_loop

# #endregion TestLLMAsyncHttp
