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

from __future__ import annotations

import asyncio
import threading
import time
from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager
from pathlib import Path
from types import TracebackType
from typing import Any, cast

import httpx
import pytest
import respx

from infra_core import asset_client


def _client_with_http_client(
    http_client: object,
    **client_kwargs: Any,
) -> asset_client.AssetDownloadClient:
    def factory(_factory_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        return cast(httpx.AsyncClient, http_client)

    return asset_client.AssetDownloadClient(_client_factory=factory, **client_kwargs)


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


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())


def test_get_sync_loop_runner_is_thread_safe(monkeypatch: pytest.MonkeyPatch) -> None:
    created = 0

    class DummyRunner:
        def __init__(self) -> None:
            nonlocal created
            created += 1
            time.sleep(0.05)

        def close(self) -> None:
            return None

    monkeypatch.setattr(asset_client, "_SyncLoopRunner", DummyRunner)
    monkeypatch.setattr(asset_client, "_SYNC_LOOP_RUNNER", None)

    barrier = threading.Barrier(4)
    results: list[object] = []
    errors: list[BaseException] = []

    def worker() -> None:
        try:
            barrier.wait()
            results.append(asset_client._get_sync_loop_runner())
        except BaseException as exc:  # pragma: no cover - defensive
            errors.append(exc)

    threads = [threading.Thread(target=worker) for _ in range(4)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join(timeout=1.0)

    assert not errors
    assert created == 1
    assert len({id(result) for result in results}) == 1
    asset_client._shutdown_sync_loop_runner()


@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:
        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:
        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) -> AsyncIterator[bytes]:
        yield self._payload


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

    def stream(self, *_args: object, **_kwargs: object) -> AbstractAsyncContextManager[object]:
        client = self

        class _StreamCtx:
            async def __aenter__(self_inner) -> object:
                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: type[BaseException] | None,
                exc: BaseException | None,
                tb: TracebackType | None,
            ) -> bool:
                return False

        return _StreamCtx()

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


@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])

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

    client = _client_with_http_client(dummy_client, 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])

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

    client = _client_with_http_client(dummy_client, 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

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


@pytest.mark.asyncio
async def test_get_shared_client_closes_closed_loop_client_before_recreating(tmp_path: Path) -> None:
    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    stale_http_client = _DummyAsyncClient([_DummyResponse(b"stale")])
    shared_client = _client_with_http_client(stale_http_client)

    asset_client.set_shared_client(shared_client)
    await asset_client.download_asset_async(
        "https://files.test/stale",
        tmp_path / "stale.bin",
        compute_checksum=False,
    )
    asset_client._CLIENT_LOOP = cast(asyncio.AbstractEventLoop, ClosedLoop())

    new_client = await asset_client.get_shared_client()

    assert stale_http_client.closed is True
    assert new_client is not shared_client

    await asset_client.reset_shared_client()


@pytest.mark.asyncio
async def test_get_shared_client_ignores_event_loop_closed_when_recreating(tmp_path: Path) -> None:
    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    class RaisingClient(_DummyAsyncClient):
        async def aclose(self) -> None:
            raise RuntimeError("Event loop is closed")

    stale_http_client = RaisingClient([_DummyResponse(b"stale")])
    shared_client = _client_with_http_client(stale_http_client)

    asset_client.set_shared_client(shared_client)
    await asset_client.download_asset_async(
        "https://files.test/stale-closed-loop",
        tmp_path / "stale-closed-loop.bin",
        compute_checksum=False,
    )
    asset_client._CLIENT_LOOP = cast(asyncio.AbstractEventLoop, ClosedLoop())
    shared_client._client_loop = cast(asyncio.AbstractEventLoop, ClosedLoop())

    new_client = await asset_client.get_shared_client()

    assert new_client is not shared_client

    await asset_client.reset_shared_client()


@pytest.mark.asyncio
async def test_asset_client_client_factory_receives_user_agent(tmp_path: Path) -> None:
    captured: list[dict[str, Any]] = []
    dummy_client = _DummyAsyncClient([_DummyResponse(b"user-agent")])

    def factory(client_kwargs: dict[str, Any]) -> httpx.AsyncClient:
        captured.append(dict(client_kwargs))
        return cast(httpx.AsyncClient, dummy_client)

    client = asset_client.AssetDownloadClient(
        _client_factory=factory,
        user_agent="InfraCoreTest/1.0",
    )

    result = await client.download(
        "https://files.test/user-agent",
        tmp_path / "user-agent.bin",
        compute_checksum=False,
    )

    assert result.path.read_bytes() == b"user-agent"
    assert captured == [
        {
            "follow_redirects": True,
            "headers": {"User-Agent": "InfraCoreTest/1.0"},
        }
    ]

    await client.close()


@pytest.mark.asyncio
async def test_reset_shared_client_closes_existing_client(tmp_path: Path) -> None:
    dummy_client = _DummyAsyncClient([_DummyResponse(b"shared")])
    shared_client = _client_with_http_client(dummy_client)

    asset_client.set_shared_client(shared_client)

    result = await asset_client.download_asset_async(
        "https://files.test/shared",
        tmp_path / "shared.bin",
        compute_checksum=False,
    )

    assert result.path.read_bytes() == b"shared"

    await asset_client.reset_shared_client()

    assert dummy_client.closed is True


@pytest.mark.asyncio
async def test_reset_shared_client_closes_client_even_when_recorded_loop_is_closed(
    tmp_path: Path,
) -> None:
    class ClosedLoop:
        def is_closed(self) -> bool:
            return True

    dummy_client = _DummyAsyncClient([_DummyResponse(b"shared")])
    shared_client = _client_with_http_client(dummy_client)

    asset_client.set_shared_client(shared_client)
    await asset_client.download_asset_async(
        "https://files.test/shared-closed-loop",
        tmp_path / "shared-closed-loop.bin",
        compute_checksum=False,
    )
    asset_client._CLIENT_LOOP = cast(asyncio.AbstractEventLoop, ClosedLoop())

    await asset_client.reset_shared_client()

    assert dummy_client.closed is True


@pytest.mark.asyncio
async def test_set_shared_client_disposes_previous_client(tmp_path: Path) -> None:
    old_dummy_client = _DummyAsyncClient([_DummyResponse(b"old")])
    old_shared_client = _client_with_http_client(old_dummy_client)

    asset_client.set_shared_client(old_shared_client)
    await asset_client.download_asset_async(
        "https://files.test/old",
        tmp_path / "old.bin",
        compute_checksum=False,
    )

    asset_client.set_shared_client(asset_client.AssetDownloadClient())
    await _drain_pending_disposals()

    assert old_dummy_client.closed is True

    await asset_client.reset_shared_client()


def test_shared_client_handles_multiple_event_loops() -> None:
    """Verify shared client is recreated when called from different event loops.

    This tests the fix for cross-loop access errors: if you call get_shared_client()
    from loop A then from loop B, the client should be recreated for loop B instead
    of returning a client bound to (potentially closed) loop A.
    """
    clients: list[asset_client.AssetDownloadClient] = []
    loops: list[asyncio.AbstractEventLoop] = []

    async def acquire_client() -> None:
        client = await asset_client.get_shared_client()
        clients.append(client)
        loops.append(asyncio.get_running_loop())

    # Run in two separate event loops
    asyncio.run(acquire_client())
    asyncio.run(acquire_client())

    # Should have gotten different clients since they're in different loops
    assert len(clients) == 2
    assert len(loops) == 2
    # The loops should be different (first is closed after asyncio.run exits)
    assert loops[0].is_closed()
    # The clients might be the same object if reused, but the important thing
    # is that the second call didn't crash due to cross-loop access


def test_shared_client_loop_tracking_on_set() -> None:
    """Verify set_shared_client tracks loop binding correctly."""
    # Outside async context, loop should be None
    client = asset_client.AssetDownloadClient()
    asset_client.set_shared_client(client)

    # Loop should be None since we're not in an async context
    assert asset_client._CLIENT_LOOP is None

    # Cleanup
    asset_client.set_shared_client(None)
    assert asset_client._CLIENT is None
    assert asset_client._CLIENT_LOOP is None
