from __future__ import annotations

import base64
import platform
import sys
from json import dumps, loads
from random import randint
from time import sleep
from typing import TYPE_CHECKING, Any, Mapping
from urllib.parse import urlencode

import requests
from .exceptions import Auth0Error, RateLimitError
from .types import RequestData, TimeoutType

if TYPE_CHECKING:
    from .rest_async import RequestsResponse

UNKNOWN_ERROR = "a0.sdk.internal.unknown"


class RestClientOptions:
    """Configuration object for RestClient. Used for configuring
            additional RestClient options, such as rate-limit
            retries.

    Args:
        telemetry (bool, optional): Enable or disable Telemetry
            (defaults to True)
        timeout (float or tuple, optional): Change the requests
            connect and read timeout. Pass a tuple to specify
            both values separately or a float to set both to it.
            (defaults to 5.0 for both)
        retries (integer): In the event an API request returns a
            429 response header (indicating rate-limit has been
            hit), the RestClient will retry the request this many
            times using an exponential backoff strategy, before
            raising a RateLimitError exception. 10 retries max.
            (defaults to 3)
        client_info (dict, optional): Custom telemetry data to send
            in the Auth0-Client header instead of the default SDK
            info. Useful for wrapper SDKs that need to identify
            themselves. When provided, this dict is JSON-encoded
            and base64-encoded as the header value. Ignored when
            telemetry is False.
            (defaults to None)
    """

    def __init__(
        self,
        telemetry: bool = True,
        timeout: TimeoutType = 5.0,
        retries: int = 3,
        client_info: dict[str, Any] | None = None,
    ) -> None:
        self.telemetry = telemetry
        self.timeout = timeout
        self.retries = retries
        self.client_info = client_info


class RestClient:
    """Provides simple methods for handling all RESTful api endpoints.

    Args:
        jwt (str, optional): The JWT to be used with the RestClient.
        telemetry (bool, optional): Enable or disable Telemetry
            (defaults to True)
        timeout (float or tuple, optional): Change the requests
            connect and read timeout. Pass a tuple to specify
            both values separately or a float to set both to it.
            (defaults to 5.0 for both)
        options (RestClientOptions): Pass an instance of
            RestClientOptions to configure additional RestClient
            options, such as rate-limit retries. Overrides matching
            options passed to the constructor.
            (defaults to 3)
    """

    def __init__(
        self,
        jwt: str | None,
        telemetry: bool = True,
        timeout: TimeoutType = 5.0,
        options: RestClientOptions | None = None,
    ) -> None:
        if options is None:
            options = RestClientOptions(telemetry=telemetry, timeout=timeout)

        self.options = options
        self.jwt = jwt

        self._metrics = {"retries": 0, "backoff": []}
        self._skip_sleep = False

        self.base_headers = {
            "Content-Type": "application/json",
        }

        if jwt is not None:
            self.base_headers["Authorization"] = f"Bearer {self.jwt}"

        if options.telemetry:
            py_version = platform.python_version()

            if options.client_info is not None:
                auth0_client_dict = options.client_info
            else:
                version = sys.modules["auth0"].__version__
                auth0_client_dict = {
                    "name": "auth0-python",
                    "version": version,
                    "env": {
                        "python": py_version,
                    },
                }

            auth0_client = dumps(auth0_client_dict).encode("utf-8")

            self.base_headers.update(
                {
                    "User-Agent": f"Python/{py_version}",
                    "Auth0-Client": base64.b64encode(auth0_client).decode(),
                }
            )

        # Cap the maximum number of retries to 10 or fewer. Floor the retries at 0.
        self._retries = min(self.MAX_REQUEST_RETRIES(), max(0, options.retries))

        # For backwards compatibility reasons only
        # TODO: Deprecate in the next major so we can prune these arguments. Guidance should be to use RestClient.options.*
        self.telemetry = options.telemetry
        self.timeout = options.timeout

    # Returns a hard cap for the maximum number of retries allowed (10)
    def MAX_REQUEST_RETRIES(self) -> int:
        return 10

    # Returns the maximum amount of jitter to introduce in milliseconds (100ms)
    def MAX_REQUEST_RETRY_JITTER(self) -> int:
        return 100

    # Returns the maximum delay window allowed (1000ms)
    def MAX_REQUEST_RETRY_DELAY(self) -> int:
        return 1000

    # Returns the minimum delay window allowed (100ms)
    def MIN_REQUEST_RETRY_DELAY(self) -> int:
        return 100

    def _request(
        self,
        method: str,
        url: str,
        params: dict[str, Any] | None = None,
        data: RequestData | None = None,
        json: RequestData | None = None,
        headers: dict[str, str] | None = None,
        files: dict[str, Any] | None = None,
    ) -> Any:
        # Track the API request attempt number
        attempt = 0

        # Reset the metrics tracker
        self._metrics = {"retries": 0, "backoff": []}

        if data is None and json is not None and headers:
            content_type = headers.get("Content-Type", "").lower()  # Get Content-Type 
            if "application/x-www-form-urlencoded" in content_type:
                data = urlencode(json)  # Copy JSON data into data
                json = None  # Prevent JSON from being sent

        kwargs = {
            k: v
            for k, v in {
                "params": params,
                "json": json,
                "data": data,
                "headers": headers,
                "files": files,
                "timeout": self.options.timeout,
            }.items()
            if v is not None
        }

        while True:
            # Increment attempt number
            attempt += 1

            # Issue the request
            response = requests.request(method, url, **kwargs)

            # If the response did not have a 429 header, or the attempt number is greater than the configured retries, break
            if response.status_code != 429 or attempt > self._retries:
                break

            wait = self._calculate_wait(attempt)

            # Skip calling sleep() when running unit tests
            if self._skip_sleep is False:
                # sleep() functions in seconds, so convert the milliseconds formula above accordingly
                sleep(wait / 1000)

        # Return the final Response
        return self._process_response(response)

    def get(
        self,
        url: str,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
    ) -> Any:
        request_headers = self.base_headers.copy()
        request_headers.update(headers or {})
        return self._request("GET", url, params=params, headers=request_headers)

    def post(
        self,
        url: str,
        data: RequestData | None = None,
        headers: dict[str, str] | None = None,
    ) -> Any:
        request_headers = self.base_headers.copy()
        request_headers.update(headers or {})
        return self._request("POST", url, json=data, headers=request_headers)

    def file_post(
        self,
        url: str,
        data: RequestData | None = None,
        files: dict[str, Any] | None = None,
    ) -> Any:
        headers = self.base_headers.copy()
        headers.pop("Content-Type", None)
        return self._request("POST", url, data=data, files=files, headers=headers)

    def patch(self, url: str, data: RequestData | None = None) -> Any:
        headers = self.base_headers.copy()
        return self._request("PATCH", url, json=data, headers=headers)

    def put(self, url: str, data: RequestData | None = None) -> Any:
        headers = self.base_headers.copy()
        return self._request("PUT", url, json=data, headers=headers)

    def delete(
        self,
        url: str,
        params: dict[str, Any] | None = None,
        data: RequestData | None = None,
    ) -> Any:
        headers = self.base_headers.copy()
        return self._request("DELETE", url, params=params, json=data, headers=headers)

    def _calculate_wait(self, attempt: int) -> int:
        # Retry the request. Apply a exponential backoff for subsequent attempts, using this formula:
        # max(MIN_REQUEST_RETRY_DELAY, min(MAX_REQUEST_RETRY_DELAY, (100ms * (2 ** attempt - 1)) + random_between(1, MAX_REQUEST_RETRY_JITTER)))

        # Increases base delay by (100ms * (2 ** attempt - 1))
        wait = 100 * 2 ** (attempt - 1)

        # Introduces jitter to the base delay; increases delay between 1ms to MAX_REQUEST_RETRY_JITTER (100ms)
        wait += randint(1, self.MAX_REQUEST_RETRY_JITTER())

        # Is never more than MAX_REQUEST_RETRY_DELAY (1s)
        wait = min(self.MAX_REQUEST_RETRY_DELAY(), wait)

        # Is never less than MIN_REQUEST_RETRY_DELAY (100ms)
        wait = max(self.MIN_REQUEST_RETRY_DELAY(), wait)

        self._metrics["retries"] = attempt
        self._metrics["backoff"].append(wait)  # type: ignore[attr-defined]

        return wait

    def _process_response(self, response: requests.Response) -> Any:
        return self._parse(response).content()

    def _parse(self, response: requests.Response) -> Response:
        if not response.text:
            return EmptyResponse(response.status_code)
        try:
            return JsonResponse(response)
        except ValueError:
            return PlainResponse(response)


class Response:
    def __init__(
        self, status_code: int, content: Any, headers: Mapping[str, str]
    ) -> None:
        self._status_code = status_code
        self._content = content
        self._headers = headers

    def content(self) -> Any:
        if self._is_error():
            if self._status_code == 429:
                reset_at = int(self._headers.get("x-ratelimit-reset", "-1"))
                raise RateLimitError(
                    error_code=self._error_code(),
                    message=self._error_message(),
                    reset_at=reset_at,
                    headers=self._headers,
                )
            if self._error_code() == "mfa_required":
                raise Auth0Error(
                    status_code=self._status_code,
                    error_code=self._error_code(),
                    message=self._error_message(),
                    content=self._content,
                    headers=self._headers
                )

            raise Auth0Error(
                status_code=self._status_code,
                error_code=self._error_code(),
                message=self._error_message(),
                headers=self._headers
            )
        else:
            return self._content

    def _is_error(self) -> bool:
        return self._status_code is None or self._status_code >= 400

    # Adding these methods to force implementation in subclasses because they are references in this parent class
    def _error_code(self):
        raise NotImplementedError

    def _error_message(self):
        raise NotImplementedError


class JsonResponse(Response):
    def __init__(self, response: requests.Response | RequestsResponse) -> None:
        content = loads(response.text)
        super().__init__(response.status_code, content, response.headers)

    def _error_code(self) -> str:
        if "errorCode" in self._content:
            return self._content.get("errorCode")
        elif "error" in self._content:
            return self._content.get("error")
        elif "code" in self._content:
            return self._content.get("code")
        else:
            return UNKNOWN_ERROR

    def _error_message(self) -> str:
        if "error_description" in self._content:
            return self._content.get("error_description")
        message = self._content.get("message", "")
        if message is not None and message != "":
            return message
        return self._content.get("error", "")


class PlainResponse(Response):
    def __init__(self, response: requests.Response | RequestsResponse) -> None:
        super().__init__(response.status_code, response.text, response.headers)

    def _error_code(self) -> str:
        return UNKNOWN_ERROR

    def _error_message(self) -> str:
        return self._content


class EmptyResponse(Response):
    def __init__(self, status_code: int) -> None:
        super().__init__(status_code, "", {})

    def _error_code(self) -> str:
        return UNKNOWN_ERROR

    def _error_message(self) -> str:
        return ""
