from functools import lru_cache
import logging
import os
import re
import time
from typing import Optional, Union
import warnings

import requests
from requests.adapters import HTTPAdapter
import urllib3
from urllib3.util.retry import Retry
from requests_ratelimiter import LimiterAdapter
from pyrate_limiter import SQLiteBucket

import pynsee
from pynsee.utils._get_credentials import _get_credentials_from_configfile
from pynsee.utils._create_insee_folder import _create_insee_folder
from pynsee.constants import SIRENE_KEY, HTTPS_PROXY_KEY, HTTP_PROXY_KEY

logger = logging.getLogger(__name__)


@lru_cache(maxsize=None)
def _warn_env_credentials(var) -> None:
    logger.warning(
        "Existing environment variable %s used, instead of locally "
        "saved credentials",
        var,
    )


def get_env_case_insensitive(x: str):
    """Fetch an environment variable (case insentitive)"""
    return os.environ.get(x, os.environ.get(x.upper()))


class PynseeAPISession(requests.Session):
    """
    Session class used throughout pynsee for http(s) queries. This session
    object uses a specific set of config values, use
    `help(PynseeAPISession.__init__)` for more informations.
    """

    INSEE_API_CODES = {
        # 200:"Opération réussie",
        # 301:"Moved Permanently" -> r.headers['location']
        400: "Bad Request",
        401: "Unauthorized : token missing",
        403: "Forbidden : missing subscription to API",  #
        # 404: "Not Found : no results available",
        406: "Not acceptable : incorrect 'Accept' header",
        413: "Too many results, query must be splitted",
        414: "Request-URI Too Long",
        # Unused (managed through a specific if/else), kept for memory purpose
        # 429: "Too Many Requests : allocated quota overloaded",
        500: "Internal Server Error ",
        503: "Service Unavailable",
    }

    def __init__(
        self,
        sirene_key: Optional[str] = None,
        http_proxy: Optional[str] = None,
        https_proxy: Optional[str] = None,
    ):
        """
        Generates a PynseeAPISession used to perform http(s) requests in pynsee

        Arguments are loaded from this order :
            * explicit argument;
            * if not given, from os.environ
            * if still not found, will try to get the previous valid
              configuration.

        **This function should only be loaded with arguments through
        pynsee.utils.init_conn**.

        See :func:`~pynsee.utils.init_conn` to create a new configuration.

        Parameters
        ----------
        sirene_key : str, optional
            Token generated by the API portal. Use this if you wish to access
            the SIRENE API and pynsee has not already been configured. The
            default is None.

        """

        super().__init__()
        self._mount_adapters()

        config = {
            HTTP_PROXY_KEY: http_proxy,
            HTTPS_PROXY_KEY: https_proxy,
            SIRENE_KEY: sirene_key,
        }

        stored_config = _get_credentials_from_configfile()

        for k, v in config.items():
            venv = get_env_case_insensitive(k)
            if v is None:
                # Use env variable if it exist, otherwise stored value.
                # Note: v might be set to the empty string for proxy config,
                # which is **not** the same as None
                if venv is not None:
                    config[k] = venv
                    _warn_env_credentials(k)
                else:
                    config[k] = stored_config.get(k)

        self.proxies.update(
            {
                "http": config[HTTP_PROXY_KEY],
                "https": config[HTTPS_PROXY_KEY],
            }
        )

        username = os.environ.get("USERNAME", "username")
        version = pynsee.__version__
        useragent = {"User-Agent": f"python_pynsee_{username}/{version}"}

        # Note : geoplatform seems to impose the "/version" to the user-agent
        self.headers.update(useragent)

        self.sirene_key = config[SIRENE_KEY]
        self.headers["X-INSEE-Api-Key-Integration"] = self.sirene_key

    def _mount_adapters(self):
        """
        Mount adapters for http queries :
            * default retries config for every http(s) requests
            * and with specific rate-limiting for each API called

        Those rate-limits are ensured with requests-ratelimiter and an SQLite
        backend, stored by default in pynsee's appdata folder. They are **NOT**
        (yet) adequate for any task shared between multiple machines.

        """

        # default retries adapter
        # 429 Too Many Requests (RFC 6585)
        # 502 Bad Gateway
        # 503 Service Unavailable
        # 504 Gateway Timeout
        retry_adapt = Retry(
            total=7, backoff_factor=1, status_forcelist=[429, 502, 503, 504]
        )
        adapter = HTTPAdapter(max_retries=retry_adapt)
        self.mount("http://", adapter)
        self.mount("https://", adapter)

        insee_folder = _create_insee_folder()
        rate_folder = os.path.join(insee_folder, "rate_limiter")

        os.makedirs(rate_folder, exist_ok=True)

        def kw_adapter(api: str):
            "set given arguments to configure an ratelimiter adapter"
            return {
                "max_retries": retry_adapt,
                "bucket_class": SQLiteBucket,
                "bucket_kwargs": {
                    "path": os.path.join(rate_folder, f"{api}.db"),
                    "isolation_level": "EXCLUSIVE",
                    "check_same_thread": False,
                },
            }

        # set custom adapters for each API, to allow a separate rate tracking
        # for each
        rates = {
            "sirene": {
                "url": "https://api.insee.fr/api-sirene",
                # 30 queries/min for SIRENE
                "rates": {"per_minute": 30},
            },
            "bdm": {
                "url": "https://api.insee.fr/series/BDM",
                # 30 queries/min for BDM: from documentation, though there is
                # no need of a token?!
                "rates": {"per_minute": 30},
            },
            "sdmx": {
                "url": "https://bdm.insee.fr",
                # not documented afik, but let's set it to the same rate as bdm
                "rates": {"per_minute": 30},
            },
            "localdata": {
                "url": "https://api.insee.fr/donnees-locales",
                # 30 queries/min for (old) localdata: from documentation,
                # though there is no need of a token?!
                "rates": {"per_minute": 30},
            },
            "melodi": {
                "url": "https://api.insee.fr/melodi",
                # 30 queries/min for melodi: from documentation, though there
                # is no need of a token?!
                "rates": {"per_minute": 30},
            },
            "metadata": {
                "url": "https://api.insee.fr/metadonnees/",
                # 10_000 queries/min for metadata (from subscription page)
                # but INSEE confirm this is a default value (corresponding
                # to the theoric coverage of simultaneous requests by the
                # portal). Let's go for a more realistic rate instead...
                "rates": {"per_minute": 600},
            },
            "nominatim": {
                "url": "https://nominatim.openstreetmap.org/",
                # https://operations.osmfoundation.org/policies/nominatim/
                "rates": {"per_second": 1},
            },
            # note : nothing found in data.geopf.fr's documentation ?
        }

        for api, config in rates.items():
            adapter = LimiterAdapter(**config["rates"], **kw_adapter(api))
            self.mount(config["url"], adapter)

        # add retry on 400 for geoplatform
        retry_adapt = Retry(
            total=7,
            backoff_factor=1,
            status_forcelist=[400],
        )
        adapter = HTTPAdapter(max_retries=retry_adapt)
        self.mount("https://data.geopf.fr", adapter)

    def request(
        self,
        method: str,
        url: str,
        timeout: Union[tuple, int] = (10, 15),
        raise_if_not_ok: bool = True,
        **kwargs,
    ) -> requests.Response:
        """
        Overwrite requests.Session's request. Allows to set specific timeouts
        and to raise exceptions if response is not ok. Also silences urllib's
        warnings on insecure requests being performed.

        Parameters
        ----------
        method : str
            Usually, "GET" or "POST".
        url : str
            URL to query.
        timeout : Union[tuple, int], optional
            timeout used for the query. See requests' documentation for more
            info. The default is (10, 15).
        raise_if_not_ok : bool, optional
            If set to True, a RequestException will automatically be raised if
            the response is not ok (= `status_code` < 400).
            The default is True.
        **kwargs :
            Any other kwargs are passed directly to requests.Session.request

        Raises
        ------
        RequestException
            If the requests fails (only if raise_if_not_ok is set to True).

        Returns
        -------
        response : requests.Response
            HTTP response from the requests package.

        """

        logger.info(url)
        with warnings.catch_warnings():
            warnings.simplefilter(
                "ignore", urllib3.exceptions.InsecureRequestWarning
            )
            response = super().request(method, url, timeout=timeout, **kwargs)
            if raise_if_not_ok and not response.ok:
                raise requests.exceptions.RequestException(
                    f"response was {response.status_code} for {url}",
                    response=response,
                )
        return response

    def request_insee(
        self,
        api_url: Optional[str] = None,
        sdmx_url: Optional[str] = None,
        file_format: str = "application/xml",
        print_msg: bool = True,
        raise_if_not_ok: bool = False,
    ) -> requests.Response:
        """
        Performs a query to INSEE, either through API or sdmx_url

        Parameters
        ----------
        api_url : Optional[str]
            URL to be queried on the API portal, optional. The default is None.
        sdmx_url : Optional[str]
            URL to be queried on the SDMX (Statistical Data and Metadata
            eXchange) webservice of INSEE, optional. The default is None.
        file_format : str, optional
            Which king of file to expect. This currently alters the output of
            INSEE's APIs. The default is "application/xml".
        print_msg : bool, optional
            If True, will log critical entries to warn of failures to query
            the APIs. The default is True.
        raise_if_not_ok : bool, optional
            See PynseeAPISession.request. The default is False.

        Raises
        ------
        RequestException
            In case sirene_key is missing for a call to SIRENE API.

        ValueError
            If neither api_url or sdmx_url have been set.

        Returns
        -------
        result : requests.Response
            HTTP response from the requests package.

        """

        try:
            if api_url and self._query_is_valid_sirene_call(api_url):
                if not self.sirene_key:
                    commands = "\n\ninit_conn(sirene_key='my_sirene_key')\n"
                    msg = (
                        "Sirene key is missing, please check your credentials "
                        "on portail-api.insee.fr !\n"
                        "Please do the following to use your "
                        f"credentials: {commands}\n\n"
                        "If your token still does not work, please try to "
                        "clear the cache :\n "
                        "from pynsee.utils import clear_all_cache;"
                        " clear_all_cache()\n"
                    )
                    raise requests.exceptions.RequestException(msg)

            if api_url:
                result = self._request_api_insee(
                    api_url,
                    file_format=file_format,
                    raise_if_not_ok=raise_if_not_ok,
                    print_msg=print_msg,
                )
            elif sdmx_url:
                result = self._request_sdmx_insee(
                    sdmx_url, raise_if_not_ok=raise_if_not_ok
                )
            else:
                raise ValueError(
                    "either api_url or sdmx_url must be set: urls are "
                    "currently missing"
                )

        except requests.exceptions.RequestException:
            if sdmx_url:
                if print_msg:
                    logger.critical("SDMX web service used instead of API")
                result = self._request_sdmx_insee(
                    sdmx_url, raise_if_not_ok=raise_if_not_ok
                )
            else:
                # in that case, simply raise the exception
                raise

        return result

    def _query_is_valid_sirene_call(self, url: str) -> bool:
        """
        Check if an URL is a valid API call to SIRENE.

        Parameters
        ----------
        url : str
            Url to check.

        Returns
        -------
        bool
            True if the API is a SIRENE api query. False if not.

        """
        return bool(re.match(".*api-sirene.*", url))

    def _request_sdmx_insee(
        self, url: str, raise_if_not_ok: bool = True
    ) -> requests.Response:
        """
        Performs a "get" query on the SDMX (Statistical Data and Metadata
        eXchange) webservice of INSEE.

        Parameters
        ----------
        url : str
            URL to query.
        raise_if_not_ok : bool, optional
            If set to True, a RequestException will automatically be raised if
            the response is not ok (= `status_code` < 400).
            The default is True.

        Raises
        ------
        RequestException
            If the requests fails (only if raise_if_not_ok is set to True).

        Returns
        -------
        results : requests.Response
            HTTP response from the requests package.

        """

        results = self.get(url, verify=False)
        if raise_if_not_ok and not results.ok:
            raise requests.exceptions.RequestException(
                results.text + "\n" + url, response=results
            )
        return results

    def _request_api_insee(
        self,
        url: str,
        file_format: str = "application/xml",
        raise_if_not_ok: bool = False,
        print_msg: bool = True,
    ) -> requests.Response:
        """
        Performs a "get" query on the INSEE's API portal.

        Parameters
        ----------
        url : str
            URL to query.
        file_format : str, optional
            Which king of file to expect. This currently alters the output of
            INSEE's APIs. The default is "application/xml".
        raise_if_not_ok : bool, optional
            If set to True, a RequestException will automatically be raised if
            the response is not ok (= `status_code` < 400).
            The default is False.
        print_msg : bool, optional
            If True, will log critical entries to warn that the call to
            APIs failed. The default is True.

        Raises
        ------
        RequestException
            In case of requests failure.

        Returns
        -------
        results : requests.Response
            HTTP response from the requests package.

        """

        headers = {"Accept": file_format}

        try:
            results = self.get(
                url,
                headers=headers,
                verify=False,
                raise_if_not_ok=raise_if_not_ok,
            )
        except Exception:
            results = None
            success = False
        else:

            code = results.status_code

            if "status_code" not in dir(results):
                success = False
            elif code == 429:

                display_warnings = os.environ.get(
                    "PYNSEE_DISPLAY_ALL_WARNINGS", ""
                ).lower()
                if display_warnings == "true":
                    msg = (
                        "API query number limit reached - "
                        "function might be slowed down"
                    )
                    logger.warning(msg)

                time.sleep(10)

                request_again = self._request_api_insee(
                    url=url, file_format=file_format
                )

                return request_again

            elif code in self.INSEE_API_CODES and raise_if_not_ok:
                msg = (
                    f"Error {code} - {self.INSEE_API_CODES[code]}\n"
                    f"Query:\n{url}"
                )
                raise requests.exceptions.RequestException(
                    msg, response=results
                )
            elif code not in (200, 404) and raise_if_not_ok:
                # Note : 404 means no results from API, this should not trigger
                # any exception
                success = False
            else:
                success = True

        if success is True:
            return results

        else:

            try:
                results = results.text
            except Exception:
                results = ""

            if print_msg:
                msg = (
                    "An error occurred !\n"
                    f"Query : {url}\nResults : {results}\n"
                )

                logger.warning(msg)
            raise requests.exceptions.RequestException(
                "An error occured", response=results
            )

    def _test_connections(self) -> dict:
        """
        Test the valid connection to each API.

        Raises
        ------
        RequestException
            If we got a proxy error or a 404 exception.
        ValueError
            If no API was reached.

        Returns
        -------
        invalid_requests : dict
            Dict of {"api name": response.status_code} for invalid queries.

        """
        queries = {
            "BDM": "https://api.insee.fr/series/BDM/dataflow/FR1/all",
            "Metadata": "https://api.insee.fr/metadonnees/codes/cj/n3/5599",
            "Sirene": "https://api.insee.fr/api-sirene/3.11/siret?q=activitePrincipaleUniteLegale:86.10*&nombre=1000",
            "Local Data": "https://api.insee.fr/donnees-locales/donnees/geo-SEXE-DIPL_19@GEO2020RP2017/FE-1.all.all",
        }
        invalid_requests = {}

        for api, api_url in queries.items():

            try:
                if api == "Sirene" and not self.sirene_key:
                    # the user is probably not expecting to use SIRENE anyway:
                    # simple warning and jump to next API in order to avoid
                    # urllib retries
                    logger.warning(
                        "Remember to subscribe to SIRENE API on api.insee.fr "
                        "if you ever want to use it (type `help(init_conn)` "
                        "to know more about this)."
                    )
                    invalid_requests[api] = 999

                self.get(api_url, verify=False, stream=True)
            except requests.exceptions.RequestException as exc:
                # raise error only if 404 or not status code was returned
                if not hasattr(exc.response, "status_code"):
                    raise requests.exceptions.RequestException(
                        f"Could not reach {api} at {api_url}, please control "
                        "your proxy configuration "
                        f"- proxies were {self.proxies}."
                    ) from exc
                elif exc.response.status_code == 404:
                    raise requests.exceptions.RequestException(
                        f"Could not reach {api} at {api_url}, the server "
                        "returned 404 (not found); please get in touch if "
                        "the issue persists."
                    ) from exc

                invalid_requests[api] = exc.response.status_code

        if len(invalid_requests) == len(queries):
            raise ValueError(
                "No API was reached. That's strange, please get in touch if "
                "the issue persists."
            )

        return invalid_requests
