# -----------------------------------------------------------------------------
# Copyright (c) 2014--, The Qiita Development Team.
#
# Distributed under the terms of the BSD 3-clause License.
#
# The full license is in the file LICENSE, distributed with this software.
# -----------------------------------------------------------------------------

from base64 import urlsafe_b64decode
from string import ascii_letters, digits
import datetime
from random import SystemRandom
import functools
from traceback import format_exception

from tornado.web import RequestHandler
from qiita_core.qiita_settings import r_client

from qiita_core.exceptions import (IncorrectPasswordError, IncorrectEmailError,
                                   UnverifiedEmailError)
import qiita_db as qdb


def _oauth_error(handler, error_msg, error):
    """Set expected status and error formatting for Oauth2 style error

   Parameters
   ----------
   error_msg : str
        Human parsable error message
    error : str
        Oauth2 controlled vocab error

    Returns
    -------
    Writes out Oauth2 formatted error JSON of
        {error: error,
         error_description: error_msg}

    Notes
    -----
    Expects handler to be a tornado RequestHandler or subclass
    """
    handler.set_status(400)
    handler.write({'error': error,
                   'error_description': error_msg})
    handler.finish()


def authenticate_oauth(f):
    """Decorate methods to require valid Oauth2 Authorization header[1]

    If a valid header is given, the handoff is done and the page is rendered.
    If an invalid header is given, a 400 error code is returned and the json
    error message is automatically sent.

    Returns
    -------
    Sends oauth2 formatted error JSON if authorizaton fails

    Notes
    -----
    Expects handler to be a tornado RequestHandler or subclass

    References
    ----------
    [1] The OAuth 2.0 Authorization Framework.
    http://tools.ietf.org/html/rfc6749
    """
    @functools.wraps(f)
    def wrapper(handler, *args, **kwargs):
        header = handler.request.headers.get('Authorization', None)
        if header is None:
            _oauth_error(handler, 'Oauth2 error: invalid access token',
                         'invalid_request')
            return
        token_info = header.split()
        # Based on RFC6750 if reply is not 2 elements in the format of:
        # ['Bearer', token] we assume a wrong reply
        if len(token_info) != 2 or token_info[0] != 'Bearer':
            _oauth_error(handler, 'Oauth2 error: invalid access token',
                         'invalid_grant')
            return

        token = token_info[1]
        db_token = r_client.hgetall(token)
        if not db_token:
            # token has timed out or never existed
            _oauth_error(handler, 'Oauth2 error: token has timed out',
                         'invalid_grant')
            return
        # Check daily rate limit for key if password style key
        if db_token[b'grant_type'] == b'password':
            limit_key = '%s_%s_daily_limit' % (
                db_token[b'client_id'].decode('ascii'),
                db_token[b'user'].decode('ascii'))
            limiter = r_client.get(limit_key)
            if limiter is None:
                # Set limit to 5,000 requests per day
                r_client.setex(limit_key, 86400, 5000)
            else:
                r_client.decr(limit_key)
                if int(r_client.get(limit_key)) <= 0:
                    _oauth_error(
                        handler, 'Oauth2 error: daily request limit reached',
                        'invalid_grant')
                    return

        return f(handler, *args, **kwargs)
    return wrapper


class OauthBaseHandler(RequestHandler):
    def write_error(self, status_code, **kwargs):
        """Overriding the default write error in tornado RequestHandler

        Instead of writing all errors to stderr, this writes them to the logger
        tables.

        Parameters
        ----------
        status_code : int
            HTML status code of the error
        **kwargs : dict
            Other parameters describing the error

        Notes
        -----
        This function is automatically called by the tornado package on errors,
        and should never be called directly.
        """
        exc_info = kwargs['exc_info']

        # We don't need to log 403, 404 or 405 failures in the logging table
        if status_code not in {403, 404, 405}:
            # log the error
            error_lines = ['%s\n' % line
                           for line in format_exception(*exc_info)]
            trace_info = ''.join(error_lines)
            req_dict = self.request.__dict__
            # must trim body to 1024 chars to prevent huge error messages
            req_dict['body'] = req_dict.get('body', '')[:1024]
            request_info = ''.join(['<strong>%s</strong>: %s\n' %
                                   (k, req_dict[k]) for k in
                                    req_dict.keys() if k != 'files'])
            error = exc_info[1]
            qdb.logger.LogEntry.create(
                'Runtime',
                'ERROR:\n%s\nTRACE:\n%s\nHTTP INFO:\n%s\n' %
                (error, trace_info, request_info))

        message = str(exc_info[1])
        if hasattr(exc_info[1], 'log_message'):
            message = exc_info[1].log_message

        self.finish(message)

    def head(self):
        """Adds proper response for head requests"""
        self.finish()


class TokenAuthHandler(OauthBaseHandler):
    def generate_access_token(self, length=55):
        """Creates the random alphanumeric token

        Parameters
        ----------
        length : int, optional
            Length of token to generate. Default 55 characters.
            Can be a max of 255 characters, which is a hard HTTP transfer limit

        Returns
        -------
        str
            Random alphanumeric string of passed length

        Raises
        ------
        ValueError
            length is not between 1 and 255, inclusive
        """
        if not 0 < length < 256:
            raise ValueError("Invalid token length: %d" % length)

        pool = ascii_letters + digits
        return ''.join((SystemRandom().choice(pool) for _ in range(length)))

    def set_token(self, client_id, grant_type, user=None, timeout=3600):
        """Create access token for the client on redis and send json response

        Parameters
        ----------
        client_id : str
            Client that requested the token
        grant_type : str
            Type of key being requested
        user : str, optional
            If password grant type requested, the user requesting the key.
        timeout : int, optional
            The timeout, in seconds, for the token. Default 3600

        Returns
        -------
        Writes token information JSON in the form expected by RFC6750:
        {'access_token': token,
         'token_type': 'Bearer',
         'expires_in': timeout}

         access_token: the actual token to use
         token_type: 'Bearer', which is the expected token type for Oauth2
         expires_in: time to token expiration, in seconds.
        """
        token = self.generate_access_token()

        token_info = {
            'timestamp': datetime.datetime.now().strftime('%m-%d-%y %H:%M:%S'),
            'client_id': client_id,
            'grant_type': grant_type
        }
        if user:
            token_info['user'] = user

        r_client.hmset(token, token_info)
        r_client.expire(token, timeout)
        if grant_type == 'password':
            # Check if client has access limit key, and if not, create it
            limit_key = '%s_%s_daily_limit' % (client_id, user)
            limiter = r_client.get(limit_key)
            if limiter is None:
                # Set limit to 5,000 requests per day
                r_client.setex(limit_key, 86400, 5000)

        self.write({'access_token': token,
                    'token_type': 'Bearer',
                    'expires_in': timeout})
        self.finish()

    def validate_client(self, client_id, client_secret):
        """Make sure client exists, then set the token and send it

        Parameters
        ----------
        client_id : str
            The client making the request
        client_secret : str
            The secret key for the client

        Returns
        -------
        Writes out Oauth2 formatted error JSON if error occured
            {error: error,
             error_description: error_msg}

        error: RFC6750 controlled vocabulary of errors
         error_description: Human readable explanation of error
        """
        with qdb.sql_connection.TRN:
            sql = """SELECT EXISTS(
                        SELECT *
                        FROM qiita.oauth_identifiers
                        WHERE client_id = %s AND client_secret = %s)"""
            qdb.sql_connection.TRN.add(sql, [client_id, client_secret])
            if qdb.sql_connection.TRN.execute_fetchlast():
                self.set_token(client_id, 'client')
            else:
                _oauth_error(self, 'Oauth2 error: invalid client information',
                             'invalid_client')

    def validate_resource_owner(self, username, password, client_id):
        """Make sure user and client exist, then set the token and send it

        Parameters
        ----------
        username : str
            The username to validate
        password : str
            The password for the username
        client_id : str
            The client making the request

        Returns
        -------
        Writes out Oauth2 formatted error JSON if error occured
            {error: error,
             error_description: error_msg}

        error: RFC6750 controlled vocabulary of errors
         error_description: Human readable explanation of error
        """
        try:
            qdb.user.User.login(username, password)
        except (IncorrectEmailError, IncorrectPasswordError,
                UnverifiedEmailError):
            _oauth_error(self, 'Oauth2 error: invalid user information',
                         'invalid_client')
            return

        with qdb.sql_connection.TRN:
            sql = """SELECT EXISTS(
                        SELECT *
                        FROM qiita.oauth_identifiers
                        WHERE client_id = %s AND client_secret IS NULL)"""
            qdb.sql_connection.TRN.add(sql, [client_id])
            if qdb.sql_connection.TRN.execute_fetchlast():
                self.set_token(client_id, 'password', user=username)
            else:
                _oauth_error(self, 'Oauth2 error: invalid client information',
                             'invalid_client')

    def post(self):
        """ Authenticate given information as per RFC6750

        Parameters
        ----------
        grant_type : {'client', 'password'}
            What type of token to grant
        client_id : str
            Client requesting the token

        One of the following, if password grant type:
        HTTP Header in the form
            Authorization: Bearer [base64encode of username:password]

        OR

        username : str
            Username to authenticate
        password : str
            Password for the username

        If client grant type:
        client_secret : str
            client authentication secret

        Returns
        -------
        Writes token information JSON in the form expected by RFC6750:
        {'access_token': token,
         'token_type': 'Bearer',
         'expires_in': timeout}

         access_token: the actual token to use
         token_type: 'Bearer', which is the expected token type for Oauth2
         expires_in: time to token expiration, in seconds.

         or an error message in the form
         {error: error,
         error_description: error_msg}

         error: RFC6750 controlled vocabulary of errors
         error_description: Human readable explanation of error
         """
        # first check for header version of sending auth, meaning client ID
        header = self.request.headers.get('Authorization', None)
        if header is not None:
            header_info = header.split()
            # Based on RFC6750 if reply is not 2 elements in the format of:
            # ['Basic', base64 encoded username:password] we assume the header
            # is invalid
            if len(header_info) != 2 or header_info[0] != 'Basic':
                # Invalid Authorization header type for this page
                _oauth_error(self, 'Oauth2 error: invalid token type',
                             'invalid_request')
                return

            # Get client information from the header and validate it
            grant_type = self.get_argument('grant_type', None)
            if grant_type != 'client':
                _oauth_error(self, 'Oauth2 error: invalid grant_type',
                             'invalid_request')
                return
            try:
                client_id, client_secret = urlsafe_b64decode(
                    header_info[1]).decode('ascii').split(':')
            except ValueError:
                # Split didn't work, so invalid information sent
                _oauth_error(self, 'Oauth2 error: invalid base64 encoded info',
                             'invalid_request')
                return
            self.validate_client(client_id, client_secret)
            return

        # Otherwise, do either password or client based authentication
        client_id = self.get_argument('client_id', None)
        grant_type = self.get_argument('grant_type', None)
        if grant_type == 'password':
            username = self.get_argument('username', None)
            password = self.get_argument('password', None)
            if not all([username, password, client_id]):
                _oauth_error(self, 'Oauth2 error: missing user information',
                             'invalid_request')
            else:
                self.validate_resource_owner(username, password, client_id)

        elif grant_type == 'client':
            client_secret = self.get_argument('client_secret', None)
            if not all([client_id, client_secret]):
                _oauth_error(self, 'Oauth2 error: missing client information',
                             'invalid_request')
                return
            self.validate_client(client_id, client_secret)
        else:
            _oauth_error(self, 'Oauth2 error: invalid grant_type',
                         'unsupported_grant_type')
            return
