"""
Django DDP authentication.

Matches Meteor 1.1 Accounts package: https://www.meteor.com/accounts

See http://docs.meteor.com/#/full/accounts_api for details of each method.
"""
from binascii import Error
import collections
import hashlib

from ejson import loads, dumps

from django.conf import settings
from django.contrib import auth
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth.signals import user_login_failed
from django.dispatch import Signal
from django.utils import timezone

from dddp import THREAD_LOCAL as this, ADDED, REMOVED
from dddp.models import get_meteor_id, get_object, Subscription
from dddp.api import API, APIMixin, api_endpoint, Collection, Publication
from dddp.websocket import MeteorError


create_user = Signal(providing_args=['request', 'params'])
password_changed = Signal(providing_args=['request', 'user'])
forgot_password = Signal(providing_args=['request', 'user', 'token', 'expiry'])
password_reset = Signal(providing_args=['request', 'user'])


class HashPurpose(object):

    """HashPurpose enumeration."""

    PASSWORD_RESET = 'password_reset'
    RESUME_LOGIN = 'resume_login'
    CHANGE_EMAIL = 'change_email'
    CREATE_USER = 'create_user'


class Users(Collection):

    """Mimic `users` collection of Meteor's `accounts-password` package."""

    name = 'users'
    api_path_prefix = '/users/'
    model = auth.get_user_model()

    user_rel = [
        'pk',
    ]

    def serialize(self, obj, *args, **kwargs):
        """Serialize user as per Meteor accounts serialization."""
        # use default serialization, then modify to suit our needs.
        data = super(Users, self).serialize(obj, *args, **kwargs)

        # everything that isn't handled explicitly ends up in `profile`
        profile = data.pop('fields')
        profile.setdefault('name', obj.get_full_name())
        fields = data['fields'] = {
            'username': obj.get_username(),
            'emails': [],
            'profile': profile,
            'permissions': sorted(self.model.get_all_permissions(obj)),
        }

        # clear out sensitive data
        for sensitive in [
                'password',
                'user_permissions_ids',
                'is_active',
                'is_staff',
                'is_superuser',
                'groups_ids',
        ]:
            profile.pop(sensitive, None)

        # createdAt (default is django.contrib.auth.models.User.date_joined)
        try:
            fields['createdAt'] = profile.pop('date_joined')
        except KeyError:
            date_joined = getattr(
                obj, 'get_date_joined',
                lambda: getattr(obj, 'date_joined', None)
            )()
            if date_joined:
                fields['createdAt'] = date_joined

        # email (default is django.contrib.auth.models.User.email)
        try:
            email = profile.pop('email')
        except KeyError:
            email = getattr(
                obj, 'get_email',
                lambda: getattr(obj, 'email', None)
            )()
        if email:
            fields['emails'].append({'address': email, 'verified': True})

        return data

    @staticmethod
    def deserialize_profile(user, profile, key_prefix='', pop=False):
        """De-serialize user profile fields into concrete model fields."""
        result = {}
        if pop:
            getter = profile.pop
        else:
            getter = profile.get

        def prefixed(name):
            """Return name prefixed by `key_prefix`."""
            return '%s%s' % (key_prefix, name)

        for key in profile.keys():
            val = getter(key)
            if key == prefixed('name'):
                result['full_name'] = val
            else:
                raise ValueError('Bad profile key: %r' % key)
        return result

    @api_endpoint
    def update(self, selector, update, options=None):
        """Update user data."""
        user = get_object(
            self.model, selector['_id'],
            pk=this.request.user.pk,
        )
        profile_update = self.deserialize_profile(
            user, update['$set'], key_prefix='profile.', pop=True,
        )
        if len(update['$set']) != 0:
            raise MeteorError(400, 'Invalid update fields: %r')

        for key, val in profile_update.items():
            setattr(user, key, val)
        user.save()


class LoginPublication(Publication):

    """Meteor Accounts emulation."""

    name = 'meteor.loginServiceConfiguration'

    queries = [
        (Users.model.objects.all(), 'users'),
    ]


class Auth(APIMixin):

    """Meteor Passwords emulation."""

    api_path_prefix = ''  # auth endpoints don't have a common prefix
    user_model = auth.get_user_model()

    def update_subs(self, new_user_id):
        """Update subs to send added/removed for collections with user_rel."""
        for sub in Subscription.objects.filter(connection=this.ws.connection):
            params = loads(sub.params_ejson)
            pub = API.get_pub_by_name(sub.publication)

            # calculate the querysets prior to update
            pre = collections.OrderedDict([
                (col, qs) for col, qs
                in API.sub_unique_objects(sub, params, pub)
            ])

            # save the subscription with the updated user_id
            sub.user_id = new_user_id
            sub.save()

            # calculate the querysets after the update
            post = collections.OrderedDict([
                (col, qs) for col, qs
                in API.sub_unique_objects(sub, params, pub)
            ])

            # first pass, send `added` for objs unique to `post`
            for col_post, qs in post.items():
                try:
                    qs_pre = pre[col_post]
                    qs = qs.exclude(pk__in=qs_pre.order_by().values('pk'))
                except KeyError:
                    # collection not included pre-auth, everything is added.
                    pass
                for obj in qs:
                    this.ws.send(col.obj_change_as_msg(obj, ADDED))

            # second pass, send `removed` for objs unique to `pre`
            for col_pre, qs in pre.items():
                try:
                    qs_post = post[col_pre]
                    qs = qs.exclude(pk__in=qs_post.order_by().values('pk'))
                except KeyError:
                    # collection not included post-auth, everything is removed.
                    pass
                for obj in qs:
                    this.ws.send(col.obj_change_as_msg(obj, REMOVED))

    @staticmethod
    def auth_failed(**credentials):
        """Consistent fail so we don't provide attackers with valuable info."""
        if credentials:
            user_login_failed.send_robust(
                sender=__name__,
                credentials=auth._clean_credentials(credentials),
            )
        raise MeteorError(403, 'Authentication failed.')

    @staticmethod
    def get_auth_hash(user, purpose):
        """Generate a user hash for a particular purpose."""
        return hashlib.sha1(
            ':'.join([
                settings.SECRET_KEY,
                user.get_session_auth_hash(),
                purpose,
            ])
        ).hexdigest()

    @classmethod
    def validated_user_and_session(cls, token, purpose):
        """Resolve and validate auth token, returns user and session objects."""
        try:
            username, session_key, auth_hash = loads(token.decode('base64'))
        except (ValueError, Error):
            cls.auth_failed(token=token)
        try:
            user = cls.user_model.objects.get(**{
                cls.user_model.USERNAME_FIELD: username,
            })
            user.backend = 'django.contrib.auth.backends.ModelBackend'
        except cls.user_model.DoesNotExist:
            cls.auth_failed(username=username, token=token)
        if cls.get_auth_hash(user, purpose) != auth_hash:
            cls.auth_failed(username=username, token=token)
        session = SessionStore(
            session_key=session_key,
        )
        if session.get_expiry_date() <= timezone.now():
            cls.auth_failed(username=username, token=token)
        return (user, session)

    @classmethod
    def get_user_token(cls, user, session_key, expiry_date, purpose):
        """Return login token info for given user."""
        token = ''.join(
            dumps([
                user.get_username(),
                session_key,
                cls.get_auth_hash(user, purpose),
            ]).encode('base64').split('\n')
        )
        return {
            'id': get_meteor_id(user),
            'token': token,
            'tokenExpires': expiry_date,
        }

    @staticmethod
    def check_secure():
        """Check request, return False if using SSL or local connection."""
        if this.request.is_secure():
            return True  # using SSL
        elif this.request.META['REMOTE_ADDR'] in [
                'localhost',
                '127.0.0.1',
        ]:
            return True  # localhost
        raise MeteorError(403, 'Authentication refused without SSL.')

    def get_username(self, user):
        """Retrieve username from user selector."""
        if isinstance(user, basestring):
            return user
        elif isinstance(user, dict) and len(user) == 1:
            [(key, val)] = user.items()
            if key == 'username' or (key == self.user_model.USERNAME_FIELD):
                # username provided directly
                return val
            elif key == 'emails.address':
                email_field = getattr(self.user_model, 'EMAIL_FIELD', 'email')
                if self.user_model.USERNAME_FIELD == email_field:
                    return val  # email is username
                # find username by email
                return self.user_model.objects.values_list(
                    self.user_model.USERNAME_FIELD, flat=True,
                ).get(**{email_field: val})
            elif key in ('id', 'pk'):
                # find username by primary key (ID)
                return self.user_model.objects.values_list(
                    self.user_model.USERNAME_FIELD, flat=True,
                ).get(
                    pk=val,
                )
            else:
                raise MeteorError(400, 'Invalid user lookup: %r' % key)
        else:
            raise MeteorError(400, 'Invalid user expression: %r' % user)

    @staticmethod
    def get_password(password):
        """Return password in plain-text from string/dict."""
        if isinstance(password, basestring):
            # regular Django authentication - plaintext password... but you're
            # using HTTPS (SSL) anyway so it's protected anyway, right?
            return password
        else:
            # Meteor is trying to be smart by doing client side hashing of the
            # password so that passwords are "...not sent in plain text over the
            # wire".  This behaviour doesn't make HTTP any more secure - it just
            # gives a false sense of security as replay attacks and
            # code-injection are both still viable attack vectors for the
            # malicious MITM.  Also as no salt is used with hashing, the
            # passwords are vulnerable to rainbow-table lookups anyway.
            #
            # If you're doing security, do it right from the very outset.  Fors
            # web services that means using SSL and not relying on half-baked
            # security concepts put together by people with no security
            # background.
            #
            # We protest loudly to anyone who cares to listen in the server logs
            # until upstream developers see the light and drop the password
            # hashing mis-feature.
            raise MeteorError(
                400,
                "Outmoded password hashing, run "
                "`meteor add tysonclugg:accounts-secure` to fix.",
            )

    @api_endpoint('createUser')
    def create_user(self, params):
        """Register a new user account."""
        receivers = create_user.send(
            sender=__name__,
            request=this.request,
            params=params,
        )
        if len(receivers) == 0:
            raise MeteorError(501, 'Handler for `create_user` not registered.')
        user = receivers[0][1]
        user = auth.authenticate(
            username=user.get_username(), password=params['password'],
        )
        auth.login(this.request, user)
        self.update_subs(user.pk)
        return self.get_user_token(
            user=user,
            session_key=this.request.session.session_key,
            expiry_date=this.request.session.get_expiry_date(),
            purpose=HashPurpose.CREATE_USER,
        )

    @api_endpoint
    def logout(self):
        """Logout current user."""
        auth.logout(this.request)
        self.update_subs(None)

    @api_endpoint
    def login(self, params):
        """Login either with resume token or password."""
        if 'password' in params:
            return self.login_with_password(params)
        elif 'resume' in params:
            return self.login_with_resume_token(params)
        else:
            self.auth_failed(**params)

    def login_with_password(self, params):
        """Authenticate using credentials supplied in params."""
        # never allow insecure login
        self.check_secure()

        username = self.get_username(params['user'])
        password = self.get_password(params['password'])

        user = auth.authenticate(username=username, password=password)
        if user is not None:
            # the password verified for the user
            if user.is_active:
                auth.login(this.request, user)
                self.update_subs(user.pk)
                this.request.session.save()
                return self.get_user_token(
                    user=user,
                    session_key=this.request.session.session_key,
                    expiry_date=this.request.session.get_expiry_date(),
                    purpose=HashPurpose.RESUME_LOGIN,
                )

        # Call to `authenticate` was unable to verify the username and password.
        # It will have sent the `user_login_failed` signal, no need to pass the
        # `username` argument to auth_failed().
        self.auth_failed()

    def login_with_resume_token(self, params):
        """
        Login with existing resume token.

        Either the token is valid and the user is logged in, or the token is
        invalid and a non-specific ValueError("Login failed.") exception is
        raised - don't be tempted to give clues to attackers as to why their
        logins are invalid!
        """
        # never allow insecure login
        self.check_secure()

        # pull the username, session_key and auth_hash from the token
        user, session = self.validated_user_and_session(
            params['resume'], purpose=HashPurpose.RESUME_LOGIN,
        )

        auth.login(this.request, user)
        self.update_subs(user.pk)
        this.request.session.save()
        return self.get_user_token(
            user=user,
            session_key=session.session_key,
            expiry_date=session.get_expiry_date(),
            purpose=HashPurpose.RESUME_LOGIN,
        )

    @api_endpoint('changePassword')
    def change_password(self, old_password, new_password):
        """Change password."""
        user = auth.authenticate(
            username=this.request.user.get_username(),
            password=self.get_password(old_password),
        )
        if user is None:
            self.auth_failed()
        else:
            user.set_password(self.get_password(new_password))
            user.save()
            password_changed.send(
                sender=__name__,
                request=this.request,
                user=user,
            )
            return {"passwordChanged": True}

    @api_endpoint('forgotPassword')
    def forgot_password(self, params):
        """Request password reset email."""
        username = self.get_username(params['user'])
        try:
            user = self.user_model.objects.get(**{
                self.user_model.USERNAME_FIELD: username,
            })
        except self.user_model.DoesNotExist:
            self.auth_failed()

        expiry_date = this.request.session.get_expiry_date()
        token = self.get_user_token(
            user=user, session_key=this.request.session.session_key,
            expiry_date=expiry_date,
            purpose=HashPurpose.PASSWORD_RESET,
        )

        forgot_password.send(
            sender=__name__,
            user=user,
            token=token,
            request=this.request,
            expiry_date=expiry_date,
        )

    @api_endpoint('resetPassword')
    def reset_password(self, token, new_password):
        """Reset password using a token received in email then logs user in."""
        user, _ = self.validated_user_and_session(
            token, purpose=HashPurpose.PASSWORD_RESET,
        )
        user.set_password(new_password)
        user.save()
        auth.login(this.request, user)
        self.update_subs(user.pk)
        return {"userId": get_meteor_id(this.request.user)};


API.register([Users, LoginPublication, Auth])
