"""Implements DID-based authentication for requests to any of the endpoints
generated by `datacustodian`. Authentication happens by signing certain of the
headers in the HTTP request using a private key. The public key is used to
verify the headers, thus proving that the entity making the request is in
control of the private key associated with the DID.
"""
from didauth.headers import HeaderVerifier, HeaderSigner
from didauth.base import KeyFinderBase, SignerBase, VerifierBase
from didauth.registry import ALL
from didauth.error import VerifierException
from datetime import datetime
from urllib.parse import urlparse
import logging
from os import environ

from datacustodian.utility import flatten
from datacustodian.dlt import Did, get_cred_def_id
from datacustodian.crypto import get_master_seed, b58_to_bytes

roles = None
"""list: of `str` role names configured by the application.
"""
role_map = {}
"""dict: keys are role names; values are lists of `did` that have access to the
role for managing the app.
"""
databases = None
"""attrdict.AttrDict: keys are configured `couchdb` instances; values are
:class:`datacustodian.db.Database` for convenience in accessing them.
"""
agents = None
"""dict: keys are agent names; values are :class:`CloudAgent`.
"""

log = logging.getLogger(__name__)

class CloudAgentKeyFinder(KeyFinderBase):
    """Key finder that works with an Aries Cloud Agent to find keys for DIDs
    using a ledger.

    Args:
        agent (str): name of the configured agent to use for the key lookups.
    """
    def __init__(self, agent: str, source: KeyFinderBase = None, caching: bool = True):
        super(CloudAgentKeyFinder, self).__init__(source)
        self._caching = caching
        self.agent = agents[agent]

    async def _lookup_key(self, key_id: str, key_type: str) -> bytes:
        vk_base58 = await self.agent.ledger.get_verkey(key_id)
        result = b58_to_bytes(vk_base58)
        log.debug(f"Found {result} as public key for {key_id}.")
        return result


async def auth_endpoint_roles(authroles, headers, method, url_path, agent, credentials):
    """Determines whether the specified request should be allowed.

    Args:
        authroles (list): of `str` role names that should be *allowed* to execute
            the endpoint.
        agent (str): name of the agent to use for cryptographic functions.
        headers (dict): headers from the request object.
        method (str): one of the HTTP methods used in the request.
        url_path (str): full request path (excluding the host name and query
            string parameter).
        credentials (list): of `str` credential names. Each one should be
            configured in the application specs for `dlt`. The requester must
            present proof of each of the credentials before the endpoint will
            execute.

    Returns:
        bool: `True` if the request is authorized and the identity of the
        requester has been proven using cryptography.
    """
    # First step, verify the API key.
    apikey = environ.get("DC_API_KEY")
    if apikey is not None:
        try:
            subkey = headers["x-api-key"]
            if apikey != subkey:
                log.debug("API key verification failed.")
                return False
        except:
            return False

    key_finder = CloudAgentKeyFinder(agent)
    verifier = HeaderVerifier(key_finder)
    log.debug(f"Verifying headers: {headers} with {method} at {url_path}.")
    try:
        verified = await verifier.verify(headers, method, url_path)
    except VerifierException:
        log.exception(f"Verifying DID-based signature for {headers} and {method}>{url_path}.")
        return False

    if "did:" in verified["keyId"]:
        their_did = Did(verified["keyId"]).vk
    else:
        their_did = verified["keyId"]

    did_ok = False
    if verified['verified']:
        # Check that the datetime is within a minute of ours.
        their_date = headers["date"]
        delta = datetime.utcnow()-datetime.strptime(their_date, "%Y-%m-%d %H:%M:%S.%f")
        did_ok = delta.seconds < 60
        if not did_ok:
            log.debug(f"DID auth failed; too long: {delta.seconds} > 60.")

    role_ok = len(authroles) == 0
    if did_ok and not role_ok:
        # Since the did authentication passed, we can now check the roles and
        # any credentials.
        for r in authroles:
            role_ok = role_ok or can_access(r, their_did)
            if role_ok:
                break

        if not role_ok:
            log.debug(f"Role auth failed for {r} in {authroles} and {their_did}.")

    creds_ok = len(credentials) == 0
    if role_ok and not creds_ok:
        # We need to get the credential ids before we can call the validation
        # function.
        cred_def_ids = [get_cred_def_id(c) for c in credentials]
        creds_ok = True
        for cid in cred_def_ids:
            try:
                creds_ok = creds_ok and cred_valid(agent, cid, their_did)
            except:
                log.exception(f"While verifying credential {cid}.")
                creds_ok = False

            if not creds_ok:
                break

        if not creds_ok:
            log.debug(f"Cred auth failed for {cid} in {cred_def_ids} and {their_did}.")

    log.debug(f"AUTH: did_ok: {did_ok}; role_ok: {role_ok}; creds_ok: {creds_ok}")
    return did_ok and role_ok and creds_ok


async def get_signed_headers(_agent, url, method):
    """Signs a request and adds the relevant information needed to populate the
    request headers to enable `didauth`.

    Args:
        _agent (str): name of the configured agent to use for signing.
        url (str): the URL that is being requested with these headers.
        method (str): HTTP method being requested.
    """
    from datacustodian import get_version
    # First, get the agent and find the DID and master seed.
    agent = agents[_agent]
    did = (await agent.wallet.get_public())["did"]
    seed = get_master_seed(_agent)

    o = urlparse(url)
    headers = {
        'Date': str(datetime.utcnow()),
        'Host': o.netloc,
        'User-Agent': f'DataCustodian/{get_version()}',
    }
    header_list = ['(request-target)']
    header_list.extend(headers.keys())
    url_path = o.path

    signer = ALL.create_signer("ed25519", seed)
    log.debug(f"Using public key {signer.public_key} to sign headers.")
    hdr_signer = HeaderSigner(did, signer, header_list)

    signed_headers = hdr_signer.sign(headers, method, url_path)
    assert 'authorization' in signed_headers
    return signed_headers


async def cred_valid(_agent, cred_def_id, did):
    """Determines if the specified DID has a valid credential that has been sent
    to us as proof.

    Args:
        _agent (str): name of the configured agent to check credentials for.
        cred_def_id (str): identifier of the credential that needs be presented
            with valid proof for this endpoint.
        did (str): DID of the entity making the request (who should have also
            sent proof for a credential to us).
    """
    agent = agents[_agent]
    connection_id = await agent.connections.ls(their_did=did)
    okay = False
    if connection_id is not None:
        presx = await agent.presentation.ls(connection_id=connection_id, credential_definition_id=cred_def_id)
        okay = any(await agent.presentation.verify(i["presentation_exchange_id"]) for i in presx)

    return okay


def purge():
    """Cleans up all roles in the databases, effectively revoking *all* access
    to everything. Intended only for use during unit testing.
    """
    from datacustodian.base import testmode
    if testmode:
        for role in roles:
            dbid = f"role/{role}"
            with databases.app[dbid] as doc:
                dids = doc["dids"]

            for did in dids:
                with databases.app[did] as diddoc:
                    if diddoc.exists():
                        diddoc["_deleted"] = True

            with databases.app[dbid] as doc:
                doc["dids"] = []

            repopulate_role(role)


def can_access(role, did):
    """Checks if the `did` has authority to access `role`.

    Args:
        role (str): the role to check access for.
        did (str): DID of the entity making the request.
    """
    return did in role_map.get(role, {})


def configure(norun=False, **authspec):
    """Configures the application authentication and authorization standards

    Args:
        authspec (dict): key-value pairs describing the auth settings.
    """
    # We only import these dbs now because they must first be configured at
    # application level. If this gets imported first, the variables may not be
    # initialized properly yet.
    global roles, databases, agents
    roles = authspec.get("roles", ["admin", "grantee", "anonymous"])
    # Agents had to be configured here because they don't exist before the DLT
    # configure function is called.
    from datacustodian.dlt import agents as dltagents
    agents = dltagents

    if norun:
        return

    from datacustodian.db import databases as dcdbs
    databases = dcdbs

    for role in roles:
        dbid = f"role/{role}"
        if dbid not in databases.app.db:
            with databases.app[dbid] as doc:
                doc["dids"] = []

        repopulate_role(role)


def set_did(did, members=None):
    """Adds/updates the specified `did` to the app for authorization purposes.

    Args:
        did (str): DID to add/update.
        members (list): of `str` DIDs that "belong" to this `did`.
    """
    with databases.app[did] as doc:
        if members is not None:
            doc["members"] = members
        else:
            if "members" in doc:
                del doc["members"]


def rm_did(did):
    """Removes the specified `did` from the app for authorization purposes.

    Args:
        did (str): DID to remove from roles/auth.
    """
    with databases.app[did] as doc:
        doc["_deleted"] = True


def expand_did(did):
    """Expands the given `did` recursively to capture all the DIDs that belong
    to it. This allows, for example, circles to be nested within circles using
    their DIDs.

    Args:
        did (str): identifier to expand with members from the database.

    Returns:
        list: of `str` DID that *includes* the specified `did` in the parameters.
    """
    didlist = [did]
    with databases.app[did] as doc:
        if "members" in doc:
            for child in doc["members"]:
                didlist.extend(expand_did(child))

    return didlist


def repopulate_role(role):
    """Repopulates :data:`role_map` recursively to make sure that it is up to
    data after changes to role authorizations.

    Args:
        role (str): name of the role to repopulate with DIDs that have access
            to it.
    """
    global role_map
    dbid = f"role/{role}"
    didlist = []
    with databases.app[dbid] as doc:
        for did in doc["dids"]:
            didlist.extend(expand_did(did))

    role_map[role] = didlist


def revoke_role(role, did):
    """Removes the specified `did` to list of dids that have access to a role.

     Args:
        role (str): role name that `did` should *not* belong to.
        did (str): DID that should *not* have access to `role`.
    """
    dbid = f"role/{role}"
    changed = False
    with databases.app[dbid] as doc:
        if did in doc["dids"]:
            doc["dids"].remove(did)
            changed = True

    if changed:
        repopulate_role(role)


def grant_role(role, did):
    """Adds the specified `did` to list of dids that have access to a role.

     Args:
        role (str): role name that `did` should belong to.
        did (str): DID that should have access to `role`.
    """
    dbid = f"role/{role}"
    changed = False
    with databases.app[dbid] as doc:
        if did not in doc["dids"]:
            doc["dids"].append(did)
            changed = True

    if changed:
        repopulate_role(role)


def verify_roles(role_list, did):
    """Adds the specified `did` to the role for authorization purposes.

    Args:
        roles (list): of `str` role names that `did` should belong to.
        did (str): DID that should have access to each of `roles`.
    """
    for role in role_list:
        if did not in role_map[role]:
            if role in role_list:
                grant_role(role, did)
        elif role not in role_list:
            # The did is in the role_map, but that role shouldn't belong to the
            # did anymore. Revoke it.
            revoke_role(role, did)
        else:
            repopulate_role(role)
