"""Async tests for infra_core.http_async.fetch_async."""

from __future__ import annotations

import asyncio
from typing import Any, cast

import httpx
import pytest
import respx

from infra_core import http_async


def _manager_for_client(client: object) -> http_async.AsyncHttpClient:
    def factory(_client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(httpx.AsyncClient, client)

    return http_async.AsyncHttpClient(_client_factory=factory)


async def _drain_pending_disposals() -> None:
    pending = tuple(http_async._PENDING_HTTP_CLIENT_DISPOSALS)
    if pending:
        await asyncio.gather(*pending)


@pytest.mark.asyncio
@respx.mock
async def test_request_async_supports_post_json() -> None:
    route = respx.post("https://example.com/api").mock(return_value=httpx.Response(200, json={"ok": True}))

    response = await http_async.request_async(
        "POST",
        "https://example.com/api",
        json={"foo": "bar"},
        headers={"X-Test": "1"},
    )

    assert response.json()["ok"] is True
    assert route.called
    last_call = route.calls.last
    assert last_call is not None
    assert last_call.request.headers["X-Test"] == "1"


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_success(monkeypatch: pytest.MonkeyPatch) -> None:
    route = respx.get("https://example.com/").mock(return_value=httpx.Response(200, text="ok"))

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    body = await http_async.fetch_async("https://example.com/")

    assert body == "ok"
    assert route.called


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_retries(monkeypatch: pytest.MonkeyPatch) -> None:
    calls = 0

    def responder(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        if calls == 1:
            return httpx.Response(503)
        return httpx.Response(200, text="done")

    respx.get("https://retry.me/").mock(side_effect=responder)

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    result = await http_async.fetch_async("https://retry.me/", max_retries=2)

    assert result == "done"
    assert calls == 2


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_raises_after_non_retryable(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    respx.get("https://fail.me/").mock(return_value=httpx.Response(404))

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    with pytest.raises(httpx.HTTPStatusError):
        await http_async.fetch_async("https://fail.me/", max_retries=1)


@pytest.mark.asyncio
async def test_fetch_async_reuses_shared_client() -> None:
    class DummyResponse:
        def __init__(self, text: str = "ok") -> None:
            self.text = text

        def raise_for_status(self) -> None:
            return None

    class DummyClient:
        instances: list[DummyClient] = []

        def __init__(self, follow_redirects: bool = True) -> None:
            self.follow_redirects = follow_redirects
            self.closed = False
            DummyClient.instances.append(self)

        async def request(self, *args: object, **kwargs: object) -> DummyResponse:
            return DummyResponse()

        async def aclose(self) -> None:
            self.closed = True

    def factory(client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(
            httpx.AsyncClient,
            DummyClient(follow_redirects=cast(bool, client_kwargs["follow_redirects"])),
        )

    http_async.reset_shared_http_client(_client_factory=factory)

    await http_async.fetch_async("https://dummy.local")
    await http_async.fetch_async("https://dummy.local/second")

    assert len(DummyClient.instances) == 1
    await http_async.close_async_http_client()
    http_async.reset_shared_http_client()


@pytest.mark.asyncio
async def test_close_async_http_client_disposes_underlying_client() -> None:
    class DummyClient:
        def __init__(self) -> None:
            self.closed = False

        async def aclose(self) -> None:
            self.closed = True

    dummy = DummyClient()
    http_async.reset_shared_http_client(_client_factory=lambda _kwargs: cast(Any, dummy))
    client_manager = http_async.get_shared_http_client()
    await client_manager.get_client()

    await http_async.close_async_http_client()

    assert dummy.closed is True
    assert client_manager._client is None
    http_async.reset_shared_http_client()


@pytest.mark.asyncio
async def test_async_http_client_closes_closed_loop_client_before_recreating() -> None:
    class DummyClient:
        def __init__(self) -> None:
            self.closed = False

        async def aclose(self) -> None:
            self.closed = True

    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    stale_client = DummyClient()
    replacement = DummyClient()
    created = [replacement]

    def factory(_client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(httpx.AsyncClient, created.pop(0))

    manager = http_async.AsyncHttpClient(_client_factory=factory)
    manager._client = cast(httpx.AsyncClient, stale_client)
    manager._client_loop = cast(asyncio.AbstractEventLoop, ClosedLoop())

    client = await manager.get_client()

    assert stale_client.closed is True
    assert cast(Any, client) is replacement

    await manager.close()


@pytest.mark.asyncio
async def test_async_http_client_ignores_event_loop_closed_when_recreating() -> None:
    class DummyClient:
        async def aclose(self) -> None:
            raise RuntimeError("Event loop is closed")

    class ReplacementClient:
        async def aclose(self) -> None:
            return None

    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    replacement = ReplacementClient()

    def factory(_client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(httpx.AsyncClient, replacement)

    manager = http_async.AsyncHttpClient(_client_factory=factory)
    manager._client = cast(httpx.AsyncClient, DummyClient())
    manager._client_loop = cast(asyncio.AbstractEventLoop, ClosedLoop())

    client = await manager.get_client()

    assert cast(Any, client) is replacement
    assert manager._client_loop is asyncio.get_running_loop()

    await manager.close()


@pytest.mark.asyncio
async def test_reconfigure_shared_http_client_applies_kwargs() -> None:
    class DummyResponse:
        def __init__(self) -> None:
            self.text = "ok"

        def raise_for_status(self) -> None:
            return None

    class DummyClient:
        instances: list[DummyClient] = []

        def __init__(self, **kwargs: object) -> None:
            self.kwargs = kwargs
            DummyClient.instances.append(self)

        async def request(self, *args: object, **kwargs: object) -> DummyResponse:
            return DummyResponse()

        async def aclose(self) -> None:
            return None

    def factory(client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(httpx.AsyncClient, DummyClient(**client_kwargs))

    http_async.reset_shared_http_client(_client_factory=factory)

    await http_async.reconfigure_shared_http_client(base_url="https://config.local", http2=True)
    body = await http_async.fetch_async("https://config.local/")

    assert body == "ok"
    assert DummyClient.instances[-1].kwargs["base_url"] == "https://config.local"
    assert DummyClient.instances[-1].kwargs["http2"] is True

    await http_async.close_async_http_client()
    http_async.reset_shared_http_client()


@pytest.mark.asyncio
async def test_async_http_client_close_closes_client_even_when_recorded_loop_is_closed() -> None:
    class DummyClient:
        def __init__(self) -> None:
            self.closed = False

        async def aclose(self) -> None:
            self.closed = True

    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    dummy = DummyClient()
    manager = http_async.AsyncHttpClient()
    manager._client = cast(httpx.AsyncClient, dummy)
    manager._client_loop = cast(asyncio.AbstractEventLoop, ClosedLoop())

    await manager.close()

    assert dummy.closed is True
    assert manager._client is None


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_handles_concurrent_requests(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    calls: list[str] = []

    def responder(request: httpx.Request) -> httpx.Response:
        calls.append(str(request.url))
        return httpx.Response(200, text="ok")

    respx.get("https://concurrent.local/one").mock(side_effect=responder)
    respx.get("https://concurrent.local/two").mock(side_effect=responder)

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    http_async.reset_shared_http_client()

    results = await asyncio.gather(
        http_async.fetch_async("https://concurrent.local/one"),
        http_async.fetch_async("https://concurrent.local/two"),
    )

    assert list(results) == ["ok", "ok"]
    assert sorted(calls) == [
        "https://concurrent.local/one",
        "https://concurrent.local/two",
    ]
    await http_async.close_async_http_client()
    http_async.reset_shared_http_client()


@pytest.mark.asyncio
async def test_async_http_client_context_manager_closes_client() -> None:
    class DummyClient:
        def __init__(self) -> None:
            self.closed = False

        async def aclose(self) -> None:
            self.closed = True

    created: list[DummyClient] = []

    def factory(**_kwargs: object) -> DummyClient:
        client = DummyClient()
        created.append(client)
        return client

    manager = http_async.AsyncHttpClient(
        _client_factory=lambda client_kwargs: cast(
            httpx.AsyncClient,
            factory(**client_kwargs),
        )
    )
    async with manager as client:
        assert cast(Any, client) is created[0]

    assert created[0].closed is True
    assert manager._client is None


@pytest.mark.asyncio
async def test_async_http_client_reconfigure_recreates_client_with_new_kwargs() -> None:
    class DummyClient:
        def __init__(self, **kwargs: object) -> None:
            self.kwargs = kwargs
            self.closed = False

        async def aclose(self) -> None:
            self.closed = True

    created: list[DummyClient] = []

    def factory(client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        client = DummyClient(**client_kwargs)
        created.append(client)
        return cast(httpx.AsyncClient, client)

    manager = http_async.AsyncHttpClient(
        _client_factory=factory,
        base_url="https://first.local",
    )

    first = cast(Any, await manager.get_client())
    await manager.reconfigure(base_url="https://second.local", http2=True)
    second = cast(Any, await manager.get_client())

    assert first is created[0]
    assert created[0].kwargs["base_url"] == "https://first.local"
    assert created[0].closed is True
    assert second is created[1]
    assert created[1].kwargs["base_url"] == "https://second.local"
    assert created[1].kwargs["http2"] is True
    assert created[1].kwargs["follow_redirects"] is True

    await manager.close()


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_delay_occurs_before_request(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    timeline: list[tuple[str, float | str]] = []

    async def fake_sleep(duration: float) -> None:
        timeline.append(("sleep", duration))

    def responder(request: httpx.Request) -> httpx.Response:
        timeline.append(("request", str(request.url)))
        return httpx.Response(200, text="ok")

    respx.get("https://delay.local/").mock(side_effect=responder)
    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    body = await http_async.fetch_async("https://delay.local/", delay=1.5)

    assert body == "ok"
    assert timeline[0] == ("sleep", 1.5)
    assert timeline[1] == ("request", "https://delay.local/")


@pytest.mark.asyncio
async def test_fetch_async_retries_timeout_exception(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class DummyResponse:
        def __init__(self) -> None:
            self.text = "success"

        def raise_for_status(self) -> None:
            return None

    class TimeoutThenSuccessClient:
        def __init__(self) -> None:
            self.calls = 0

        async def request(self, *args: object, **kwargs: object) -> DummyResponse:
            self.calls += 1
            if self.calls == 1:
                raise httpx.TimeoutException("boom")
            return DummyResponse()

        async def aclose(self) -> None:
            return None

    dummy = TimeoutThenSuccessClient()
    custom_manager = _manager_for_client(dummy)

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    body = await http_async.fetch_async(
        "https://timeout.local/",
        client=custom_manager,
        max_retries=2,
    )

    assert body == "success"
    assert dummy.calls == 2
    await custom_manager.close()


@pytest.mark.asyncio
async def test_fetch_async_does_not_retry_invalid_url(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class InvalidURLClient:
        def __init__(self) -> None:
            self.calls = 0

        async def request(self, *args: object, **kwargs: object) -> object:
            self.calls += 1
            raise httpx.InvalidURL("bad url")

        async def aclose(self) -> None:
            return None

    dummy = InvalidURLClient()
    custom_manager = _manager_for_client(dummy)

    async def fake_sleep(*_args: object, **_kwargs: object) -> None:
        return None

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    with pytest.raises(httpx.InvalidURL):
        await http_async.fetch_async(
            "https://invalid.local/",
            client=custom_manager,
            max_retries=3,
        )

    assert dummy.calls == 1
    await custom_manager.close()


@pytest.mark.asyncio
async def test_fetch_async_accepts_custom_client() -> None:
    class DummyResponse:
        def __init__(self, text: str) -> None:
            self.text = text

        def raise_for_status(self) -> None:
            return None

    class DummyClient:
        def __init__(self) -> None:
            self.calls = 0

        async def request(self, *args: object, **kwargs: object) -> DummyResponse:
            self.calls += 1
            return DummyResponse("custom")

        async def aclose(self) -> None:
            return None

    dummy = DummyClient()
    custom_manager = _manager_for_client(dummy)

    result = await http_async.fetch_async("https://custom.local/", client=custom_manager)

    assert result == "custom"
    assert dummy.calls == 1
    await custom_manager.close()


@pytest.mark.asyncio
@respx.mock
async def test_fetch_async_backoff_is_capped(monkeypatch: pytest.MonkeyPatch) -> None:
    calls = 0

    def responder(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        if calls == 1:
            return httpx.Response(503)
        return httpx.Response(200, text="done")

    respx.get("https://backoff.local/").mock(side_effect=responder)

    recorded: list[float] = []

    async def fake_sleep(duration: float) -> None:
        recorded.append(duration)

    monkeypatch.setattr("infra_core.http_async.asyncio.sleep", fake_sleep)

    result = await http_async.fetch_async(
        "https://backoff.local/",
        max_retries=3,
        backoff=10.0,
        max_backoff=0.25,
    )

    assert result == "done"
    assert recorded == [0.25]


@pytest.mark.asyncio
async def test_fetch_async_logs_sanitized_urls(monkeypatch: pytest.MonkeyPatch) -> None:
    logged_urls: list[str] = []

    class DummyResponse:
        def __init__(self) -> None:
            self.text = "ok"

        def raise_for_status(self) -> None:
            return None

    class DummyClient:
        async def request(self, *args: object, **kwargs: object) -> DummyResponse:
            return DummyResponse()

        async def aclose(self) -> None:
            return None

    def fake_debug(*_args: object, **kwargs: Any) -> None:
        extra = kwargs.get("extra", {})
        if "url" in extra:
            logged_urls.append(cast(str, extra["url"]))

    monkeypatch.setattr(http_async.logger, "debug", fake_debug)

    manager = _manager_for_client(DummyClient())

    result = await http_async.fetch_async(
        "https://user:pass@example.com/path?sig=secret&token=abc",
        client=manager,
    )

    assert result == "ok"
    assert logged_urls
    assert all("secret" not in url and "abc" not in url for url in logged_urls)
    assert all("user:pass@" not in url for url in logged_urls)
    assert all("<redacted>" in url for url in logged_urls)

    await manager.close()


@pytest.mark.asyncio
async def test_set_shared_http_client_disposes_previous_client() -> None:
    class DummyClient:
        def __init__(self) -> None:
            self.closed = False

        async def request(self, *args: object, **kwargs: object) -> httpx.Response:
            return httpx.Response(200, text="ok")

        async def aclose(self) -> None:
            self.closed = True

    old_client = DummyClient()
    old_manager = _manager_for_client(old_client)

    http_async.reset_shared_http_client()
    http_async.set_shared_http_client(old_manager)
    await old_manager.get_client()

    http_async.set_shared_http_client(http_async.AsyncHttpClient())
    await _drain_pending_disposals()

    assert old_client.closed is True

    await http_async.close_async_http_client()
    http_async.reset_shared_http_client()
