"""Linking, refreshing and unlinking a user's Spotify account.

Routers call into here; nothing here knows about HTTP. This is the only module
that decrypts a stored token, and the only one that writes one.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime, timedelta

from sqlalchemy import select
from sqlalchemy.orm import Session

from ...models.spotify import SpotifyAccount
from ..crypto import TokenCryptoError, decrypt, encrypt
from .oauth import SpotifyError, TokenSet, current_user, refresh_tokens

log = logging.getLogger(__name__)

# Refresh a little before the token actually dies, so a request that takes a
# second or two does not race the expiry it just checked.
REFRESH_MARGIN = timedelta(seconds=90)


def get_account(db: Session, user_id: int) -> SpotifyAccount | None:
    return db.scalar(
        select(SpotifyAccount).where(SpotifyAccount.user_id == user_id)
    )


def link_account(db: Session, user_id: int, tokens: TokenSet) -> SpotifyAccount:
    """Create or replace the Spotify link for a user. Caller commits.

    The profile is fetched here rather than passed in, so that the row can never
    record a Spotify identity that the token does not actually belong to.
    """
    profile = current_user(tokens.access_token)
    spotify_user_id = profile.get("id") or ""
    if not spotify_user_id:
        raise SpotifyError("Spotify profile response contained no account id")

    account = get_account(db, user_id)
    now = datetime.now(UTC)
    if account is None:
        account = SpotifyAccount(user_id=user_id, connected_at=now)
        db.add(account)

    account.spotify_user_id = spotify_user_id
    account.display_name = profile.get("display_name") or spotify_user_id
    account.product = profile.get("product") or ""
    account.access_token_encrypted = encrypt(tokens.access_token)
    account.refresh_token_encrypted = encrypt(tokens.refresh_token)
    account.access_expires_at = tokens.expires_at
    account.scopes = tokens.scopes
    account.updated_at = now
    db.flush()
    return account


def unlink_account(db: Session, user_id: int) -> bool:
    """Disconnect. Returns whether there was anything to disconnect.

    Deleting the row is the whole operation — the tokens go with it. Note this
    does NOT revoke the grant on Spotify's side: Spotify has no revocation
    endpoint, so a user who wants the authorization itself removed must do it at
    spotify.com/account/apps. The UI says so.
    """
    account = get_account(db, user_id)
    if account is None:
        return False
    db.delete(account)
    return True


def valid_access_token(db: Session, account: SpotifyAccount) -> str:
    """A usable access token, refreshing first if it is expired or about to be.

    Raises SpotifyError if the refresh fails — which most often means the user
    revoked access on Spotify's side. Callers should surface that as
    "reconnect Spotify", not as a server fault.
    """
    expires_at = account.access_expires_at
    if expires_at.tzinfo is None:  # defensive: a naive column value
        expires_at = expires_at.replace(tzinfo=UTC)

    if datetime.now(UTC) + REFRESH_MARGIN < expires_at:
        return decrypt(account.access_token_encrypted)

    refresh_token = decrypt(account.refresh_token_encrypted)
    if not refresh_token:
        raise SpotifyError("no refresh token stored for this account")

    tokens = refresh_tokens(refresh_token)
    account.access_token_encrypted = encrypt(tokens.access_token)
    if tokens.refresh_token:
        account.refresh_token_encrypted = encrypt(tokens.refresh_token)
    account.access_expires_at = tokens.expires_at
    if tokens.scopes:
        account.scopes = tokens.scopes
    account.updated_at = datetime.now(UTC)
    db.flush()
    return tokens.access_token


def connection_health(db: Session, account: SpotifyAccount) -> tuple[bool, str]:
    """Is the stored connection actually usable right now?

    Used by the status endpoint so the UI can tell "connected" apart from
    "connected but the token is dead". Returns (ok, reason) and never raises —
    a health probe that throws is worse than one that reports.
    """
    try:
        valid_access_token(db, account)
        return True, ""
    except TokenCryptoError:
        return False, "stored credentials could not be decrypted"
    except SpotifyError as exc:
        log.info("Spotify connection unhealthy for user %s: %s", account.user_id, exc)
        return False, "Spotify access needs to be reconnected"
