"""Thin asynchronous client for the Spotify Web API."""

import asyncio

import httpx2

from app.adapters.spotify.auth import refresh_access_token
from app.adapters.spotify.errors import (
    SpotifyAuthenticationError,
    SpotifyQuotaExhaustedError,
    SpotifyRateLimitedError,
    SpotifyRequestError,
    SpotifyUnavailableError,
)
from app.adapters.spotify.mapping import (
    CurrentUser,
    parse_created_playlist,
    parse_current_user,
    parse_saved_track_page,
    parse_search_tracks,
    parse_top_artists,
    parse_track_page,
)
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import CreatedPlaylist, Track
from app.observability.timing import increment_spotify_calls
from app.ports.protocols import TimeRange

SPOTIFY_SEARCH_LIMIT = 10
SPOTIFY_PAGE_LIMIT = 50


class SpotifyClient:
    """Call the supported Spotify endpoints with bounded retry behavior."""

    def __init__(
        self,
        http: httpx2.AsyncClient,
        session: SpotifySession,
        settings: Settings,
    ) -> None:
        """Bind the shared transport to one authenticated Spotify session."""
        self.http = http
        self.session = session
        self.settings = settings

    async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
        """Search Spotify tracks and return only valid mapped results."""
        if not 1 <= limit <= SPOTIFY_SEARCH_LIMIT:
            raise ValueError("Spotify search limit must be between 1 and 10")
        response = await self._request(
            "GET",
            "/search",
            params={"q": query, "type": "track", "limit": limit},
        )
        return parse_search_tracks(response.json())

    async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
        """Fetch the user's top artists for a supported time range."""
        response = await self._request(
            "GET",
            "/me/top/artists",
            params={"time_range": time_range, "limit": limit},
        )
        return parse_top_artists(response.json())

    async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
        """Fetch the user's top tracks for a supported time range."""
        response = await self._request(
            "GET",
            "/me/top/tracks",
            params={"time_range": time_range, "limit": limit},
        )
        return parse_track_page(response.json())

    async def fetch_saved_tracks(self, limit: int) -> list[Track]:
        """Fetch a bounded saved-track sample across Spotify pages."""
        tracks: list[Track] = []
        while len(tracks) < limit:
            page_limit = min(SPOTIFY_PAGE_LIMIT, limit - len(tracks))
            response = await self._request(
                "GET",
                "/me/tracks",
                params={"limit": page_limit, "offset": len(tracks)},
            )
            page = parse_saved_track_page(response.json())
            tracks.extend(page)
            if len(page) < page_limit:
                break
        return tracks

    async def fetch_current_user(self) -> CurrentUser:
        """Fetch the authenticated Spotify user's stable identity."""
        response = await self._request("GET", "/me")
        return parse_current_user(response.json())

    async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
        """Create a private playlist for the authenticated user."""
        response = await self._request(
            "POST",
            "/me/playlists",
            json={"name": name, "description": description, "public": False},
        )
        return parse_created_playlist(response.json())

    async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
        """Add tracks to a playlist without retrying an ambiguous write."""
        await self._request(
            "POST",
            f"/playlists/{playlist_id}/items",
            json={"uris": track_uris},
        )

    async def _request(
        self,
        method: str,
        path: str,
        *,
        params: dict[str, str | int] | None = None,
        json: dict[str, object] | None = None,
    ) -> httpx2.Response:
        access_token, refresh_generation = await self._access_token()
        response = await self._send(
            method,
            path,
            access_token,
            params=params,
            json=json,
        )
        response, refresh_generation = await self._retry_once_if_unauthorized(
            response,
            method,
            path,
            refresh_generation,
            params=params,
            json=json,
        )
        response, refresh_generation = await self._retry_once_if_rate_limited(
            response,
            method,
            path,
            refresh_generation,
            params=params,
            json=json,
        )
        response, _ = await self._retry_once_if_unauthorized(
            response,
            method,
            path,
            refresh_generation,
            params=params,
            json=json,
        )
        return self._raise_for_error(response)

    async def _send(
        self,
        method: str,
        path: str,
        access_token: str,
        *,
        params: dict[str, str | int] | None,
        json: dict[str, object] | None,
    ) -> httpx2.Response:
        increment_spotify_calls()
        try:
            return await self.http.request(
                method,
                f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
                params=params,
                json=json,
                headers={"Authorization": f"Bearer {access_token}"},
            )
        except httpx2.HTTPError as error:
            raise SpotifyUnavailableError(504, "Spotify request failed") from error

    async def _retry_once_if_unauthorized(
        self,
        response: httpx2.Response,
        method: str,
        path: str,
        refresh_generation: int,
        *,
        params: dict[str, str | int] | None,
        json: dict[str, object] | None,
    ) -> tuple[httpx2.Response, int]:
        if response.status_code != 401:
            return response, refresh_generation

        await self._refresh_if_current(refresh_generation)
        access_token, retry_generation = self._token_snapshot()
        return (
            await self._send(
                method,
                path,
                access_token,
                params=params,
                json=json,
            ),
            retry_generation,
        )

    async def _retry_once_if_rate_limited(
        self,
        response: httpx2.Response,
        method: str,
        path: str,
        refresh_generation: int,
        *,
        params: dict[str, str | int] | None,
        json: dict[str, object] | None,
    ) -> tuple[httpx2.Response, int]:
        if response.status_code != 429:
            return response, refresh_generation

        retry_after_seconds = _parse_retry_after(response)
        _, reason = _parse_error_details(response)
        if (
            method != "GET"
            or reason == "QUOTA_EXCEEDED"
            or retry_after_seconds is None
            or retry_after_seconds > self.settings.spotify_retry_after_cap_seconds
        ):
            return response, refresh_generation

        await asyncio.sleep(retry_after_seconds)
        access_token, retry_generation = self._token_snapshot()
        return (
            await self._send(
                method,
                path,
                access_token,
                params=params,
                json=json,
            ),
            retry_generation,
        )

    def _raise_for_error(self, response: httpx2.Response) -> httpx2.Response:
        if response.status_code < 400:
            return response

        message, reason = _parse_error_details(response)
        if response.status_code == 401:
            raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
        if response.status_code == 429:
            if reason == "QUOTA_EXCEEDED":
                raise SpotifyQuotaExhaustedError(_parse_retry_after(response), reason)
            raise SpotifyRateLimitedError(_parse_retry_after(response), reason)
        if response.status_code >= 500:
            raise SpotifyUnavailableError(response.status_code, message)
        raise SpotifyRequestError(response.status_code, message)

    async def _access_token(self) -> tuple[str, int]:
        tokens = self.session.tokens
        refresh_generation = self.session.refresh_generation
        if tokens.is_expired:
            await self._refresh_if_current(refresh_generation)
        return self._token_snapshot()

    async def _refresh_if_current(self, refresh_generation: int) -> None:
        async with self.session.refresh_lock:
            if self.session.refresh_generation != refresh_generation:
                return
            self.session.tokens = await refresh_access_token(
                self.http,
                client_id=self.settings.spotify_client_id,
                tokens=self.session.tokens,
            )
            self.session.refresh_generation += 1

    def _token_snapshot(self) -> tuple[str, int]:
        return self.session.tokens.access_token, self.session.refresh_generation


def _parse_retry_after(response: httpx2.Response) -> float | None:
    value = response.headers.get("Retry-After")
    if value is None:
        return None
    try:
        retry_after_seconds = float(value)
    except ValueError:
        return None
    return retry_after_seconds if retry_after_seconds >= 0 else None


def _parse_error_details(response: httpx2.Response) -> tuple[str | None, str | None]:
    try:
        payload: object = response.json()
    except ValueError:
        return None, None
    if not isinstance(payload, dict):
        return None, None

    error = payload.get("error")
    if not isinstance(error, dict):
        return None, None
    message = error.get("message")
    reason = error.get("reason")
    return (
        message if isinstance(message, str) else None,
        reason if isinstance(reason, str) else None,
    )
