# -*- coding: utf-8 -*-
"""API utilities."""

import os
import time
from base64 import urlsafe_b64encode
from datetime import datetime, timezone
from email.utils import formatdate
from json import dumps
from random import randint  # noqa: DUO102
from socket import AI_CANONNAME, gaierror, getaddrinfo, gethostname

import requests
from nacl.encoding import Base64Encoder
from nacl.signing import SigningKey
from requests import Request, Session

from procustodibus_broker import DOCS_URL
from procustodibus_broker import __version__ as version
from procustodibus_broker.cnf import load_cnf


def setup_api(cnf):
    """Generates and saves new credential for configured broker.

    Arguments:
        cnf (Config): Config object.
    """
    raise_unless_has_cnf(cnf)
    setup = load_setup(cnf)
    code = setup["code"]

    signing_key = SigningKey.generate()
    public_key = signing_key.verify_key.encode(Base64Encoder).decode("utf-8")

    url = f"{cnf.api}/users/{cnf.broker}/credentials/signature"
    headers = {
        "authorization": f"X-Custos user=^{cnf.broker} broker_setup=^{code}",
        "content-type": "application/json",
    }
    data = dumps(
        {
            "secret": public_key,
            "description": f"on {getfqdn()}",
            "keep_others": setup.get("keep_others") or False,
        },
        separators=(",", ":"),
    )

    response = requests.post(url, data=data, headers=headers)
    response.raise_for_status()

    save_signing_key(cnf, signing_key)


def login_api(cnf):
    """Logs into the api and saves session token on cnf obj.

    Arguments:
        cnf (Config): Config object.
    """
    raise_unless_has_cnf(cnf)

    url = f"{cnf.api}/sessions"

    now = time.time()
    sig = sign_data(cnf, url, now)
    headers = {
        "authorization": f"X-Custos user=^{cnf.broker} signature={sig}",
        "date": formatdate(timeval=now, usegmt=True),
    }

    response = requests.post(url, headers=headers)
    response.raise_for_status()

    # add 1-10 minutes of jitter before real session timeout
    # to prevent repeating stampeding herd on every login
    jitter = randint(60, 600)  # noqa: S311

    session = response.json()["data"][0]
    cnf.session = {
        "token": session["attributes"]["token"],
        "expiration": now + session["meta"]["expiration_timeout"] - jitter,
    }


def poll_api(cnf, event_type, max_events=10):
    """Polls API for next N events.

    Arguments:
        cnf (Config): Config object.
        event_type (str): Event type (eg 'alerts').
        max_events (int): Max events to pull (default 10).

    Returns:
        Response: Response json.
    """
    raise_unless_has_cnf(cnf)

    data = {"max": max_events}

    request = Request(
        "POST",
        f"{cnf.api}/queues/{event_type}/next",
        data=dumps(data, separators=(",", ":")),
        headers={"content-type": "application/json"},
    )

    response = send_with_session(cnf, request)
    return response.json()


def get_health_info(cnf):
    """Gets the api's health info.

    Arguments:
        cnf (Config): Config object.

    Returns:
        Response: Response json.
    """
    raise_unless_has_cnf(cnf)

    response = requests.get(f"{cnf.api}/health")
    response.raise_for_status()
    return response.json()


def hello_api(cnf):
    """Says hello to the API.

    Arguments:
        cnf (Config): Config object.

    Returns:
        Response: Response json.
    """
    raise_unless_has_cnf(cnf)

    request = Request("GET", f"{cnf.api}/queues/{version}/hello")
    response = send_with_session(cnf, request)
    return response.json()


def send_with_session(cnf, request):
    """Sends request with session authn header.

    Arguments:
        cnf (Config): Config object.
        request (Request): Request object to send.

    Returns:
        Response: Response object.
    """
    create_session_if_expired(cnf)
    prepped = request.prepare()
    prepped.headers["authorization"] = build_authn_header(cnf)

    session = Session()
    response = session.send(prepped)

    if response.status_code == requests.codes.unauthorized:
        login_api(cnf)
        prepped = request.prepare()
        prepped.headers["authorization"] = build_authn_header(cnf)
        response = session.send(prepped)

    response.raise_for_status()
    return response


def create_session_if_expired(cnf):
    """Creates a new session if session is missing or has timed-out.

    Arguments:
        cnf (Config): Config object.
    """
    session = cnf.__dict__.get("session")
    if not session or session["expiration"] < time.time():
        login_api(cnf)


def build_authn_header(cnf):
    """Constructs authn header with session token.

    Arguments:
        cnf (Config): Config object.

    Returns:
        str: Authn header
    """
    return f"X-Custos user=^{cnf.broker}, session=^{cnf.session['token']}"


def sign_data(cnf, data, now):
    """Signs the specified message.

    Arguments:
        cnf (Config): Config object.
        data (str): Message to sign.
        now (float): Current time in seconds.

    Returns:
        str: Base64-web-encoded signature.
    """
    signing_key = load_signing_key(cnf)
    message = format_signed_message(data, now)
    signed_message = signing_key.sign(message)
    return encode_base64_web(signed_message.signature)


def load_signing_key(cnf):
    """Loads the broker's signing key.

    Arguments:
        cnf (Config): Config object.

    Returns:
        SingingKey: Broker's signing key.

    Propagates:
        ValueError: If the signing credentials are missing or invalid.
    """
    credentials = cnf.credentials

    try:
        if type(credentials) is not dict:
            credentials = load_cnf(credentials)["broker"]["credentials"]
        private_key = credentials["private_key"]
    except KeyError:
        _raise_setup_issue("Missing private key in credentials file")

    try:
        return SigningKey(private_key, Base64Encoder)
    except Exception:
        _raise_setup_issue("Invalid private key in credentials file")


def format_signed_message(data, now):
    """Formats the specified message with timestamp for signing.

    Arguments:
        data (str): Message to sign.
        now (float): Current time in seconds.

    Returns:
        bytes: Bytes ready to sign.
    """
    return (data + "#" + format_datetime(now)).encode("utf-8")


def encode_base64_web(message):
    """Base64-web-encodes the specified message.

    Arguments:
        message (str): Message to encode.

    Returns:
        str: Base64-web-encoded message.
    """
    return urlsafe_b64encode(message).decode("utf-8").rstrip("=")


def format_datetime(now=None):
    """Formats datetime as an RFC 3339 string.

    Arguments:
        now (datetime): Datetime to format (defaults to now).

    Returns:
        str: Formatted datetime.
    """
    if not now:
        now = time.time()
    return datetime.fromtimestamp(now, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def load_setup(cnf):
    """Loads the setup configuration dict, or raises an error.

    Arguments:
        cnf (Config): Config object.

    Returns:
        dict: Setup configuration dict.

    Propagates:
        ValueError: If the setup config is missing the "code" property, or
                    the "expires" property indicates it has expired, or
                    if generated credentials cannot be saved because there is
                    no configured path to the credentials file.
    """
    if type(cnf.credentials) is dict:
        _raise_setup_issue("Missing path to credentials file")

    setup = cnf.setup

    try:
        if type(setup) is not dict:
            setup = load_cnf(setup)["broker"]["setup"]
        setup["code"]
    except KeyError:
        _raise_setup_issue("Missing code in setup file")

    expires = setup.get("expires")
    if expires:
        expires = datetime.strptime(expires, "%Y-%m-%dT%H:%M:%SZ")
        now = datetime.utcnow()
        if expires < now:
            _raise_setup_issue("Setup code has expired")

    return setup


def save_signing_key(cnf, signing_key):
    """Saves the specified signing key to the configured credentials location.

    Also deletes the configured setup file (to indicate setup is complete).

    Arguments:
        cnf (Config): Config object.
        signing_key (SigningKey): Signing key to save.
    """
    private_key = signing_key.encode(Base64Encoder).decode("utf-8")
    public_key = signing_key.verify_key.encode(Base64Encoder).decode("utf-8")

    credentials = f"""
        # broker-credentials.conf generated {format_datetime()}
        # for broker {cnf.broker} on {getfqdn()}
        [Broker.Credentials]
        PublicKey = {public_key}
        PrivateKey = {private_key}
    """.strip()

    with open(cnf.credentials, "w") as f:
        for line in credentials.splitlines():
            # print to file
            print(line.strip(), file=f)  # noqa: T001

    os.chmod(cnf.credentials, 0o640)

    if type(cnf.setup) is not dict:
        os.remove(cnf.setup)


def getfqdn():
    """Looks up fully-qualified domain name of localhost.

    Returns:
        str: Fully-qualified domain name (eg 'foo.example.com').
    """
    hostname = gethostname()
    try:
        return getaddrinfo(hostname, 0, flags=AI_CANONNAME)[0][3]
    except gaierror:
        return hostname


def raise_unless_has_cnf(cnf):
    """Raises an error unless the specified cnf has all required settings.

    Arguments:
        cnf (Config): Config object.

    Propagates:
        ValueError: If the specified cnf is missing some required settings.
    """
    missing = []

    if not cnf.api:
        missing.append("API endpoint")

    if not cnf.broker:
        missing.append("Broker ID")

    if missing:
        _raise_setup_issue("Missing conf for {}".format(" and ".join(missing)))


def _raise_setup_issue(problem):
    raise ValueError(
        f"{problem}; see {DOCS_URL}/guide/integrations/brokers/#troubleshoot to fix"
    )
