# -*- coding: utf-8 -*-

"""Cloud Token implementation"""

from __future__ import annotations

__all__ = ['NucleusToken']

import datetime
import os
import pickle  # nosec
import typing

import jwt

from anaconda_navigator.config.base import SUBFOLDER, get_home_dir
from .utilities import pickle as pickle_utils

if typing.TYPE_CHECKING:
    import requests


TOKEN_FILE: typing.Final[str] = os.path.join(os.path.join(get_home_dir(), SUBFOLDER), 'anaconda_nucleus.pkl')
TOKEN_LIFETIME: typing.Final[int] = 900
BASIC_TOKEN_TYPE: typing.Final[str] = 'Bearer'


EMPTY: typing.Any = object()


class NucleusToken:  # CloudToken
    """
    Represent tokens and metadata provided and required by Cloud API as a single object.

    :param username: Name of the logged user.
    :param access_token: Token to perform operations on Cloud API.
    :param refresh_token: Token used to refresh expired `access_token`.
    :param token_type: Type of `access_token`.
    :param expires_in: Value in seconds to calculate expiration date of `access_token` if `expiration_date` is not set.
    :param expiration_date: The expiration date of `access_token`.
    """

    __slots__ = ('__username', '__access_token', '__refresh_token', '__token_type', '__expiration_date')

    def __init__(  # pylint: disable=too-many-arguments
            self,
            username: typing.Optional[str] = None,
            access_token: typing.Optional[str] = None,
            refresh_token: typing.Optional[str] = None,
            token_type: str = BASIC_TOKEN_TYPE,
            expires_in: int = TOKEN_LIFETIME,
            expiration_date: typing.Optional[datetime.datetime] = None,
    ) -> None:
        """Initialize new :class:`~NucleusToken` instance."""
        if expiration_date is None:
            expiration_date = datetime.datetime.now() + datetime.timedelta(seconds=expires_in)

        self.__username: typing.Final[typing.Optional[str]] = username
        self.__access_token: typing.Final[typing.Optional[str]] = access_token
        self.__refresh_token: typing.Final[typing.Optional[str]] = refresh_token
        self.__token_type: typing.Final[str] = token_type
        self.__expiration_date: typing.Final[datetime.datetime] = expiration_date

    # def __getstate__(self) -> typing.Mapping[str, typing.Any]:
    #     """Serialize current instance."""
    #     return {
    #         'username': self.__username,
    #         'access_token': self.__access_token,
    #         'refresh_token': self.__refresh_token,
    #         'token_type': self.__token_type,
    #         'expiration_date': self.__expiration_date,
    #     }

    def __setstate__(
            self,
            state: typing.Union[
                None,
                typing.Mapping[str, typing.Any],
                typing.Sequence[typing.Optional[typing.Mapping[str, typing.Any]]],
            ],
    ) -> None:
        """Deserialize state of an instance."""
        if not isinstance(state, typing.Sequence):
            state = (state,)

        item: typing.Optional[typing.Mapping[str, typing.Any]]
        result: typing.Dict[str, typing.Any] = {}
        for item in state:
            if item is None:
                continue
            result.update({
                key.rsplit('__', 1)[-1]: value
                for key, value in item.items()
            })

        self.__init__(**result)  # type: ignore

    # Value properties

    @property
    def username(self) -> typing.Optional[str]:  # noqa: D401
        """Name of the logged user."""
        return self.__username

    @property
    def access_token(self) -> typing.Optional[str]:  # noqa: D401
        """Token to perform operations on Cloud API."""
        return self.__access_token

    @property
    def refresh_token(self) -> typing.Optional[str]:  # noqa: D401
        """Token used to refresh expired `access_token`."""
        return self.__refresh_token

    @property
    def token_type(self) -> str:  # noqa: D401
        """Type of `access_token`."""
        return self.__token_type

    @property
    def expiration_date(self) -> datetime.datetime:  # noqa: D401
        """The expiration date of `access_token`."""
        return self.__expiration_date

    # Calculated properties

    @property
    def expired(self) -> bool:  # noqa: D401
        """Token is past its expiration date."""
        return datetime.datetime.now() > self.expiration_date

    @property
    def valid(self) -> bool:  # noqa: D401
        """Token can be used for authentication."""
        return not (self.access_token is None or self.expired)

    @property
    def authorization(self) -> str:  # noqa: D401
        """Value for `AUTHORIZATION` header."""
        if self.valid:
            return f'{self.token_type} {self.access_token}'
        return ''

    # Modifications

    def replace(  # pylint: disable=too-many-arguments
            self,
            username: typing.Optional[str] = EMPTY,
            access_token: typing.Optional[str] = EMPTY,
            refresh_token: typing.Optional[str] = EMPTY,
            token_type: str = EMPTY,
            expires_in: int = EMPTY,
            expiration_date: typing.Optional[datetime.datetime] = EMPTY,
    ) -> NucleusToken:
        """
        Create a copy of current :class:`~NucleusToken` instance with optional modifications.

        :param username: Custom `username` value.
        :param access_token: Custom `access_token` value.
        :param refresh_token: Custom `refresh_token` value.
        :param token_type: Custom `token_type` value.
        :param expires_in: Custom `expires_in` value.
        :param expiration_date: Custom `expiration_date` value.
        :return: Copy of current instance with requested modifications.
        """
        if username is EMPTY:
            username = self.__username
        if access_token is EMPTY:
            access_token = self.__access_token
        if refresh_token is EMPTY:
            refresh_token = self.__refresh_token
        if token_type is EMPTY:
            token_type = self.__token_type
        if expires_in is EMPTY:
            expires_in = TOKEN_LIFETIME
            if expiration_date is EMPTY:
                expiration_date = self.__expiration_date
        elif expiration_date is EMPTY:
            expiration_date = None

        return NucleusToken(
            username=username,
            access_token=access_token,
            refresh_token=refresh_token,
            token_type=token_type,
            expires_in=expires_in,
            expiration_date=expiration_date,
        )

    def from_response(self, response: 'requests.Response', username: typing.Optional[str] = None) -> NucleusToken:
        """
        Import details from authentication HTTP response.

        :param response: Response to get details from.
        :param username: Optional username, which was used for login operation.
        :return: New token with imported details.
        """
        body: typing.Mapping[str, typing.Any] = response.json()
        changes: typing.Dict[str, typing.Any] = {
            'access_token': body['access_token'],
            'token_type': BASIC_TOKEN_TYPE,
        }

        if username is not None:
            changes['username'] = username

        refresh_token: typing.Optional[str] = response.cookies.get('refresh_token', None)
        if refresh_token:
            changes['refresh_token'] = refresh_token

        try:
            access_data: typing.Mapping[str, typing.Any] = jwt.decode(
                body['access_token'],
                options={'verify_signature': False},
            )
            changes['expiration_date'] = datetime.datetime.fromtimestamp(access_data['exp'])
        except (jwt.exceptions.PyJWTError, KeyError, TypeError):
            changes['expires_in'] = TOKEN_LIFETIME

        return self.replace(**changes)

    # Serialization

    @staticmethod
    def from_file(path: str = TOKEN_FILE) -> typing.Optional[NucleusToken]:
        """
        Update token attributes from the pickle file.

        :returns: :class:`~NucleusToken` instance, if requested file exists. :code:`None` otherwise.
        """
        try:
            stream: typing.BinaryIO
            with open(path, 'rb') as stream:
                result: typing.Any = pickle_utils.MigrationUnpickler(stream).load()
                if not isinstance(result, NucleusToken):
                    return None
                return result
        except (FileNotFoundError, pickle.PickleError):
            return None

    def save(self, path: str = TOKEN_FILE) -> None:
        """
        Dump token attributes to the pickle file.

        :param path: Path to file to save token to.
        """
        stream: typing.BinaryIO
        with open(path, 'wb') as stream:
            pickle.dump(self, stream)
