import time

from abc import ABC, abstractmethod
from base64 import b64encode as _b64encode
from typing import Callable, Union
from functools import wraps

from requests import Request, Response
from urllib.parse import urlencode

from .scope import Scope
from tekore._error import get_error
from tekore._sender import Sender, Client

OAUTH_AUTHORIZE_URL = 'https://accounts.spotify.com/authorize'
OAUTH_TOKEN_URL = 'https://accounts.spotify.com/api/token'


def b64encode(msg: str) -> str:
    """Encode a unicode string in base-64."""
    return _b64encode(msg.encode()).decode()


class AccessToken(ABC):
    """Access token base class."""

    @property
    @abstractmethod
    def access_token(self) -> str:
        """
        Bearer token value.

        Used as the string representation of the instance.
        """
        raise NotImplementedError

    def __str__(self):
        """Bearer token value."""
        return self.access_token


class Token(AccessToken):
    """
    Expiring access token.

    Represents both client and user tokens.
    The refresh token of a client token is ``None``.
    """

    def __init__(self, token_info: dict):
        self._access_token = token_info['access_token']
        self._token_type = token_info['token_type']

        self._scope = Scope(*token_info['scope'].split(' '))
        if str(self._scope) == '':
            self._scope = Scope()

        self._refresh_token = token_info.get('refresh_token', None)
        self._expires_at = int(time.time()) + token_info['expires_in']

    @property
    def access_token(self) -> str:
        """Bearer token value."""
        return self._access_token

    @property
    def refresh_token(self) -> Union[str, None]:
        """
        Refresh token for generating new access tokens.

        ``None`` if the token is an application token.
        """
        return self._refresh_token

    @property
    def token_type(self) -> str:
        """How the token may be used, always 'Bearer'."""
        return self._token_type

    @property
    def scope(self) -> Scope:
        """
        Privileges granted to the token.

        Empty :class:`Scope` if the token is an application token
        or a user token without any scopes.
        """
        return self._scope

    @property
    def expires_in(self) -> int:
        """Seconds until token expiration."""
        return self.expires_at - int(time.time())

    @property
    def expires_at(self) -> int:
        """When the token expires."""
        return self._expires_at

    @property
    def is_expiring(self) -> bool:
        """Determine whether token is about to expire."""
        return self.expires_in < 60


def handle_errors(response: Response) -> None:
    """Examine response and raise errors accordingly."""
    if response.status_code < 400:
        return

    if response.status_code < 500:
        content = response.json()
        error_str = '{} {}: {}'.format(
            response.status_code,
            content['error'],
            content['error_description']
        )
    else:
        error_str = 'Unexpected error!'

    error_cls = get_error(response.status_code)
    raise error_cls(error_str, response=response)


def parse_token(response):
    """Parse token object from response."""
    handle_errors(response)
    content = response.json()
    return Token(content)


def send_and_process_token(
        function: Callable[..., Request]
) -> Callable[..., Token]:
    """Send request and parse reponse for token."""
    async def async_send(self, request: Request):
        response = await self._send(request)
        return parse_token(response)

    @wraps(function)
    def wrapper(self, *args, **kwargs):
        request = function(self, *args, **kwargs)

        if self.is_async:
            return async_send(self, request)

        response = self._send(request)
        return parse_token(response)
    return wrapper


def parse_refreshed_token(response, refresh_token: str) -> Token:
    """Replace new refresh token with old value if empty."""
    refreshed = parse_token(response)

    if refreshed.refresh_token is None:
        refreshed._refresh_token = refresh_token

    return refreshed


def send_and_process_refreshed_token(
        function: Callable[..., Request]
) -> Callable[..., Token]:
    """Send request and parse refreshed token."""
    async def async_send(self, request: Request, refresh_token: str):
        response = await self._send(request)
        return parse_refreshed_token(response, refresh_token)

    @wraps(function)
    def wrapper(self, *args, **kwargs):
        request, refresh_token = function(self, *args, **kwargs)

        if self.is_async:
            return async_send(self, request, refresh_token)

        response = self._send(request)
        return parse_refreshed_token(response, refresh_token)
    return wrapper


class Credentials(Client):
    """
    Client for retrieving access tokens.

    Specifying a ``redirect_uri`` is required only when authorising users.

    Parameters
    ----------
    client_id
        client id
    client_secret
        client secret
    redirect_uri
        whitelisted redirect URI
    sender
        request sender
    asynchronous
        synchronicity requirement
    """

    def __init__(
            self,
            client_id: str,
            client_secret: str,
            redirect_uri: str = None,
            sender: Sender = None,
            asynchronous: bool = None,
    ):
        super().__init__(sender, asynchronous)
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri

    @property
    def _auth(self) -> str:
        return b64encode(self.client_id + ':' + self.client_secret)

    def _request_token(self, payload: dict):
        headers = {'Authorization': f'Basic {self._auth}'}
        return Request('POST', OAUTH_TOKEN_URL, data=payload, headers=headers)

    @send_and_process_token
    def request_client_token(self) -> Token:
        """
        Request a client token.

        Returns
        -------
        Token
            client access token
        """
        payload = {'grant_type': 'client_credentials'}
        return self._request_token(payload)

    def user_authorisation_url(
            self,
            scope=None,
            state: str = None,
            show_dialog: bool = False
    ) -> str:
        """
        Construct an authorisation URL.

        Step 1/2 in authorisation code flow.
        User should be redirected to the resulting URL for authorisation.

        Parameters
        ----------
        scope
            token privileges, accepts a :class:`Scope`, a single :class:`scope`,
            a list of :class:`scopes <scope>` and strings for :class:`Scope`,
            or a space-separated list of scopes as a string
        state
            additional state
        show_dialog
            force login dialog even if previously authorised

        Returns
        -------
        str
            login URL
        """
        payload = {
            'show_dialog': str(show_dialog).lower(),
            'client_id': self.client_id,
            'response_type': 'code',
            'redirect_uri': self.redirect_uri
        }
        if isinstance(scope, list):
            scope = Scope(*scope)
        if scope is not None:
            payload['scope'] = str(scope)
        if state is not None:
            payload['state'] = state

        return OAUTH_AUTHORIZE_URL + '?' + urlencode(payload)

    @send_and_process_token
    def request_user_token(self, code: str) -> Token:
        """
        Request a new user token.

        Step 2/2 in authorisation code flow.
        Code is provided as a URL parameter in the redirect URI
        after login in step 1.

        Parameters
        ----------
        code
            code from redirect parameters

        Returns
        -------
        Token
            user access token
        """
        payload = {
            'code': code,
            'redirect_uri': self.redirect_uri,
            'grant_type': 'authorization_code'
        }
        return self._request_token(payload)

    @send_and_process_refreshed_token
    def refresh_user_token(self, refresh_token: str) -> Token:
        """
        Request a refreshed user token.

        Parameters
        ----------
        refresh_token
            refresh token

        Returns
        -------
        Token
            refreshed user access token
        """
        payload = {
            'refresh_token': refresh_token,
            'grant_type': 'refresh_token'
        }

        return self._request_token(payload), refresh_token

    def refresh(self, token: Token) -> Token:
        """
        Refresh an access token.

        Both client and user tokens are accepted and refreshed.
        For client tokens, a new token is returned.
        For user tokens, a refreshed token is returned.

        Parameters
        ----------
        token
            token to be refreshed

        Returns
        -------
        Token
            refreshed access token
        """
        if token.refresh_token is None:
            return self.request_client_token()
        else:
            return self.refresh_user_token(token.refresh_token)