import ast
import logging
import os
import time
import webbrowser
from urllib.parse import parse_qsl, urlencode, urlparse

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from .cache import CacheHandler

log = logging.getLogger(__name__)


def requests_retry_session(
    retries=3,
    backoff_factor=1,
    status_forcelist=(405, 500, 502, 504),
    session=None,
    allowed_methods=frozenset(["GET", "POST", "PUT", "DELETE"]),
):
    """
    Method for http retries
    """
    session = session or requests.session()
    retry = Retry(
        total=retries,
        read=retries,
        connect=retries,
        backoff_factor=backoff_factor,
        status_forcelist=status_forcelist,
        allowed_methods=allowed_methods,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session


class OAuth2:
    """
    Implements the Authorization flow for TickTick's Open API
    """

    OAUTH_AUTHORIZE_URL = "https://ticktick.com/oauth/authorize"
    OBTAIN_TOKEN_URL = "https://ticktick.com/oauth/token"

    def __init__(
        self,
        client_id: str,
        client_secret: str,
        redirect_uri: str,
        scope: str = "tasks:write tasks:read",  # only available options right now
        state: str = None,
        session=None,
        env_key: str = None,
        cache_path: str = ".token-oauth",
        check_cache: bool = True,
    ):
        """
        Initialize the object.

        Arguments:
            client_id: Client ID string
            client_secret: Client secret string
            redirect_uri: Redirect uri
            scope: Scope for the permissions. Current options are only the default.
            state (str): State parameter
            session (requests session): Requests session
            env_key: The environment variable name where the access token dictionary is stored as a string literal.
            cache_path: The desired path of the file where the access token information will be stored.
            check_cache: Whether to check the cache file for the access token information

        !!! examples

            === "Standard Method"

                This way would instantiate the steps to get a new access token, or just retrieve the cached one.

                ```python
                oauth = OAuth2(client_id=cliend_id,
                               client_secret=client_secret,
                               redirect_uri=redirect_uri)
                ```

            === "Check Environment Method"

                If you are in a situation where you don't want to keep the cached token file, you can save the
                access token dictionary as a string literal in your environment, and pass the name of the variable to
                prevent having to request a new access token.

                ``` python
                auth_client = OAuth2(client_id=client_id,
                                client_secret=client_secret,
                                redirect_uri=redirect_uri,
                                env_key='ACCESS_TOKEN_DICT')
                ```

                Where in the environment you have declared `ACCESS_TOKEN_DICT` to be
                the string literal of the token dictionary:

                ```
                '{'access_token': '628ff081-5331-4a37-8ddk-021974c9f43g',
                'token_type': 'bearer', 'expires_in': 14772375,
                'scope': 'tasks:read tasks:write',
                'expire_time': 1637192935,
                'readable_expire_time':
                'Wed Nov 17 15:48:55 2021'}'
                ```
        """
        # If a proper session is passed then we will just use the existing session
        self.session = session or requests_retry_session()

        # Set the client_id
        self._client_id = client_id

        # Set the client_secret
        self._client_secret = client_secret

        # Set the redirect_uri
        self._redirect_uri = redirect_uri

        # Set the scope
        self._scope = scope

        # Set the state
        self._state = state

        # Initialize code parameter
        self._code = None

        # Set the cache handler
        self.cache = CacheHandler(cache_path)

        # Set the access token
        self.access_token_info = None

        # get access token
        self.get_access_token(check_cache=check_cache, check_env=env_key)

    def _get_auth_url(self):
        """
        Returns the url for authentication
        """
        payload = {
            "client_id": self._client_id,
            "scope": self._scope,
            "response_type": "code",
            "redirect_uri": self._redirect_uri,
            "state": self._state,
        }

        parameters = urlencode(payload)

        return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, parameters)

    def _open_auth_url_in_browser(self):
        """
        Opens the authorization url in the browser

        Docs link: https://developer.ticktick.com/api#/openapi?id=first-step
        """
        log.info(
            "Providing authentication requires interacting with the web browser. Once you accept the "
            "authorization you will be redirected to the redirect url that you provided with extra parameters "
            "provided in the url. Paste the url that you "
            "were redirected to into the console"
        )
        url = self._get_auth_url()
        webbrowser.open(url)

    def _get_redirected_url(self):
        """
        Prompts the user for the redirected url to parse the token and state
        """
        prompt = "Enter the URL you were redirected to: "
        url = self._get_user_input(prompt)

        # get the parsed parameters from the url
        self._code, self._state = self._get_auth_response_parameters(url)

    @staticmethod
    def _get_user_input(prompt: str = ""):
        """
        Prompts the user for input from the console based on the prompt
        """
        return input(prompt)

    @staticmethod
    def _get_auth_response_parameters(url):
        """
        Gets the code and state members contained in the redirected url.

        Docs link: https://developer.ticktick.com/api#/openapi?id=second-step
        :param url:
        :return:
        """
        # isolates "code" and "state" parameters in a string
        parsed = urlparse(url).query

        # creates a dictionary containing the "code" and "state" parameters returned from the url
        isolated = dict(parse_qsl(parsed))

        # return the parameters
        return isolated["code"], isolated["state"]

    def _request_access_token(self):
        """
        Makes the POST request to get the token and returns the token info dictionary

        Docs link: https://developer.ticktick.com/api#/openapi?id=third-step
        :return:
        """

        # Get the manual authentication from the user, and prompt for the redirected url
        self._open_auth_url_in_browser()
        self._get_redirected_url()

        # create the payload
        payload = {
            "client_id": self._client_id,
            "client_secret": self._client_secret,
            "code": self._code,
            "grant_type": "authorization_code",  # currently only option
            "scope": self._scope,
            "redirect_uri": self._redirect_uri,
        }

        # make the request
        token_info = self._post(self.OBTAIN_TOKEN_URL, params=payload)

        token_info = self._set_expire_time(token_info)
        self.cache.write_token_to_cache(token_info)

        return token_info

    def _post(self, url, **kwargs):
        """
        Sends an http post request with the specified url and keyword arguments.

        Arguments:
            url (str): Url to send the request.
            **kwargs: Arguments to send with the request.

        Returns:
            dict: The json parsed response if possible or just a string of the response text if not.

        Raises:
            RunTimeError: If the request could not be completed.
        """

        response = self.session.post(url, **kwargs)
        if response.status_code != 200:
            raise RuntimeError("POST request could not be completed")

        try:
            return response.json()
        except ValueError:
            return response.text

    def get_access_token(self, check_cache: bool = True, check_env: str = None):
        """
        Retrieves the authorization token from cache or makes a new request for it.


        !!! note
            This method does not need to be called explicitly.

        Arguments:
            check_cache (bool): Boolean on whether to check if the access token is in a cache file.
            check_env (str): The environment variable name where the token dictionary is saved as a string literal.

        Priority order for getting the access token:

        1) From an already set class member in the current running instance

        2) From an environment variable where the token dictionary is in a string literal form,
        and the name of the environment variable name is the value passed to the "check_env" parameter

        3) From a cache file that contains the access token dictionary (normal case)

        4) From a new token request (which will create a new cache file that contains the access
        token dictionary) (initial case if never setup)
        """
        # check the local state for if the access token exists
        if self.access_token_info is not None:
            token_info = self.validate_token(self.access_token_info)
            if token_info is not None:
                self.access_token_info = token_info
                return token_info["access_token"]

        # check if in the environment the access token is set
        if check_env is not None:
            # get the access token string
            token_dict_string = os.getenv(check_env)
            try:
                converted_token_dict = ast.literal_eval(token_dict_string)
            except:
                raise ValueError(
                    "Access token in the environment must be a python dictionary contained"
                    " in a string literal"
                )
            token_info = self.validate_token(converted_token_dict)
            if token_info is not None:
                self.cache.write_token_to_cache(token_info)
                self.access_token_info = token_info
                return token_info["access_token"]

        # check if the cache file exists with the token
        if check_cache:
            token_info = self.validate_token(self.cache.get_cached_token())
            # validate token will always return a valid token
            if token_info is not None:
                self.access_token_info = token_info
                return token_info["access_token"]

        # access token is not stored anywhere, request a new token
        token_info = self._request_access_token()
        self.access_token_info = token_info
        return token_info["access_token"]

    @staticmethod
    def _set_expire_time(token_dict):
        """
        Adds two members to the access_token_info dictionary containing the expire time of the token

        self._access_token_info["expire_time"]: The integer representation of the token expiration
        self._access_token_info["readable_expire_time"]: The readable date in the form like 'Wed Nov 17 15:48:57 2021'
        :return:
        """
        token_dict["expire_time"] = int(time.time()) + token_dict["expires_in"]
        token_dict["readable_expire_time"] = time.asctime(
            time.localtime(time.time() + token_dict["expires_in"])
        )
        return token_dict

    @staticmethod
    def is_token_expired(token_dict):
        """
        Returns a boolean for if the access token is expired

        Arguments:
            token_dict (dict): Access token dictionary

        Returns:
            bool: Whether the access token is expired
        """
        current_time = int(time.time())
        return token_dict["expire_time"] - current_time < 60

    def validate_token(self, token_dict):
        """
        Validates whether the access token is valid

        Arguments:
            token_dict (dict): Access token dictionary

        Returns:
            None or dict: None if the token_dict is not valid, else token_dict
        """
        # if the token info dictionary does not exist then bounce
        if token_dict is None:
            return None

        # check if the token is expired
        if self.is_token_expired(token_dict):
            # make a new request for a valid token since there is currently no refresh token
            new_token_dict = self._request_access_token()
            return new_token_dict

        return token_dict  # original token_dict is valid
