import logging
from typing import Callable

import httpx

from .exceptions import AnaplanException, InvalidCredentialsException

logger = logging.getLogger("anaplan_sdk")


class _BaseOauth:
    def __init__(
        self,
        client_id: str,
        client_secret: str,
        redirect_uri: str,
        authorization_url: str = "https://us1a.app.anaplan.com/auth/prelogin",
        token_url: str = "https://us1a.app.anaplan.com/oauth/token",
        validation_url: str = "https://auth.anaplan.com/token/validate",
        scope: str = "openid profile email offline_access",
        state_generator: Callable[[], str] | None = None,
    ):
        """
        Initializes the OAuth Client. This class provides the two utilities needed to implement
        the OAuth 2.0 authorization code flow for user-facing Web Applications. It differs from the
        other Authentication Strategies in this SDK in two main ways:

        1. You must implement the actual authentication flow in your application. You cannot pass
        the credentials directly to the `Client` or `AsyncClient`, and this class does not
        implement the SDK internal authentication flow, i.e. it does not subclass `httpx.Auth`.

        2. You then simply pass the resulting token to the `Client` or `AsyncClient`, rather than
        passing the credentials directly, which will internally construct an `httpx.Auth` instance

        Note that this class exist for convenience only, and you can implement the OAuth 2.0 Flow
        yourself in your preferred library, or bring an existing implementation. For details on the
        Anaplan OAuth 2.0 Flow, see the [the Docs](https://anaplanoauth2service.docs.apiary.io/#reference/overview-of-the-authorization-code-grant).
        :param client_id: The client ID of your Anaplan Oauth 2.0 application. This Application
               must be an Authorization Code Grant application.
        :param client_secret: The client secret of your Anaplan Oauth 2.0 application.
        :param redirect_uri: The URL to which the user will be redirected after authorizing the
               application.
        :param authorization_url: The URL to which the user will be redirected to authorize the
               application. Defaults to the Anaplan Prelogin Page, where the user can select the
               login method.
        :param token_url: The URL to post the authorization code to in order to fetch the access
               token.
        :param validation_url: The URL to validate the access token.
        :param scope: The scope of the access request.
        :param state_generator: A callable that generates a random state string. You can optionally
                pass this if you need to customize the state generation logic. If not provided,
                the state will be generated by `oauthlib`.
        """
        self._client_id = client_id
        self._client_secret = client_secret
        self._redirect_uri = redirect_uri
        self._authorization_url = authorization_url
        self._token_url = token_url
        self._validation_url = validation_url
        self._scope = scope

        try:
            from oauthlib.oauth2 import WebApplicationClient
        except ImportError as e:
            raise AnaplanException(
                "oauthlib is not available. Please install anaplan-sdk with the oauth extra "
                "`pip install anaplan-sdk[oauth]` or install oauthlib separately."
            ) from e
        self._oauth = WebApplicationClient(client_id=client_id, client_secret=client_secret)
        self._state_generator = state_generator if state_generator else self._oauth.state_generator

    def authorization_url(
        self, authorization_url: str | None = None, state: str | None = None
    ) -> tuple[str, str]:
        """
        Generates the authorization URL for the OAuth 2.0 flow.
        :param authorization_url: You can optionally pass a custom authorization URL. This is
               useful if you want to redirect i.e. redirect the user directly to the Anaplan login
               page rather than the Prelogin page in only one scenario, while still reusing the
               Client.
        :param state: You can optionally pass a custom state string. If not provided, a random
                state string will be generated by the `oauthlib` library, or by the
                `state_generator` callable if provided.
        :return: A tuple containing the authorization URL and the state string.
        """
        auth_url = authorization_url or self._authorization_url
        state = state or self._state_generator()
        url, _, _ = self._oauth.prepare_authorization_request(
            auth_url, state, self._redirect_uri, self._scope
        )
        return url, state

    def _token_request(self, authorization_response: str) -> httpx.Request:
        url, headers, body = self._oauth.prepare_token_request(
            authorization_response=authorization_response,
            token_url=self._token_url,
            redirect_url=self._redirect_uri,
            client_secret=self._client_secret,
        )
        return httpx.Request(method="POST", url=url, headers=headers, content=body)

    def _refresh_token_request(self, refresh_token: str) -> httpx.Request:
        url, headers, body = self._oauth.prepare_refresh_token_request(
            self._token_url,
            refresh_token=refresh_token,
            client_id=self._client_id,
            client_secret=self._client_secret,
        )
        return httpx.Request(method="POST", url=url, headers=headers, content=body)

    def _parse_response(self, response: httpx.Response) -> dict[str, str]:
        if response.status_code == 401:
            raise InvalidCredentialsException
        if not response.is_success:
            raise AnaplanException(
                f"Token request for Client {self._client_id} failed: "
                f"{response.status_code} {response.text}"
            )
        return response.json()


class _OAuthRequestFactory(_BaseOauth):
    def token_request(self, authorization_response: str) -> httpx.Request:
        return self._token_request(authorization_response)

    def refresh_token_request(self, refresh_token: str) -> httpx.Request:
        return self._refresh_token_request(refresh_token)


class AsyncOauth(_BaseOauth):
    """
    Asynchronous Variant of the Anaplan OAuth client for interactive OAuth Flows in Web
    Applications.
    """

    async def fetch_token(self, authorization_response: str) -> dict[str, str | int]:
        """
        Fetches the token using the authorization response from the OAuth 2.0 flow.
        :param authorization_response: The full URL that the user was redirected to after
               authorizing the application. This URL will contain the authorization code and state.
        :return: The token as a dictionary containing the access token, refresh token, scope,
                 expires_in, and type.
        """
        from oauthlib.oauth2 import OAuth2Error

        try:
            async with httpx.AsyncClient() as client:
                response = await client.send(self._token_request(authorization_response))
            return self._parse_response(response)
        except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
            logger.error(error)
            raise AnaplanException("Error during token creation.") from error

    async def validate_token(self, token: str) -> dict[str, str | dict[str, str | int]]:
        """
        Validates the provided token by checking its validity with the Anaplan Authentication API.
        If the token is not valid, an `InvalidCredentialsException` is raised.
        :param token: The access token to validate.
        :return: The Token information as a dictionary containing the token's details.
        """
        try:
            async with httpx.AsyncClient() as client:
                response = await client.get(
                    url=self._validation_url, headers={"Authorization": f"AnaplanAuthToken {token}"}
                )
            return self._parse_response(response)
        except httpx.HTTPError as error:
            logger.error(error)
            raise AnaplanException("Error during token validation.") from error

    async def refresh_token(self, refresh_token: str) -> dict[str, str | int]:
        """
        Refreshes the token using a refresh token.
        :param refresh_token: The refresh token to use for refreshing the access token.
        :return: The new token as a dictionary containing the access token, refresh token, scope,
                 expires_in, and type.
        """
        from oauthlib.oauth2 import OAuth2Error

        try:
            async with httpx.AsyncClient() as client:
                response = await client.send(self._refresh_token_request(refresh_token))
            return self._parse_response(response)
        except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
            logger.error(error)
            raise AnaplanException("Error during token refresh.") from error


class Oauth(_BaseOauth):
    """
    Synchronous Variant of the Anaplan OAuth client for interactive OAuth Flows in Web
    Applications.
    """

    def fetch_token(self, authorization_response: str) -> dict[str, str | int]:
        """
        Fetches the token using the authorization response from the OAuth 2.0 flow.
        :param authorization_response: The full URL that the user was redirected to after
               authorizing the application. This URL will contain the authorization code and state.
        :return: The token as a dictionary containing the access token, refresh token, scope,
                 expires_in, and type.
        """
        from oauthlib.oauth2 import OAuth2Error

        try:
            url, headers, body = self._oauth.prepare_token_request(
                authorization_response=authorization_response,
                token_url=self._token_url,
                redirect_url=self._redirect_uri,
                client_secret=self._client_secret,
            )
            with httpx.Client() as client:
                response = client.post(url=url, headers=headers, content=body)
            return self._parse_response(response)
        except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
            logger.error(error)
            raise AnaplanException("Error during token creation.") from error

    def validate_token(self, token: str) -> dict[str, str | dict[str, str | int]]:
        """
        Validates the provided token by checking its validity with the Anaplan Authentication API.
        If the token is not valid, an `InvalidCredentialsException` is raised.
        :param token: The access token to validate.
        :return: The Token information as a dictionary containing the token's details.
        """
        try:
            with httpx.Client() as client:
                response = client.get(
                    url=self._validation_url, headers={"Authorization": f"AnaplanAuthToken {token}"}
                )
            return self._parse_response(response)
        except httpx.HTTPError as error:
            logger.error(error)
            raise AnaplanException("Error during token validation.") from error

    def refresh_token(self, refresh_token: str) -> dict[str, str | int]:
        """
        Refreshes the token using a refresh token.
        :param refresh_token: The refresh token to use for refreshing the access token.
        :return: The new token as a dictionary containing the access token, refresh token, scope,
                 expires_in, and type.
        """
        from oauthlib.oauth2 import OAuth2Error

        try:
            url, headers, body = self._oauth.prepare_refresh_token_request(
                self._token_url,
                refresh_token=refresh_token,
                client_id=self._client_id,
                client_secret=self._client_secret,
            )
            with httpx.Client() as client:
                response = client.post(url=url, headers=headers, content=body)
            return self._parse_response(response)
        except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
            logger.error(error)
            raise AnaplanException("Error during token refresh.") from error
