# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.

"""
Drivers for CloudSigma API v1.0 and v2.0.
"""

import re
import copy
import time
import base64
import hashlib

from libcloud.utils.py3 import b, httplib
from libcloud.utils.misc import dict2str, str2list, str2dicts, get_secure_random_string
from libcloud.common.base import Response, JsonResponse, ConnectionUserAndKey
from libcloud.common.types import ProviderError, InvalidCredsError
from libcloud.compute.base import Node, KeyPair, NodeSize, NodeImage, NodeDriver, is_private_subnet
from libcloud.compute.types import Provider, NodeState
from libcloud.utils.iso8601 import parse_date
from libcloud.common.cloudsigma import (
    SPECS_TO_SIZE,
    DEFAULT_REGION,
    INSTANCE_TYPES,
    MAX_VIRTIO_UNITS,
    API_ENDPOINTS_1_0,
    API_ENDPOINTS_2_0,
    DEFAULT_API_VERSION,
    MAX_VIRTIO_CONTROLLERS,
)

try:
    import simplejson as json
except Exception:
    import json


__all__ = [
    "CloudSigmaNodeDriver",
    "CloudSigma_1_0_NodeDriver",
    "CloudSigma_2_0_NodeDriver",
    "CloudSigmaError",
    "CloudSigmaNodeSize",
    "CloudSigmaDrive",
    "CloudSigmaTag",
    "CloudSigmaSubscription",
    "CloudSigmaFirewallPolicy",
    "CloudSigmaFirewallPolicyRule",
]


class CloudSigmaNodeDriver(NodeDriver):
    name = "CloudSigma"
    website = "http://www.cloudsigma.com/"

    def __new__(
        cls,
        key,
        secret=None,
        secure=True,
        host=None,
        port=None,
        api_version=DEFAULT_API_VERSION,
        **kwargs,
    ):
        if cls is CloudSigmaNodeDriver:
            if api_version == "1.0":
                cls = CloudSigma_1_0_NodeDriver
            elif api_version == "2.0":
                cls = CloudSigma_2_0_NodeDriver
            else:
                raise NotImplementedError("Unsupported API version: %s" % (api_version))

        return super().__new__(cls)


class CloudSigmaException(Exception):
    def __str__(self):
        # pylint: disable=unsubscriptable-object

        return self.args[0]

    def __repr__(self):
        # pylint: disable=unsubscriptable-object

        return "<CloudSigmaException '%s'>" % (self.args[0])


class CloudSigmaInsufficientFundsException(Exception):
    def __repr__(self):
        # pylint: disable=unsubscriptable-object

        return "<CloudSigmaInsufficientFundsException '%s'>" % (self.args[0])


class CloudSigmaNodeSize(NodeSize):
    def __init__(self, id, name, cpu, ram, disk, bandwidth, price, driver, extra=None):
        self.id = id
        self.name = name
        self.cpu = cpu
        self.ram = ram
        self.disk = disk
        self.bandwidth = bandwidth
        self.price = price
        self.driver = driver
        self.extra = extra or {}

    def __repr__(self):
        return (
            "<NodeSize: id=%s, name=%s, cpu=%s, ram=%s disk=%s "
            "bandwidth=%s price=%s driver=%s ...>"
        ) % (
            self.id,
            self.name,
            self.cpu,
            self.ram,
            self.disk,
            self.bandwidth,
            self.price,
            self.driver.name,
        )


class CloudSigma_1_0_Response(Response):
    def success(self):
        if self.status == httplib.UNAUTHORIZED:
            raise InvalidCredsError()

        return 200 <= self.status <= 299

    def parse_body(self):
        if not self.body:
            return self.body

        return str2dicts(self.body)

    def parse_error(self):
        return "Error: %s" % (self.body.replace("errors:", "").strip())


class CloudSigma_1_0_Connection(ConnectionUserAndKey):
    host = API_ENDPOINTS_1_0[DEFAULT_REGION]["host"]
    responseCls = CloudSigma_1_0_Response

    def add_default_headers(self, headers):
        headers["Accept"] = "application/json"
        headers["Content-Type"] = "application/json"

        headers["Authorization"] = "Basic %s" % (
            base64.b64encode(b("{}:{}".format(self.user_id, self.key))).decode("utf-8")
        )

        return headers


class CloudSigma_1_0_NodeDriver(CloudSigmaNodeDriver):
    type = Provider.CLOUDSIGMA
    name = "CloudSigma (API v1.0)"
    website = "http://www.cloudsigma.com/"
    connectionCls = CloudSigma_1_0_Connection

    IMAGING_TIMEOUT = 20 * 60  # Default timeout (in seconds) for the drive
    # imaging process

    NODE_STATE_MAP = {
        "active": NodeState.RUNNING,
        "stopped": NodeState.TERMINATED,
        "dead": NodeState.TERMINATED,
        "dumped": NodeState.TERMINATED,
    }

    def __init__(
        self,
        key,
        secret=None,
        secure=True,
        host=None,
        port=None,
        region=DEFAULT_REGION,
        **kwargs,
    ):
        if region not in API_ENDPOINTS_1_0:
            raise ValueError("Invalid region: %s" % (region))

        self._host_argument_set = host is not None
        self.api_name = "cloudsigma_%s" % (region)
        super().__init__(
            key=key,
            secret=secret,
            secure=secure,
            host=host,
            port=port,
            region=region,
            **kwargs,
        )

    def reboot_node(self, node):
        """
        Reboot a node.

        Because Cloudsigma API does not provide native reboot call,
        it's emulated using stop and start.

        @inherits: :class:`NodeDriver.reboot_node`
        """
        node = self._get_node(node.id)
        state = node.state

        if state == NodeState.RUNNING:
            stopped = self.ex_stop_node(node)
        else:
            stopped = True

        if not stopped:
            raise CloudSigmaException("Could not stop node with id %s" % (node.id))

        success = self.ex_start_node(node)

        return success

    def destroy_node(self, node):
        """
        Destroy a node (all the drives associated with it are NOT destroyed).

        If a node is still running, it's stopped before it's destroyed.

        @inherits: :class:`NodeDriver.destroy_node`
        """
        node = self._get_node(node.id)
        state = node.state

        # Node cannot be destroyed while running so it must be stopped first

        if state == NodeState.RUNNING:
            stopped = self.ex_stop_node(node)
        else:
            stopped = True

        if not stopped:
            raise CloudSigmaException("Could not stop node with id %s" % (node.id))

        response = self.connection.request(action="/servers/%s/destroy" % (node.id), method="POST")

        return response.status == 204

    def list_images(self, location=None):
        """
        Return a list of available standard images (this call might take up
        to 15 seconds to return).

        @inherits: :class:`NodeDriver.list_images`
        """
        response = self.connection.request(action="/drives/standard/info").object

        images = []

        for value in response:
            if value.get("type"):
                if value["type"] == "disk":
                    image = NodeImage(
                        id=value["drive"],
                        name=value["name"],
                        driver=self.connection.driver,
                        extra={"size": value["size"]},
                    )
                    images.append(image)

        return images

    def list_sizes(self, location=None):
        sizes = []

        for value in INSTANCE_TYPES:
            key = value["id"]
            size = CloudSigmaNodeSize(
                id=value["id"],
                name=value["name"],
                cpu=value["cpu"],
                ram=value["memory"],
                disk=value["disk"],
                bandwidth=value["bandwidth"],
                price=self._get_size_price(size_id=key),
                driver=self.connection.driver,
            )
            sizes.append(size)

        return sizes

    def list_nodes(self):
        response = self.connection.request(action="/servers/info").object

        nodes = []

        for data in response:
            node = self._to_node(data)

            if node:
                nodes.append(node)

        return nodes

    def create_node(
        self,
        name,
        size,
        image,
        smp="auto",
        nic_model="e1000",
        vnc_password=None,
        drive_type="hdd",
    ):
        """
        Creates a CloudSigma instance

        @inherits: :class:`NodeDriver.create_node`

        :keyword    name: String with a name for this new node (required)
        :type       name: ``str``

        :keyword    smp: Number of virtual processors or None to calculate
                         based on the cpu speed.
        :type       smp: ``int``

        :keyword    nic_model: e1000, rtl8139 or virtio (is not specified,
                               e1000 is used)
        :type       nic_model: ``str``

        :keyword    vnc_password: If not set, VNC access is disabled.
        :type       vnc_password: ``bool``

        :keyword    drive_type: Drive type (ssd|hdd). Defaults to hdd.
        :type       drive_type: ``str``
        """

        if nic_model not in ["e1000", "rtl8139", "virtio"]:
            raise CloudSigmaException("Invalid NIC model specified")

        if drive_type not in ["hdd", "ssd"]:
            raise CloudSigmaException(
                'Invalid drive type "%s". Valid types' " are: hdd, ssd" % (drive_type)
            )

        drive_data = {}
        drive_data.update({"name": name, "size": "%sG" % (size.disk), "driveType": drive_type})

        response = self.connection.request(
            action="/drives/%s/clone" % image.id,
            data=dict2str(drive_data),
            method="POST",
        ).object

        if not response:
            raise CloudSigmaException("Drive creation failed")

        drive_uuid = response[0]["drive"]

        response = self.connection.request(action="/drives/%s/info" % (drive_uuid)).object
        imaging_start = time.time()

        while "imaging" in response[0]:
            response = self.connection.request(action="/drives/%s/info" % (drive_uuid)).object
            elapsed_time = time.time() - imaging_start
            timed_out = elapsed_time >= self.IMAGING_TIMEOUT

            if "imaging" in response[0] and timed_out:
                raise CloudSigmaException("Drive imaging timed out")
            time.sleep(1)

        node_data = {}
        node_data.update(
            {
                "name": name,
                "cpu": size.cpu,
                "mem": size.ram,
                "ide:0:0": drive_uuid,
                "boot": "ide:0:0",
                "smp": smp,
            }
        )
        node_data.update({"nic:0:model": nic_model, "nic:0:dhcp": "auto"})

        if vnc_password:
            node_data.update({"vnc:ip": "auto", "vnc:password": vnc_password})

        response = self.connection.request(
            action="/servers/create", data=dict2str(node_data), method="POST"
        ).object

        if not isinstance(response, list):
            response = [response]

        node = self._to_node(response[0])

        if node is None:
            # Insufficient funds, destroy created drive
            self.ex_drive_destroy(drive_uuid)
            raise CloudSigmaInsufficientFundsException("Insufficient funds, node creation failed")

        # Start the node after it has been created
        started = self.ex_start_node(node)

        if started:
            node.state = NodeState.RUNNING

        return node

    def ex_destroy_node_and_drives(self, node):
        """
        Destroy a node and all the drives associated with it.

        :param      node: Node which should be used
        :type       node: :class:`libcloud.compute.base.Node`

        :rtype: ``bool``
        """
        node = self._get_node_info(node)

        drive_uuids = []

        for key, value in node.items():
            if (
                key.startswith("ide:") or key.startswith("scsi") or key.startswith("block")
            ) and not (
                key.endswith(":bytes") or key.endswith(":requests") or key.endswith("media")
            ):
                drive_uuids.append(value)

        node_destroyed = self.destroy_node(self._to_node(node))

        if not node_destroyed:
            return False

        for drive_uuid in drive_uuids:
            self.ex_drive_destroy(drive_uuid)

        return True

    def ex_static_ip_list(self):
        """
        Return a list of available static IP addresses.

        :rtype: ``list`` of ``str``
        """
        response = self.connection.request(action="/resources/ip/list", method="GET")

        if response.status != 200:
            raise CloudSigmaException("Could not retrieve IP list")

        ips = str2list(response.body)

        return ips

    def ex_drives_list(self):
        """
        Return a list of all the available drives.

        :rtype: ``list`` of ``dict``
        """
        response = self.connection.request(action="/drives/info", method="GET")

        result = str2dicts(response.body)

        return result

    def ex_static_ip_create(self):
        """
        Create a new static IP address.p

        :rtype: ``list`` of ``dict``
        """
        response = self.connection.request(action="/resources/ip/create", method="GET")

        result = str2dicts(response.body)

        return result

    def ex_static_ip_destroy(self, ip_address):
        """
        Destroy a static IP address.

        :param      ip_address: IP address which should be used
        :type       ip_address: ``str``

        :rtype: ``bool``
        """
        response = self.connection.request(
            action="/resources/ip/%s/destroy" % (ip_address), method="GET"
        )

        return response.status == 204

    def ex_drive_destroy(self, drive_uuid):
        """
        Destroy a drive with a specified uuid.
        If the drive is currently mounted an exception is thrown.

        :param      drive_uuid: Drive uuid which should be used
        :type       drive_uuid: ``str``

        :rtype: ``bool``
        """
        response = self.connection.request(
            action="/drives/%s/destroy" % (drive_uuid), method="POST"
        )

        return response.status == 204

    def ex_set_node_configuration(self, node, **kwargs):
        """
        Update a node configuration.
        Changing most of the parameters requires node to be stopped.

        :param      node: Node which should be used
        :type       node: :class:`libcloud.compute.base.Node`

        :param      kwargs: keyword arguments
        :type       kwargs: ``dict``

        :rtype: ``bool``
        """
        valid_keys = (
            "^name$",
            "^parent$",
            "^cpu$",
            "^smp$",
            "^mem$",
            "^boot$",
            "^nic:0:model$",
            "^nic:0:dhcp",
            "^nic:1:model$",
            "^nic:1:vlan$",
            "^nic:1:mac$",
            "^vnc:ip$",
            "^vnc:password$",
            "^vnc:tls",
            "^ide:[0-1]:[0-1](:media)?$",
            "^scsi:0:[0-7](:media)?$",
            "^block:[0-7](:media)?$",
        )

        invalid_keys = []
        keys = list(kwargs.keys())

        for key in keys:
            matches = False

            for regex in valid_keys:
                if re.match(regex, key):
                    matches = True

                    break

            if not matches:
                invalid_keys.append(key)

        if invalid_keys:
            raise CloudSigmaException(
                "Invalid configuration key specified: %s" % (",".join(invalid_keys))
            )

        response = self.connection.request(
            action="/servers/%s/set" % (node.id), data=dict2str(kwargs), method="POST"
        )

        return response.status == 200 and response.body != ""

    def ex_start_node(self, node):
        """
        Start a node.

        :param      node: Node which should be used
        :type       node: :class:`libcloud.compute.base.Node`

        :rtype: ``bool``
        """
        response = self.connection.request(action="/servers/%s/start" % (node.id), method="POST")

        return response.status == 200

    def ex_stop_node(self, node):
        # NOTE: This method is here for backward compatibility reasons after
        # this method was promoted to be part of the standard compute API in
        # Libcloud v2.7.0

        return self.stop_node(node=node)

    def stop_node(self, node):
        """
        Stop (shutdown) a node.

        :param      node: Node which should be used
        :type       node: :class:`libcloud.compute.base.Node`

        :rtype: ``bool``
        """
        response = self.connection.request(action="/servers/%s/stop" % (node.id), method="POST")

        return response.status == 204

    def ex_shutdown_node(self, node):
        """
        Stop (shutdown) a node.

        @inherits: :class:`CloudSigmaBaseNodeDriver.ex_stop_node`
        """

        return self.ex_stop_node(node)

    def ex_destroy_drive(self, drive_uuid):
        """
        Destroy a drive.

        :param      drive_uuid: Drive uuid which should be used
        :type       drive_uuid: ``str``

        :rtype: ``bool``
        """
        response = self.connection.request(
            action="/drives/%s/destroy" % (drive_uuid), method="POST"
        )

        return response.status == 204

    def _ex_connection_class_kwargs(self):
        """
        Return the host value based on the user supplied region.
        """
        kwargs = {}

        if not self._host_argument_set:
            kwargs["host"] = API_ENDPOINTS_1_0[self.region]["host"]

        return kwargs

    def _to_node(self, data):
        if data:
            try:
                state = self.NODE_STATE_MAP[data["status"]]
            except KeyError:
                state = NodeState.UNKNOWN

            if "server" not in data:
                # Response does not contain server UUID if the server
                # creation failed because of insufficient funds.

                return None

            public_ips = []

            if "nic:0:dhcp" in data:
                if isinstance(data["nic:0:dhcp"], list):
                    public_ips = data["nic:0:dhcp"]
                else:
                    public_ips = [data["nic:0:dhcp"]]

            extra = {}
            extra_keys = [
                ("cpu", "int"),
                ("smp", "auto"),
                ("mem", "int"),
                ("status", "str"),
            ]

            for key, value_type in extra_keys:
                if key in data:
                    value = data[key]

                    if value_type == "int":
                        value = int(value)
                    elif value_type == "auto":
                        try:
                            value = int(value)
                        except ValueError:
                            pass

                    extra.update({key: value})

            if "vnc:ip" in data and "vnc:password" in data:
                extra.update({"vnc_ip": data["vnc:ip"], "vnc_password": data["vnc:password"]})

            node = Node(
                id=data["server"],
                name=data["name"],
                state=state,
                public_ips=public_ips,
                private_ips=None,
                driver=self.connection.driver,
                extra=extra,
            )

            return node

        return None

    def _get_node(self, node_id):
        nodes = self.list_nodes()
        node = [node for node in nodes if node.id == node.id]

        if not node:
            raise CloudSigmaException("Node with id %s does not exist" % (node_id))

        return node[0]

    def _get_node_info(self, node):
        response = self.connection.request(action="/servers/%s/info" % (node.id))

        result = str2dicts(response.body)

        return result[0]


class CloudSigmaZrhConnection(CloudSigma_1_0_Connection):
    """
    Connection class for the CloudSigma driver for the Zurich end-point
    """

    host = API_ENDPOINTS_1_0["zrh"]["host"]


class CloudSigmaZrhNodeDriver(CloudSigma_1_0_NodeDriver):
    """
    CloudSigma node driver for the Zurich end-point
    """

    connectionCls = CloudSigmaZrhConnection
    api_name = "cloudsigma_zrh"


class CloudSigmaLvsConnection(CloudSigma_1_0_Connection):
    """
    Connection class for the CloudSigma driver for the Las Vegas end-point
    """

    host = API_ENDPOINTS_1_0["lvs"]["host"]


class CloudSigmaLvsNodeDriver(CloudSigma_1_0_NodeDriver):
    """
    CloudSigma node driver for the Las Vegas end-point
    """

    connectionCls = CloudSigmaLvsConnection
    api_name = "cloudsigma_lvs"


class CloudSigmaError(ProviderError):
    """
    Represents CloudSigma API error.
    """

    def __init__(self, http_code, error_type, error_msg, error_point, driver):
        """
        :param http_code: HTTP status code.
        :type http_code: ``int``

        :param error_type: Type of error (validation / notexist / backend /
                           permissions  database / concurrency / billing /
                           payment)
        :type error_type: ``str``

        :param error_msg: A description of the error that occurred.
        :type error_msg: ``str``

        :param error_point: Point at which the error occurred. Can be None.
        :type error_point: ``str`` or ``None``
        """
        super().__init__(http_code=http_code, value=error_msg, driver=driver)
        self.error_type = error_type
        self.error_msg = error_msg
        self.error_point = error_point


class CloudSigmaSubscription:
    """
    Represents CloudSigma subscription.
    """

    def __init__(
        self,
        id,
        resource,
        amount,
        period,
        status,
        price,
        start_time,
        end_time,
        auto_renew,
        subscribed_object=None,
    ):
        """
        :param id: Subscription ID.
        :type id: ``str``

        :param resource: Resource (e.g vlan, ip, etc.).
        :type resource: ``str``

        :param period: Subscription period.
        :type period: ``str``

        :param status: Subscription status (active / inactive).
        :type status: ``str``

        :param price: Subscription price.
        :type price: ``str``

        :param start_time: Start time for this subscription.
        :type start_time: ``datetime.datetime``

        :param end_time: End time for this subscription.
        :type end_time: ``datetime.datetime``

        :param auto_renew: True if the subscription is auto renewed.
        :type auto_renew: ``bool``

        :param subscribed_object: Optional UUID of the subscribed object.
        :type subscribed_object: ``str``
        """
        self.id = id
        self.resource = resource
        self.amount = amount
        self.period = period
        self.status = status
        self.price = price
        self.start_time = start_time
        self.end_time = end_time
        self.auto_renew = auto_renew
        self.subscribed_object = subscribed_object

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return (
            "<CloudSigmaSubscription id=%s, resource=%s, amount=%s, "
            "period=%s, object_uuid=%s>"
            % (self.id, self.resource, self.amount, self.period, self.subscribed_object)
        )


class CloudSigmaTag:
    """
    Represents a CloudSigma tag object.
    """

    def __init__(self, id, name, resources=None):
        """
        :param id: Tag ID.
        :type id: ``str``

        :param name: Tag name.
        :type name: ``str``

        :param resource: IDs of resources which are associated with this tag.
        :type resources: ``list`` of ``str``
        """
        self.id = id
        self.name = name
        self.resources = resources if resources else []

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return "<CloudSigmaTag id={}, name={}, resources={}>".format(
            self.id,
            self.name,
            repr(self.resources),
        )


class CloudSigmaDrive(NodeImage):
    """
    Represents a CloudSigma drive.
    """

    def __init__(self, id, name, size, media, status, driver, extra=None):
        """
        :param id: Drive ID.
        :type id: ``str``

        :param name: Drive name.
        :type name: ``str``

        :param size: Drive size (in GBs).
        :type size: ``float``

        :param media: Drive media (cdrom / disk).
        :type media: ``str``

        :param status: Drive status (unmounted / mounted).
        :type status: ``str``
        """
        super().__init__(id=id, name=name, driver=driver, extra=extra)
        self.size = size
        self.media = media
        self.status = status

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return ("<CloudSigmaSize id=%s, name=%s size=%s, media=%s, " "status=%s>") % (
            self.id,
            self.name,
            self.size,
            self.media,
            self.status,
        )


class CloudSigmaFirewallPolicy:
    """
    Represents a CloudSigma firewall policy.
    """

    def __init__(self, id, name, rules):
        """
        :param id: Policy ID.
        :type id: ``str``

        :param name: Policy name.
        :type name: ``str``

        :param rules: Rules associated with this policy.
        :type rules: ``list`` of :class:`.CloudSigmaFirewallPolicyRule` objects
        """
        self.id = id
        self.name = name
        self.rules = rules if rules else []

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return ("<CloudSigmaFirewallPolicy id=%s, name=%s rules=%s>") % (
            self.id,
            self.name,
            repr(self.rules),
        )


class CloudSigmaFirewallPolicyRule:
    """
    Represents a CloudSigma firewall policy rule.
    """

    def __init__(
        self,
        action,
        direction,
        ip_proto=None,
        src_ip=None,
        src_port=None,
        dst_ip=None,
        dst_port=None,
        comment=None,
    ):
        """
        :param action: Action (drop / accept).
        :type action: ``str``

        :param direction: Rule direction (in / out / both)>
        :type direction: ``str``

        :param ip_proto: IP protocol (tcp / udp).
        :type ip_proto: ``str``.

        :param src_ip: Source IP in CIDR notation.
        :type src_ip: ``str``

        :param src_port: Source port or a port range.
        :type src_port: ``str``

        :param dst_ip: Destination IP in CIDR notation.
        :type dst_ip: ``str``

        :param src_port: Destination port or a port range.
        :type src_port: ``str``

        :param comment: Comment associated with the policy.
        :type comment: ``str``
        """
        self.action = action
        self.direction = direction
        self.ip_proto = ip_proto
        self.src_ip = src_ip
        self.src_port = src_port
        self.dst_ip = dst_ip
        self.dst_port = dst_port
        self.comment = comment

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return ("<CloudSigmaFirewallPolicyRule action=%s, direction=%s>") % (
            self.action,
            self.direction,
        )


class CloudSigma_2_0_Response(JsonResponse):
    success_status_codes = [
        httplib.OK,
        httplib.ACCEPTED,
        httplib.NO_CONTENT,
        httplib.CREATED,
    ]

    def success(self):
        return self.status in self.success_status_codes

    def parse_error(self):
        if int(self.status) == httplib.UNAUTHORIZED:
            raise InvalidCredsError("Invalid credentials")

        body = self.parse_body()
        errors = self._parse_errors_from_body(body=body)

        if errors:
            # Throw first error
            raise errors[0]

        return body

    def _parse_errors_from_body(self, body):
        """
        Parse errors from the response body.

        :return: List of error objects.
        :rtype: ``list`` of :class:`.CloudSigmaError` objects
        """
        errors = []

        if not isinstance(body, list):
            return None

        for item in body:
            if "error_type" not in item:
                # Unrecognized error

                continue

            error = CloudSigmaError(
                http_code=self.status,
                error_type=item["error_type"],
                error_msg=item["error_message"],
                error_point=item["error_point"],
                driver=self.connection.driver,
            )
            errors.append(error)

        return errors


class CloudSigma_2_0_Connection(ConnectionUserAndKey):
    host = API_ENDPOINTS_2_0[DEFAULT_REGION]["host"]
    responseCls = CloudSigma_2_0_Response
    api_prefix = "/api/2.0"

    def add_default_headers(self, headers):
        headers["Accept"] = "application/json"
        headers["Content-Type"] = "application/json"

        headers["Authorization"] = "Basic %s" % (
            base64.b64encode(b("{}:{}".format(self.user_id, self.key))).decode("utf-8")
        )

        return headers

    def encode_data(self, data):
        data = json.dumps(data)

        return data

    def request(self, action, params=None, data=None, headers=None, method="GET", raw=False):
        params = params or {}
        action = self.api_prefix + action

        if method == "GET":
            params["limit"] = 0  # we want all the items back

        return super().request(
            action=action,
            params=params,
            data=data,
            headers=headers,
            method=method,
            raw=raw,
        )


class CloudSigma_2_0_NodeDriver(CloudSigmaNodeDriver):
    """
    Driver for CloudSigma API v2.0.
    """

    name = "CloudSigma (API v2.0)"
    api_name = "cloudsigma_zrh"
    website = "http://www.cloudsigma.com/"
    connectionCls = CloudSigma_2_0_Connection

    # Default drive transition timeout in seconds
    DRIVE_TRANSITION_TIMEOUT = 500

    # How long to sleep between different polling periods while waiting for
    # drive transition
    DRIVE_TRANSITION_SLEEP_INTERVAL = 5

    NODE_STATE_MAP = {
        "starting": NodeState.PENDING,
        "stopping": NodeState.PENDING,
        "unavailable": NodeState.ERROR,
        "running": NodeState.RUNNING,
        "stopped": NodeState.STOPPED,
        "paused": NodeState.PAUSED,
    }

    def __init__(
        self,
        key,
        secret,
        secure=True,
        host=None,
        port=None,
        region=DEFAULT_REGION,
        **kwargs,
    ):
        if region not in API_ENDPOINTS_2_0:
            raise ValueError("Invalid region: %s" % (region))

        if not secure:
            # CloudSigma drive uses Basic Auth authentication and we don't want
            # to allow user to accidentally send credentials over the wire in
            # plain-text
            raise ValueError("CloudSigma driver only supports a " "secure connection")

        self._host_argument_set = host is not None
        super().__init__(
            key=key,
            secret=secret,
            secure=secure,
            host=host,
            port=port,
            region=region,
            **kwargs,
        )

    def list_nodes(self, ex_tag=None):
        """
        List available nodes.

        :param ex_tag: If specified, only return servers tagged with the
                       provided tag.
        :type ex_tag: :class:`CloudSigmaTag`
        """

        if ex_tag:
            action = "/tags/%s/servers/detail/" % (ex_tag.id)
        else:
            action = "/servers/detail/"

        response = self.connection.request(action=action, method="GET").object
        nodes = [self._to_node(data=item) for item in response["objects"]]

        return nodes

    def list_sizes(self):
        """
        List available sizes.
        """
        sizes = []

        for value in INSTANCE_TYPES:
            key = value["id"]
            size = CloudSigmaNodeSize(
                id=value["id"],
                name=value["name"],
                cpu=value["cpu"],
                ram=value["memory"],
                disk=value["disk"],
                bandwidth=value["bandwidth"],
                price=self._get_size_price(size_id=key),
                driver=self.connection.driver,
            )
            sizes.append(size)

        return sizes

    def list_images(self):
        """
        Return a list of available pre-installed library drives.

        Note: If you want to list all the available library drives (both
        pre-installed and installation CDs), use :meth:`ex_list_library_drives`
        method.
        """
        response = self.connection.request(action="/libdrives/").object
        images = [self._to_image(data=item) for item in response["objects"]]

        # We filter out non pre-installed library drives by default because
        # they can't be used directly following a default Libcloud server
        # creation flow.
        images = [image for image in images if image.extra["image_type"] == "preinst"]

        return images

    def create_node(
        self,
        name,
        size,
        image,
        ex_metadata=None,
        ex_vnc_password=None,
        ex_avoid=None,
        ex_vlan=None,
        public_keys=None,
    ):
        """
        Create a new server.

        Server creation consists multiple steps depending on the type of the
        image used.

        1. Installation CD:

            1. Create a server and attach installation cd
            2. Start a server

        2. Pre-installed image:

            1. Clone provided library drive so we can use it
            2. Resize cloned drive to the desired size
            3. Create a server and attach cloned drive
            4. Start a server

        :param ex_metadata: Key / value pairs to associate with the
                            created node. (optional)
        :type ex_metadata: ``dict``

        :param ex_vnc_password: Password to use for VNC access. If not
                                provided, random password is generated.
        :type ex_vnc_password: ``str``

        :param ex_avoid: A list of server UUIDs to avoid when starting this
                         node. (optional)
        :type ex_avoid: ``list``

        :param ex_vlan: Optional UUID of a VLAN network to use. If specified,
                        server will have two nics assigned - 1 with a public ip
                        and 1 with the provided VLAN.
        :type ex_vlan: ``str``

        :param public_keys: Optional list of SSH key UUIDs
        :type public_keys: ``list`` of ``str``
        """
        is_installation_cd = self._is_installation_cd(image=image)

        if ex_vnc_password:
            vnc_password = ex_vnc_password
        else:
            # VNC password is not provided, generate a random one.
            vnc_password = get_secure_random_string(size=12)

        drive_name = "%s-drive" % (name)

        drive_size = size.disk

        if not is_installation_cd:
            # 1. Clone library drive so we can use it
            drive = self.ex_clone_drive(drive=image, name=drive_name)

            # Wait for drive clone to finish
            drive = self._wait_for_drive_state_transition(drive=drive, state="unmounted")

            # 2. Resize drive to the desired disk size if the desired disk size
            # is larger than the cloned drive size.

            if drive_size > drive.size:
                drive = self.ex_resize_drive(drive=drive, size=drive_size)

            # Wait for drive resize to finish
            drive = self._wait_for_drive_state_transition(drive=drive, state="unmounted")
        else:
            # No need to clone installation CDs
            drive = image

        # 3. Create server and attach cloned drive
        # ide 0:0
        data = {}
        data["name"] = name
        # cloudsigma uses MHz to measure cpu cores,
        # where 1 cpu core equals 2000MHz
        data["cpu"] = size.cpu * 2000
        data["mem"] = size.ram * 1024 * 1024
        data["vnc_password"] = vnc_password

        if public_keys:
            data["pubkeys"] = public_keys

        if ex_metadata:
            data["meta"] = ex_metadata

        # Assign 1 public interface (DHCP) to the node
        nic = {"boot_order": None, "ip_v4_conf": {"conf": "dhcp"}, "ip_v6_conf": None}

        nics = [nic]

        if ex_vlan:
            # Assign another interface for VLAN
            nic = {
                "boot_order": None,
                "ip_v4_conf": None,
                "ip_v6_conf": None,
                "vlan": ex_vlan,
            }
            nics.append(nic)

        # Need to use IDE for installation CDs

        if is_installation_cd:
            device_type = "ide"
        else:
            device_type = "virtio"

        drive = {
            "boot_order": 1,
            "dev_channel": "0:0",
            "device": device_type,
            "drive": drive.id,
        }

        drives = [drive]

        data["nics"] = nics
        data["drives"] = drives

        action = "/servers/"
        response = self.connection.request(action=action, method="POST", data=data)
        node = self._to_node(response.object["objects"][0])

        # 4. Start server
        self.ex_start_node(node=node, ex_avoid=ex_avoid)

        return node

    def destroy_node(self, node, ex_delete_drives=False):
        """
        Destroy the node and all the associated drives.

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        action = "/servers/%s/" % (node.id)

        if ex_delete_drives is True:
            params = {"recurse": "all_drives"}
        else:
            params = None
        response = self.connection.request(action=action, method="DELETE", params=params)

        return response.status == httplib.NO_CONTENT

    def reboot_node(self, node):
        """
        Reboot a node.

        Because Cloudsigma API does not provide native reboot call,
        it's emulated using stop and start.

        :param node: Node to reboot.
        :type node: :class:`libcloud.compute.base.Node`
        """
        state = node.state

        if state == NodeState.RUNNING:
            stopped = self.stop_node(node)
        else:
            stopped = True

        if not stopped:
            raise CloudSigmaException("Could not stop node with id %s" % (node.id))

        success = False

        for _ in range(5):
            try:
                success = self.start_node(node)
            except CloudSigmaError:
                time.sleep(1)

                continue
            else:
                break

        return success

    # Server extension methods

    def ex_edit_node(self, node, params):
        """
        Edit a node.

        :param node: Node to edit.
        :type node: :class:`libcloud.compute.base.Node`

        :param params: Node parameters to update.
        :type params: ``dict``

        :return Edited node.
        :rtype: :class:`libcloud.compute.base.Node`
        """
        data = {}

        # name, cpu, mem and vnc_password attributes must always be present so
        # we just copy them from the to-be-edited node
        data["name"] = node.name
        data["cpu"] = node.extra["cpus"]
        data["mem"] = node.extra["memory"]
        data["vnc_password"] = node.extra["vnc_password"]

        nics = copy.deepcopy(node.extra.get("nics", []))

        data["nics"] = nics

        data.update(params)

        action = "/servers/%s/" % (node.id)
        response = self.connection.request(action=action, method="PUT", data=data).object
        node = self._to_node(data=response)

        return node

    def start_node(self, node, ex_avoid=None):
        """
        Start a node.

        :param node: Node to start.
        :type node: :class:`libcloud.compute.base.Node`

        :param ex_avoid: A list of other server uuids to avoid when
                         starting this node. If provided, node will
                         attempt to be started on a different
                         physical infrastructure from other servers
                         specified using this argument. (optional)
        :type ex_avoid: ``list``
        """
        params = {}

        if ex_avoid:
            params["avoid"] = ",".join(ex_avoid)

        path = "/servers/%s/action/" % (node.id)
        response = self._perform_action(path=path, action="start", params=params, method="POST")

        return response.status == httplib.ACCEPTED

    def stop_node(self, node):
        path = "/servers/%s/action/" % (node.id)
        response = self._perform_action(path=path, action="stop", method="POST")

        return response.status == httplib.ACCEPTED

    def ex_start_node(self, node, ex_avoid=None):
        # NOTE: This method is here for backward compatibility reasons after
        # this method was promoted to be part of the standard compute API in
        # Libcloud v2.7.0

        return self.start_node(node=node, ex_avoid=ex_avoid)

    def ex_stop_node(self, node):
        # NOTE: This method is here for backward compatibility reasons after
        # this method was promoted to be part of the standard compute API in
        # Libcloud v2.7.0
        """
        Stop a node.
        """

        return self.stop_node(node=node)

    def ex_clone_node(self, node, name=None, random_vnc_password=None):
        """
        Clone the provided node.

        :param name: Optional name for the cloned node.
        :type name: ``str``
        :param random_vnc_password: If True, a new random VNC password will be
                                    generated for the cloned node. Otherwise
                                    password from the cloned node will be
                                    reused.
        :type random_vnc_password: ``bool``

        :return: Cloned node.
        :rtype: :class:`libcloud.compute.base.Node`
        """
        data = {}

        data["name"] = name
        data["random_vnc_password"] = random_vnc_password

        path = "/servers/%s/action/" % (node.id)
        response = self._perform_action(path=path, action="clone", method="POST", data=data).object
        node = self._to_node(data=response)

        return node

    def ex_get_node(self, node_id, return_json=False):
        action = "/servers/%s/" % (node_id)
        response = self.connection.request(action=action).object

        if return_json is True:
            return response

        return self._to_node(response)

    def ex_open_vnc_tunnel(self, node):
        """
        Open a VNC tunnel to the provided node and return the VNC url.

        :param node: Node to open the VNC tunnel to.
        :type node: :class:`libcloud.compute.base.Node`

        :return: URL of the opened VNC tunnel.
        :rtype: ``str``
        """
        path = "/servers/%s/action/" % (node.id)
        response = self._perform_action(path=path, action="open_vnc", method="POST").object
        vnc_url = response["vnc_url"]

        return vnc_url

    def ex_close_vnc_tunnel(self, node):
        """
        Close a VNC server to the provided node.

        :param node: Node to close the VNC tunnel to.
        :type node: :class:`libcloud.compute.base.Node`

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        path = "/servers/%s/action/" % (node.id)
        response = self._perform_action(path=path, action="close_vnc", method="POST")

        return response.status == httplib.ACCEPTED

    # Drive extension methods

    def ex_list_library_drives(self):
        """
        Return a list of all the available library drives (pre-installed and
        installation CDs).

        :rtype: ``list`` of :class:`.CloudSigmaDrive` objects
        """
        response = self.connection.request(action="/libdrives/").object
        drives = [self._to_drive(data=item) for item in response["objects"]]

        return drives

    def ex_list_user_drives(self):
        """
        Return a list of all the available user's drives.

        :rtype: ``list`` of :class:`.CloudSigmaDrive` objects
        """
        response = self.connection.request(action="/drives/detail/").object
        drives = [self._to_drive(data=item) for item in response["objects"]]

        return drives

    def list_volumes(self):
        return self.ex_list_user_drives()

    def ex_create_drive(self, name, size, media="disk", ex_avoid=None):
        """
        Create a new drive.

        :param name: Drive name.
        :type name: ``str``

        :param size: Drive size in GBs.
        :type size: ``int``

        :param media: Drive media type (cdrom, disk).
        :type media: ``str``

        :param ex_avoid: A list of other drive uuids to avoid when
                         creating this drive. If provided, drive will
                         attempt to be created on a different
                         physical infrastructure from other drives
                         specified using this argument. (optional)
        :type ex_avoid: ``list``

        :return: Created drive object.
        :rtype: :class:`.CloudSigmaDrive`
        """
        params = {}
        data = {"name": name, "size": size * 1024 * 1024 * 1024, "media": media}

        if ex_avoid:
            params["avoid"] = ",".join(ex_avoid)

        action = "/drives/"
        response = self.connection.request(
            action=action, method="POST", params=params, data=data
        ).object
        drive = self._to_drive(data=response["objects"][0])

        return drive

    def create_volume(self, name, size, media="disk", ex_avoid=None):
        return self.ex_create_drive(name=name, size=size, media=media, ex_avoid=ex_avoid)

    def ex_clone_drive(self, drive, name=None, ex_avoid=None):
        """
        Clone a library or a standard drive.

        :param drive: Drive to clone.
        :type drive: :class:`libcloud.compute.base.NodeImage` or
                     :class:`.CloudSigmaDrive`

        :param name: Optional name for the cloned drive.
        :type name: ``str``

        :param ex_avoid: A list of other drive uuids to avoid when
                         creating this drive. If provided, drive will
                         attempt to be created on a different
                         physical infrastructure from other drives
                         specified using this argument. (optional)
        :type ex_avoid: ``list``

        :return: New cloned drive.
        :rtype: :class:`.CloudSigmaDrive`
        """
        params = {}
        data = {}

        if ex_avoid:
            params["avoid"] = ",".join(ex_avoid)

        if name:
            data["name"] = name

        path = "/drives/%s/action/" % (drive.id)
        response = self._perform_action(
            path=path, action="clone", params=params, data=data, method="POST"
        )
        drive = self._to_drive(data=response.object["objects"][0])

        return drive

    def ex_resize_drive(self, drive, size):
        """
        Resize a drive.

        :param drive: Drive to resize.

        :param size: New drive size in GBs.
        :type size: ``int``

        :return: Drive object which is being resized.
        :rtype: :class:`.CloudSigmaDrive`
        """
        path = "/drives/%s/action/" % (drive.id)
        data = {"name": drive.name, "size": size * 1024 * 1024 * 1024, "media": "disk"}
        response = self._perform_action(path=path, action="resize", method="POST", data=data)

        drive = self._to_drive(data=response.object["objects"][0])

        return drive

    def ex_attach_drive(self, node, drive):
        """
        Attach drive to node

        :param node: Node to attach the drive to.
        :type node: :class:`libcloud.compute.base.Node`

        :param drive: Drive to attach.
        :type drive: :class:`.CloudSigmaDrive`

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        data = self.ex_get_node(node.id, return_json=True)
        # find the first available device channel to attach the drive
        # device channel format is: {controller}:{unit}
        # total of 920 virtio drives are supported
        # 0:0, …, 0:3, …, 1:0, …, 1:3, …
        # https://cloudsigma-docs.readthedocs.io/en/2.14.3/servers_kvm.html?highlight=dev%20channel#device-channel
        dev_channels = [item["dev_channel"] for item in data["drives"]]
        dev_channel = None

        for controller in range(MAX_VIRTIO_CONTROLLERS):
            for unit in range(MAX_VIRTIO_UNITS):
                if "{}:{}".format(controller, unit) not in dev_channels:
                    dev_channel = "{}:{}".format(controller, unit)

                    break

            if dev_channel:
                break
        else:
            raise CloudSigmaException("Could not attach drive to %s" % (node.id))

        item = {
            "boot_order": None,
            "dev_channel": dev_channel,
            "device": "virtio",
            "drive": str(drive.id),
        }
        data["drives"].append(item)
        action = "/servers/%s/" % (node.id)
        response = self.connection.request(action=action, data=data, method="PUT")

        return response.status == 200

    def attach_volume(self, node, volume):
        return self.ex_attach_drive(node=node, drive=volume)

    def ex_detach_drive(self, node, drive):
        data = self.ex_get_node(node.id, return_json=True)
        data["drives"] = [item for item in data["drives"] if item["drive"]["uuid"] != drive.id]
        action = "/servers/%s/" % (node.id)
        response = self.connection.request(action=action, data=data, method="PUT")

        return response.status == 200

    def detach_volume(self, node, volume):
        return self.ex_detach_drive(node=node, drive=volume)

    def ex_get_drive(self, drive_id):
        """
        Retrieve information about a single drive.

        :param drive_id: ID of the drive to retrieve.
        :type drive_id: ``str``

        :return: Drive object.
        :rtype: :class:`.CloudSigmaDrive`
        """
        action = "/drives/%s/" % (drive_id)
        response = self.connection.request(action=action).object
        drive = self._to_drive(data=response)

        return drive

    def ex_destroy_drive(self, drive):
        action = "/drives/%s/" % (drive.id)
        response = self.connection.request(action=action, method="DELETE")

        return response.status == httplib.NO_CONTENT

    def destroy_volume(self, drive):
        return self.ex_destroy_drive(drive=drive)

    # Firewall policies extension methods

    def ex_list_firewall_policies(self):
        """
        List firewall policies.

        :rtype: ``list`` of :class:`.CloudSigmaFirewallPolicy`
        """
        action = "/fwpolicies/detail/"
        response = self.connection.request(action=action, method="GET").object
        policies = [self._to_firewall_policy(data=item) for item in response["objects"]]

        return policies

    def ex_create_firewall_policy(self, name, rules=None):
        """
        Create a firewall policy.

        :param name: Policy name.
        :type name: ``str``

        :param rules: List of firewall policy rules to associate with this
                      policy. (optional)
        :type rules: ``list`` of ``dict``

        :return: Created firewall policy object.
        :rtype: :class:`.CloudSigmaFirewallPolicy`
        """
        data = {}
        obj = {}
        obj["name"] = name

        if rules:
            obj["rules"] = rules

        data["objects"] = [obj]

        action = "/fwpolicies/"
        response = self.connection.request(action=action, method="POST", data=data).object
        policy = self._to_firewall_policy(data=response["objects"][0])

        return policy

    def ex_attach_firewall_policy(self, policy, node, nic_mac=None):
        """
        Attach firewall policy to a public NIC interface on the server.

        :param policy: Firewall policy to attach.
        :type policy: :class:`.CloudSigmaFirewallPolicy`

        :param node: Node to attach policy to.
        :type node: :class:`libcloud.compute.base.Node`

        :param nic_mac: Optional MAC address of the NIC to add the policy to.
                        If not specified, first public interface is used
                        instead.
        :type nic_mac: ``str``

        :return: Node object to which the policy was attached to.
        :rtype: :class:`libcloud.compute.base.Node`
        """
        nics = copy.deepcopy(node.extra.get("nics", []))

        if nic_mac:
            nic = [n for n in nics if n["mac"] == nic_mac]
        else:
            nic = nics

        if len(nic) == 0:
            raise ValueError("Cannot find the NIC interface to attach " "a policy to")

        nic = nic[0]
        nic["firewall_policy"] = policy.id

        params = {"nics": nics}
        node = self.ex_edit_node(node=node, params=params)

        return node

    def ex_delete_firewall_policy(self, policy):
        """
        Delete a firewall policy.

        :param policy: Policy to delete to.
        :type policy: :class:`.CloudSigmaFirewallPolicy`

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        action = "/fwpolicies/%s/" % (policy.id)
        response = self.connection.request(action=action, method="DELETE")

        return response.status == httplib.NO_CONTENT

    # Availability groups extension methods

    def ex_list_servers_availability_groups(self):
        """
        Return which running servers share the same physical compute host.

        :return: A list of server UUIDs which share the same physical compute
                 host. Servers which share the same host will be stored under
                 the same list index.
        :rtype: ``list`` of ``list``
        """
        action = "/servers/availability_groups/"
        response = self.connection.request(action=action, method="GET")

        return response.object

    def ex_list_drives_availability_groups(self):
        """
        Return which drives share the same physical storage host.

        :return: A list of drive UUIDs which share the same physical storage
                 host. Drives which share the same host will be stored under
                 the same list index.
        :rtype: ``list`` of ``list``
        """
        action = "/drives/availability_groups/"
        response = self.connection.request(action=action, method="GET")

        return response.object

    # Tag extension methods

    def ex_list_tags(self):
        """
        List all the available tags.

        :rtype: ``list`` of :class:`.CloudSigmaTag` objects
        """
        action = "/tags/detail/"
        response = self.connection.request(action=action, method="GET").object
        tags = [self._to_tag(data=item) for item in response["objects"]]

        return tags

    def ex_get_tag(self, tag_id):
        """
        Retrieve a single tag.

        :param tag_id: ID of the tag to retrieve.
        :type tag_id: ``str``

        :rtype: ``list`` of :class:`.CloudSigmaTag` objects
        """
        action = "/tags/%s/" % (tag_id)
        response = self.connection.request(action=action, method="GET").object
        tag = self._to_tag(data=response)

        return tag

    def ex_create_tag(self, name, resource_uuids=None):
        """
        Create a tag.

        :param name: Tag name.
        :type name: ``str``

        :param resource_uuids: Optional list of resource UUIDs to assign this
                               tag go.
        :type resource_uuids: ``list`` of ``str``

        :return: Created tag object.
        :rtype: :class:`.CloudSigmaTag`
        """
        data = {}
        data["objects"] = [{"name": name}]

        if resource_uuids:
            data["resources"] = resource_uuids

        action = "/tags/"
        response = self.connection.request(action=action, method="POST", data=data).object
        tag = self._to_tag(data=response["objects"][0])

        return tag

    def ex_tag_resource(self, resource, tag):
        """
        Associate tag with the provided resource.

        :param resource: Resource to associate a tag with.
        :type resource: :class:`libcloud.compute.base.Node` or
                        :class:`.CloudSigmaDrive`

        :param tag: Tag to associate with the resources.
        :type tag: :class:`.CloudSigmaTag`

        :return: Updated tag object.
        :rtype: :class:`.CloudSigmaTag`
        """

        if not hasattr(resource, "id"):
            raise ValueError("Resource doesn't have id attribute")

        return self.ex_tag_resources(resources=[resource], tag=tag)

    def ex_tag_resources(self, resources, tag):
        """
        Associate tag with the provided resources.

        :param resources: Resources to associate a tag with.
        :type resources: ``list`` of :class:`libcloud.compute.base.Node` or
                        :class:`.CloudSigmaDrive`

        :param tag: Tag to associate with the resources.
        :type tag: :class:`.CloudSigmaTag`

        :return: Updated tag object.
        :rtype: :class:`.CloudSigmaTag`
        """

        resources = tag.resources[:]

        for resource in resources:
            if not hasattr(resource, "id"):
                raise ValueError("Resource doesn't have id attribute")

            resources.append(resource.id)

        resources = list(set(resources))

        data = {"name": tag.name, "resources": resources}

        action = "/tags/%s/" % (tag.id)
        response = self.connection.request(action=action, method="PUT", data=data).object
        tag = self._to_tag(data=response)

        return tag

    def ex_delete_tag(self, tag):
        """
        Delete a tag.

        :param tag: Tag to delete.
        :type tag: :class:`.CloudSigmaTag`

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        action = "/tags/%s/" % (tag.id)
        response = self.connection.request(action=action, method="DELETE")

        return response.status == httplib.NO_CONTENT

    # Account extension methods

    def ex_get_balance(self):
        """
        Retrieve account balance information.

        :return: Dictionary with two items ("balance" and "currency").
        :rtype: ``dict``
        """
        action = "/balance/"
        response = self.connection.request(action=action, method="GET")

        return response.object

    def ex_get_pricing(self):
        """
        Retrieve pricing information that are applicable to the cloud.

        :return: Dictionary with pricing information.
        :rtype: ``dict``
        """
        action = "/pricing/"
        response = self.connection.request(action=action, method="GET")

        return response.object

    def ex_get_usage(self):
        """
        Retrieve account current usage information.

        :return: Dictionary with two items ("balance" and "usage").
        :rtype: ``dict``
        """
        action = "/currentusage/"
        response = self.connection.request(action=action, method="GET")

        return response.object

    def ex_list_subscriptions(self, status="all", resources=None):
        """
        List subscriptions for this account.

        :param status: Only return subscriptions with the provided status
                       (optional).
        :type status: ``str``
        :param resources: Only return subscriptions for the provided resources
                          (optional).
        :type resources: ``list``

        :rtype: ``list``
        """
        params = {}

        if status:
            params["status"] = status

        if resources:
            params["resource"] = ",".join(resources)

        response = self.connection.request(action="/subscriptions/", params=params).object
        subscriptions = self._to_subscriptions(data=response)

        return subscriptions

    def ex_toggle_subscription_auto_renew(self, subscription):
        """
        Toggle subscription auto renew status.

        :param subscription: Subscription to toggle the auto renew flag for.
        :type subscription: :class:`.CloudSigmaSubscription`

        :return: ``True`` on success, ``False`` otherwise.
        :rtype: ``bool``
        """
        path = "/subscriptions/%s/action/" % (subscription.id)
        response = self._perform_action(path=path, action="auto_renew", method="POST")

        return response.status == httplib.OK

    def ex_create_subscription(self, amount, period, resource, auto_renew=False):
        """
        Create a new subscription.

        :param amount: Subscription amount. For example, in dssd case this
                       would be disk size in gigabytes.
        :type amount: ``int``

        :param period: Subscription period. For example: 30 days, 1 week, 1
                                            month, ...
        :type period: ``str``

        :param resource: Resource the purchase the subscription for.
        :type resource: ``str``

        :param auto_renew: True to automatically renew the subscription.
        :type auto_renew: ``bool``
        """
        data = [
            {
                "amount": amount,
                "period": period,
                "auto_renew": auto_renew,
                "resource": resource,
            }
        ]

        response = self.connection.request(action="/subscriptions/", data=data, method="POST")
        data = response.object["objects"][0]
        subscription = self._to_subscription(data=data)

        return subscription

    # Misc extension methods

    def ex_list_capabilities(self):
        """
        Retrieve all the basic and sensible limits of the API.

        :rtype: ``dict``
        """
        action = "/capabilities/"
        response = self.connection.request(action=action, method="GET")
        capabilities = response.object

        return capabilities

    def list_key_pairs(self):
        """
        List all the available key pair objects.

        :rtype: ``list`` of :class:`KeyPair` objects
        """
        action = "/keypairs"
        response = self.connection.request(action=action, method="GET").object

        keys = [self._to_key_pair(data=item) for item in response["objects"]]

        return keys

    def get_key_pair(self, key_uuid):
        """
        Retrieve a single key pair.

        :param name: The uuid of the key pair to retrieve.
        :type name: ``str``

        :rtype: :class:`.KeyPair`
        """
        action = "/keypairs/%s/" % (key_uuid)
        response = self.connection.request(action=action, method="GET").object

        return self._to_key_pair(response)

    def create_key_pair(self, name):
        """
        Create a new SSH key.

        :param name: Key pair name.
        :type name: ``str``
        """

        action = "/keypairs/"
        data = {"objects": [{"name": name}]}
        response = self.connection.request(action=action, method="POST", data=data).object

        return self._to_key_pair(response["objects"][0])

    def import_key_pair_from_string(self, name, key_material):
        """
        Import a new public key from string.

        :param name: Key pair name.
        :type name: ``str``

        :param key_material: Public key material.
        :type key_material: ``str``

        :rtype: :class:`.KeyPair` object
        """
        action = "/keypairs/"
        data = {"objects": [{"name": name, "public_key": key_material.replace("\n", "")}]}
        response = self.connection.request(action=action, method="POST", data=data).object

        return self._to_key_pair(response["objects"][0])

    def delete_key_pair(self, key_pair):
        """
        Delete an existing key pair.

        :param key_pair: Key pair object
        :type key_pair: :class:`.KeyPair`

        :rtype: ``bool``
        """
        action = "/keypairs/%s/" % (key_pair.extra["uuid"])
        response = self.connection.request(action=action, method="DELETE")

        return response.status == 204

    def _parse_ips_from_nic(self, nic):
        """
        Parse private and public IP addresses from the provided network
        interface object.

        :param nic: NIC object.
        :type nic: ``dict``

        :return: (public_ips, private_ips) tuple.
        :rtype: ``tuple``
        """
        public_ips, private_ips = [], []

        ipv4_conf = nic["ip_v4_conf"]
        ipv6_conf = nic["ip_v6_conf"]

        ip_v4 = ipv4_conf["ip"] if ipv4_conf else None
        ip_v6 = ipv6_conf["ip"] if ipv6_conf else None

        ipv4 = ip_v4["uuid"] if ip_v4 else None
        ipv6 = ip_v4["uuid"] if ip_v6 else None

        ips = []

        if ipv4:
            ips.append(ipv4)

        if ipv6:
            ips.append(ipv6)

        runtime = nic["runtime"]

        ip_v4 = runtime["ip_v4"] if nic["runtime"] else None
        ip_v6 = runtime["ip_v6"] if nic["runtime"] else None

        ipv4 = ip_v4["uuid"] if ip_v4 else None
        ipv6 = ip_v4["uuid"] if ip_v6 else None

        if ipv4:
            ips.append(ipv4)

        if ipv6:
            ips.append(ipv6)

        ips = set(ips)

        for ip in ips:
            if is_private_subnet(ip):
                private_ips.append(ip)
            else:
                public_ips.append(ip)

        return public_ips, private_ips

    def _to_node(self, data):
        id = data["uuid"]
        name = data["name"]
        state = self.NODE_STATE_MAP.get(data["status"], NodeState.UNKNOWN)

        public_ips = []
        private_ips = []
        extra = {
            "cpus": data["cpu"] / 2000,
            "memory": data["mem"] / 1024 / 1024,
            "nics": data["nics"],
            "vnc_password": data["vnc_password"],
            "meta": data["meta"],
            "runtime": data["runtime"],
            "drives": data["drives"],
        }
        # find image name and boot drive size
        image = None
        drive_size = 0

        for item in extra["drives"]:
            if item["boot_order"] == 1:
                drive = self.ex_get_drive(item["drive"]["uuid"])
                drive_size = drive.size
                image = "{} {}".format(
                    drive.extra.get("distribution", ""), drive.extra.get("version", "")
                )

                break
        # try to find if node size is from example sizes given by CloudSigma
        try:
            kwargs = SPECS_TO_SIZE[(extra["cpus"], extra["memory"], drive_size)]
            size = CloudSigmaNodeSize(**kwargs, driver=self)
        except KeyError:
            id_to_hash = str(extra["cpus"]) + str(extra["memory"]) + str(drive_size)
            size_id = hashlib.md5(id_to_hash.encode("utf-8")).hexdigest()  # nosec
            size_name = "custom, {} CPUs, {}MB RAM, {}GB disk".format(
                extra["cpus"], extra["memory"], drive_size
            )  # noqa
            size = CloudSigmaNodeSize(
                id=size_id,
                name=size_name,
                cpu=extra["cpus"],
                ram=extra["memory"],
                disk=drive_size,
                bandwidth=None,
                price=0,
                driver=self,
            )

        for nic in data["nics"]:
            _public_ips, _private_ips = self._parse_ips_from_nic(nic=nic)

            public_ips.extend(_public_ips)
            private_ips.extend(_private_ips)

        node = Node(
            id=id,
            name=name,
            state=state,
            public_ips=public_ips,
            image=image,
            private_ips=private_ips,
            driver=self,
            size=size,
            extra=extra,
        )

        return node

    def _to_image(self, data):
        extra_keys = [
            "description",
            "arch",
            "image_type",
            "os",
            "licenses",
            "media",
            "meta",
        ]

        id = data["uuid"]
        name = data["name"]
        extra = self._extract_values(obj=data, keys=extra_keys)

        image = NodeImage(id=id, name=name, driver=self, extra=extra)

        return image

    def _to_drive(self, data):
        id = data["uuid"]
        name = data["name"]
        size = data["size"] / 1024 / 1024 / 1024
        media = data["media"]
        status = data["status"]
        extra = {
            "mounted_on": data.get("mounted_on", []),
            "storage_type": data.get("storage_type", ""),
            "distribution": data["meta"].get("distribution", ""),
            "version": data["meta"].get("version", ""),
            "os": data["meta"].get("os", ""),
            "paid": data["meta"].get("paid", ""),
            "architecture": data["meta"].get("arch", ""),
            "created_at": data["meta"].get("created_at", ""),
        }

        drive = CloudSigmaDrive(
            id=id,
            name=name,
            size=size,
            media=media,
            status=status,
            driver=self,
            extra=extra,
        )

        return drive

    def _to_tag(self, data):
        resources = data["resources"]
        resources = [resource["uuid"] for resource in resources]

        tag = CloudSigmaTag(id=data["uuid"], name=data["name"], resources=resources)

        return tag

    def _to_subscriptions(self, data):
        subscriptions = []

        for item in data["objects"]:
            subscription = self._to_subscription(data=item)
            subscriptions.append(subscription)

        return subscriptions

    def _to_subscription(self, data):
        if data.get("start_time", None):
            start_time = parse_date(data["start_time"])
        else:
            start_time = None

        if data.get("end_time", None):
            end_time = parse_date(data["end_time"])
        else:
            end_time = None

        obj_uuid = data["subscribed_object"]

        subscription = CloudSigmaSubscription(
            id=data["id"],
            resource=data["resource"],
            amount=int(data["amount"]),
            period=data["period"],
            status=data["status"],
            price=data["price"],
            start_time=start_time,
            end_time=end_time,
            auto_renew=data["auto_renew"],
            subscribed_object=obj_uuid,
        )

        return subscription

    def _to_firewall_policy(self, data):
        rules = []

        for item in data.get("rules", []):
            rule = CloudSigmaFirewallPolicyRule(
                action=item["action"],
                direction=item["direction"],
                ip_proto=item["ip_proto"],
                src_ip=item["src_ip"],
                src_port=item["src_port"],
                dst_ip=item["dst_ip"],
                dst_port=item["dst_port"],
                comment=item["comment"],
            )
            rules.append(rule)

        policy = CloudSigmaFirewallPolicy(id=data["uuid"], name=data["name"], rules=rules)

        return policy

    def _to_key_pair(self, data):
        extra = {
            "uuid": data["uuid"],
            "tags": data["tags"],
            "resource_uri": data["resource_uri"],
            "permissions": data["permissions"],
            "meta": data["meta"],
        }

        return KeyPair(
            name=data["name"],
            public_key=data["public_key"],
            fingerprint=data["fingerprint"],
            driver=self,
            private_key=data["private_key"],
            extra=extra,
        )

    def _perform_action(self, path, action, method="POST", params=None, data=None):
        """
        Perform API action and return response object.
        """

        if params:
            params = params.copy()
        else:
            params = {}

        params["do"] = action
        response = self.connection.request(action=path, method=method, params=params, data=data)

        return response

    def _is_installation_cd(self, image):
        """
        Detect if the provided image is an installation CD.

        :rtype: ``bool``
        """

        if isinstance(image, CloudSigmaDrive) and image.media == "cdrom":
            return True

        return False

    def _extract_values(self, obj, keys):
        """
        Extract values from a dictionary and return a new dictionary with
        extracted values.

        :param obj: Dictionary to extract values from.
        :type obj: ``dict``

        :param keys: Keys to extract.
        :type keys: ``list``

        :return: Dictionary with extracted values.
        :rtype: ``dict``
        """
        result = {}

        for key in keys:
            result[key] = obj[key]

        return result

    def _wait_for_drive_state_transition(self, drive, state, timeout=DRIVE_TRANSITION_TIMEOUT):
        """
        Wait for a drive to transition to the provided state.

        Note: This function blocks and periodically calls "GET drive" endpoint
        to check if the drive has already transitioned to the desired state.

        :param drive: Drive to wait for.
        :type drive: :class:`.CloudSigmaDrive`

        :param state: Desired drive state.
        :type state: ``str``

        :param timeout: How long to wait for the transition (in seconds) before
                        timing out.
        :type timeout: ``int``

        :return: Drive object.
        :rtype: :class:`.CloudSigmaDrive`
        """

        start_time = time.time()

        while drive.status != state:
            drive = self.ex_get_drive(drive_id=drive.id)

            if drive.status == state:
                break

            current_time = time.time()
            delta = current_time - start_time

            if delta >= timeout:
                msg = "Timed out while waiting for drive transition " "(timeout=%s seconds)" % (
                    timeout
                )
                raise Exception(msg)

            time.sleep(self.DRIVE_TRANSITION_SLEEP_INTERVAL)

        return drive

    def _ex_connection_class_kwargs(self):
        """
        Return the host value based on the user supplied region.
        """
        kwargs = {}

        if not self._host_argument_set:
            kwargs["host"] = API_ENDPOINTS_2_0[self.region]["host"]

        return kwargs
