"""Transport-level tests for the Spotify API client."""

import asyncio
import time
from collections.abc import Callable, Coroutine

import httpx2
import pytest

from app.adapters.spotify.auth import TokenSet
from app.adapters.spotify.client import SpotifyClient
from app.adapters.spotify.errors import (
    SpotifyQuotaExhaustedError,
    SpotifyRateLimitedError,
    SpotifyRequestError,
    SpotifyUnavailableError,
)
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import Track
from app.ports.protocols import CatalogQuotaExhaustedError

TransportHandler = Callable[[httpx2.Request], Coroutine[None, None, httpx2.Response]]


def test_unauthorized_response_refreshes_once_and_returns_result() -> None:
    async def run() -> None:
        token_calls = 0
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls, token_calls
            if request.url.host == "accounts.spotify.com":
                token_calls += 1
                return _token_response()
            api_calls += 1
            if request.headers["Authorization"] == "Bearer old-access":
                return httpx2.Response(401)
            return httpx2.Response(200, json=_search_payload())

        tracks = await _search_with_handler(handler)

        assert [track.id for track in tracks] == ["track-1"]
        assert token_calls == 1
        assert api_calls == 2

    asyncio.run(run())


def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
    async def run() -> None:
        token_calls = 0
        api_calls = 0
        both_initial_requests_arrived = asyncio.Event()

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls, token_calls
            if request.url.host == "accounts.spotify.com":
                token_calls += 1
                return _token_response("old-access")
            api_calls += 1
            if api_calls <= 2:
                if api_calls == 2:
                    both_initial_requests_arrived.set()
                await asyncio.wait_for(both_initial_requests_arrived.wait(), timeout=1)
                return httpx2.Response(401)
            return httpx2.Response(200, json=_search_payload())

        async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
            client = _client(http)
            first, second = await asyncio.gather(
                client.search_tracks("first"),
                client.search_tracks("second"),
            )

        assert first == second
        assert token_calls == 1
        assert client.session.refresh_generation == 1

    asyncio.run(run())


def test_get_rate_limit_with_small_delay_retries_once() -> None:
    async def run() -> None:
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls
            api_calls += 1
            if api_calls == 1:
                return httpx2.Response(429, headers={"Retry-After": "0"})
            return httpx2.Response(200, json=_search_payload())

        tracks = await _search_with_handler(handler)

        assert len(tracks) == 1
        assert api_calls == 2

    asyncio.run(run())


def test_get_rate_limit_above_cap_raises_without_retry() -> None:
    async def run() -> None:
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls
            api_calls += 1
            return httpx2.Response(429, headers={"Retry-After": "6"})

        with pytest.raises(SpotifyRateLimitedError) as error:
            await _search_with_handler(handler)

        assert error.value.retry_after_seconds == 6
        assert not isinstance(error.value, CatalogQuotaExhaustedError)
        assert api_calls == 1

    asyncio.run(run())


def test_quota_exhaustion_raises_without_retry_or_sleep(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    async def run() -> None:
        api_calls = 0
        sleep_calls = 0

        async def fake_sleep(delay: float) -> None:
            nonlocal sleep_calls
            sleep_calls += 1

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls
            api_calls += 1
            return httpx2.Response(
                429,
                headers={"Retry-After": "0"},
                json={
                    "error": {
                        "message": "Quota exhausted",
                        "reason": "QUOTA_EXCEEDED",
                    }
                },
            )

        monkeypatch.setattr("app.adapters.spotify.client.asyncio.sleep", fake_sleep)
        with pytest.raises(SpotifyQuotaExhaustedError) as error:
            await _search_with_handler(handler)

        assert error.value.reason == "QUOTA_EXCEEDED"
        assert isinstance(error.value, CatalogQuotaExhaustedError)
        assert api_calls == 1
        assert sleep_calls == 0

    asyncio.run(run())


def test_authentication_then_rate_limit_retries_each_policy_once() -> None:
    async def run() -> None:
        token_calls = 0
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls, token_calls
            if request.url.host == "accounts.spotify.com":
                token_calls += 1
                return _token_response()
            api_calls += 1
            if api_calls == 1:
                return httpx2.Response(401)
            if api_calls == 2:
                return httpx2.Response(429, headers={"Retry-After": "0"})
            return httpx2.Response(200, json=_search_payload())

        tracks = await _search_with_handler(handler)

        assert len(tracks) == 1
        assert token_calls == 1
        assert api_calls == 3

    asyncio.run(run())


def test_rate_limit_retry_landing_on_unauthorized_refreshes_once() -> None:
    async def run() -> None:
        token_calls = 0
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls, token_calls
            if request.url.host == "accounts.spotify.com":
                token_calls += 1
                return _token_response()
            api_calls += 1
            if api_calls == 1:
                return httpx2.Response(429, headers={"Retry-After": "0"})
            if api_calls == 2:
                return httpx2.Response(401)
            return httpx2.Response(200, json=_search_payload())

        tracks = await _search_with_handler(handler)

        assert len(tracks) == 1
        assert token_calls == 1
        assert api_calls == 3

    asyncio.run(run())


def test_transport_error_becomes_spotify_unavailable() -> None:
    async def run() -> None:
        async def handler(request: httpx2.Request) -> httpx2.Response:
            raise httpx2.ConnectError("connection failed", request=request)

        with pytest.raises(SpotifyUnavailableError) as error:
            await _search_with_handler(handler)

        assert error.value.status_code == 504
        assert str(error.value) == "Spotify request failed"

    asyncio.run(run())


def test_request_error_includes_parsed_spotify_message() -> None:
    async def run() -> None:
        async def handler(request: httpx2.Request) -> httpx2.Response:
            return httpx2.Response(
                400,
                json={"error": {"message": "Invalid search request"}},
            )

        with pytest.raises(SpotifyRequestError) as error:
            await _search_with_handler(handler)

        assert error.value.message == "Invalid search request"
        assert str(error.value) == "Invalid search request"

    asyncio.run(run())


def test_playlist_write_rate_limit_is_not_retried() -> None:
    async def run() -> None:
        api_calls = 0

        async def handler(request: httpx2.Request) -> httpx2.Response:
            nonlocal api_calls
            api_calls += 1
            assert request.method == "POST"
            assert request.url.path == "/v1/playlists/playlist-1/items"
            return httpx2.Response(429, headers={"Retry-After": "0"})

        async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
            with pytest.raises(SpotifyRateLimitedError):
                await _client(http).add_tracks_to_playlist("playlist-1", ["spotify:track:1"])

        assert api_calls == 1

    asyncio.run(run())


def test_search_maps_valid_fields_and_drops_malformed_item() -> None:
    async def run() -> None:
        async def handler(request: httpx2.Request) -> httpx2.Response:
            return httpx2.Response(200, json=_search_payload())

        tracks = await _search_with_handler(handler)

        assert len(tracks) == 1
        assert tracks[0].id == "track-1"
        assert tracks[0].uri == "spotify:track:1"
        assert tracks[0].title == "Mapped song"
        assert tracks[0].artists == ("First artist", "Second artist")
        assert tracks[0].album_name == "Mapped album"
        assert tracks[0].album_art_url == "https://images.example/cover.jpg"
        assert tracks[0].external_url == "https://open.spotify.com/track/track-1"

    asyncio.run(run())


def test_taste_endpoints_map_supported_response_shapes() -> None:
    async def run() -> None:
        async def handler(request: httpx2.Request) -> httpx2.Response:
            if request.url.path == "/v1/me/top/artists":
                assert request.url.params["time_range"] == "short_term"
                return httpx2.Response(200, json={"items": [{"name": "Top Artist"}]})
            if request.url.path == "/v1/me/top/tracks":
                return httpx2.Response(200, json={"items": [_track_payload()]})
            assert request.url.path == "/v1/me/tracks"
            assert request.url.params["offset"] == "0"
            return httpx2.Response(200, json={"items": [{"track": _track_payload()}]})

        async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
            client = _client(http)
            artists = await client.fetch_top_artists("short_term", 50)
            top_tracks = await client.fetch_top_tracks("long_term", 50)
            saved_tracks = await client.fetch_saved_tracks(100)

        assert artists == ["Top Artist"]
        assert [track.id for track in top_tracks] == ["track-1"]
        assert [track.id for track in saved_tracks] == ["track-1"]

    asyncio.run(run())


async def _search_with_handler(handler: TransportHandler) -> list[Track]:
    async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
        return list(await _client(http).search_tracks("mapped"))


def _client(http: httpx2.AsyncClient) -> SpotifyClient:
    session = SpotifySession(
        tokens=TokenSet("old-access", "refresh", time.monotonic() + 3600),
        account_id="account",
        display_name="Listener",
    )
    return SpotifyClient(http, session, Settings(spotify_client_id="client"))


def _token_response(access_token: str = "new-access") -> httpx2.Response:
    return httpx2.Response(200, json={"access_token": access_token, "expires_in": 3600})


def _search_payload() -> dict[str, object]:
    return {
        "tracks": {
            "total": 0,
            "items": [
                _track_payload(),
                {"id": "missing-required-fields"},
            ],
        }
    }


def _track_payload() -> dict[str, object]:
    return {
        "id": "track-1",
        "uri": "spotify:track:1",
        "name": "Mapped song",
        "artists": [{"name": "First artist"}, {"name": "Second artist"}],
        "album": {
            "name": "Mapped album",
            "images": [{"url": "https://images.example/cover.jpg"}],
        },
        "external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
    }
