from base64 import b64encode, b64decode
from hashlib import sha1
import hmac
from random import choice
import string

from six.moves.urllib.parse import urlencode
from six.moves.urllib.request import urlopen
from six.moves import xrange


class YubiClient10(object):
    """
    Client for the Yubico validation service, version 1.0.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV10

    :param int api_id: Your API id.
    :param bytes api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/verify'``.
    """
    _NONCE_CHARS = string.ascii_letters + string.digits

    def __init__(self, api_id=1, api_key=None, ssl=False):
        self.api_id = api_id
        self.api_key = api_key
        self.ssl = ssl

    def verify(self, token):
        """
        Verify a single Yubikey OTP against the validation service.

        :param str token: A modhex-encoded YubiKey OTP, as generated by a YubiKey
            device.

        :returns: A response from the validation service.
        :rtype: :class:`YubiResponse`
        """
        nonce = self.nonce()

        url = self.url(token, nonce)
        stream = urlopen(url)
        response = YubiResponse(stream.read().decode('utf-8'), self.api_key, token, nonce)
        stream.close()

        return response

    def url(self, token, nonce=None):
        """
        Generates the validation URL without sending a request.

        :param str token: A modhex-encoded YubiKey OTP, as generated by a
            YubiKey.
        :param str nonce: A nonce string, or ``None`` to generate a random one.

        :returns: The URL that we would use to validate the token.
        :rtype: str
        """
        if nonce is None:
            nonce = self.nonce()

        return '{0}?{1}'.format(self.base_url, self.param_string(token, nonce))

    _base_url = None

    @property
    def base_url(self):
        if self._base_url is None:
            self._base_url = self.default_base_url()

        return self._base_url

    @base_url.setter
    def base_url(self, url):
        self._base_url = url

    @base_url.deleter
    def base_url(self):
        delattr(self, '_base_url')

    def default_base_url(self):
        if self.ssl:
            return 'https://api.yubico.com/wsapi/verify'
        else:
            return 'http://api.yubico.com/wsapi/verify'

    def nonce(self):
        return ''.join(choice(self._NONCE_CHARS) for i in xrange(32))

    def param_string(self, token, nonce):
        params = self.params(token, nonce)

        if self.api_key is not None:
            signature = param_signature(params, self.api_key)
            params.append(('h', b64encode(signature)))

        return urlencode(params)

    def params(self, token, nonce):
        return [
            ('id', self.api_id),
            ('otp', token),
        ]


class YubiClient11(YubiClient10):
    """
    Client for the Yubico validation service, version 1.1.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV11

    :param int api_id: Your API id.
    :param bytes api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.
    :param bool timestamp: ``True`` if we want the server to include timestamp
        and counter information in the response.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/verify'``.
    """
    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False):
        super(YubiClient11, self).__init__(api_id, api_key, ssl)

        self.timestamp = timestamp

    def params(self, token, nonce):
        params = super(YubiClient11, self).params(token, nonce)

        if self.timestamp:
            params.append(('timestamp', '1'))

        return params


class YubiClient20(YubiClient11):
    """
    Client for the Yubico validation service, version 2.0.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV20

    :param int api_id: Your API id.
    :param bytes api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.
    :param bool timestamp: ``True`` if we want the server to include timestamp
        and counter information in the response.
    :param sl: See protocol spec.
    :param timeout: See protocol spec.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/2.0/verify'``.
    """
    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False, sl=None, timeout=None):
        super(YubiClient20, self).__init__(api_id, api_key, ssl, timestamp)

        self.sl = sl
        self.timeout = timeout

    def default_base_url(self):
        if self.ssl:
            return 'https://api.yubico.com/wsapi/2.0/verify'
        else:
            return 'http://api.yubico.com/wsapi/2.0/verify'

    def params(self, token, nonce):
        params = super(YubiClient20, self).params(token, nonce)

        params.append(('nonce', nonce))

        if self.sl is not None:
            params.append(('sl', self.sl))

        if self.timeout is not None:
            params.append(('timeout', self.timeout))

        return params


class YubiResponse(object):
    """
    A response from the Yubico validation service.

    .. attribute:: fields

        A dictionary of the response fields (excluding 'h').
    """
    def __init__(self, raw, api_key, token, nonce):
        self.raw = raw
        self.api_key = api_key
        self.token = token
        self.nonce = nonce

        self.fields = {}
        self.signature = None

        self._parse_response()

    def _parse_response(self):
        self.fields = dict(tuple(line.split('=', 1)) for line in self.raw.splitlines() if '=' in line)

        if 'h' in self.fields:
            self.signature = b64decode(self.fields['h'].encode())
            del self.fields['h']

    def is_ok(self):
        """
        Returns true if all validation checks pass and the status is 'OK'.

        :rtype: bool
        """
        return self.is_valid() and (self.fields.get('status') == 'OK')

    def status(self):
        """
        If the response is valid, this returns the value of the status field.
        Otherwise, it returns the special status ``'BAD_RESPONSE'``
        """
        status = self.fields.get('status')

        if status == 'BAD_SIGNATURE' or self.is_valid(strict=False):
            return status
        else:
            return 'BAD_RESPONSE'

    def is_valid(self, strict=True):
        """
        Performs all validity checks (signature, token, and nonce).

        :param bool strict: If ``True``, all validity checks must pass
            unambiguously. Otherwise, this only requires that no validity check
            fails.
        :returns: ``True`` if none of the validity checks fail.
        :rtype: bool
        """
        results = [
            self.is_signature_valid(),
            self.is_token_valid(),
            self.is_nonce_valid(),
        ]

        if strict:
            is_valid = all(results)
        else:
            is_valid = False not in results

        return is_valid

    def is_signature_valid(self):
        """
        Validates the response signature.

        :returns: ``True`` if the signature is valid or if we did not sign the
            request. ``False`` if the signature is invalid.
        :rtype: bool
        """
        if self.api_key is not None:
            signature = param_signature(self.fields.items(), self.api_key)
            is_valid = (signature == self.signature)
        else:
            is_valid = True

        return is_valid

    def is_token_valid(self):
        """
        Validates the otp token sent in the response.

        :returns: ``True`` if the token in the response is the same as the one
            in the request; ``False`` if not; ``None`` if the response does not
            contain a token.
        :rtype: bool for a positive result or ``None`` for an ambiguous result.
        """
        if 'otp' in self.fields:
            is_valid = (self.fields['otp'] == self.token)
        else:
            is_valid = None

        return is_valid

    def is_nonce_valid(self):
        """
        Validates the nonce value sent in the response.

        :returns: ``True`` if the nonce in the response matches the one we sent
            (or didn't send). ``False`` if the two do not match. ``None`` if we
            sent a nonce and did not receive one in the response: this is often
            true of error responses.
        :rtype: bool for a positive result or ``None`` for an ambiguous result.
        """
        reply = self.fields.get('nonce')

        if (self.nonce is not None) and (reply is None):
            is_valid = None
        else:
            is_valid = (reply == self.nonce)

        return is_valid

    @property
    def public_id(self):
        """
        Returns the public id of the response token as a modhex string.

        :rtype: str or ``None``.
        """
        try:
            public_id = self.fields['otp'][:-32]
        except KeyError:
            public_id = None

        return public_id


def param_signature(params, api_key):
    """
    Returns the signature over a list of Yubico validation service parameters.
    Note that the signature algorithm packs the paramters into a form similar
    to URL parameters, but without any escaping.

    :param params: An association list of parameters, such as you would give to
        urllib.urlencode.
    :type params: list of 2-tuples

    :param bytes api_key: The Yubico API key (raw, not base64-encoded).

    :returns: The parameter signature (raw, not base64-encoded).
    :rtype: bytes
    """
    param_string = '&'.join('{0}={1}'.format(k, v) for k, v in sorted(params))
    signature = hmac.new(api_key, param_string.encode('utf-8'), sha1).digest()

    return signature
