import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional, Tuple, TYPE_CHECKING, Union, Any

from boxsdk.auth.oauth2 import OAuth2
from boxsdk.exception import BoxOAuthException
from boxsdk.config import API
from boxsdk.object.user import User

if TYPE_CHECKING:
    from boxsdk.network.network_interface import NetworkResponse


class ServerAuth(ABC, OAuth2):
    USER_SUBJECT_TYPE = 'user'
    ENTERPRISE_SUBJECT_TYPE = 'enterprise'

    def __init__(
            self,
            client_id: str,
            client_secret: str,
            enterprise_id: Optional[str] = None,
            user: Optional[Union[str, 'User']] = None,
            **kwargs: Any
    ):
        super().__init__(client_id=client_id, client_secret=client_secret, **kwargs)
        self._enterprise_id = enterprise_id
        self._user_id = self._normalize_user_id(user)

    def _refresh(self, access_token: str) -> Tuple[str, None]:
        """
        Base class override.

        Instead of refreshing an access token using a refresh token, we just issue a new JWT request.
        """
        # pylint:disable=unused-argument
        if self._user_id is None:
            new_access_token = self.authenticate_instance()
        else:
            new_access_token = self.authenticate_user()
        return new_access_token, None

    def authenticate_user(self, user: Union[str, 'User'] = None) -> str:
        """
        Get an access token for a User.

        May be one of this application's created App User. Depending on the
        configured User Access Level, may also be any other App User or Managed
        User in the enterprise.

        <https://developer.box.com/en/guides/applications/>
        <https://developer.box.com/en/guides/authentication/select/>

        :param user:
            (optional) The user to authenticate, expressed as a Box User ID or
            as a :class:`User` instance.

            If not given, then the most recently provided user ID, if
            available, will be used.
        :raises:
            :exc:`ValueError` if no user ID was passed and the object is not
            currently configured with one.
        :return:
            The access token for the user.
        """
        sub = self._normalize_user_id(user) or self._user_id
        if not sub:
            raise ValueError("authenticate_user: Requires the user ID, but it was not provided.")
        self._user_id = sub
        return self._authenticate(sub, self.USER_SUBJECT_TYPE)

    authenticate_app_user = authenticate_user

    def authenticate_instance(self, enterprise: Optional[str] = None) -> str:
        """
        Get an access token for a Box Developer Edition enterprise.

        :param enterprise:
            The ID of the Box Developer Edition enterprise.

            Optional if the value was already given to `__init__`,
            otherwise required.
        :raises:
            :exc:`ValueError` if `None` was passed for the enterprise ID here
            and in `__init__`, or if the non-`None` value passed here does not
            match the non-`None` value passed to `__init__`.
        :return:
            The access token for the enterprise which can provision/deprovision app users.
        """
        enterprises = [enterprise, self._enterprise_id]
        if not any(enterprises):
            raise ValueError("authenticate_instance: Requires the enterprise ID, but it was not provided.")
        if all(enterprises) and (enterprise != self._enterprise_id):
            raise ValueError(
                f"authenticate_instance: Given enterprise ID {enterprise!r}, "
                f"but {self} already has ID {self._enterprise_id!r}"
            )
        if not self._enterprise_id:
            self._enterprise_id = enterprise
        self._user_id = None
        return self._authenticate(self._enterprise_id, self.ENTERPRISE_SUBJECT_TYPE)

    def _authenticate(self, subject_id: str, subject_type: str) -> str:
        """
        Authenticate with server type authentication (JWT or CCG).
        If authorization fails because the expiration time is out of sync with the Box servers,
        retry using the time returned in the error response.
        Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users),
        or a user ID to get a user token.

        :param subject_id:
            The enterprise ID or user ID to auth.
        :param subject_type:
            Either 'enterprise' or 'user'
        :return:
            The access token for the enterprise or app user.
        """
        attempt_number = 0
        date = None
        while True:
            try:
                return self._fetch_access_token(subject_id, subject_type, date)
            except BoxOAuthException as ex:
                network_response = ex.network_response
                code = network_response.status_code  # pylint: disable=maybe-no-member
                box_datetime = self._get_date_header(network_response)

                if attempt_number >= API.MAX_RETRY_ATTEMPTS:
                    raise ex

                if code == 429 or code >= 500:
                    date = None
                elif box_datetime is not None and self._is_auth_error_retryable(network_response):
                    date = box_datetime
                else:
                    raise ex

                time_delay = self._session.get_retry_after_time(
                    attempt_number,
                    network_response.headers.get('Retry-After', None)
                )
                time.sleep(time_delay)
                attempt_number += 1
                self._logger.debug('Retrying authentication request')

    @abstractmethod
    def _fetch_access_token(self, subject_id: str, subject_type: str, now_time: Optional[datetime] = None) -> str:
        pass

    @staticmethod
    def _get_date_header(network_response: 'NetworkResponse') -> Optional[datetime]:
        """
        Get datetime object for Date header, if the Date header is available.

        :param network_response:
            The response from the Box API that should include a Date header.
        :return:
            The datetime parsed from the Date header, or None if the header is absent or if it couldn't be parsed.
        """
        box_date_header = network_response.headers.get('Date', None)
        if box_date_header is not None:
            try:
                return datetime.strptime(box_date_header, '%a, %d %b %Y %H:%M:%S %Z')
            except ValueError:
                pass
        return None

    @staticmethod
    def _is_auth_error_retryable(network_response: 'NetworkResponse') -> bool:
        """
        Determine whether the network response indicates that the authorization request was rejected because of
        the exp or jti claim and can be retried. Exp claim error can happen if the current system time is too
        different from the Box server time. If got an error: "A unique 'jti' value is required",
         we also retry auth in order to use new 'jti' claim.

        Returns True if the status code is 400, the error code is invalid_grant, and the error description indicates
        a problem with the exp or jti claim; False, otherwise.

        :param network_response:
            The response from the Box API that should include a Date header.
        """
        status_code = network_response.status_code
        try:
            json_response = network_response.json()
        except ValueError:
            return False
        error_code = json_response.get('error', '')
        error_description = json_response.get('error_description', '')
        return status_code == 400 and error_code == 'invalid_grant' \
            and ('exp' in error_description or 'jti' in error_description)

    @classmethod
    def _normalize_user_id(cls, user: Any) -> Optional[str]:
        """Get a Box user ID from a selection of supported param types.

        :param user:
            An object representing the user or user ID.

            Currently supported types are `unicode` (which represents the user
            ID) and :class:`User`.

            If `None`, returns `None`.
        :raises:  :exc:`TypeError` for unsupported types.
        """
        if user is None:
            return None
        if isinstance(user, User):
            return user.object_id
        if isinstance(user, str):
            return str(user)
        raise TypeError(f"Got unsupported type {user.__class__.__name__!r} for user.")
