# This file is part of Buildbot.  Buildbot is free software: you can
# redistribute it and/or modify it under the terms of the GNU General Public
# License as published by the Free Software Foundation, version 2.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# Portions Copyright Buildbot Team Members
# Portions Copyright 2013 Cray Inc.


import math
import time

from twisted.internet import defer
from twisted.internet import threads
from twisted.python import log

from buildbot import config
from buildbot.interfaces import LatentWorkerFailedToSubstantiate
from buildbot.util.latent import CompatibleLatentWorkerMixin
from buildbot.worker import AbstractLatentWorker

try:
    from keystoneauth1 import loading
    from keystoneauth1 import session
    from novaclient import client
    from novaclient.exceptions import NotFound
    _hush_pyflakes = [client]
except ImportError:
    NotFound = Exception
    client = None
    loading = None
    session = None


ACTIVE = 'ACTIVE'
BUILD = 'BUILD'
DELETED = 'DELETED'
UNKNOWN = 'UNKNOWN'


class OpenStackLatentWorker(AbstractLatentWorker,
                            CompatibleLatentWorkerMixin):

    instance = None
    _poll_resolution = 5  # hook point for tests

    def __init__(self, name, password,
                 flavor,
                 os_username,
                 os_password,
                 os_tenant_name,
                 os_auth_url,
                 os_user_domain=None,
                 os_project_domain=None,
                 block_devices=None,
                 region=None,
                 image=None,
                 meta=None,
                 # Have a nova_args parameter to allow passing things directly
                 # to novaclient.
                 nova_args=None,
                 client_version='2',
                 **kwargs):

        if not client:
            config.error("The python module 'novaclient' is needed  "
                         "to use a OpenStackLatentWorker. "
                         "Please install 'python-novaclient' package.")
        if not loading or not session:
            config.error("The python module 'keystoneauth1' is needed "
                         "to use a OpenStackLatentWorker. "
                         "Please install the 'keystoneauth1' package.")

        if not block_devices and not image:
            raise ValueError('One of block_devices or image must be given')

        super().__init__(name, password, **kwargs)

        self.flavor = flavor
        self.client_version = client_version
        if client:
            self.novaclient = self._constructClient(
                client_version, os_username, os_user_domain, os_password, os_tenant_name, os_project_domain,
                os_auth_url)
            if region is not None:
                self.novaclient.client.region_name = region

        if block_devices is not None:
            self.block_devices = [
                self._parseBlockDevice(bd) for bd in block_devices]
        else:
            self.block_devices = None
        self.image = image
        self.meta = meta
        self.nova_args = nova_args if nova_args is not None else {}

    @staticmethod
    def _constructClient(client_version, username, user_domain, password, project_name, project_domain,
                         auth_url):
        """Return a novaclient from the given args."""
        loader = loading.get_plugin_loader('password')

        # These only work with v3
        if user_domain is not None or project_domain is not None:
            auth = loader.load_from_options(auth_url=auth_url, username=username, user_domain_name=user_domain,
                                            password=password, project_name=project_name, project_domain_name=project_domain)
        else:
            auth = loader.load_from_options(auth_url=auth_url, username=username,
                                            password=password, project_name=project_name)

        sess = session.Session(auth=auth)
        return client.Client(client_version, session=sess)

    def _parseBlockDevice(self, block_device):
        """
        Parse a higher-level view of the block device mapping into something
        novaclient wants. This should be similar to how Horizon presents it.
        Required keys:
            device_name: The name of the device; e.g. vda or xda.
            source_type: image, snapshot, volume, or blank/None.
            destination_type: Destination of block device: volume or local.
            delete_on_termination: True/False.
            uuid: The image, snapshot, or volume id.
            boot_index: Integer used for boot order.
            volume_size: Size of the device in GiB.
        """
        client_block_device = {}
        client_block_device['device_name'] = block_device.get(
            'device_name', 'vda')
        client_block_device['source_type'] = block_device.get(
            'source_type', 'image')
        client_block_device['destination_type'] = block_device.get(
            'destination_type', 'volume')
        client_block_device['delete_on_termination'] = bool(
            block_device.get('delete_on_termination', True))
        client_block_device['uuid'] = block_device['uuid']
        client_block_device['boot_index'] = int(
            block_device.get('boot_index', 0))
        # Allow None here. It will be rendered later.
        client_block_device['volume_size'] = block_device.get('volume_size')
        return client_block_device

    @defer.inlineCallbacks
    def _renderBlockDevice(self, block_device, build):
        """Render all of the block device's values."""
        rendered_block_device = yield build.render(block_device)
        if rendered_block_device['volume_size'] is None:
            source_type = rendered_block_device['source_type']
            source_uuid = rendered_block_device['uuid']
            volume_size = self._determineVolumeSize(source_type, source_uuid)
            rendered_block_device['volume_size'] = volume_size
        return rendered_block_device

    def _determineVolumeSize(self, source_type, source_uuid):
        """
        Determine the minimum size the volume needs to be for the source.
        Returns the size in GiB.
        """
        nova = self.novaclient
        if source_type == 'image':
            # The size returned for an image is in bytes. Round up to the next
            # integer GiB.
            image = nova.images.get(source_uuid)
            if hasattr(image, 'OS-EXT-IMG-SIZE:size'):
                size = getattr(image, 'OS-EXT-IMG-SIZE:size')
                size_gb = int(math.ceil(size / 1024.0**3))
                return size_gb
        elif source_type == 'volume':
            # Volumes are easy because they are already in GiB.
            volume = nova.volumes.get(source_uuid)
            return volume.size
        elif source_type == 'snapshot':
            snap = nova.volume_snapshots.get(source_uuid)
            return snap.size
        else:
            unknown_source = ("The source type '%s' for UUID '%s' is"
                              " unknown" % (source_type, source_uuid))
            raise ValueError(unknown_source)

    @defer.inlineCallbacks
    def _getImage(self, build):
        # If image is a callable, then pass it the list of images. The
        # function should return the image's UUID to use.
        image = self.image
        if callable(image):
            image_uuid = image(self.novaclient.images.list())
        else:
            image_uuid = yield build.render(image)
        return image_uuid

    @defer.inlineCallbacks
    def renderWorkerProps(self, build):
        image = yield self._getImage(build)
        if self.block_devices is not None:
            block_devices = []
            for bd in self.block_devices:
                rendered_block_device = yield self._renderBlockDevice(bd, build)
                block_devices.append(rendered_block_device)
        else:
            block_devices = None
        return (image, block_devices)

    @defer.inlineCallbacks
    def start_instance(self, build):
        if self.instance is not None:
            raise ValueError('instance active')

        image, block_devices = yield self.renderWorkerPropsOnStart(build)
        res = yield threads.deferToThread(self._start_instance, image,
                                          block_devices)
        return res

    def _start_instance(self, image_uuid, block_devices):
        boot_args = [self.workername, image_uuid, self.flavor]
        boot_kwargs = dict(
            meta=self.meta,
            block_device_mapping_v2=block_devices,
            **self.nova_args)
        instance = self.novaclient.servers.create(*boot_args, **boot_kwargs)
        # There is an issue when using sessions that the status is not
        # available on the first try. Trying again will work fine. Fetch the
        # instance to avoid that.
        try:
            instance = self.novaclient.servers.get(instance.id)
        except NotFound:
            log.msg('{class_name} {name} instance {instance.id} '
                    '({instance.name}) never found',
                    class_name=self.__class__.__name__, name=self.workername,
                    instance=instance)
            raise LatentWorkerFailedToSubstantiate(
                instance.id, BUILD)
        self.instance = instance
        log.msg('%s %s starting instance %s (image %s)' %
                (self.__class__.__name__, self.workername, instance.id,
                 image_uuid))
        duration = 0
        interval = self._poll_resolution
        while instance.status.startswith(BUILD):
            time.sleep(interval)
            duration += interval
            if duration % 60 == 0:
                log.msg('%s %s has waited %d minutes for instance %s' %
                        (self.__class__.__name__, self.workername, duration // 60,
                         instance.id))
            try:
                instance = self.novaclient.servers.get(instance.id)
            except NotFound:
                log.msg('%s %s instance %s (%s) went missing' %
                        (self.__class__.__name__, self.workername,
                         instance.id, instance.name))
                raise LatentWorkerFailedToSubstantiate(
                    instance.id, instance.status)
        if instance.status == ACTIVE:
            minutes = duration // 60
            seconds = duration % 60
            log.msg('%s %s instance %s (%s) started '
                    'in about %d minutes %d seconds' %
                    (self.__class__.__name__, self.workername,
                     instance.id, instance.name, minutes, seconds))
            return [instance.id, image_uuid,
                    '%02d:%02d:%02d' % (minutes // 60, minutes % 60, seconds)]
        else:
            self.failed_to_start(instance.id, instance.status)

    def stop_instance(self, fast=False):
        if self.instance is None:
            # be gentle.  Something may just be trying to alert us that an
            # instance never attached, and it's because, somehow, we never
            # started.
            return defer.succeed(None)
        instance = self.instance
        self.instance = None
        self._stop_instance(instance, fast)

    def _stop_instance(self, instance, fast):
        try:
            instance = self.novaclient.servers.get(instance.id)
        except NotFound:
            # If can't find the instance, then it's already gone.
            log.msg('%s %s instance %s (%s) already terminated' %
                    (self.__class__.__name__, self.workername, instance.id,
                     instance.name))
            return
        if instance.status not in (DELETED, UNKNOWN):
            instance.delete()
            log.msg('%s %s terminating instance %s (%s)' %
                    (self.__class__.__name__, self.workername, instance.id,
                     instance.name))
