import time
from logging import getLogger

from django.conf import settings
from django.core.cache import cache
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from datahub.company.models import Advisor
from datahub.oauth.sso_api_client import introspect_token, SSORequestError, SSOTokenDoesNotExist

NO_CREDENTIALS_MESSAGE = 'Authentication credentials were not provided.'
INCORRECT_CREDENTIALS_MESSAGE = 'Incorrect authentication credentials.'
INCORRECT_SCHEME = 'Incorrect authentication scheme.'
INVALID_CREDENTIALS_MESSAGE = 'Invalid authentication credentials.'


logger = getLogger(__name__)


class SSOIntrospectionAuthentication(BaseAuthentication):
    """OAuth token introspection (RFC 7662) authentication class, with Staff SSO extensions."""

    def authenticate_header(self, request):
        """
        The value for the WWW-Authenticate for when credentials aren't provided.

        This returns the same value as django-oauth-toolkit.
        """
        return 'Bearer realm="api"'

    def authenticate(self, request):
        """
        Authenticate the user using token introspection.

        This first checks if the token is cached. If it's not cached, the token is looked
        up in Staff SSO. An adviser is then looked up using the retrieved token data.
        """
        try:
            authorization_header = request.META['HTTP_AUTHORIZATION']
        except KeyError as exc:
            raise AuthenticationFailed(NO_CREDENTIALS_MESSAGE) from exc

        scheme, _, token = authorization_header.partition(' ')

        if scheme.lower() != 'bearer':
            raise AuthenticationFailed(INCORRECT_SCHEME)

        if not token:
            raise AuthenticationFailed(NO_CREDENTIALS_MESSAGE)

        token_data = _look_up_token(token)
        if not token_data:
            raise AuthenticationFailed(INVALID_CREDENTIALS_MESSAGE)

        user = _look_up_adviser(token_data)
        if not (user and user.is_active):
            raise AuthenticationFailed(INVALID_CREDENTIALS_MESSAGE)

        return user, None


def _look_up_token(token):
    """
    Look up data about an access token.

    This first checks the cache, and falls back to querying Staff SSO if the token isn't cached.
    """
    cache_key = f'access_token:{token}'
    cached_token_data = cache.get(cache_key)

    if cached_token_data:
        return cached_token_data

    try:
        token_data = introspect_token(token)
    except SSOTokenDoesNotExist:
        return None
    except SSORequestError:
        logger.exception('SSO introspection request failed')
        return None

    # This should not be possible as all valid tokens should be active
    if not token_data['active']:
        logger.warning('Introspected token was inactive')
        return None

    relative_expiry = _calculate_expiry(token_data['exp'])

    # This should not happen as expiry times should be in the future
    if relative_expiry <= 0:
        logger.warning('Introspected token has an expiry time in the past')
        return None

    cache.set(cache_key, token_data, timeout=relative_expiry)

    return token_data


def _look_up_adviser(token_data):
    """
    Look up the adviser using data about an access token.

    This first tries to look up the adviser using the SSO email user ID, and falls
    back to using the email field if no match is found using the SSO email user ID.
    """
    username = token_data['username']
    sso_email_user_id = token_data['email_user_id']

    try:
        return _get_adviser(sso_email_user_id=sso_email_user_id)
    except Advisor.DoesNotExist:
        pass

    # No match on sso_email_user_id – fall back to looking up using the email field
    # TODO: Remove the below logic once active users have sso_email_user_id set
    try:
        # Note: The email field uses CICharField so this lookup is case-insensitive
        adviser = _get_adviser(email=username, sso_email_user_id__isnull=True)
    except Advisor.DoesNotExist:
        return None

    adviser.sso_email_user_id = sso_email_user_id
    adviser.save(update_fields=('sso_email_user_id',))
    return adviser


def _calculate_expiry(timestamp):
    expires_in = timestamp - time.time()
    return min(expires_in, settings.STAFF_SSO_USER_TOKEN_CACHING_PERIOD)


def _get_adviser(**kwargs):
    return Advisor.objects.select_related('dit_team').get(**kwargs)

import time
from logging import getLogger

from django.conf import settings
from django.core.cache import cache
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from datahub.company.models import Advisor
from datahub.oauth.sso_api_client import introspect_token, SSORequestError, SSOTokenDoesNotExist

NO_CREDENTIALS_MESSAGE = 'Authentication credentials were not provided.'
INCORRECT_CREDENTIALS_MESSAGE = 'Incorrect authentication credentials.'
INCORRECT_SCHEME = 'Incorrect authentication scheme.'
INVALID_CREDENTIALS_MESSAGE = 'Invalid authentication credentials.'


logger = getLogger(__name__)


class SSOIntrospectionAuthentication(BaseAuthentication):
    """OAuth token introspection (RFC 7662) authentication class, with Staff SSO extensions."""

    def authenticate_header(self, request):
        """
        The value for the WWW-Authenticate for when credentials aren't provided.

        This returns the same value as django-oauth-toolkit.
        """
        return 'Bearer realm="api"'

    def authenticate(self, request):
        """
        Authenticate the user using token introspection.

        This first checks if the token is cached. If it's not cached, the token is looked
        up in Staff SSO. An adviser is then looked up using the retrieved token data.
        """
        try:
            authorization_header = request.META['HTTP_AUTHORIZATION']
        except KeyError as exc:
            raise AuthenticationFailed(NO_CREDENTIALS_MESSAGE) from exc

        scheme, _, token = authorization_header.partition(' ')

        if scheme.lower() != 'bearer':
            raise AuthenticationFailed(INCORRECT_SCHEME)

        if not token:
            raise AuthenticationFailed(NO_CREDENTIALS_MESSAGE)

        token_data = _look_up_token(token)
        if not token_data:
            raise AuthenticationFailed(INVALID_CREDENTIALS_MESSAGE)

        user = _look_up_adviser(token_data)
        if not (user and user.is_active):
            raise AuthenticationFailed(INVALID_CREDENTIALS_MESSAGE)

        return user, None


def _look_up_token(token):
    """
    Look up data about an access token.

    This first checks the cache, and falls back to querying Staff SSO if the token isn't cached.
    """
    cache_key = f'access_token:{token}'
    cached_token_data = cache.get(cache_key)

    if cached_token_data:
        return cached_token_data

    try:
        token_data = introspect_token(token)
    except SSOTokenDoesNotExist:
        return None
    except SSORequestError:
        logger.exception('SSO introspection request failed')
        return None

    # This should not be possible as all valid tokens should be active
    if not token_data['active']:
        logger.warning('Introspected token was inactive')
        return None

    relative_expiry = _calculate_expiry(token_data['exp'])

    # This should not happen as expiry times should be in the future
    if relative_expiry <= 0:
        logger.warning('Introspected token has an expiry time in the past')
        return None

    cache.set(cache_key, token_data, timeout=relative_expiry)

    return token_data


def _look_up_adviser(token_data):
    """
    Look up the adviser using data about an access token.

    This first tries to look up the adviser using the SSO email user ID, and falls
    back to using the email field if no match is found using the SSO email user ID.
    """
    username = token_data['username']
    sso_email_user_id = token_data['email_user_id']

    try:
        return _get_adviser(sso_email_user_id=sso_email_user_id)
    except Advisor.DoesNotExist:
        pass

    # No match on sso_email_user_id – fall back to looking up using the email field
    # TODO: Remove the below logic once active users have sso_email_user_id set
    try:
        # Note: The email field uses CICharField so this lookup is case-insensitive
        adviser = _get_adviser(email=username, sso_email_user_id__isnull=True)
    except Advisor.DoesNotExist:
        return None

    adviser.sso_email_user_id = sso_email_user_id
    adviser.save(update_fields=('sso_email_user_id',))
    return adviser


def _calculate_expiry(timestamp):
    expires_in = timestamp - time.time()
    return min(expires_in, settings.STAFF_SSO_USER_TOKEN_CACHING_PERIOD)


def _get_adviser(**kwargs):
    return Advisor.objects.select_related('dit_team').get(**kwargs)

OAuth token introspection (RFC 7662) authentication class, with Staff SSO extensions. The value for the WWW-Authenticate for when credentials aren't provided.          This returns the same value as django-oauth-toolkit. Authenticate the user using token introspection.          This first checks if the token is cached. If it's not cached, the token is looked         up in Staff SSO. An adviser is then looked up using the retrieved token data. Look up data about an access token.      This first checks the cache, and falls back to querying Staff SSO if the token isn't cached. # This should not be possible as all valid tokens should be active # This should not happen as expiry times should be in the future Look up the adviser using data about an access token.      This first tries to look up the adviser using the SSO email user ID, and falls     back to using the email field if no match is found using the SSO email user ID. # No match on sso_email_user_id – fall back to looking up using the email field # TODO: Remove the below logic once active users have sso_email_user_id set # Note: The email field uses CICharField so this lookup is case-insensitive