import logging
import os
from base64 import b64encode
from typing import Awaitable, Callable

import httpx

from ._oauth import _OAuthRequestFactory
from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPrivateKeyException

logger = logging.getLogger("anaplan_sdk")

AuthCodeCallback = (Callable[[str], str] | Callable[[str], Awaitable[str]]) | None
AuthTokenRefreshCallback = (
    Callable[[dict[str, str]], None] | Callable[[dict[str, str]], Awaitable[None]]
) | None


class _AnaplanAuth(httpx.Auth):
    requires_response_body = True

    def __init__(self, token: str | None = None):
        self._token: str = token or ""
        if not token:
            logger.info("Creating Authentication Token.")
            with httpx.Client(timeout=15.0) as client:
                self._parse_auth_response(client.send(self._build_auth_request()))

    def _build_auth_request(self) -> httpx.Request:
        raise NotImplementedError("Must be implemented in subclass.")

    def auth_flow(self, request):
        request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
        response = yield request
        if response.status_code == 401:
            logger.info("Token expired, refreshing.")
            auth_res = yield self._build_auth_request()
            self._parse_auth_response(auth_res)
            request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
            yield request

    def _parse_auth_response(self, response: httpx.Response) -> None:
        if response.status_code == 401:
            raise InvalidCredentialsException
        if not response.is_success:
            raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
        self._token = response.json()["tokenInfo"]["tokenValue"]


class _StaticTokenAuth(httpx.Auth):
    def __init__(self, token: str):
        self._token = token

    def auth_flow(self, request):
        request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
        response = yield request
        if response.status_code == 401:
            raise InvalidCredentialsException("Token is invalid or expired.")


class _AnaplanBasicAuth(_AnaplanAuth):
    def __init__(self, user_email: str, password: str, token: str | None = None):
        self.user_email = user_email
        self.password = password
        super().__init__(token)

    def _build_auth_request(self) -> httpx.Request:
        cred = b64encode(f"{self.user_email}:{self.password}".encode()).decode()
        return httpx.Request(
            method="post",
            url="https://auth.anaplan.com/token/authenticate",
            headers={"Authorization": f"Basic {cred}"},
        )


class _AnaplanCertAuth(_AnaplanAuth):
    requires_request_body = True

    def __init__(
        self,
        certificate: str | bytes,
        private_key: str | bytes,
        private_key_password: str | bytes | None = None,
        token: str | None = None,
    ):
        self.__set_certificate(certificate)
        self.__set_private_key(private_key, private_key_password)
        super().__init__(token)

    def _build_auth_request(self) -> httpx.Request:
        encoded_cert, encoded_string, encoded_signed_string = self._prep_credentials()
        return httpx.Request(
            method="post",
            url="https://auth.anaplan.com/token/authenticate",
            headers={
                "Authorization": f"CACertificate {encoded_cert}",
                "Content-Type": "application/json",
            },
            json={"encodedData": encoded_string, "encodedSignedData": encoded_signed_string},
        )

    def _prep_credentials(self) -> tuple[str, str, str]:
        from cryptography.hazmat.primitives import hashes
        from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15

        message = os.urandom(150)
        return (
            b64encode(self._certificate).decode(),
            b64encode(message).decode(),
            b64encode(self._private_key.sign(message, PKCS1v15(), hashes.SHA512())).decode(),
        )

    def __set_certificate(self, certificate: str | bytes) -> None:
        if isinstance(certificate, str):
            if os.path.isfile(certificate):
                with open(certificate, "rb") as f:
                    self._certificate = f.read()
            else:
                self._certificate = certificate.encode()
        else:
            self._certificate = certificate

    def __set_private_key(
        self, private_key: str | bytes, private_key_password: str | bytes
    ) -> None:
        try:
            from cryptography.exceptions import InvalidKey, UnsupportedAlgorithm
            from cryptography.hazmat.backends import default_backend
            from cryptography.hazmat.primitives import serialization
            from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
        except ImportError as e:
            raise AnaplanException(
                "cryptography is not available. Please install anaplan-sdk with the cert extra "
                "`pip install anaplan-sdk[cert]` or install cryptography separately."
            ) from e
        try:
            if isinstance(private_key, str):
                if os.path.isfile(private_key):
                    with open(private_key, "rb") as f:
                        data = f.read()
                else:
                    data = private_key.encode()
            else:
                data = private_key
            password = (
                private_key_password.encode()
                if isinstance(private_key_password, str)
                else private_key_password
            )
            self._private_key: RSAPrivateKey = serialization.load_pem_private_key(
                data, password, backend=default_backend()
            )
        except (IOError, InvalidKey, UnsupportedAlgorithm) as error:
            raise InvalidPrivateKeyException from error


class AnaplanOAuthCodeAuth(_AnaplanAuth):
    def __init__(
        self,
        client_id: str,
        client_secret: str,
        redirect_url: str,
        token: dict[str, str] | None = None,
        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 AnaplanOAuthCodeAuth class for OAuth2 authentication using the
        Authorization Code Flow. This is a utility class for local development and requires user
        interaction. For Web Applications and other scenarios, refer to `Oauth` or `AsyncOauth`.
        This class will refresh the access token automatically when it expires.
        :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_url: 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._oauth_token = token or {}
        self._oauth = _OAuthRequestFactory(
            client_id=client_id,
            client_secret=client_secret,
            redirect_url=redirect_url,
            scope=scope,
            authorization_url=authorization_url,
            token_url=token_url,
            validation_url=validation_url,
            state_generator=state_generator,
        )
        if not token:
            self.__auth_code_flow()
        super().__init__(self._token)

    def _build_auth_request(self) -> httpx.Request:
        return self._oauth.refresh_token_request(self._oauth_token["refresh_token"])

    def _parse_auth_response(self, response: httpx.Response) -> None:
        if response.status_code == 401:
            raise InvalidCredentialsException
        if not response.is_success:
            raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
        self._oauth_token = response.json()
        self._token: str = self._oauth_token["access_token"]

    def __auth_code_flow(self):
        from oauthlib.oauth2 import OAuth2Error

        try:
            logger.info("Creating Authentication Token with OAuth2 Authorization Code Flow.")
            url, _ = self._oauth.authorization_url()
            authorization_response = input(
                f"Please go to {url} and authorize the app.\n"
                "Then paste the entire redirect URL here: "
            )
            with httpx.Client() as client:
                res = client.send(self._oauth.token_request(authorization_response))
            self._parse_auth_response(res)
        except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
            raise InvalidCredentialsException("Error during OAuth2 authorization flow.") from error


class AnaplanRefreshTokenAuth(_AnaplanAuth):
    def __init__(
        self,
        client_id: str,
        client_secret: str,
        redirect_url: str,
        token: dict[str, str],
        token_url: str = "https://us1a.app.anaplan.com/oauth/token",
    ):
        """
        This class is a utility class for long-lived `Client` or `AsyncClient` instances that use
        OAuth. It expects that you have a valid OAuth token with a refresh token, which will be used
        to refresh the access token when it expires.

        :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_url: The URL to which the user will be redirected after authorizing the
               application.
        :param token: The OAuth token dictionary containing at least the `access_token` and
               `refresh_token`.
        :param token_url: The URL to post the refresh token request to in order to fetch the access
               token.
        """
        self._oauth_token = token
        self._oauth = _OAuthRequestFactory(
            client_id=client_id,
            client_secret=client_secret,
            redirect_url=redirect_url,
            token_url=token_url,
        )
        super().__init__(self._oauth_token["access_token"])

    def _build_auth_request(self) -> httpx.Request:
        return self._oauth.refresh_token_request(self._oauth_token["refresh_token"])

    def _parse_auth_response(self, response: httpx.Response) -> None:
        if response.status_code == 401:
            raise InvalidCredentialsException
        if not response.is_success:
            raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
        self._oauth_token = response.json()
        self._token: str = self._oauth_token["access_token"]


def _create_auth(
    user_email: str | None = None,
    password: str | None = None,
    certificate: str | bytes | None = None,
    private_key: str | bytes | None = None,
    private_key_password: str | bytes | None = None,
    token: str | None = None,
) -> httpx.Auth:
    if certificate and private_key:
        return _AnaplanCertAuth(certificate, private_key, private_key_password, token)
    if user_email and password:
        return _AnaplanBasicAuth(user_email, password, token)
    if token:
        return _StaticTokenAuth(token)
    raise ValueError(
        "No valid authentication parameters provided. Please provide either:\n"
        "- `user_email` and `password`, or\n"
        "- `certificate` and `private_key`\n"
    )
