# -*- coding: utf-8 -*-
"""Utilities collection."""

# Created: 2019-05-08 Guy K. Kloss <guy@mysinglesource.io>
#
# (c) 2018-2019 by SingleSource Limited, Auckland, New Zealand
#     http://mysinglesource.io/
#     Apache 2.0 Licence.
#
# This work is licensed under the Apache 2.0 open source licence.
# Terms and conditions apply.
#
# You should have received a copy of the licence along with this
# program.

import base64
import json
import logging
import requests
import time
from typing import Optional, Dict, Union
import urllib

from sspyjose.jwe import Jwe
from sspyjose.jwk import Jwk

from singlesource.config import config  # noqa: F401 # @UnusedImport

# Used to cache the bearer token (access_token, expiry_ts).
_bearer_token = None  # type: Optional[tuple[str, int]]
# Used to cache the HTTP session to various hosts {host: session}.
_http_sessions = {}  # type: Dict[str, requests.Session]


def _get_http_session(url: str) -> requests.Session:
    """
    Get a session object for a particular server/host.

    :param url: URL to determine the host to cache a session object for.
    """
    host = urllib.parse.urlsplit(url).netloc
    global _http_sessions
    if host not in _http_sessions:
        logging.debug('Making new HTTP(S) session.')
        _http_sessions[host] = requests.Session()
    return _http_sessions[host]


def _make_request(url: str,
                  method: str = 'GET',
                  body: str = None,
                  *,
                  authorisation: Optional[str] = None,
                  accept: str = 'application/json',
                  headers: Optional[dict] = None) -> requests.Request:
    """
    Make an actual HTTP request to the server.

    :param url: URL to request.
    :param method: HTTP request operation (currently only "GET" and "POST").
    :param body: HTTP request body (for POST and PUT requests).
    :param authorisation: Content of HTTP `Authorization` header
        (e.g. `Bearer xyz...`).
    :param accept: Content type specification to accept (in the "Accept"
        header). Defaulting to "application/json".
    :param headers: Dictionary of additional HTTP headers to pass
        (default: None).
    :return: Request object containing the result of the request (incl.
        response). This will be the one generated by the `requests` module
        in raw.
    """
    logging.debug('Making HTTP(S) request.')
    session = _get_http_session(url)
    if not headers:
        headers = {}
    headers.update({
        'Accept': accept,
        'Accept-Encoding': ''
    })
    if authorisation:
        headers['Authorization'] = authorisation
    request = None
    if method == 'GET':
        request = session.get(url, headers=headers)
    elif method == 'POST':
        if not body:
            headers['Content-Length'] = '0'
        request = session.post(url, headers=headers, data=body)
    elif method == 'PUT':
        if not body:
            headers['Content-Length'] = '0'
        request = session.put(url, headers=headers, data=body)
    elif method == 'DELETE':
        request = session.delete(url, headers=headers)
    else:
        raise ValueError('HTTP method not supported: {}'.format(method))
    return request


def _get_oauth_token() -> Optional[str]:
    """
    Get an OAuth bearer token from the API gateway.

    :return: Valid bearer token, `None` if none is required.
    """
    if not config.oauth_consumer_key:
        # Short cut, we don't need one.
        return None

    global _bearer_token
    if _bearer_token and time.time() < _bearer_token[1]:
        return _bearer_token[0]
    host = urllib.parse.urlsplit(config.oauth_url).netloc
    auth_content = '{}:{}'.format(config.oauth_consumer_key,
                                  config.oauth_consumer_secret)
    auth_string = base64.standard_b64encode(auth_content.encode()).decode()
    authorisation = 'Basic {}'.format(auth_string)
    response = _make_request(config.oauth_url, authorisation, method='POST')
    if not response.status_code == 200:
        logging.error('Error on request {}: {} - {}'
                      .format(config.oauth_url, response.status_code,
                              response.content))
        raise RuntimeError('Could not obtain OAuth bearer token.')
    body = json.loads(response.content.decode())
    access_token = body['access_token']
    exp = int(body['issued_at']) / 1000 + int(body['expires_in'])
    _bearer_token = (access_token, exp)
    logging.debug('OAuth - host: {}, bearer token: {}, expires: {}'
                  .format(host, access_token, exp))
    return access_token


def request_api(base_url: str, resource: str, body: Optional[str] = None,
                method: str = 'GET',
                headers: Optional[dict] = None) -> requests.Request:
    """
    Make a request to the API.

    :param base_url: URL to request.
    :param resource: Resource path for the request.
    :param body: HTTP request body (for POST and PUT requests).
    :param method: HTTP request operation (currently only "GET" and "POST").
    :param headers: Dictionary of additional HTTP headers to pass
        (default: None).
    :return: Request object containing the result of the request (incl.
        response). This will be the one generated by the `requests` module
        in raw.
    """
    oauth_token = _get_oauth_token()
    authorisation = 'Bearer {}'.format(oauth_token) if oauth_token else None
    url = '{}{}'.format(base_url, resource)
    return _make_request(url, method=method, body=body,
                         authorisation=authorisation, headers=headers)


def jwk_from(jwk_data: Union[Jwk, str, dict]) -> Jwk:
    """
    Unifies a JWK key representation into a Jwk object.

    :param jwk_data: Input key data.
    :return: Jwk object from given JWK data.
    """
    if isinstance(jwk_data, str):
        jwk_data = Jwk.get_instance(from_json=jwk_data)
    elif isinstance(jwk_data, dict):
        jwk_data = Jwk.get_instance(from_dict=jwk_data)
    return jwk_data


def encrypt_to(data: dict, recipient_pub_key: Union[Jwk, str, dict]) -> str:
    """
    Encrypt data to a recipient.

    :param data: Payload data.
    :param recipient_pub_key: Public encryption key of the recipient (X25519).
    :return: JWE encrypted, string serialised cipher text.
    """
    recipient_pub_key = jwk_from(recipient_pub_key)
    encrypter = Jwe.get_instance(alg='ECDH-ES', jwk=recipient_pub_key)
    encrypter.message = data
    encrypter.encrypt()
    return encrypter.serialise()


def decrypt_from(ciphertext: Union[str, bytes],
                 own_priv_key: Union[Jwk, str, dict]) -> dict:
    """
    Decrypt data from a sender.

    :param ciphertext: JWE encrypted ciphertext in compact serialisation.
    :param own_priv_key: Own private encryption key (X25519).
    :return: Decrypted payload in dictionary form.
    :raises: An exception (depending on what cipher suite is used) in case
        of decryption or verification errors.
    """
    own_priv_key = jwk_from(own_priv_key)
    decrypter = Jwe.get_instance(alg='ECDH-ES', jwk=own_priv_key,
                                 from_compact=ciphertext)
    return decrypter.decrypt()
