# -*- coding: utf-8 -*-
"""request_token models."""
import datetime
import json
import logging

from django.contrib.auth import login
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.utils.timezone import now as tz_now

from jwt.exceptions import InvalidAudienceError

from .exceptions import MaxUseError
from .settings import JWT_SESSION_TOKEN_EXPIRY
from .utils import to_seconds, encode

logger = logging.getLogger(__name__)


class RequestTokenQuerySet(models.query.QuerySet):

    """Custom QuerySet for RquestToken objects."""

    def create_token(self, scope, **kwargs):
        """Create a new RequestToken."""
        return RequestToken(scope=scope, **kwargs).save()


class RequestToken(models.Model):

    """A link token, targeted for use by a known Django User.

    A RequestToken contains information that can be encoded as a JWT
    (JSON Web Token). It is designed to be used in conjunction with the
    RequestTokenMiddleware (responsible for JWT verification) and the
    @use_request_token decorator (responsible for validating the token
    and setting the request.user correctly).

    Each token must have a 'scope', which is used to tie it to a view function
    that is decorated with the `use_request_token` decorator. The token can
    only be used by functions with matching scopes.

    The token may be set to a specific User, in which case, if the existing
    request is unauthenticated, it will use that user as the `request.user`
    property, allowing access to authenticated views.

    The token may be timebound by the `not_before_time` and `expiration_time`
    properties, which are registered JWT 'claims'.

    The token may be restricted by the number of times it can be used, through
    the `max_use` property, which is incremented each time it's used (NB *not*
    thread-safe).

    The token may also store arbitrary serializable data, which can be used
    by the view function if the request token is valid.

    JWT spec: https://tools.ietf.org/html/rfc7519

    """

    # do not login the user on the request
    LOGIN_MODE_NONE = 'None'
    # login the user, but only for the original request
    LOGIN_MODE_REQUEST = 'Request'
    # login the user fully, but only for single-use short-duration links
    LOGIN_MODE_SESSION = 'Session'

    LOGIN_MODE_CHOICES = (
        (LOGIN_MODE_NONE, 'Do not authenticate'),
        (LOGIN_MODE_REQUEST, 'Authenticate a single request'),
        (LOGIN_MODE_SESSION, 'Authenticate for the entire session'),
    )
    login_mode = models.CharField(
        max_length=10,
        default=LOGIN_MODE_NONE,
        choices=LOGIN_MODE_CHOICES,
        help_text="How should the request be authenticated?"
    )
    user = models.ForeignKey(
        User,
        blank=True, null=True,
        help_text="Intended recipient of the JWT (can be used by anyone if not set)."
    )
    scope = models.CharField(
        max_length=100,
        help_text="Label used to match request to view function in decorator."
    )
    expiration_time = models.DateTimeField(
        blank=True, null=True,
        help_text="Token will expire at this time (raises ExpiredSignatureError)."
    )
    not_before_time = models.DateTimeField(
        blank=True, null=True,
        help_text="Token cannot be used before this time (raises ImmatureSignatureError)."
    )
    data = models.TextField(
        max_length=1000,
        help_text="Custom data add to the token, but not encoded (must be fetched from DB).",
        blank=True,
        default='{}'
    )
    issued_at = models.DateTimeField(
        blank=True, null=True,
        help_text="Time the token was created (set in the initial save)."
    )
    max_uses = models.IntegerField(
        default=1,
        help_text="The maximum number of times the token can be used."
    )
    used_to_date = models.IntegerField(
        default=0,
        help_text="Number of times the token has been used to date (raises MaxUseError)."
    )

    objects = RequestTokenQuerySet.as_manager()

    def __unicode__(self):
        return u"Request token #%s" % (self.id)

    def __str__(self):
        return unicode(self).encode('utf-8')

    def __repr__(self):
        return u"<RequestToken id=%s scope=%s login_mode='%s'>" % (
            self.id, self.scope, self.login_mode)

    @property
    def aud(self):
        """The 'aud' claim, maps to user.id."""
        return self.claims.get('aud')

    @property
    def exp(self):
        """The 'exp' claim, maps to expiration_time."""
        return self.claims.get('exp')

    @property
    def nbf(self):
        """The 'nbf' claim, maps to not_before_time."""
        return self.claims.get('nbf')

    @property
    def iat(self):
        """The 'iat' claim, maps to issued_at."""
        return self.claims.get('iat')

    @property
    def jti(self):
        """The 'jti' claim, maps to id."""
        return self.claims.get('jti')

    @property
    def max(self):
        """The 'max' claim, maps to max_uses."""
        return self.claims.get('max')

    @property
    def sub(self):
        """The 'sub' claim, maps to scope."""
        return self.claims.get('sub')

    @property
    def claims(self):
        """A dict containing all of the DEFAULT_CLAIMS (where values exist)."""
        claims = {
            'max': self.max_uses,
            'sub': self.scope,
            'mod': self.login_mode[:1].lower()
        }
        if self.id is not None:
            claims['jti'] = self.id
        if self.user is not None:
            claims['aud'] = self.user.id
        if self.expiration_time is not None:
            claims['exp'] = to_seconds(self.expiration_time)
        if self.issued_at is not None:
            claims['iat'] = to_seconds(self.issued_at)
        if self.not_before_time is not None:
            claims['nbf'] = to_seconds(self.not_before_time)
        return claims

    @property
    def json(self):
        """Return the data field as a dict."""
        return json.loads(self.data)

    @json.setter
    def json(self, value):
        """Set the underlying data text field from a dict."""
        self.data = json.dumps(value)

    def clean(self):
        """Ensure that login_mode setting is valid."""
        if self.login_mode == RequestToken.LOGIN_MODE_NONE:
            if self.user is not None:
                raise ValidationError(
                    {'user': u"User must be None if login_mode is LOGIN_MODE_NONE."}
                )
        if self.login_mode == RequestToken.LOGIN_MODE_SESSION:
            if self.user is None:
                raise ValidationError(
                    {'user': u"Session token must have a user."}
                )
            if self.max_uses != 1:
                raise ValidationError(
                    {'max_uses': u"Session token must have max_use of 1."}
                )
            if self.expiration_time is None:
                raise ValidationError(
                    {'expiration_time': u"Session token must have an expiration_time."}
                )
        if self.login_mode == RequestToken.LOGIN_MODE_REQUEST:
            if self.user is None:
                raise ValidationError(
                    {'expiration_time': u"Request token must have a user."}
                )

    def save(self, *args, **kwargs):
        if 'update_fields' not in kwargs:
            self.issued_at = self.issued_at or tz_now()
            if self.login_mode == RequestToken.LOGIN_MODE_SESSION:
                self.expiration_time = self.expiration_time or (
                    self.issued_at +
                    datetime.timedelta(minutes=JWT_SESSION_TOKEN_EXPIRY)
                )
        self.clean()
        super(RequestToken, self).save(*args, **kwargs)
        return self

    def jwt(self):
        """Encode the token claims into a JWT."""
        return encode(self.claims)

    def validate_max_uses(self):
        """Check the token max_uses is still valid.

        Raises MaxUseError if invalid.

        """
        if self.used_to_date >= self.max_uses:
            raise MaxUseError(
                u"RequestToken [%s] has exceeded max uses" % self.id
            )

    def _auth_is_anonymous(self, request):
        """Authenticate anonymous requests."""
        assert request.user.is_anonymous(), u"User is authenticated."

        if self.login_mode == RequestToken.LOGIN_MODE_NONE:
            pass

        if self.login_mode == RequestToken.LOGIN_MODE_REQUEST:
            logger.debug(
                u"Setting request.user to %r from token %i.",
                self.user, self.id
            )
            request.user = self.user

        if self.login_mode == RequestToken.LOGIN_MODE_SESSION:
            logger.debug(
                u"Authenticating request.user as %r from token %i.",
                self.user, self.id
            )
            # I _think_ we can get away with this as we are pulling the
            # user out of the DB, and we are explicitly authenticating
            # the user.
            self.user.backend = 'django.contrib.auth.backends.ModelBackend'
            login(request, self.user)

        return request

    def _auth_is_authenticated(self, request):
        """Authenticate requests with existing users."""
        assert request.user.is_authenticated(), u"User is anonymous."

        if self.login_mode == RequestToken.LOGIN_MODE_NONE:
            return request

        if request.user == self.user:
            return request

        raise InvalidAudienceError(
            "RequestToken [%i] audience mismatch: '%s' != '%s'" %
            (self.id, request.user, self.user)
        )

    def authenticate(self, request):
        """Authenticate an HttpRequest with the token user.

        This method encapsulates the request handling - if the token
        has a user assigned, then this will be added to the request.

        """
        if request.user.is_anonymous():
            return self._auth_is_anonymous(request)
        else:
            return self._auth_is_authenticated(request)

    @transaction.atomic
    def log(self, request, response):
        """Record the use of a token.

        This is used by the decorator to log each time someone uses the token,
        or tries to. Used for reporting, diagnostics.

        Args:
            request: the HttpRequest object that used the token, from which the
                user, ip and user-agenct are extracted.
            response: the corresponding HttpResponse object, from which the status
                code is extracted.
            duration: float, the duration of the view function in ms - just because
                you can never measure too many things.

        Returns a RequestTokenUse object.

        """
        assert hasattr(request, 'user'), (
            "Request is missing user property. Please ensure that the Django "
            "authentication middleware is installed."
        )
        meta = request.META
        xff = meta.get('HTTP_X_FORWARDED_FOR', None)
        client_ip = xff or meta.get('REMOTE_ADDR', 'unknown')
        user = None if request.user.is_anonymous() else request.user
        rtu = RequestTokenLog(
            token=self,
            user=user,
            user_agent=meta.get('HTTP_USER_AGENT', 'unknown'),
            client_ip=client_ip,
            status_code=response.status_code
        ).save()
        # NB this could already be out-of-date
        self.used_to_date = models.F('used_to_date') + 1
        self.save()
        return rtu


class RequestTokenLog(models.Model):

    """Used to log the use of a RequestToken."""

    token = models.ForeignKey(
        RequestToken,
        help_text="The RequestToken that was used.",
        db_index=True
    )
    user = models.ForeignKey(
        User,
        blank=True, null=True,
        help_text="The user who made the request (None if anonymous)."
    )
    user_agent = models.TextField(
        blank=True,
        help_text="User-agent of client used to make the request."
    )
    client_ip = models.GenericIPAddressField(
        blank=True,
        null=True,
        unpack_ipv4=True,
        help_text="Client IP of device used to make the request."
    )
    status_code = models.IntegerField(
        blank=True, null=True,
        help_text="Response status code associated with this use of the token."
    )
    timestamp = models.DateTimeField(
        blank=True,
        help_text="Time the request was logged."
    )

    class Meta:
        verbose_name = "Token use"
        verbose_name_plural = "Token use logs"

    def __unicode__(self):
        if self.user is None:
            return u"%s used %s" % (self.token, self.timestamp)
        else:
            return u"%s used by %s at %s" % (self.token, self.user, self.timestamp)

    def __str__(self):
        return unicode(self).encode('utf-8')

    def __repr__(self):
        return u"<RequestTokenLog id=%s token=%s timestamp='%s'>" % (
            self.id, self.token.id, self.timestamp)

    def save(self, *args, **kwargs):
        if 'update_fields' not in kwargs:
            self.timestamp = self.timestamp or tz_now()
        super(RequestTokenLog, self).save(*args, **kwargs)
        return self
