# The Okta software accompanied by this notice is provided pursuant to the following terms:
# Copyright © 2025-Present, Okta, Inc.
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
# License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and limitations under the License.
# coding: utf-8

import asyncio
import json
import logging
import time
from http import HTTPStatus

from okta.error_messages import ERROR_MESSAGE_429_MISSING_DATE_X_RESET
from okta.http_client import HTTPClient
from okta.oauth import OAuth
from okta.user_agent import UserAgent
from okta.utils import convert_date_time_to_seconds

logger = logging.getLogger("okta-sdk-python")


class RequestExecutor:
    """
    This class handles all of the requests sent by the Okta Client.
    """

    RETRY_COUNT_HEADER = "X-Okta-Retry-Count"
    RETRY_FOR_HEADER = "X-Okta-Retry-For"

    def __init__(self, config, cache, http_client=None):
        """
        Constructor for Request Executor object for Okta Client

        Arguments:
            config {dict} -- This dictionary contains the configuration
                             of the Request Executor
        """
        # Raise Value Error if numerical inputs are invalid (< 0)
        self._request_timeout = config["client"].get("requestTimeout", 0)
        if self._request_timeout < 0:
            raise ValueError(
                (
                    "okta.client.requestTimeout provided as "
                    f"{self._request_timeout} but must be 0 (disabled) or "
                    "greater than zero"
                )
            )
        self._max_retries = config["client"]["rateLimit"].get("maxRetries", 2)
        if self._max_retries < 0:
            raise ValueError(
                (
                    "okta.client.rateLimit.maxRetries provided as "
                    f"{self._max_retries} but must be 0 (disabled) or "
                    "greater than zero"
                )
            )
        # Setup other fields
        self._authorization_mode = config["client"]["authorizationMode"]
        self._base_url = config["client"]["orgUrl"]
        self._config = config
        self._cache = cache
        self._default_headers = {
            "User-Agent": UserAgent(
                config["client"].get("userAgent", None)
            ).get_user_agent_string(),
            "Accept": "application/json",
        }

        # SSWS or Bearer header
        token_type = config["client"]["authorizationMode"]
        if token_type in ("SSWS", "Bearer"):
            self._default_headers["Authorization"] = (
                f"{token_type} {self._config['client']['token']}"
            )
        else:
            # OAuth
            self._oauth = OAuth(self, self._config)

        http_client_impl = http_client or HTTPClient
        self._http_client = http_client_impl(
            {
                "requestTimeout": self._request_timeout,
                "headers": self._default_headers,
                "proxy": self._config["client"].get("proxy"),
                "sslContext": self._config["client"].get("sslContext"),
            }
        )
        HTTPClient.raise_exception = self._config["client"].get("raiseException", False)
        self._custom_headers = {}

    def clear_empty_params(self, body: dict):
        """
        Removes all key-value pairs where value is empty, i.e. empty string, list or dict.

        Args:
            body (dict): request body to be cleared

        Returns:
            dict: body without empty values.
        """
        if isinstance(body, dict):
            return {
                k: v
                for k, v in ((k, self.clear_empty_params(v)) for k, v in body.items())
                if v or v == 0 or v is False
            }
        if isinstance(body, list):
            return [
                v
                for v in map(self.clear_empty_params, body)
                if v or v == 0 or v is False
            ]
        return body

    async def create_request(
        self,
        method: str,
        url: str,
        body: dict = None,
        headers: dict = {},
        form: dict = {},
        oauth=False,
        keep_empty_params=False,
    ):
        """
        Creates request for request executor's HTTP client.

        Args:
            method (str): HTTP Method to be used
            url (str): URL to send request to
            body (dict, optional): Request body. Defaults to None.
            headers (dict, optional): Request headers. Defaults to {}.
            oauth: Should use oauth? Defaults to False.
            keep_empty_params: Should request body keep parameters with empty values? Defaults to False.

        Returns:
            dict, Exception: Tuple of Dictionary repr of HTTP request and
            exception raised during execution
        """
        # Base HTTP Request
        request = {"method": method}

        # Build request
        # Get predetermined headers and build URL
        headers = {**self._custom_headers, **headers}
        headers = {**self._default_headers, **headers}
        if self._config["client"]["orgUrl"] not in url:
            url = self._config["client"]["orgUrl"] + url

        # OAuth
        if self._authorization_mode == "PrivateKey" and not oauth:
            # check if access token exists
            if self._cache.contains("OKTA_ACCESS_TOKEN"):
                access_token = self._cache.get("OKTA_ACCESS_TOKEN")
            else:
                # if not, make one
                # Generate using private key provided
                access_token, error = await self._oauth.get_access_token()
                # return error if problem retrieving token
                if error:
                    return (None, error)

            # finally, add to header and cache
            headers.update({"Authorization": f"Bearer {access_token}"})
            self._cache.add("OKTA_ACCESS_TOKEN", access_token)

        # Add content type header if request body exists
        if body:
            headers.update({"Content-Type": "application/json"})
            if not keep_empty_params:
                body = self.clear_empty_params(body)

        # finish building request and return
        request["headers"] = headers
        request["url"] = url
        request["data"] = body
        request["form"] = form

        return (request, None)

    async def execute(self, request, response_type=None):
        """
        This function is the high level request execution method. Performs the
        API call and returns a formatted response object

        Args:
            request (dict): dictionary object containing request details

        Returns:
            (OktaAPIResponse, Exception): Response obj for the Okta API, Error
        """
        _, response, response_body, error = await self.fire_request(request)

        if error is not None:
            return (None, error)

        _, error = self._http_client.check_response_for_error(
            request["url"], response, response_body
        )

        return response, response_body, error

    async def fire_request(self, request):
        """
        Send Request using HTTP Client

        Args:
            request (dict): HTTP request in dictionary format

        Returns:
            aiohttp.RequestInfo, aiohttp.ClientResponse, json, Exception: Tuple
            of request, response object, response json, and error if raised
        """
        # Retrieve URL from request and generate cache key
        url = request["url"]
        url_cache_key = self._cache.create_key(url)

        # Remove cache entry if not GET call
        method = request["method"]
        if method.upper() != "GET":
            self._cache.delete(url_cache_key)

        # check if in cache
        if not self._cache.contains(url_cache_key):
            # shoot request and return
            _, res_details, resp_body, error = await self.fire_request_helper(
                request, 0, time.time()
            )
            if error is not None:
                return (None, res_details, resp_body, error)

            # add to cache if not a list and valid response
            if method.upper() == "GET" and 200 <= res_details.status <= 299:
                try:
                    json_object = json.loads(resp_body)
                    if not isinstance(json_object, list):
                        self._cache.add(url_cache_key, (res_details, resp_body))
                except Exception:
                    pass

            return (request, res_details, resp_body, error)

        # otherwise return cache response
        res_details, resp_body = self._cache.get(url_cache_key)
        return (request, res_details, resp_body, None)

    async def fire_request_helper(self, request, attempts, request_start_time):
        """
        Helper method to perform HTTP call with retries if needed

        Args:
            request (dict): HTTP request representation
            attempts (int): number of attempted HTTP calls so far
            request_start_time (float): original start time of request

        Returns:
            aiohttp.RequestInfo, aiohttp.ClientResponse, json, Exception: Tuple
            of request, response object, response json, and error if raised
        """
        # Get start request time
        current_req_start_time = time.time()
        max_retries = self._max_retries
        req_timeout = self._request_timeout

        if (
            req_timeout > 0
            and (current_req_start_time - request_start_time) > req_timeout
        ):
            # Timeout is hit for request
            return (None, None, None, Exception("Request Timeout exceeded."))

        # Execute request
        _, res_details, resp_body, error = await self._http_client.send_request(request)
        # return immediately if request failed to launch (e.g. network is down, thus res_details is None)
        if res_details is None:
            return (None, None, None, error)

        headers = res_details.headers

        if attempts < max_retries and self.is_retryable_status(res_details.status):
            date_time = headers.get("Date", "")
            if date_time:
                date_time = convert_date_time_to_seconds(date_time)

            # Get X-Rate-Limit-Reset header
            retry_limit_reset_headers = list(
                map(float, headers.getall("X-Rate-Limit-Reset", []))
            )
            # header might be in lowercase, so check this too
            retry_limit_reset_headers.extend(
                list(map(float, headers.getall("x-rate-limit-reset", [])))
            )
            retry_limit_reset = (
                min(retry_limit_reset_headers)
                if len(retry_limit_reset_headers) > 0
                else None
            )

            # Get X-Rate-Limit-Limit Header
            retry_limit_limit_headers = list(
                map(float, headers.getall("X-Rate-Limit-Limit", []))
            )
            # header might be in lowercase, so check this too
            retry_limit_limit_headers.extend(
                list(map(float, headers.getall("x-rate-limit-limit", [])))
            )
            retry_limit_limit = (
                min(retry_limit_limit_headers)
                if len(retry_limit_limit_headers) > 0
                else None
            )

            # Get X-Rate-Limit-Remaining Header
            retry_limit_remaining_headers = list(
                map(float, headers.getall("X-Rate-Limit-Remaining", []))
            )
            # header might be in lowercase, so check this too
            retry_limit_remaining_headers.extend(
                list(map(float, headers.getall("x-rate-limit-remaining", [])))
            )
            retry_limit_remaining = (
                min(retry_limit_remaining_headers)
                if len(retry_limit_remaining_headers) > 0
                else None
            )

            # both X-Rate-Limit-Limit and X-Rate-Limit-Remaining being 0 indicates concurrent rate limit error
            if retry_limit_limit is not None and retry_limit_remaining is not None:
                if retry_limit_limit == 0 and retry_limit_remaining == 0:
                    logger.warning("Concurrent limit rate exceeded")

            if not date_time or not retry_limit_reset:
                return (
                    None,
                    res_details,
                    resp_body,
                    Exception(ERROR_MESSAGE_429_MISSING_DATE_X_RESET),
                )

            check_429 = self.is_too_many_requests(res_details.status, resp_body)
            if check_429:
                # backoff
                backoff_seconds = self.calculate_backoff(retry_limit_reset, date_time)
                logger.info(
                    f"Hit rate limit. Retry request in {backoff_seconds} seconds."
                )
                logger.debug(f"Value of retry_limit_reset: {retry_limit_reset}")
                logger.debug(f"Value of date_time: {date_time}")
                await self.pause_for_backoff(backoff_seconds)
                if (
                    current_req_start_time + backoff_seconds
                ) - request_start_time > req_timeout and req_timeout > 0:
                    return (None, res_details, resp_body, resp_body)

            # Setup retry request
            attempts += 1
            request["headers"].update(
                {
                    RequestExecutor.RETRY_FOR_HEADER: headers.get(
                        "X-Okta-Request-Id", ""
                    ),
                    RequestExecutor.RETRY_COUNT_HEADER: str(attempts),
                }
            )

            _, res_details, resp_body, error = await self.fire_request_helper(
                request, attempts, request_start_time
            )
            if error:
                return (None, res_details, resp_body, error)

        return (request, res_details, resp_body, error)

    def is_retryable_status(self, status):
        """
        Checks if HTTP status is retryable.

        Retryable statuses: 429, 503, 504
        """
        return status is not None and status in (
            HTTPStatus.TOO_MANY_REQUESTS,
            HTTPStatus.SERVICE_UNAVAILABLE,
            HTTPStatus.GATEWAY_TIMEOUT,
        )

    def is_too_many_requests(self, status, response):
        """
        Determines if HTTP request has been made too many times

        Args:
            status (int): HTTP response status code
            response (json): Response Body

        Returns:
            bool: Returns True if this request has been called too many times
        """
        return (
            response is not None
            and status is not None
            and status == HTTPStatus.TOO_MANY_REQUESTS
        )

    def parse_response(self, request, response):
        pass

    def calculate_backoff(self, retry_limit_reset, date_time):
        return retry_limit_reset - date_time + 1

    async def pause_for_backoff(self, backoff_time):
        await asyncio.sleep(float(backoff_time))

    def set_custom_headers(self, headers):
        self._custom_headers.update(headers)

    def set_session(self, session):
        self._http_client.set_session(session)

    def clear_custom_headers(self):
        self._custom_headers = {}

    def get_custom_headers(self):
        return self._custom_headers

    def get_default_headers(self):
        return self._default_headers
