"""Spotify's OAuth 2.0 Authorization Code flow, and the few API calls we make.

This is the CONFIDENTIAL-client flow: the code is exchanged server-side using
the client secret, which never reaches a browser. CSRF protection is the `state`
parameter, echoed back and compared against an HttpOnly cookie the router sets.

Scopes are declared once, here, in SCOPES — with a reason for each. Requesting a
scope "just in case" is not free: the user reads the consent screen, and a scope
we cannot justify is one they are right to refuse.

**API surface note (February 2026).** Spotify heavily reduced what Development
Mode apps may call. Confirmed still available: `GET /me`, playlist listing and
creation, and playlist items (renamed `/tracks` -> `/items`). Confirmed gone for
new apps: Recommendations, Audio Features, Audio Analysis, Related Artists, and
the batch fetch endpoints; search is capped at 10 results. Check the migration
guide before assuming any endpoint beyond `GET /me` works:
https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide
"""

from __future__ import annotations

import base64
import contextlib
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode

import httpx

from ...config import get_settings

log = logging.getLogger(__name__)

AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
API_BASE = "https://api.spotify.com/v1"

HTTP_TIMEOUT = 15.0

# Requested scopes, each with the capability it buys. Keep this list honest:
# drop anything the app does not actually use before real users see the consent
# screen. (playlist-modify-* are requested now because the whole point of the
# product is writing a playlist back; asking for them later would force every
# existing user through a second consent.)
SCOPES: tuple[tuple[str, str], ...] = (
    ("user-read-private", "read the account's country and product tier"),
    ("user-read-email", "identify which Spotify account is connected"),
    ("playlist-read-private", "see the user's existing playlists"),
    ("playlist-modify-private", "create and update a private daily playlist"),
    ("playlist-modify-public", "publish the daily playlist if the user chooses"),
)

SCOPE_STRING = " ".join(scope for scope, _reason in SCOPES)


class SpotifyError(RuntimeError):
    """A Spotify call failed. Carries no token material."""


@dataclass(frozen=True)
class TokenSet:
    access_token: str
    refresh_token: str
    expires_at: datetime
    scopes: str


def _client_credentials_header() -> dict[str, str]:
    """Spotify accepts the client id/secret as HTTP Basic on the token endpoint.
    Basic keeps the secret out of the request BODY, so it cannot end up in a
    proxy access log the way a form field can."""
    settings = get_settings()
    raw = f"{settings.spotify_client_id}:{settings.spotify_client_secret}"
    encoded = base64.b64encode(raw.encode()).decode()
    return {"Authorization": f"Basic {encoded}"}


def authorize_url(state: str) -> str:
    """Where to send the browser to begin the connect flow."""
    settings = get_settings()
    params = {
        "client_id": settings.spotify_client_id,
        "response_type": "code",
        "redirect_uri": settings.spotify_redirect_uri,
        "state": state,
        "scope": SCOPE_STRING,
        # Always show the consent screen. Without this, a user who wants to
        # connect a DIFFERENT Spotify account is silently re-linked to the one
        # already signed in to spotify.com, with no way to choose.
        "show_dialog": "true",
    }
    return f"{AUTHORIZE_URL}?{urlencode(params)}"


def _parse_token_response(payload: dict, fallback_refresh: str = "") -> TokenSet:
    access = payload.get("access_token") or ""
    if not access:
        raise SpotifyError("token response contained no access_token")
    # Spotify does not always return a new refresh token on refresh; when it
    # omits one, the existing token stays valid and must be carried forward.
    refresh = payload.get("refresh_token") or fallback_refresh
    expires_in = int(payload.get("expires_in") or 3600)
    return TokenSet(
        access_token=access,
        refresh_token=refresh,
        expires_at=datetime.now(UTC) + timedelta(seconds=expires_in),
        scopes=payload.get("scope") or "",
    )


def _post_token(data: dict, fallback_refresh: str = "") -> TokenSet:
    try:
        resp = httpx.post(
            TOKEN_URL,
            data=data,
            headers=_client_credentials_header(),
            timeout=HTTP_TIMEOUT,
        )
    except httpx.HTTPError as exc:
        raise SpotifyError(f"could not reach Spotify: {exc}") from exc

    if resp.status_code != 200:
        # Log the CODE and Spotify's error slug only. The request body carries
        # an authorization code or refresh token; it never goes to the log.
        detail = ""
        with contextlib.suppress(ValueError):
            detail = resp.json().get("error", "")
        log.warning(
            "Spotify token endpoint returned %s (%s)", resp.status_code, detail
        )
        raise SpotifyError(f"Spotify rejected the token request ({resp.status_code})")

    return _parse_token_response(resp.json(), fallback_refresh)


def exchange_code(code: str) -> TokenSet:
    """Redeem the one-time authorization code for tokens."""
    settings = get_settings()
    return _post_token(
        {
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": settings.spotify_redirect_uri,
        }
    )


def refresh_tokens(refresh_token: str) -> TokenSet:
    """Exchange a refresh token for a fresh access token.

    A 4xx here usually means the user revoked access from their Spotify account
    page. Callers should treat that as "disconnected", not as a server error.
    """
    return _post_token(
        {"grant_type": "refresh_token", "refresh_token": refresh_token},
        fallback_refresh=refresh_token,
    )


def current_user(access_token: str) -> dict:
    """GET /me — who this token belongs to. The only API call the connect flow
    needs, and one of the few endpoints Development Mode still allows."""
    try:
        resp = httpx.get(
            f"{API_BASE}/me",
            headers={"Authorization": f"Bearer {access_token}"},
            timeout=HTTP_TIMEOUT,
        )
    except httpx.HTTPError as exc:
        raise SpotifyError(f"could not reach Spotify: {exc}") from exc

    if resp.status_code == 403:
        # The single most likely failure in Development Mode: this Spotify user
        # is not on the app's allowlist. Say so precisely — the generic message
        # would send someone hunting for a bug that is a dashboard setting.
        raise SpotifyError(
            "Spotify returned 403. In Development Mode only users explicitly "
            "added to the app's allowlist in the Spotify dashboard can connect."
        )
    if resp.status_code != 200:
        raise SpotifyError(f"Spotify profile request failed ({resp.status_code})")
    return resp.json()
