""" Communication with the .Main uuSubApp. """

import datetime
import json
import logging
import time
from functools import partial
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

import requests
from requests_toolbelt.multipart.encoder import MultipartEncoder
from requests_toolbelt.utils import user_agent as ua

import uun_iot

from .exceptions import TokenCommandError, TokenError, UuAppClientException

logger = logging.getLogger(__name__)


class UuAppClient:
    """Library functions for communication and authentication with uuApp.

    The class gets a dictionary ``config`` containing the configuration of the application.
    The (root) keys and subkeys of interest to this class are

        - ``uuApp``: information about server uuApp

            - ``gateway``: domain gateway of the corresponding uuApp
            - ``uuAppName``: full name of the uuApp
            - ``awid``
            - ``uuCmdList``: containing uuCmd endpoint. The keys are not used
              by :class:`UuAppClient` class - they are used by the application
              user modules (most commonly specified in Python package path
              ``<package_name>/modules/__init__.py``.

        - ``uuThing``: authentication information about the IoT gateway

            - ``uuIdentity``
            - ``accessCode1``
            - ``accessCode2``

        - ``oidcGrantToken``: information about the authentication token
          service used in server communication

            - ``gateway``: (usually ``uuidentity.plus4u.net``) domain gateway of the `OIDC` service
            - ``uuAppName``: (usually ``uu-oidc-maing02``) full name of the `OIDC` uuApp
            - ``awid``
            - ``clientId``
            - ``clientSecret``
            - ``uuCmd``: (usually ``oidc/grantToken``) uuCmd enpoint for token granting
            - ``tokenPath``: (usually ``./oidc-token``) filesystem location (on
              the IoT gateway) where the token will be saved

    Args:
        config: dictionary with configuration, see above
        fn_post_request: optional function to get a token response from server,
            defaults to :meth:`requests.post`
        refresh_token_on_init: call :meth:`.refresh_token` on initialization of this class
        token_leeway: leeway (drift) between Client and Server Clock [s], defaults to 60 s
            fresh token is requested when token is to be expired in less than token_leeway [s]
            the lower the leeway, the more often is token going to be requested
            the token validity time is controlled by the server, this option accounts only for drift
    """

    _config: dict
    _token: Optional[str]
    _user_agent: str
    _token_expires_at: Optional[float]
    _auth_active: bool
    _token_leeway: float

    def __init__(
        self,
        config: dict,
        fn_token_request: Callable[..., requests.Response] = requests.post,
        refresh_token_on_init: bool = True,
        token_leeway: float = 60,
    ):
        self._config = config
        self._token = None
        self._token_expires_at = None
        self._user_agent = ua.UserAgentBuilder("uun-iot", uun_iot.__version__).build()
        self._token_leeway = token_leeway
        self._auth_active = (
            "uuApp" in self._config
            and "uuThing" in self._config
            and "oidcGrantToken" in self._config
        )
        self._fn_token_request = fn_token_request

        if refresh_token_on_init and self._auth_active:
            self.refresh_token()
        else:
            if refresh_token_on_init:
                logger.info(
                    "UuApp connection & gateway authentication is not "
                    "defined in the configuration file. Are you sure, you do not "
                    "want to use the connection? It will not be possible to "
                    "issue requests to uu servers."
                )
            logger.debug("Not granting any auth token.")

    def _store_token(self, token_json: dict) -> None:
        """Save serialized token to file."""
        with open(self._config["oidcGrantToken"]["tokenPath"], "w") as file:
            file.write(json.dumps(token_json))

    def is_token_expired(self) -> bool:
        """
        # view with respect to server-centered time
        #       <-> leeway
        # ++++++==|-----
        #         |
        #    server_token_expire
        #
        # +: when (local) now is there, token is valid
        # =: when (local) now is there, token is valid but apply for a new token
        #      because of the possible leeway between client and server clocks
        # -: when (local) now is there, token is invalid
        """
        return (
            self._token_expires_at is None
            or self._token_expires_at - self._token_leeway < time.time()
        )

    def refresh_token(self) -> bool:
        """Refresh token if expired. If authentication is disabled, noops and returns False.

        Returns False if token refresh was not needed - that is, if token is
        already loaded and valid. Refresh token and return True otherwise.

        Raises:
            TokenError: when a valid token could not be obtained due to network or server error
            TokenCommandError (subclass of TokenError): server-side uuApp returned error
        """
        if not self._auth_active:
            return False

        if self._token is None:
            # load from file
            token_path = self._config["oidcGrantToken"]["tokenPath"]
            try:
                with open(token_path) as json_file:
                    token = json.load(json_file)
                    self._token = token["id_token"]
                    self._token_expires_at = float(token["expires_at"])

                logger.debug(
                    "loaded token from %s, expires at [%s]",
                    token_path,
                    datetime.datetime.fromtimestamp(self._token_expires_at),
                )
            except IOError:
                logger.debug("loading token from %s failed, file not found", token_path)
            except json.JSONDecodeError:
                logger.debug("Invalid JSON token file.")
            except KeyError:
                logger.debug("Invalid structure of token file.")

        if self.is_token_expired():
            self._token_expires_at = self._grant_token(self._fn_token_request)
            logger.debug(
                "granted new token, expires at [%s]",
                datetime.datetime.fromtimestamp(self._token_expires_at),
            )
            return True

        return False

    def _grant_token(self, fn_post_request: Callable[..., requests.Response]) -> float:
        """Grant token.

        Refresh and save the new token.

        Args:
            fn_post_request: function to get a token response from server

        Returns:
             expiry date as unix timestamp

        Raises:
            TokenError: when a valid token could not be obtained due to network or server error
            TokenCommandError: server-side uuApp returned error
        """
        scope_host = self._config["uuApp"]["gateway"]
        scope_context = self._config["uuApp"]["uuAppName"]
        scope_awid = self._config["uuApp"]["awid"]
        post_data = {
            "grant_type": "password",
            "username": None,
            "password": None,
            "accessCode1": self._config["uuThing"]["accessCode1"],
            "accessCode2": self._config["uuThing"]["accessCode2"],
            "scope": f"openid https://{scope_host}/{scope_context}/{scope_awid}",
        }

        try:
            post_data.update(
                {
                    "client_id": self._config["oidcGrantToken"]["clientId"],
                    "client_secret": self._config["oidcGrantToken"]["clientSecret"],
                }
            )
        except KeyError:
            # does not matter if these are not present - not required - depends on server settings
            pass

        headers = {"Content-Type": "application/json", "User-Agent": self._user_agent}
        uucmd = self._config["oidcGrantToken"]["uuCmd"]

        host = self._config["oidcGrantToken"]["gateway"]
        uu_app_name = self._config["oidcGrantToken"]["uuAppName"]
        awid = self._config["oidcGrantToken"]["awid"]
        url = f"https://{host}/{uu_app_name}/{awid}/{uucmd}"
        response = None
        try:
            response = fn_post_request(url, headers=headers, json=post_data, timeout=20)
            response.raise_for_status()
        except requests.HTTPError as e:
            assert response is not None
            logger.log(
                logging.DEBUG,
                "`%s` POST response (%s): %s",
                uucmd,
                response.status_code,
                response.text,
            )
            status = response.status_code
            try:
                error_json = response.json()
            except json.JSONDecodeError as exc:
                raise TokenError(
                    "A server error occured while getting a token and could not decode"
                    " JSON error object."
                ) from exc
            if "uuAppErrorMap" in error_json:
                raise TokenCommandError(error_json["uuAppErrorMap"], status) from e
            raise TokenError(
                "Server error occured but could not get more information."
            ) from e
        except requests.RequestException as e:
            logger.log(logging.WARNING, "`%s` POST RequestException when getting a new token: %s", uucmd, str(e))
            raise TokenError(
                "A network error occured when requesting new token."
            ) from e

        try:
            token_json = response.json()
            self._token = token_json["id_token"]
            token_json["expires_at"] = time.time() + float(token_json["expires_in"])
        except json.JSONDecodeError as e:
            raise TokenError(
                "While getting a token, could not decode received JSON server response."
            ) from e
        except KeyError as e:
            raise TokenError("Token did not have required fields.") from e
        except ValueError as e:
            raise TokenError("Token has invalid value types.") from e

        self._store_token(token_json)
        return token_json["expires_at"]

    def get_uucmd_url(self, uucmd: str) -> str:
        """Return fully quallified URL from relative uucmd string.

        Args:
            uucmd: uucmd in relative path, eg. ``gateway/getWeather``
        """
        host = self._config["uuApp"]["gateway"]
        uu_app_name = self._config["uuApp"]["uuAppName"]
        awid = self._config["uuApp"]["awid"]
        return f"https://{host}/{uu_app_name}/{awid}/{uucmd}"

    def get_auth_headers(self, content_type: str = "application/json"):
        """Get request headers with Bearer authentication.
        The token is refreshed automatically, if expired.

        Headers also includes specified ``Content-type`` and uun-iot's ``User-Agent``.
        """
        if self.is_token_expired():
            self.refresh_token()
        return {
            "Authorization": f"Bearer {self._token}",
            "Content-type": content_type,
            "User-Agent": self._user_agent,
        }

    def custom_request(
        self,
        custom_send_f: Callable[[object], requests.Response],
        dto_in: Optional[object] = None,
        label: Optional[str] = None,
        log_level: int = logging.WARNING,
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """
        Custom request wrapper for catching :mod:`requests`-related and token-acquiring-related
        Exceptions. Token errors, request exceptions, including HTTP non-success status codes,
        are suppressed, logged with specified level and returned.

        Supply your own :attr:`custom_send_f` function (taking :attr:`dto_in` as
        only argument) and outputting a :attr:`requests.Response`. To make an
        authenticated request, you should use authentication headers available
        through :meth:`.get_auth_headers`. Note that you can specify the headers
        either directly during *definition*, or during *evaluation* of
        :attr:`custom_send_f`. The latter is preferred due to handling TokenErrors
        in a unified way with other exceptions. Note that token acquisition is not in
        scope of :meth:`.custom_request` and has to be supplied by caller.

        See also :meth:`.get_uucmd_url` and :meth:`.get_auth_headers` for
        constructing own function.

        .. note::
            Example for ``custom_send_f`` follows.

            .. code-block:: python

                uucmd = "gateway/heartbeat"
                full_url = uuappclient.get_uucmd_url(uucmd)
                def handler(dto_in):
                    return requests.get(full_url, headers=self.get_auth_headers(), json=dto_in_, timeout=20)
                uuappclient.custom_request(handler, {"key": "value"}, uucmd)


        This function will
            - call :attr:`custom_send_f` with :attr:`dto_in` positional
              argument,
            - raise for non-success HTTP status codes
            - catch :attr:`requests.HTTPError` and
              :attr:`requests.RequestException`,
            - log the exceptions and
            - returns response along with the exception

        Args:
            custom_send_f: custom send function which is used instead of
                :meth:`requests.get` label: label used in log messages to identify the request
            dto_in: dictionary data to send. If ``None``, pass ``{}`` to :attr:`custom_send_f`.
            log_level: optional logging level for possible exceptions, defaults to ``logging.WARNING``

        Returns: Tuple ``(response, exc)``,
                - :class:`requests.Response` response, ``None`` if non-HTTP (not
                  200 HTTP status code) exception was raised
                - ``exc``: :class:`requests.RequestException` or :class:`TokenError` if the same
                  exception was raised during request. ``None`` if no exception during request was raised

        Raises: None
        """

        if dto_in is None:
            dto_in = {}

        response = None
        exc = None
        try:
            response = custom_send_f(dto_in)
            response.raise_for_status()
        except requests.HTTPError as e:
            assert response is not None
            logger.log(
                log_level,
                "`%s` response (%s): %s",
                label,
                response.status_code,
                response.text,
            )
            exc = e
        except requests.RequestException as e:
            logger.log(log_level, "`%s` RequestException: %s", label, str(e))
            exc = e
        except TokenError as e:
            logger.log(log_level, "`%s` TokenError: %s", label, str(e))
            exc = e

        return response, exc

    def get(
        self,
        uucmd: str,
        dto_in: Optional[dict] = None,
        log_level: int = logging.WARNING,
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """
        Get request using an authenticated Bearer token.
        Token errors, request exceptions, including HTTP non-success status codes, are suppressed,
        logged with specified level and returned.
        See :meth:`.custom_request` for more informaton.

        Args:
            uucmd: UuCmd in relative path format, eg. ``gateway/heartbeat``
            dto_in: data to send
            log_level: optional logging level for possible exceptions,
                defaults to ``logging.WARNING``

        Returns:
            Tuple ``(response, exc)``,
                - :class:`requests.Response` response, ``None`` if non-HTTP (not 200 HTTP status code)
                  exception was raised
                - :class:`requests.RequestException` exc is None if no exception
                  during request was raised
        """

        full_url = self.get_uucmd_url(uucmd)

        def get_f(dto_in_):
            # ask for headers in the handler function, custom_request first refreshes token
            logger.debug("`%s` GET request, payload: %s", uucmd, dto_in)
            return requests.get(
                full_url, headers=self.get_auth_headers(), json=dto_in_, timeout=20
            )

        return self.custom_request(get_f, dto_in, uucmd, log_level)

    def get_ignore_http_err(
        self,
        uucmd: str,
        dto_in: Optional[dict] = None,
        log_level: int = logging.WARNING,
    ) -> requests.Response:
        """
        GET request with authentication. Ignore HTTP errors, raise errors for other network errors.
        Logs all errors (including HTTP non-200) with :attr:`log_level` severity.
        See :meth:`.custom_request` for more informaton.

        Args:
            uucmd: uuCmd path of the target uuApp
            dto_in: data to be passed as JSON input to the uuApp
            log_level: logging library level of network error, default logging.WARNING

        Raises: :class:`requests.RequestException` or :class:`TokenError`
        """

        r, exc = self.get(uucmd, dto_in, log_level)
        if exc is not None:
            try:
                raise exc
            except requests.HTTPError:
                pass
        assert r is not None
        return r

    def post(
        self,
        uucmd: str,
        dto_in: Optional[Union[dict, MultipartEncoder]] = None,
        log_level: int = logging.WARNING,
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """
        POST request using an authenticated Bearer token.
        Request exceptions, including HTTP non-success status codes, are suppressed, logged with specified level and returned.
        See :meth:`.custom_request` for more informaton.

        Args:
            uucmd: UuCmd in relative path format, eg. ``gateway/heartbeat``
            dto_in: data to send, dictionary or MultipartEncoder data
            log_level: optional logging level for possible exceptions,
                defaults to ``logging.WARNING``

        Returns:
            Tuple ``(response, exc)``,
                - :class:`requests.Response` response, ``None`` if non-HTTP (not 200 HTTP status code)
                  exception was raised
                - :class:`requests.RequestException` exc is None if no exception
                  during request was raised

        Raises: ValueError if ``dto_in`` type is not ``dict`` or ``MultipartEncoder``
        """

        if dto_in is None:
            dto_in = {}

        full_url = self.get_uucmd_url(uucmd)
        if isinstance(dto_in, dict):
            headers = self.get_auth_headers
            def post_f(dto_in_):
                logger.debug("`%s` POST request, payload: %s", uucmd, dto_in)
                return requests.post(full_url, headers=headers(), json=dto_in_, timeout=20)
        elif isinstance(dto_in, MultipartEncoder):
            headers = partial(self.get_auth_headers, dto_in.content_type)
            def post_f(dto_in_):
                logger.debug("`%s` MULTIPART request, payload: %s", uucmd, dto_in)
                return requests.post(full_url, headers=headers(), data=dto_in_, timeout=20)
        else:
            raise ValueError(f"Invalid dto_in type: {type(dto_in)}")

        return self.custom_request(post_f, dto_in, uucmd, log_level)

    def post_ignore_http_err(
        self,
        uucmd: str,
        dto_in: Optional[Union[dict, MultipartEncoder]] = None,
        log_level: int = logging.WARNING,
    ) -> requests.Response:
        """
        POST request with authentication. Ignore HTTP errors, raise errors for other network errors.
        Logs all errors (including HTTP non-200) with :attr:`log_level` severity.

        Args:
            uucmd: uuCmd path of the target uuApp
            dto_in: data to be passed as JSON input to the uuApp
            log_level: logging library level of network error, default logging.WARNING

        Raises: :class:`requests.RequestException` or :class:`TokenError`
        """
        r, exc = self.post(uucmd, dto_in, log_level)
        if exc is not None:
            try:
                raise exc
            except requests.HTTPError:
                pass
        assert r is not None
        return r

    def multipart(
        self,
        uucmd: str,
        dto_in: Optional[dict] = None,
        log_level: int = logging.WARNING,
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """POST request with authentication and MULTIPART encoded data
        with oidc2 authentication. Useful for sending binary data (images, ...). See
        https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder
        for information about multipart encoder.

        Pass data to dto_in, they will be transformed using MultipartEncoder and passed to
        :meth:`UuAppClient.post` (see for more information and usage).
        """

        if dto_in is None:
            return self.post(uucmd, None, log_level)
        multipart_encoded = MultipartEncoder(fields=dto_in)
        return self.post(uucmd, multipart_encoded, log_level)


class UuCmdSession:
    """
    Send all data with UuCmd in one session to avoid creating multiple connections.

    Args:
        uuclient: initialized authentication client
        uucmd: UuCmd in relative path format, eg. ``gateway/getWeather``
        log_level: ``logging`` library log level to use for network and HTTP errors,
            defaults to :attr:`logging.WARNING`
    """

    def __init__(
        self, uuclient: UuAppClient, uucmd: str, log_level: int = logging.WARNING
    ):
        self._uucmd = uucmd
        self._loglevel = log_level
        self._url = uuclient.get_uucmd_url(uucmd)
        self._session = requests.Session()
        self._uuclient = uuclient

    def update_headers(self):
        """Update session auth headers ensured to containing a valid Bearer token."""
        self._session.headers.update(self._uuclient.get_auth_headers())

    def get(
        self, data: Optional[dict] = None, log_level: int = logging.WARNING
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """Authenticated GET request with session.
        See :meth:`UuAppClient.get` for more information."""

        full_url = self._uuclient.get_uucmd_url(self._uucmd)

        def get_f(dto_in_):
            self.update_headers()
            logger.debug("`%s` GET request, payload: %s", self._uucmd, data)
            return self._session.get(full_url, json=dto_in_, timeout=20)

        return self._uuclient.custom_request(get_f, data, self._uucmd, log_level)

    def post(
        self, data: Optional[dict] = None, log_level: int = logging.WARNING
    ) -> Tuple[
        Optional[requests.Response],
        Optional[Union[TokenError, requests.RequestException]],
    ]:
        """Authenticated POST request with session.
        See :meth:`UuAppClient.post` for more information."""

        full_url = self._uuclient.get_uucmd_url(self._uucmd)

        def post_f(dto_in_):
            self.update_headers()
            logger.debug("`%s` POST request, payload: %s", self._uucmd, data)
            return self._session.post(full_url, json=dto_in_, timeout=20)

        return self._uuclient.custom_request(post_f, data, self._uucmd, log_level)
