import logging
import json
import time
from urllib.parse import quote, urlencode

import requests
from django.utils.crypto import get_random_string
from django.urls import reverse
from django.http import HttpResponseRedirect, JsonResponse

from mozilla_django_oidc.middleware import SessionRefresh
from mozilla_django_oidc.utils import (
    absolutify,
    add_state_and_verifier_and_nonce_to_session,
    import_from_settings,
    generate_code_challenge,
)


from ..auth.backends import store_tokens

logger = logging.getLogger(__name__)


class RefreshOIDCAccessToken(SessionRefresh):
    """
    The Mozilla OIDC SessionRefresh is a fairly hacky way to
    handle expiry of the OP user session. This version
    is adapted from this PR: https://github.com/mozilla/mozilla-django-oidc/pull/377
    to use a refresh token instead.

    Refer to: https://auth0.com/docs/tokens/refresh-token/current for flow.
    """

    def is_expired(self, request):
        if not self.is_refreshable_url(request):
            logger.debug("Request is not refreshable")
            return False

        expiration = request.session.get("oidc_id_token_expiration", 0)
        now = time.time()
        if expiration > now:
            # The id_token is still valid, so we don't have to do anything.
            logger.debug("Id token is still valid (%s > %s)", expiration, now)
            return False

        return True

    def _prepare_reauthorization(self, request):
        # Constructs a new authorization grant request to refresh the session.
        # Besides constructing the request, the state and nonce included in the
        # request are registered in the current session in preparation for the
        # client following through with the authorization flow.
        auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT
        client_id = self.OIDC_RP_CLIENT_ID
        state = get_random_string(self.OIDC_STATE_SIZE)

        # Build the parameters as if we were doing a real auth handoff, except
        # we also include prompt=none.
        params = {
            "response_type": "code",
            "client_id": client_id,
            "redirect_uri": absolutify(
                request, reverse(self.OIDC_AUTHENTICATION_CALLBACK_URL)
            ),
            "state": state,
            "scope": self.OIDC_RP_SCOPES,
            "prompt": "none",
        }

        params.update(self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", {}))

        if self.get_settings("OIDC_USE_PKCE", False):
            code_verifier_length = self.get_settings("OIDC_PKCE_CODE_VERIFIER_SIZE", 64)
            # Check that code_verifier_length is between the min and max length
            # defined in https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
            if not (43 <= code_verifier_length <= 128):
                raise ValueError("code_verifier_length must be between 43 and 128")

            # Generate code_verifier and code_challenge pair
            code_verifier = get_random_string(code_verifier_length)
            code_challenge_method = self.get_settings(
                "OIDC_PKCE_CODE_CHALLENGE_METHOD", "S256"
            )
            code_challenge = generate_code_challenge(
                code_verifier, code_challenge_method
            )

            # Append code_challenge to authentication request parameters
            params.update(
                {
                    "code_challenge": code_challenge,
                    "code_challenge_method": code_challenge_method,
                }
            )
        else:
            code_verifier = None

        if self.OIDC_USE_NONCE:
            nonce = get_random_string(self.OIDC_NONCE_SIZE)
            params.update({"nonce": nonce})

        # Register the one-time parameters in the session
        add_state_and_verifier_and_nonce_to_session(
            request, state, params, code_verifier
        )
        request.session["oidc_login_next"] = request.get_full_path()

        query = urlencode(params, quote_via=quote)
        return "{auth_url}?{query}".format(auth_url=auth_url, query=query)

    def finish(self, request, prompt_reauth=True):
        """Finish request handling and handle sending downstream responses for XHR.

        This function should only be run if the session is determind to
        be expired.

        Almost all XHR request handling in client-side code struggles
        with redirects since redirecting to a page where the user
        is supposed to do something is extremely unlikely to work
        in an XHR request. Make a special response for these kinds
        of requests.

        The use of 403 Forbidden is to match the fact that this
        middleware doesn't really want the user in if they don't
        refresh their session.
        """
        default_response = None
        xhr_response_json = {"error": "the authentication session has expired"}
        if prompt_reauth:
            # The id_token has expired, so we have to re-authenticate silently.
            refresh_url = self._prepare_reauthorization(request)
            default_response = HttpResponseRedirect(refresh_url)
            xhr_response_json["refresh_url"] = refresh_url

        if request.headers.get("x-requested-with") == "XMLHttpRequest":
            xhr_response = JsonResponse(xhr_response_json, status=403)
            if "refresh_url" in xhr_response_json:
                xhr_response["refresh_url"] = xhr_response_json["refresh_url"]
            return xhr_response
        else:
            return default_response

    def process_request(self, request):
        if not self.is_expired(request):
            return

        token_url = import_from_settings("OIDC_OP_TOKEN_ENDPOINT")
        client_id = import_from_settings("OIDC_RP_CLIENT_ID")
        client_secret = import_from_settings("OIDC_RP_CLIENT_SECRET")
        refresh_token = request.session.get("oidc_refresh_token")

        if not refresh_token:
            logger.debug("no refresh token stored")
            return self.finish(request, prompt_reauth=True)

        token_payload = {
            "grant_type": "refresh_token",
            "client_id": client_id,
            "client_secret": client_secret,
            "refresh_token": refresh_token,
        }

        try:
            response = requests.post(
                token_url,
                auth=None,
                data=token_payload,
                verify=import_from_settings("OIDC_VERIFY_SSL", True),
            )
            response.raise_for_status()
            token_info = response.json()
        except requests.exceptions.Timeout:
            logger.debug("timed out refreshing access token")
            # Don't prompt for reauth as this could be a temporary problem
            return self.finish(request, prompt_reauth=False)
        except requests.exceptions.HTTPError as exc:
            status_code = exc.response.status_code
            logger.debug("http error %s when refreshing access token", status_code)
            # OAuth error response will be a 400 for various situations, including
            # an expired token.
            # https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
            return self.finish(request, prompt_reauth=(status_code == 400))
        except json.JSONDecodeError:
            logger.debug("malformed response when refreshing access token")
            # Don't prompt for reauth as this could be a temporary problem
            return self.finish(request, prompt_reauth=False)
        except Exception as exc:
            logger.debug("unknown error occurred when refreshing access token: %s", exc)
            # Don't prompt for reauth as this could be a temporary problem
            return self.finish(request, prompt_reauth=False)

        # Until we can properly validate an ID token on the refresh response
        # per the spec[1], we intentionally drop the id_token.
        # https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
        id_token = None
        access_token = token_info.get("access_token")
        refresh_token = token_info.get("refresh_token")
        store_tokens(request.session, access_token, id_token, refresh_token)
