# Copyright (C) 2020-2022 HERE Global B.V. and its affiliate(s).
# All rights reserved.
#
# This software and other materials contain proprietary information
# controlled by HERE and are protected by applicable copyright legislation.
# Any use and utilization of this software and other materials and
# disclosure to any third parties is conditional upon having a separate
# agreement with HERE for the access, use, utilization or disclosure of this
# software. In the absence of such agreement, the use of the software is not
# allowed.

"""
This module contains a :class:`BaseApi` class to perform API operations.
"""
import logging
import urllib.request
from datetime import datetime
from typing import TYPE_CHECKING, Optional, Union

import backoff
import requests
import requests.adapters
from here.platform import __version__
from here.platform.config import ApplicationConfig, PlatformConfig
from here.platform.exceptions import (
    AuthenticationException,
    InequalReadsException,
    ResourceLimitExceededException,
    ServiceUnavailableException,
    TooManyRequestsException,
)
from requests import ConnectionError, Response

if TYPE_CHECKING:
    from here.platform.auth import Auth


class BaseApi:
    """
    This class provides access to some Restful API operations.
    """

    def __init__(
        self,
        platform_config: PlatformConfig,
        application_config: ApplicationConfig,
        auth: Optional["Auth"],
        proxies: Optional[dict] = None,
    ):
        """
        Instantiate API with auth token.

        :param platform_config: a mandatory :class:`PlatformConfig` object to provide
            configuration information for the API.
        :param application_config: a mandatory :class:`ApplicationConfig` object to provide
            configuration information for the API.
        :param auth: an Authentication instance.
        :param proxies: an optional proxy configuration. Defaults to the environment proxy
        configuration.
        """
        self.auth = auth
        self.platform_config = platform_config
        self.application_config = application_config
        self.proxies = proxies or urllib.request.getproxies()

        self._user_agent = "PySDK/" + __version__

        # create our own dedicated HTTP connection pool.
        self._http_pool = requests.Session()
        adapter = requests.adapters.HTTPAdapter()
        # use our pool for both http and https
        self._http_pool.mount("http://", adapter)
        self._http_pool.mount("https://", adapter)

    @property
    def headers(self) -> dict:
        """
        Return HTTP request headers with Bearer token in ``Authorization``
        field.

        :return: authorization tokens
        """
        return {"Authorization": f"Bearer {self.auth.token}"} if self.auth else {}

    def format_url(self, base_url: str, path: str) -> str:
        """
        Formats the URL based on the base URL and path.

        :param base_url: The base URL for the request.
        :param path: The path for the URL. This is expected to have the leading '/'.
        :return: The formatted URL.
        """
        assert path.startswith("/")
        return f"{base_url}{path}"

    def get(
        self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None, **kwargs
    ) -> Response:
        """
        Perform a get request of an API at a specified URL with backoff.

        :param url: URL of the API.
        :param params: Parameters to pass to the API.
        :param headers: Request headers. Defaults to the Api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        return self.request("GET", url, params=params, headers=headers, **kwargs)

    def head(
        self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None, **kwargs
    ) -> Response:
        """
        Perform a head request of an API at specified URL with backoff.

        :param url: URL of the API.
        :param params: Parameters to pass to the API.
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        return self.request("HEAD", url, params=params, headers=headers, **kwargs)

    def post(
        self,
        url: str,
        data: Optional[Union[dict, list, bytes, str]] = None,
        params: Optional[dict] = None,
        headers: Optional[dict] = None,
        **kwargs,
    ) -> Response:
        """
        Perform a post request of an API at a specified URL with backoff.

        :param url: URL of the API.
        :param data: Post data for http request.
        :param params: Parameters to pass to the API.
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        if isinstance(data, (dict, list)):
            return self.request("POST", url, json=data, params=params, headers=headers, **kwargs)
        else:
            return self.request("POST", url, data=data, params=params, headers=headers, **kwargs)

    def put(
        self,
        url: str,
        data: Optional[Union[dict, list, bytes, str]] = None,
        params: Optional[dict] = None,
        headers: Optional[dict] = None,
        **kwargs,
    ) -> Response:
        """
        Perform a put request of an API at a specified URL with backoff.

        :param url: URL of the API
        :param data: Put data for http request.
        :param params: Parameters to pass to the API.
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        if isinstance(data, (dict, list)):
            return self.request("PUT", url, json=data, params=params, headers=headers, **kwargs)
        else:
            return self.request("PUT", url, data=data, params=params, headers=headers, **kwargs)

    def patch(
        self,
        url: str,
        data: Optional[Union[dict, list, bytes, str]] = None,
        params: Optional[dict] = None,
        headers: Optional[dict] = None,
        **kwargs,
    ) -> Response:
        """
        Perform a patch request of an API at a specified URL with backoff.

        :param url: URL of the API
        :param data: Patch data for http request.
        :param params: Parameters to pass to the API.
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        if isinstance(data, (dict, list)):
            return self.request("PATCH", url, json=data, params=params, headers=headers, **kwargs)
        else:
            return self.request("PATCH", url, data=data, params=params, headers=headers, **kwargs)

    def delete(
        self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None, **kwargs
    ) -> Response:
        """
        Perform a delete request of an API at a specified URL with backoff.

        :param url: URL of the API
        :param params: parameters to pass to the API.
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """
        return self.request("DELETE", url, params=params, headers=headers, **kwargs)

    def request(
        self,
        method: str,
        url: str,
        headers: Optional[dict] = None,
        **kwargs,
    ) -> Response:
        """
        Performs a request of an API at a specified URL with a backoff.
        :param method: The HTTP method to perform.
        :param url: URL of the API
        :param headers: Request headers. Defaults to the api headers property.
        :param kwargs: Optional arguments that request takes.
        :return: response from the API.
        """

        headers = headers or self.headers
        headers["User-Agent"] = self._user_agent

        def validate_resp_content_length(resp):
            """
            Validate content actual length vs expected length.
            If length differs raise InequalReadsException.

            :param resp: Response object.
            :raises InequalReadsException: if content acutal vs expected length differs.
            """
            expected_length = resp.headers.get("Content-Length")
            if expected_length:
                expected_length = int(expected_length)
                actual_length = int(resp.raw.tell())
                if actual_length != expected_length:
                    raise InequalReadsException(resp)

        # Inner backoff loop retries on connection error. Outer backoff loop retries based on too
        # many requests.
        @backoff.on_exception(
            backoff.expo,
            (
                ServiceUnavailableException,
                TooManyRequestsException,
                requests.exceptions.ReadTimeout,
                InequalReadsException,
            ),
            max_time=self.application_config.retry_max_time,
        )
        @backoff.on_exception(
            backoff.constant,
            (ConnectionError, requests.exceptions.ConnectTimeout),
            max_time=self.application_config.connect_retry_max_time,
        )
        def request_impl() -> Response:
            """
            Perform a request of an API at a specified URL.

            :raises TooManyRequestsException: If platform responds with HTTP 429.
            :raises ServiceUnavailableException: If platform responds with HTTP 408, 500, 502, 503,
            or 504.
            :raises AuthenticationException: If platform responds with HTTP 401 or 403.
            :raises ResourceLimitExceededException: If platform responds with HTTP 402.
            :return: response from the API.
            """
            logging.debug(f"{method} {url}")
            start_time = datetime.now()
            resp = self._http_pool.request(
                method,
                url,
                headers=headers,
                proxies=self.proxies,
                timeout=(
                    self.application_config.connect_timeout,
                    self.application_config.read_timeout,
                ),
                **kwargs,
            )
            run_time = datetime.now() - start_time
            logging.debug(f"{method} {url} - {resp.status_code} - {run_time.total_seconds()} sec.")
            # By-Passing HEAD method because Response will have headers['content-length']
            # but no message body. So that InequalReadsException is not raised.
            # The HTTP HEAD method requests the headers that would be returned
            # if the HEAD request's URL was instead requested with the HTTP GET method.
            # For example,if a URL might produce a large download,
            # a HEAD request could read its Content-Length header to check the filesize
            # without actually downloading the file.
            # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD
            if method not in ["HEAD"] and resp.status_code not in [404, 429, 500, 502, 503, 504]:
                validate_resp_content_length(resp=resp)

            if resp.status_code == 429:
                raise TooManyRequestsException(resp)
            # Request timeout, internal server error, invalid gateway, service unavailable,
            # or gateway timeout.
            elif resp.status_code in [408, 500, 502, 503, 504]:
                raise ServiceUnavailableException(resp)
            elif resp.status_code == 402:
                raise ResourceLimitExceededException(resp)
            elif resp.status_code in [401, 403]:
                raise AuthenticationException(resp)
            return resp

        result_resp: Response = request_impl()
        return result_resp
