""":mod:`geofront.backends.cloud` --- Libcloud_-backed implementations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This module provides built-in implementations of Geofront's some core
interfaces through libcloud.  Libcloud_ is "a library for interacting
with many of the popular cloud service providers using unified API."

.. versionadded:: 0.2.0

.. _Libcloud: http://libcloud.apache.org/

"""
import collections.abc
try:
    from functools import singledispatch
except ImportError:
    from singledispatch import singledispatch  # type: ignore
import io
import os.path
import re
import tempfile
from typing import (TYPE_CHECKING,
                    AbstractSet, Callable, Iterator, Mapping, Sequence)
from typing.re import Pattern  # type: ignore  # noqa: I901
import xml.etree.ElementTree

from libcloud.common.types import MalformedResponseError
from libcloud.compute.base import Node, NodeDriver
from libcloud.compute.drivers.ec2 import EC2NodeDriver
from libcloud.compute.drivers.gce import GCENodeDriver
from libcloud.compute.types import KeyPairDoesNotExistError
from libcloud.storage.base import Container, StorageDriver
from libcloud.storage.drivers.s3 import S3StorageDriver
from libcloud.storage.types import ObjectDoesNotExistError
from paramiko.ecdsakey import ECDSAKey
from paramiko.pkey import PKey
from paramiko.rsakey import RSAKey
from typeguard import typechecked

from ..identity import Identity
from ..keystore import (DuplicatePublicKeyError, KeyStore,
                        format_openssh_pubkey, get_key_fingerprint,
                        parse_openssh_pubkey)
from ..masterkey import EmptyStoreError, MasterKeyStore, read_private_key_file
from ..remote import Remote

if TYPE_CHECKING:
    from typing import MutableMapping, Optional  # noqa: F401

__all__ = ('CloudKeyStore', 'CloudMasterKeyStore', 'CloudMasterPublicKeyStore',
           'CloudRemoteSet')


class CloudRemoteSet(collections.abc.Mapping):
    """Libcloud_-backed remote set.  It supports more than 20 cloud providers
    through the efforts of Libcloud_. ::

        from geofront.backends.cloud import CloudRemoteSet
        from libcloud.compute.types import Provider
        from libcloud.compute.providers import get_driver

        driver_cls = get_driver(Provider.EC2)
        driver = driver_cls('access id', 'secret key', region='us-east-1')
        REMOTE_SET = CloudRemoteSet(driver)

    If the given ``driver`` supports metadata feature (for example,
    AWS EC2, Google Compute Engine, and OpenStack support it)
    the resulted :class:`~geofront.remote.Remote` objects will
    fill their :attr:`~geofront.remote.Remote.metadata` as well.

    :param driver: libcloud compute driver
    :type driver: :class:`libcloud.compute.base.NodeDriver`
    :param user: the username to :program:`ssh`.
                 the default is ``'ec2-user'`` which is the default user
                 of amazon linux ami
    :type user: :class:`str`
    :param port: the port number to :program:`ssh`.
                the default is 22 which is the default :program:`ssh` port
    :type port: :class:`int`
    :param alias_namer: A function to name an alias for the given node.
                        :attr:`Node.name <libcloud.compute.base.Node.name>`
                        is used by default.
    :type alias_nameer:
        :class:`~typing.Callable`\ [[:class:`libcloud.compute.base.Node`],
                                    :class:`str`]
    :param addresser: A function to get the address for the given node.
                      :attr:`Node.public_ips
                      <libcloud.compute.base.Node.public_ips>`
                      is used by default.
    :type addresser:
        :class:`~typing.Callable`\ [[:class:`libcloud.compute.base.Node`],
                                    :class:`str`]
    :param filter: A function to decide if the given node should be included as
                   a remote or not.  By default it checks if the node has any
                   public IP.
    :type filter:
        :class:`~typing.Callable`\ [[:class:`libcloud.compute.base.Node`],
                                    :class:`bool`]

    .. seealso::

       `Compute`__ --- Libcloud
          The compute component of libcloud allows you to manage
          cloud and virtual servers offered by different providers,
          more than 20 in total.

    .. _Libcloud: http://libcloud.apache.org/
    __ https://libcloud.readthedocs.org/en/latest/compute/

    .. versionadded:: 0.4.1
       ``addresser`` and ``filter`` parameters.

    .. versionadded:: 0.4.0
       ``alias_namer`` parameter.

    .. versionchanged:: 0.2.0
       It fills :attr:`~geofront.remote.Remote.metadata` of the resulted
       :class:`~geofront.remote.Remote` objects if the ``driver`` supports.

    """

    @typechecked
    def __init__(
        self,
        driver: NodeDriver,
        user: str='ec2-user',
        port: int=22,
        alias_namer: Callable[[Node], str]=lambda node: node.name,
        addresser: Callable[[Node], str]=lambda node: node.public_ips[0],
        filter: Callable[[Node], bool]=lambda node: bool(node.public_ips)
    ) -> None:
        self.driver = driver
        self.user = user
        self.port = port
        self.alias_namer = alias_namer
        self.addresser = addresser
        self.filter = filter
        self._nodes = None  # type: Optional[Mapping[str, Node]]
        self._metadata = {} if supports_metadata(driver) else None  \
            # type: Optional[MutableMapping[str, object]]

    def _get_nodes(self, refresh: bool=False) -> Mapping[str, Node]:
        if refresh or self._nodes is None:
            self._nodes = {self.alias_namer(node): node
                           for node in self.driver.list_nodes()
                           if self.filter(node)}
            if self._metadata is not None:
                self._metadata.clear()
        return self._nodes

    def __len__(self) -> int:
        return len(self._get_nodes())

    def __iter__(self) -> Iterator[str]:
        return iter(self._get_nodes(True))

    def __getitem__(self, alias: str) -> Remote:
        node = self._get_nodes()[alias]
        if self._metadata is None:
            metadata = {}  # type: object
        else:
            try:
                metadata = self._metadata[alias]
            except KeyError:
                metadata = get_metadata(self.driver, node)
                self._metadata[alias] = metadata
        return Remote(self.user, self.addresser(node), self.port, metadata)


@singledispatch
def supports_metadata(driver: NodeDriver) -> bool:
    """Whether this drive type supports metadata?"""
    return callable(getattr(driver, 'ex_get_metadata_for_node', None))


@singledispatch
def get_metadata(driver: NodeDriver, node: Node) -> Mapping[str, object]:
    return driver.ex_get_metadata_for_node(node)


@supports_metadata.register(GCENodeDriver)
def gce_supports_metadata(driver: GCENodeDriver) -> bool:
    return True


@get_metadata.register(GCENodeDriver)
def gce_get_metadata(driver: GCENodeDriver,
                     node: Node) -> Mapping[str, object]:
    return node.extra['metadata']


class CloudMasterKeyStore(MasterKeyStore):
    """Store the master key into the cloud object storage e.g. AWS S3_.
    It supports more than 20 cloud providers through the efforts of Libcloud_.
    ::

        from geofront.backends.cloud import CloudMasterKeyStore
        from libcloud.storage.types import Provider
        from libcloud.storage.providers import get_driver

        driver_cls = get_driver(Provider.S3)
        driver = driver_cls('api key', 'api secret key')
        container = driver.get_container(container_name='my-master-key-bucket')
        MASTER_KEY_STORE = CloudMasterKeyStore(container)

    :param driver: the libcloud storage driver
    :type driver: :class:`libcloud.storage.base.StorageDriver`
    :param container: the block storage container
    :type container: :class:`libcloud.storage.base.Container`
    :param object_name: the object name to use
    :type object_name: :class:`str`

    .. seealso::

       `Object Storage`__ --- Libcloud
          Storage API allows you to manage cloud object storage and
          services such as Amazon S3, Rackspace CloudFiles,
          Google Storage and others.

    .. _S3: http://aws.amazon.com/s3/
    .. _Libcloud: http://libcloud.apache.org/
    __ https://libcloud.readthedocs.org/en/latest/storage/

    """

    @typechecked
    def __init__(self,
                 driver: StorageDriver,
                 container: Container,
                 object_name: str) -> None:
        self.driver = driver
        self.container = container
        self.object_name = object_name

    @typechecked
    def load(self) -> PKey:
        try:
            obj = self.driver.get_object(self.container.name, self.object_name)
        except ObjectDoesNotExistError:
            raise EmptyStoreError()
        with io.BytesIO() as buffer_:
            for chunk in self.driver.download_object_as_stream(obj):
                if isinstance(chunk, str):  # DummyDriver yields str, not bytes
                    chunk = chunk.encode()
                buffer_.write(chunk)
            buffer_.seek(0)
            with io.TextIOWrapper(buffer_) as tio:
                return read_private_key_file(tio)

    @typechecked
    def save(self, master_key: PKey) -> None:
        extra = {'content_type': 'application/x-pem-key'}
        if isinstance(self.driver, S3StorageDriver):
            # On some cases (altough unknown condition), S3 driver failed to
            # match signature when it uploads object through stream.
            # This is workaround to upload the master key without stream.
            with tempfile.NamedTemporaryFile('w+', encoding='utf-8') as f:
                master_key.write_private_key(f)
                getattr(f, 'file').flush()  # workaround mypy
                self.driver.upload_object(
                    f.name, self.container, self.object_name, extra
                )
            return
        with io.StringIO() as buffer_:
            master_key.write_private_key(buffer_)
            pem = getattr(buffer_, 'getvalue')()  # workaround mypy
        self.driver.upload_object_via_stream(
            type(self)._countable_iterator([pem]),
            self.container,
            self.object_name,
            extra
        )

    class _countable_iterator:
        """libcloud's storage driver takes an iterator as stream,
        but some drivers e.g. dummy driver try calling :func:`len()`
        to the iterator.  This adapter workarounds the situation.

        """

        @typechecked
        def __init__(self, sequence: Sequence[object]) -> None:
            self.iterator = iter(sequence)
            self.length = len(sequence)

        def __len__(self):
            return self.length

        def __iter__(self):
            return self

        def __next__(self):
            return next(self.iterator)


class CloudKeyStore(KeyStore):
    """Store public keys into the cloud provider's key pair service.
    Note that not all providers support key pair service.  For example,
    Amazon EC2, and Rackspace (Next Gen) support it.  ::

        from geofront.backends.cloud import CloudKeyStore
        from libcloud.compute.types import Provider
        from libcloud.compute.providers import get_driver

        driver_cls = get_driver(Provider.EC2)
        driver = driver_cls('api key', 'api secret key', region='us-east-1')
        KEY_STORE = CloudKeyStore(driver)

    :param driver: libcloud compute driver
    :type driver: :class:`libcloud.compute.base.NodeDriver`
    :param key_name_format: the format which determines each key's name
                            used for the key pair service.
                            default is :const:`DEFAULT_KEY_NAME_FORMAT`
    :type key_name_format: :class:`str`

    """

    #: (:class:`str`) The default ``key_name_format``.  The type name of
    #: team followed by identifier, and then key fingerprint follows e.g.
    #: ``'geofront.backends.github.GitHubOrganization dahlia 00:11:22:..:ff'``.
    DEFAULT_KEY_NAME_FORMAT = ('{identity.team_type.__module__}.'
                               '{identity.team_type.__qualname__} '
                               '{identity.identifier} {fingerprint}')

    _sample_keys = None  # type: Optional[RSAKey]

    @typechecked
    def __init__(self, driver: NodeDriver, key_name_format: str=None) -> None:
        self.driver = driver
        self.key_name_format = key_name_format or self.DEFAULT_KEY_NAME_FORMAT

    def _get_key_name(self, identity: Identity, public_key: PKey) -> str:
        return self.key_name_format.format(
            identity=identity,
            public_key=public_key,
            fingerprint=get_key_fingerprint(public_key)
        )

    def _get_key_name_pattern(self,
                              identity: Identity) -> Pattern[str]:
        """Make the regex pattern from the format string.  Put two different
        random keys, compare two outputs, and then replace the difference
        with wildcard.

        """
        cls = type(self)
        sample_keys = cls._sample_keys
        if sample_keys is None:
            sample_keys = (RSAKey.generate(bits=512),
                           RSAKey.generate(bits=512),
                           ECDSAKey.generate(bits=256),
                           ECDSAKey.generate(bits=256))
            cls._sample_keys = sample_keys
        sample_names = [self._get_key_name(identity, k) for k in sample_keys]
        if len(frozenset(sample_names)) < 2:
            return re.compile('^' + re.escape(sample_names[0]) + '$')
        prefix = os.path.commonprefix(sample_names)
        postfix = os.path.commonprefix([n[::-1] for n in sample_names])[::-1]
        return re.compile(
            '^{}.+?{}$'.format(re.escape(prefix), re.escape(postfix))
        )

    @typechecked
    def register(self, identity: Identity, public_key: PKey) -> None:
        name = self._get_key_name(identity, public_key)
        driver = self.driver
        try:
            driver.get_key_pair(name)
        except KeyPairDoesNotExistError:
            driver.import_key_pair_from_string(
                name,
                format_openssh_pubkey(public_key)
            )
        else:
            raise DuplicatePublicKeyError()

    @typechecked
    def list_keys(self, identity: Identity) -> AbstractSet[PKey]:
        pattern = self._get_key_name_pattern(identity)
        return frozenset(
            parse_openssh_pubkey(key_pair.public_key)
            for key_pair in self.driver.list_key_pairs()
            if pattern.match(key_pair.name)
        )

    @typechecked
    def deregister(self, identity: Identity, public_key: PKey) -> None:
        try:
            key_pair = self.driver.get_key_pair(
                self._get_key_name(identity, public_key)
            )
        except KeyPairDoesNotExistError:
            return
        self.driver.delete_key_pair(key_pair)


class CloudMasterPublicKeyStore(MasterKeyStore):
    """It doesn't store the whole master key, but stores only public part of
    the master key into cloud provider's key pair registry.  So it requires
    the actual ``master_key_store`` to store the whole master key which is
    not only public part but also private part.

    It helps to create compute instances (e.g. Amazon EC2) that are already
    colonized.

    :param driver: libcloud compute driver
    :type driver: :class:`libcloud.compute.base.NodeDriver`
    :param key_pair_name: the name for cloud provider's key pair registry
    :type key_pair_name: :class:`str`
    :param master_key_store: "actual" master key store to store the whole
                             master key
    :type master_key_store: :class:`~geofront.masterkey.MasterKeyStore`

    .. versionadded:: 0.2.0

    """

    @typechecked
    def __init__(self,
                 driver: NodeDriver,
                 key_pair_name: str,
                 master_key_store: MasterKeyStore) -> None:
        self.driver = driver
        self.key_pair_name = key_pair_name
        self.master_key_store = master_key_store

    @typechecked
    def load(self) -> PKey:
        return self.master_key_store.load()

    @typechecked
    def save(self, master_key: PKey) -> None:
        public_key = format_openssh_pubkey(master_key)
        driver = self.driver
        try:
            key_pair = driver.get_key_pair(self.key_pair_name)
        except KeyPairDoesNotExistError:
            pass
        except MalformedResponseError as e:
            # FIXME: EC2 driver seems to raise MalformedResponseError
            # instead of KeyPairDoesNotExistError.
            if not issubclass(e.driver, EC2NodeDriver):
                raise
            tree = xml.etree.ElementTree.fromstring(e.body)
            if not (tree.tag == 'Response' and
                    getattr(tree.find('Errors/Error/Code'), 'text', None) ==
                    'InvalidKeyPair.NotFound'):
                raise
        else:
            driver.delete_key_pair(key_pair)
        driver.import_key_pair_from_string(self.key_pair_name, public_key)
        self.master_key_store.save(master_key)