"""Tests for infra_core.asset_client."""

from __future__ import annotations

import asyncio
from pathlib import Path

import httpx
import pytest
import respx

from infra_core import asset_client


def test_asset_client_invalid_timeout_raises() -> None:
    with pytest.raises(ValueError):
        asset_client.AssetDownloadClient(timeout=0)


def test_asset_client_invalid_max_retries_raises() -> None:
    with pytest.raises(ValueError):
        asset_client.AssetDownloadClient(max_retries=-1)


@pytest.mark.asyncio
@respx.mock
async def test_async_download_writes_file(tmp_path: Path) -> None:
    content = b"hello world"
    respx.get("https://files.test/data").mock(return_value=httpx.Response(200, content=content))

    client = asset_client.AssetDownloadClient()
    dest = tmp_path / "data.bin"

    result = await client.download("https://files.test/data", dest)

    assert dest.read_bytes() == content
    assert result.checksum is not None and result.checksum.startswith("sha256:")
    assert result.reused is False


@pytest.mark.asyncio
@respx.mock
async def test_async_download_skips_existing(tmp_path: Path) -> None:
    dest = tmp_path / "existing.bin"
    dest.write_bytes(b"cached")

    respx.get("https://files.test/data").mock(return_value=httpx.Response(200, content=b"new"))

    client = asset_client.AssetDownloadClient()
    result = await client.download("https://files.test/data", dest, skip_if_exists=True)

    assert result.path == dest
    assert result.checksum is not None
    assert result.reused is True
    assert respx.calls.call_count == 0


@pytest.mark.asyncio
async def test_download_asset_disallowed_in_running_loop(tmp_path: Path) -> None:
    with pytest.raises(RuntimeError):
        asset_client.download_asset("https://example.com", tmp_path / "file.bin")


@respx.mock
def test_download_asset_runs_event_loop(tmp_path: Path) -> None:
    respx.get("https://files.test/sync").mock(return_value=httpx.Response(200, content=b"sync"))

    dest = tmp_path / "sync.bin"
    result = asset_client.download_asset("https://files.test/sync", dest)

    assert result.path.exists()
    assert result.reused is False
    dest.unlink(missing_ok=True)
    asyncio.run(asset_client.reset_shared_client())


@pytest.mark.asyncio
async def test_reset_shared_client(tmp_path: Path) -> None:
    client1 = await asset_client.get_shared_client()
    await asset_client.reset_shared_client()
    client2 = await asset_client.get_shared_client()
    assert client1 is not client2


@pytest.mark.asyncio
async def test_deprecated_aliases_emit_warning() -> None:
    await asset_client.reset_shared_client()
    with pytest.deprecated_call():
        client = await asset_client.get_async_client()
    assert isinstance(client, asset_client.AssetDownloadClient)
    with pytest.deprecated_call():
        await asset_client.reset_async_client()


@pytest.mark.asyncio
@respx.mock
async def test_cancelled_download_cleans_partial_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    respx.get("https://files.test/cancel").mock(return_value=httpx.Response(200, content=b"delayed"))

    client = asset_client.AssetDownloadClient()
    dest = tmp_path / "cancel.bin"

    async def fake_stream(self: asset_client.AssetDownloadClient, response: httpx.Response, path: Path) -> None:  # type: ignore[override]
        path.write_bytes(b"partial")
        await asyncio.sleep(0.2)

    monkeypatch.setattr(
        asset_client.AssetDownloadClient,
        "_stream_response_to_file",
        fake_stream,
        raising=False,
    )

    task = asyncio.create_task(client.download("https://files.test/cancel", dest))
    await asyncio.sleep(0)  # Let the request start
    task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await task

    assert not dest.exists()


@pytest.mark.asyncio
@respx.mock
async def test_failed_download_does_not_cache_partial_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    responses = iter(
        [
            httpx.Response(200, content=b"data"),
            httpx.Response(200, content=b"fresh"),
        ]
    )
    route = respx.get("https://files.test/fail").mock(side_effect=lambda request: next(responses))
    client = asset_client.AssetDownloadClient()
    dest = tmp_path / "asset.bin"
    original_stream = asset_client.AssetDownloadClient._stream_response_to_file

    async def boom(self: asset_client.AssetDownloadClient, response: httpx.Response, path: Path) -> None:  # type: ignore[override]
        raise OSError("disk full")

    monkeypatch.setattr(
        asset_client.AssetDownloadClient,
        "_stream_response_to_file",
        boom,
        raising=False,
    )

    with pytest.raises(OSError):
        await client.download("https://files.test/fail", dest)

    assert not dest.exists()
    assert route.call_count == 1

    monkeypatch.setattr(
        asset_client.AssetDownloadClient,
        "_stream_response_to_file",
        original_stream,
        raising=False,
    )
    result = await client.download("https://files.test/fail", dest)

    assert route.call_count == 2
    assert result.path == dest
    assert dest.read_bytes() == b"fresh"


@pytest.mark.asyncio
async def test_asset_client_context_manager_calls_close(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    called: list[bool] = []

    async def fake_close(self: asset_client.AssetDownloadClient) -> None:
        called.append(True)

    monkeypatch.setattr(asset_client.AssetDownloadClient, "close", fake_close, raising=False)

    async with asset_client.AssetDownloadClient() as client:
        assert isinstance(client, asset_client.AssetDownloadClient)

    assert called == [True]


class _DummyResponse:
    def __init__(self, payload: bytes = b"payload") -> None:
        self._payload = payload

    def raise_for_status(self) -> None:
        return None

    async def aiter_bytes(self):
        yield self._payload


class _DummyAsyncClient:
    def __init__(self, sequence: list[object]) -> None:
        self.sequence = list(sequence)
        self.stream_calls = 0

    def stream(self, *_args, **_kwargs):
        client = self

        class _StreamCtx:
            async def __aenter__(self_inner):
                client.stream_calls += 1
                if not client.sequence:
                    raise AssertionError("sequence exhausted")
                item = client.sequence.pop(0)
                if isinstance(item, Exception):
                    raise item
                return item

            async def __aexit__(self_inner, exc_type, exc, tb):
                return False

        return _StreamCtx()

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


@pytest.mark.asyncio
async def test_download_retries_then_succeeds_without_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    request = httpx.Request("GET", "https://files.test/retry")
    failure = httpx.HTTPStatusError("fail", request=request, response=httpx.Response(503, request=request))
    success = _DummyResponse(b"fresh-bytes")
    dummy_client = _DummyAsyncClient([failure, success])
    monkeypatch.setattr(asset_client.httpx, "AsyncClient", lambda *args, **kwargs: dummy_client)

    warnings: list[dict[str, object] | None] = []
    monkeypatch.setattr(
        asset_client.logger,
        "warning",
        lambda *args, **kwargs: warnings.append(kwargs.get("extra")),
    )

    client = asset_client.AssetDownloadClient(max_retries=1)
    dest = tmp_path / "retry.bin"

    result = await client.download("https://files.test/retry", dest, compute_checksum=False)

    assert result.checksum is None
    assert dest.read_bytes() == b"fresh-bytes"
    assert dummy_client.stream_calls == 2
    assert warnings


@pytest.mark.asyncio
async def test_download_raises_after_exhausting_retries(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    request = httpx.Request("GET", "https://files.test/fail")
    failure = httpx.HTTPStatusError("fail", request=request, response=httpx.Response(503, request=request))
    dummy_client = _DummyAsyncClient([failure, failure])
    monkeypatch.setattr(asset_client.httpx, "AsyncClient", lambda *args, **kwargs: dummy_client)

    errors: list[dict[str, object] | None] = []
    monkeypatch.setattr(
        asset_client.logger,
        "error",
        lambda *args, **kwargs: errors.append(kwargs.get("extra")),
    )

    client = asset_client.AssetDownloadClient(max_retries=1)
    dest = tmp_path / "fail.bin"

    with pytest.raises(httpx.HTTPStatusError):
        await client.download("https://files.test/fail", dest, compute_checksum=False)

    assert not dest.exists()
    assert errors


def test_should_retry_exception_helper() -> None:
    timeout_exc = httpx.TimeoutException("boom")
    assert asset_client._should_retry_exception(timeout_exc) is True  # type: ignore[attr-defined]

    dns_exc = httpx.InvalidURL("bad")
    assert asset_client._should_retry_exception(dns_exc) is False


@pytest.mark.asyncio
async def test_no_await_on_closed_loop_client() -> None:
    """Regression test: verify we don't call await on clients with closed loops.

    This reproduces the bug where _get_client() would try to call
    `await client.aclose()` on a client whose loop was already closed,
    causing RuntimeError and leaving stale state that would cause the next
    request to fail with "Event loop is closed".

    The fix is to skip calling await client.aclose() when the loop is closed,
    and just set the client reference to None instead.
    """
    from unittest.mock import Mock

    # Create a client and cache it
    await asset_client.get_shared_client()
    cached_client = asset_client._CLIENT

    # Create a mock loop that reports as closed
    mock_loop = Mock()
    mock_loop.is_closed.return_value = True

    # Simulate the scenario: event loop closed but client still cached
    if cached_client:
        cached_client._client_loop = mock_loop

    # Get client again - should detect closed loop and recreate
    # WITHOUT calling await on the old client (which would fail)
    new_client = await asset_client.get_shared_client()

    # Should have successfully created a new client
    assert new_client is not None

    # Cleanup
    await asset_client.reset_shared_client()
