# Copyright 2013: Mirantis Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import abc

from oslo_config import cfg

from rally.cli import envutils
from rally.common.i18n import _
from rally.common import logging
from rally.common import objects
from rally.common.plugin import plugin
from rally import consts
from rally import exceptions


CONF = cfg.CONF

OSCLIENTS_OPTS = [
    cfg.FloatOpt("openstack_client_http_timeout", default=180.0,
                 help="HTTP timeout for any of OpenStack service in seconds")
]
CONF.register_opts(OSCLIENTS_OPTS)

_NAMESPACE = "openstack"


def configure(name, default_version=None, default_service_type=None,
              supported_versions=None):
    """OpenStack client class wrapper.

    Each client class has to be wrapped by configure() wrapper. It
    sets essential configuration of client classes.

    :param name: Name of the client
    :param default_version: Default version for client
    :param default_service_type: Default service type of endpoint(If this
        variable is not specified, validation will assume that your client
        doesn't allow to specify service type.
    :param supported_versions: List of supported versions(If this variable is
        not specified, `OSClients.validate_version` method will raise an
        exception that client doesn't support setting any versions. If this
        logic is wrong for your client, you should override `validate_version`
        in client object)
    """
    def wrapper(cls):
        cls = plugin.configure(name=name, namespace=_NAMESPACE)(cls)
        cls._meta_set("default_version", default_version)
        cls._meta_set("default_service_type", default_service_type)
        cls._meta_set("supported_versions", supported_versions or [])
        return cls

    return wrapper


class OSClient(plugin.Plugin):
    def __init__(self, credential, api_info, cache_obj):
        self.credential = credential
        self.api_info = api_info
        self.cache = cache_obj

    def choose_version(self, version=None):
        """Return version string.

        Choose version between transmitted(preferable value if present),
        version from api_info(configured from a context) and default.
        """
        # NOTE(andreykurilin): The result of choose is converted to string,
        # since most of clients contain map for versioned modules, where a key
        # is a string value of version. Example of map and its usage:
        #
        #     from oslo_utils import importutils
        #     ...
        #     version_map = {"1": "someclient.v1.client.Client",
        #                    "2": "someclient.v2.client.Client"}
        #
        #     def Client(version, *args, **kwargs):
        #         cls = importutils.import_class(version_map[version])
        #         return cls(*args, **kwargs)
        #
        # That is why type of version so important and we should ensure that
        # version is a string object.
        # For those clients which doesn't accept string value(for example
        # zaqarclient), this method should be overridden.
        return str(version
                   or self.api_info.get(self.get_name(), {}).get("version")
                   or self._meta_get("default_version"))

    @classmethod
    def get_supported_versions(cls):
        return cls._meta_get("supported_versions")

    @classmethod
    def validate_version(cls, version):
        supported_versions = cls.get_supported_versions()
        if supported_versions:
            if str(version) not in supported_versions:
                raise exceptions.ValidationError(_(
                    "'%(vers)s' is not supported. Should be one of "
                    "'%(supported)s'") % {"vers": version,
                                          "supported": supported_versions})
        else:
            raise exceptions.RallyException(
                _("Setting version is not supported."))
        try:
            float(version)
        except ValueError:
            raise exceptions.ValidationError(_(
                "'%s' is invalid. Should be numeric value.") % version)

    def choose_service_type(self, service_type=None):
        """Return service_type string.

        Choose service type between transmitted(preferable value if present),
        service type from api_info(configured from a context) and default.
        """
        return (service_type
                or self.api_info.get(self.get_name(), {}).get("service_type")
                or self._meta_get("default_service_type"))

    @classmethod
    def is_service_type_configurable(cls):
        """Just checks that client supports setting service type."""
        if cls._meta_get("default_service_type") is None:
            raise exceptions.RallyException(_(
                "Setting service type is not supported."))

    def keystone(self, *args, **kwargs):
        """Make a call to keystone client."""
        keystone = OSClient.get("keystone")(self.credential, self.api_info,
                                            self.cache)
        return keystone(*args, **kwargs)

    def _get_session(self, auth=None, endpoint=None):
        endpoint = endpoint or self._get_endpoint()

        from keystoneclient.auth import token_endpoint
        from keystoneclient import session as ks_session

        kc = self.keystone()
        if auth is None:
            auth = token_endpoint.Token(endpoint, kc.auth_token)

        return ks_session.Session(auth=auth, verify=self.credential.insecure,
                                  timeout=CONF.openstack_client_http_timeout)

    def _get_endpoint(self, service_type=None):
        kc = self.keystone()
        api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        return api_url

    def _get_auth_info(self, user_key="username",
                       password_key="password",
                       auth_url_key="auth_url",
                       project_name_key="project_id"
                       ):
        kw = {
            user_key: self.credential.username,
            password_key: self.credential.password,
            auth_url_key: self.credential.auth_url
        }
        if project_name_key:
            kw.update({project_name_key: self.credential.tenant_name})
        return kw

    @abc.abstractmethod
    def create_client(self, *args, **kwargs):
        """Create new instance of client."""

    def __call__(self, *args, **kwargs):
        """Return initialized client instance."""
        key = "{0}{1}{2}".format(self.get_name(),
                                 str(args) if args else "",
                                 str(kwargs) if kwargs else "")
        if key not in self.cache:
            self.cache[key] = self.create_client(*args, **kwargs)
        return self.cache[key]

    @classmethod
    def get(cls, name, namespace=_NAMESPACE):
        return super(OSClient, cls).get(name, namespace)


@configure("keystone")
class Keystone(OSClient):
    def keystone(self, *args, **kwargs):
        raise exceptions.RallyException(_("Method 'keystone' is restricted "
                                          "for keystoneclient. :)"))

    @staticmethod
    def _create_keystone_client(args):
        from keystoneclient import discover as keystone_discover
        discover = keystone_discover.Discover(**args)
        for version_data in discover.version_data():
            version = version_data["version"]
            if version[0] <= 2:
                from keystoneclient.v2_0 import client as keystone_v2
                return keystone_v2.Client(**args)
            elif version[0] == 3:
                from keystoneclient.v3 import client as keystone_v3
                return keystone_v3.Client(**args)
        raise exceptions.RallyException("Failed to discover keystone version "
                                        "for url %(auth_url)s.", **args)

    def create_client(self):
        """Return keystone client."""
        new_kw = {
            "timeout": CONF.openstack_client_http_timeout,
            "insecure": self.credential.insecure,
            "cacert": self.credential.cacert
        }
        kw = self.credential.to_dict()
        kw.update(new_kw)
        client = self._create_keystone_client(kw)
        if client.auth_ref is None:
            client.authenticate()
        return client


@configure("nova", default_version="2", default_service_type="compute")
class Nova(OSClient):
    @classmethod
    def validate_version(cls, version):
        from novaclient import api_versions
        from novaclient import exceptions as nova_exc

        try:
            api_versions.get_api_version(version)
        except nova_exc.UnsupportedVersion:
            raise exceptions.RallyException(
                "Version string '%s' is unsupported." % version)

    def create_client(self, version=None, service_type=None):
        """Return nova client."""
        from novaclient import client as nova
        kc = self.keystone()
        compute_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = nova.Client(self.choose_version(version),
                             auth_token=kc.auth_token,
                             http_log_debug=logging.is_debug(),
                             timeout=CONF.openstack_client_http_timeout,
                             insecure=self.credential.insecure,
                             cacert=self.credential.cacert,
                             **self._get_auth_info(password_key="api_key"))
        client.set_management_url(compute_api_url)
        return client


@configure("neutron", default_version="2.0", default_service_type="network",
           supported_versions=["2.0"])
class Neutron(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return neutron client."""
        from neutronclient.neutron import client as neutron
        kc = self.keystone()
        network_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = neutron.Client(self.choose_version(version),
                                token=kc.auth_token,
                                endpoint_url=network_api_url,
                                timeout=CONF.openstack_client_http_timeout,
                                insecure=self.credential.insecure,
                                ca_cert=self.credential.cacert,
                                **self._get_auth_info(
                                    project_name_key="tenant_name")
                                )
        return client


@configure("glance", default_version="1", default_service_type="image",
           supported_versions=["1", "2"])
class Glance(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return glance client."""
        import glanceclient as glance
        kc = self.keystone()
        image_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = glance.Client(self.choose_version(version),
                               endpoint=image_api_url,
                               token=kc.auth_token,
                               timeout=CONF.openstack_client_http_timeout,
                               insecure=self.credential.insecure,
                               cacert=self.credential.cacert)
        return client


@configure("heat", default_version="1", default_service_type="orchestration",
           supported_versions=["1"])
class Heat(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return heat client."""
        from heatclient import client as heat
        kc = self.keystone()
        orchestration_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = heat.Client(self.choose_version(version),
                             endpoint=orchestration_api_url,
                             token=kc.auth_token,
                             timeout=CONF.openstack_client_http_timeout,
                             insecure=self.credential.insecure,
                             cacert=self.credential.cacert,
                             **self._get_auth_info(project_name_key=None))
        return client


@configure("cinder", default_version="1", default_service_type="volume",
           supported_versions=["1", "2"])
class Cinder(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return cinder client."""
        from cinderclient import client as cinder
        client = cinder.Client(self.choose_version(version),
                               http_log_debug=logging.is_debug(),
                               timeout=CONF.openstack_client_http_timeout,
                               insecure=self.credential.insecure,
                               cacert=self.credential.cacert,
                               **self._get_auth_info(password_key="api_key"))
        kc = self.keystone()
        volume_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client.client.management_url = volume_api_url
        client.client.auth_token = kc.auth_token
        return client


@configure("manila", default_version="1", default_service_type="share",
           supported_versions=["1", "2"])
class Manila(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return manila client."""
        from manilaclient import client as manila
        manila_client = manila.Client(
            self.choose_version(version),
            region_name=self.credential.region_name,
            http_log_debug=logging.is_debug(),
            timeout=CONF.openstack_client_http_timeout,
            insecure=self.credential.insecure,
            cacert=self.credential.cacert,
            **self._get_auth_info(password_key="api_key",
                                  project_name_key="project_name"))
        kc = self.keystone()
        manila_client.client.management_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        manila_client.client.auth_token = kc.auth_token
        return manila_client


@configure("ceilometer", default_version="2", default_service_type="metering",
           supported_versions=["1", "2"])
class Ceilometer(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return ceilometer client."""
        from ceilometerclient import client as ceilometer
        kc = self.keystone()
        metering_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        auth_token = kc.auth_token
        if not hasattr(auth_token, "__call__"):
            # python-ceilometerclient requires auth_token to be a callable
            auth_token = lambda: kc.auth_token

        client = ceilometer.get_client(
            self.choose_version(version),
            os_endpoint=metering_api_url,
            token=auth_token,
            timeout=CONF.openstack_client_http_timeout,
            insecure=self.credential.insecure,
            cacert=self.credential.cacert,
            **self._get_auth_info(project_name_key="tenant_name"))
        return client


@configure("ironic", default_version="1", default_service_type="baremetal",
           supported_versions=["1"])
class Ironic(OSClient):

    def create_client(self, version=None, service_type=None):
        """Return Ironic client."""
        from ironicclient import client as ironic
        kc = self.keystone()
        baremetal_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = ironic.get_client(self.choose_version(version),
                                   os_auth_token=kc.auth_token,
                                   ironic_url=baremetal_api_url,
                                   timeout=CONF.openstack_client_http_timeout,
                                   insecure=self.credential.insecure,
                                   cacert=self.credential.cacert)
        return client


@configure("sahara", default_version="1.1", supported_versions=["1.0", "1.1"],
           default_service_type="data-processing")
class Sahara(OSClient):
    # NOTE(andreykurilin): saharaclient supports "1.0" version and doesn't
    # support "1". `choose_version` and `validate_version` methods are written
    # as a hack to covert 1 -> 1.0, which can simplify setting saharaclient
    # for end-users.
    def choose_version(self, version=None):
        return float(super(Sahara, self).choose_version(version))

    @classmethod
    def validate_version(cls, version):
        super(Sahara, cls).validate_version(float(version))

    def create_client(self, version=None, service_type=None):
        """Return Sahara client."""
        from saharaclient import client as sahara
        client = sahara.Client(
            self.choose_version(version),
            service_type=self.choose_service_type(service_type),
            **self._get_auth_info(password_key="api_key",
                                  project_name_key="project_name"))

        return client


@configure("zaqar", default_version="1.1", default_service_type="messaging",
           supported_versions=["1", "1.1"])
class Zaqar(OSClient):
    def choose_version(self, version=None):
        # zaqarclient accepts only int or float obj as version
        return float(super(Zaqar, self).choose_version(version))

    def create_client(self, version=None, service_type=None):
        """Return Zaqar client."""
        from zaqarclient.queues import client as zaqar
        kc = self.keystone()
        messaging_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        conf = {"auth_opts": {"backend": "keystone", "options": {
            "os_username": self.credential.username,
            "os_password": self.credential.password,
            "os_project_name": self.credential.tenant_name,
            "os_project_id": kc.auth_tenant_id,
            "os_auth_url": self.credential.auth_url,
            "insecure": self.credential.insecure,
        }}}
        client = zaqar.Client(url=messaging_api_url,
                              version=self.choose_version(version),
                              conf=conf)
        return client


@configure("murano", default_version="1",
           default_service_type="application-catalog",
           supported_versions=["1"])
class Murano(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return Murano client."""
        from muranoclient import client as murano
        kc = self.keystone()
        murano_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name
        )

        client = murano.Client(self.choose_version(version),
                               endpoint=murano_url,
                               token=kc.auth_token)

        return client


@configure("designate", default_version="1", default_service_type="dns",
           supported_versions=["1", "2"])
class Designate(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return designate client."""
        from designateclient import client

        version = self.choose_version(version)

        api_url = self._get_endpoint(service_type)
        api_url += "/v%s" % version

        session = self._get_session(endpoint=api_url)
        return client.Client(version, session=session)


@configure("trove", default_version="1.0", supported_versions=["1.0"])
class Trove(OSClient):
    def create_client(self, version=None):
        """Returns trove client."""
        from troveclient import client as trove
        client = trove.Client(self.choose_version(version),
                              region_name=self.credential.region_name,
                              timeout=CONF.openstack_client_http_timeout,
                              insecure=self.credential.insecure,
                              cacert=self.credential.cacert,
                              **self._get_auth_info(password_key="api_key")
                              )
        return client


@configure("mistral", default_service_type="workflowv2")
class Mistral(OSClient):
    def create_client(self, service_type=None):
        """Return Mistral client."""
        from mistralclient.api import client
        kc = self.keystone()

        mistral_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)

        client = client.client(
            mistral_url=mistral_url,
            service_type=self.choose_service_type(service_type),
            auth_token=kc.auth_token)
        return client


@configure("swift", default_service_type="object-store")
class Swift(OSClient):
    def create_client(self, service_type=None):
        """Return swift client."""
        from swiftclient import client as swift
        kc = self.keystone()
        object_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = swift.Connection(retries=1,
                                  preauthurl=object_api_url,
                                  preauthtoken=kc.auth_token,
                                  insecure=self.credential.insecure,
                                  cacert=self.credential.cacert,
                                  **self._get_auth_info(
                                      user_key="user",
                                      password_key="key",
                                      auth_url_key="authurl",
                                      project_name_key="tenant_name")
                                  )
        return client


@configure("ec2")
class EC2(OSClient):
    def create_client(self):
        """Return ec2 client."""
        import boto
        kc = self.keystone()
        if kc.version != "v2.0":
            raise exceptions.RallyException(
                _("Rally EC2 benchmark currently supports only"
                  "Keystone version 2"))
        ec2_credential = kc.ec2.create(user_id=kc.auth_user_id,
                                       tenant_id=kc.auth_tenant_id)
        ec2_api_url = kc.service_catalog.url_for(
            service_type=consts.ServiceType.EC2,
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        client = boto.connect_ec2_endpoint(
            url=ec2_api_url,
            aws_access_key_id=ec2_credential.access,
            aws_secret_access_key=ec2_credential.secret,
            is_secure=self.credential.insecure)
        return client


@configure("monasca", default_version="2_0",
           default_service_type="monitoring", supported_versions=["2_0"])
class Monasca(OSClient):
    def create_client(self, version=None, service_type=None):
        """Return monasca client."""
        from monascaclient import client as monasca
        kc = self.keystone()
        monitoring_api_url = kc.service_catalog.url_for(
            service_type=self.choose_service_type(service_type),
            endpoint_type=self.credential.endpoint_type,
            region_name=self.credential.region_name)
        auth_token = kc.auth_token
        client = monasca.Client(
            self.choose_version(version),
            monitoring_api_url,
            token=auth_token,
            timeout=CONF.openstack_client_http_timeout,
            insecure=self.credential.insecure,
            cacert=self.credential.cacert,
            **self._get_auth_info(project_name_key="tenant_name"))
        return client


@configure("cue", default_version="1", default_service_type="message-broker")
class Cue(OSClient):
    def create_client(self, service_type=None):
        """Return cue client."""
        from cueclient.v1 import client as cue

        version = self.choose_version()
        api_url = self._get_endpoint(service_type)
        api_url += "v%s" % version

        session = self._get_session(endpoint=api_url)
        endpoint_type = self.credential.endpoint_type,

        return cue.Client(session=session, interface=endpoint_type[0])


class Clients(object):
    """This class simplify and unify work with OpenStack python clients."""

    def __init__(self, credential, api_info=None):
        self.credential = credential
        self.api_info = api_info or {}
        self.cache = {}

    def __getattr__(self, client_name):
        """Lazy load of clients."""
        return OSClient.get(client_name)(self.credential, self.api_info,
                                         self.cache)

    @classmethod
    def create_from_env(cls):
        creds = envutils.get_creds_from_env_vars()
        return cls(
            objects.Credential(
                creds["auth_url"],
                creds["admin"]["username"],
                creds["admin"]["password"],
                creds["admin"]["tenant_name"],
                endpoint=creds["endpoint"],
                region_name=creds["region_name"],
                https_cacert=creds["https_cacert"],
                https_insecure=creds["https_insecure"]
            ))

    def clear(self):
        """Remove all cached client handles."""
        self.cache = {}

    def verified_keystone(self):
        """Ensure keystone endpoints are valid and then authenticate

        :returns: Keystone Client
        """
        from keystoneclient import exceptions as keystone_exceptions
        try:
            # Ensure that user is admin
            client = self.keystone()
            if "admin" not in [role.lower() for role in
                               client.auth_ref.role_names]:
                raise exceptions.InvalidAdminException(
                    username=self.credential.username)
        except keystone_exceptions.Unauthorized:
            raise exceptions.InvalidEndpointsException()
        except keystone_exceptions.AuthorizationFailure:
            raise exceptions.HostUnreachableException(
                url=self.credential.auth_url)
        return client

    def services(self):
        """Return available services names and types.

        :returns: dict, {"service_type": "service_name", ...}
        """
        if "services_data" not in self.cache:
            services_data = {}
            ks = self.keystone()
            available_services = ks.service_catalog.get_endpoints()
            for stype in available_services.keys():
                if stype in consts.ServiceType:
                    services_data[stype] = consts.ServiceType[stype]
            self.cache["services_data"] = services_data

        return self.cache["services_data"]

    @classmethod
    @logging.log_deprecated("Use rally.osclients.configure decorator instead.",
                            "0.1.2")
    def register(cls, client_name):
        """DEPRECATED!Decorator that adds new OpenStack client dynamically.

        Use rally.osclients.configure decorator instead!

        :param client_name: str name how client will be named in Rally clients

        Decorated class will be added to Clients in runtime, so its sole
        argument is a Clients instance.

        Decorated function will be added to Clients in runtime, so its sole
        argument is a Clients instance.

        Example:
          >>> from rally import osclients
          >>> @osclients.Clients.register("supernova")
          ... def another_nova_client(self):
          ...   from novaclient import client as nova
          ...   return nova.Client("2", auth_token=self.keystone().auth_token,
          ...                      **self._get_auth_info(password_key="key"))
          ...
          >>> clients = osclients.Clients.create_from_env()
          >>> clients.supernova().services.list()[:2]
          [<Service: nova-conductor>, <Service: nova-cert>]
        """
        def wrap(client_func):
            try:
                OSClient.get(client_name)
            except exceptions.PluginNotFound:
                # everything is ok
                pass
            else:
                raise ValueError(
                    _("Can not register client: name already exists: %s")
                    % client_name)

            @configure(client_name)
            class NewClient(OSClient):
                create_client = client_func

            return NewClient

        return wrap
