# 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.
"""
Vultr Driver
"""
import time
import json
import base64
from functools import update_wrapper
from typing import Optional, List, Dict, Union, Any

from libcloud.common.base import ConnectionKey, JsonResponse
from libcloud.common.types import InvalidCredsError
from libcloud.common.types import LibcloudError
from libcloud.common.types import ServiceUnavailableError
from libcloud.compute.base import Node, NodeImage, NodeSize, NodeLocation
from libcloud.compute.base import KeyPair, StorageVolume
from libcloud.compute.base import NodeDriver
from libcloud.compute.types import NodeState, StorageVolumeState
from libcloud.compute.types import Provider, VolumeSnapshotState
from libcloud.utils.iso8601 import parse_date
from libcloud.utils.py3 import httplib
from libcloud.utils.py3 import urlencode
from libcloud.utils.publickey import get_pubkey_openssh_fingerprint
from libcloud.common.vultr import DEFAULT_API_VERSION
from libcloud.common.vultr import VultrConnectionV2
from libcloud.common.vultr import VultrNetwork, VultrNodeSnapshot

# For matching region by id
VULTR_COMPUTE_INSTANCE_LOCATIONS = {
    "1": {
        "DCID": "1",
        "name": "New Jersey",
        "country": "US",
        "continent": "North America",
        "state": "NJ",
        "regioncode": "EWR"
    },
    "2": {
        "DCID": "2",
        "name": "Chicago",
        "country": "US",
        "continent": "North America",
        "state": "IL",
        "regioncode": "ORD"
    },
    "3": {
        "DCID": "3",
        "name": "Dallas",
        "country": "US",
        "continent": "North America",
        "state": "TX",
        "regioncode": "DFW"
    },
    "4": {
        "DCID": "4",
        "name": "Seattle",
        "country": "US",
        "continent": "North America",
        "state": "WA",
        "regioncode": "SEA"
    },
    "5": {
        "DCID": "5",
        "name": "Los Angeles",
        "country": "US",
        "continent": "North America",
        "state": "CA",
        "regioncode": "LAX"
    },
    "6": {
        "DCID": "6",
        "name": "Atlanta",
        "country": "US",
        "continent": "North America",
        "state": "GA",
        "regioncode": "ATL"
    },
    "7": {
        "DCID": "7",
        "name": "Amsterdam",
        "country": "NL",
        "continent": "Europe",
        "state": "",
        "regioncode": "AMS"
    },
    "8": {
        "DCID": "8",
        "name": "London",
        "country": "GB",
        "continent": "Europe",
        "state": "",
        "regioncode": "LHR"
    },
    "9": {
        "DCID": "9",
        "name": "Frankfurt",
        "country": "DE",
        "continent": "Europe",
        "state": "",
        "regioncode": "FRA"
    },
    "12": {
        "DCID": "12",
        "name": "Silicon Valley",
        "country": "US",
        "continent": "North America",
        "state": "CA",
        "regioncode": "SJC"
    },
    "19": {
        "DCID": "19",
        "name": "Sydney",
        "country": "AU",
        "continent": "Australia",
        "state": "",
        "regioncode": "SYD"
    },
    "22": {
        "DCID": "22",
        "name": "Toronto",
        "country": "CA",
        "continent": "North America",
        "state": "",
        "regioncode": "YTO"
    },
    "24": {
        "DCID": "24",
        "name": "Paris",
        "country": "FR",
        "continent": "Europe",
        "state": "",
        "regioncode": "CDG"
    },
    "25": {
        "DCID": "25",
        "name": "Tokyo",
        "country": "JP",
        "continent": "Asia",
        "state": "",
        "regioncode": "NRT"
    },
    "34": {
        "DCID": "34",
        "name": "Seoul",
        "country": "KR",
        "continent": "Asia",
        "state": "",
        "regioncode": "ICN"
    },
    "39": {
        "DCID": "39",
        "name": "Miami",
        "country": "US",
        "continent": "North America",
        "state": "FL",
        "regioncode": "MIA"
    },
    "40": {
        "DCID": "40",
        "name": "Singapore",
        "country": "SG",
        "continent": "Asia",
        "state": "",
        "regioncode": "SGP"
    }
}
# For matching image by id
VULTR_COMPUTE_INSTANCE_IMAGES = {
    "127": {
        "OSID": 127,
        "name": "CentOS 6 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False
    },
    "147": {
        "OSID": 147,
        "name": "CentOS 6 i386",
        "arch": "i386",
        "family": "centos",
        "windows": False
    },
    "167": {
        "OSID": 167,
        "name": "CentOS 7 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False
    },
    "381": {
        "OSID": 381,
        "name": "CentOS 7 SELinux x64",
        "arch": "x64",
        "family": "centos",
        "windows": False
    },
    "362": {
        "OSID": 362,
        "name": "CentOS 8 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False
    },
    "401": {
        "OSID": 401,
        "name": "CentOS 8 Stream x64",
        "arch": "x64",
        "family": "centos",
        "windows": False
    },
    "215": {
        "OSID": 215,
        "name": "Ubuntu 16.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False
    },
    "216": {
        "OSID": 216,
        "name": "Ubuntu 16.04 i386",
        "arch": "i386",
        "family": "ubuntu",
        "windows": False
    },
    "270": {
        "OSID": 270,
        "name": "Ubuntu 18.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False
    },
    "387": {
        "OSID": 387,
        "name": "Ubuntu 20.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False
    },
    "194": {
        "OSID": 194,
        "name": "Debian 8 i386 (jessie)",
        "arch": "i386",
        "family": "debian",
        "windows": False
    },
    "244": {
        "OSID": 244,
        "name": "Debian 9 x64 (stretch)",
        "arch": "x64",
        "family": "debian",
        "windows": False
    },
    "352": {
        "OSID": 352,
        "name": "Debian 10 x64 (buster)",
        "arch": "x64",
        "family": "debian",
        "windows": False
    },
    "230": {
        "OSID": 230,
        "name": "FreeBSD 11 x64",
        "arch": "x64",
        "family": "freebsd",
        "windows": False
    },
    "327": {
        "OSID": 327,
        "name": "FreeBSD 12 x64",
        "arch": "x64",
        "family": "freebsd",
        "windows": False
    },
    "366": {
        "OSID": 366,
        "name": "OpenBSD 6.6 x64",
        "arch": "x64",
        "family": "openbsd",
        "windows": False
    },
    "394": {
        "OSID": 394,
        "name": "OpenBSD 6.7 x64",
        "arch": "x64",
        "family": "openbsd",
        "windows": False
    },
    "391": {
        "OSID": 391,
        "name": "Fedora CoreOS",
        "arch": "x64",
        "family": "fedora-coreos",
        "windows": False
    },
    "367": {
        "OSID": 367,
        "name": "Fedora 31 x64",
        "arch": "x64",
        "family": "fedora",
        "windows": False
    },
    "389": {
        "OSID": 389,
        "name": "Fedora 32 x64",
        "arch": "x64",
        "family": "fedora",
        "windows": False
    },
    "124": {
        "OSID": 124,
        "name": "Windows 2012 R2 x64",
        "arch": "x64",
        "family": "windows",
        "windows": False
    },
    "240": {
        "OSID": 240,
        "name": "Windows 2016 x64",
        "arch": "x64",
        "family": "windows",
        "windows": False
    },
    "159": {
        "OSID": 159,
        "name": "Custom",
        "arch": "x64",
        "family": "iso",
        "windows": False
    },
    "164": {
        "OSID": 164,
        "name": "Snapshot",
        "arch": "x64",
        "family": "snapshot",
        "windows": False
    },
    "180": {
        "OSID": 180,
        "name": "Backup",
        "arch": "x64",
        "family": "backup",
        "windows": False
    },
    "186": {
        "OSID": 186,
        "name": "Application",
        "arch": "x64",
        "family": "application",
        "windows": False
    }
}
VULTR_COMPUTE_INSTANCE_SIZES = {
    "201": {
        "VPSPLANID": "201",
        "name": "1024 MB RAM,25 GB SSD,1.00 TB BW",
        "vcpu_count": "1",
        "ram": "1024",
        "disk": "25",
        "bandwidth": "1.00",
        "bandwidth_gb": "1024",
        "price_per_month": "5.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "202": {
        "VPSPLANID": "202",
        "name": "2048 MB RAM,55 GB SSD,2.00 TB BW",
        "vcpu_count": "1",
        "ram": "2048",
        "disk": "55",
        "bandwidth": "2.00",
        "bandwidth_gb": "2048",
        "price_per_month": "10.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "203": {
        "VPSPLANID": "203",
        "name": "4096 MB RAM,80 GB SSD,3.00 TB BW",
        "vcpu_count": "2",
        "ram": "4096",
        "disk": "80",
        "bandwidth": "3.00",
        "bandwidth_gb": "3072",
        "price_per_month": "20.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "204": {
        "VPSPLANID": "204",
        "name": "8192 MB RAM,160 GB SSD,4.00 TB BW",
        "vcpu_count": "4",
        "ram": "8192",
        "disk": "160",
        "bandwidth": "4.00",
        "bandwidth_gb": "4096",
        "price_per_month": "40.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "205": {
        "VPSPLANID": "205",
        "name": "16384 MB RAM,320 GB SSD,5.00 TB BW",
        "vcpu_count": "6",
        "ram": "16384",
        "disk": "320",
        "bandwidth": "5.00",
        "bandwidth_gb": "5120",
        "price_per_month": "80.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "206": {
        "VPSPLANID": "206",
        "name": "32768 MB RAM,640 GB SSD,6.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "640",
        "bandwidth": "6.00",
        "bandwidth_gb": "6144",
        "price_per_month": "160.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "207": {
        "VPSPLANID": "207",
        "name": "65536 MB RAM,1280 GB SSD,10.00 TB BW",
        "vcpu_count": "16",
        "ram": "65536",
        "disk": "1280",
        "bandwidth": "10.00",
        "bandwidth_gb": "10240",
        "price_per_month": "320.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "208": {
        "VPSPLANID": "208",
        "name": "98304 MB RAM,1600 GB SSD,15.00 TB BW",
        "vcpu_count": "24",
        "ram": "98304",
        "disk": "1600",
        "bandwidth": "15.00",
        "bandwidth_gb": "15360",
        "price_per_month": "640.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "115": {
        "VPSPLANID": "115",
        "name": "8192 MB RAM,110 GB SSD,10.00 TB BW",
        "vcpu_count": "2",
        "ram": "8192",
        "disk": "110",
        "bandwidth": "10.00",
        "bandwidth_gb": "10240",
        "price_per_month": "60.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "116": {
        "VPSPLANID": "116",
        "name": "16384 MB RAM,2x110 GB SSD,20.00 TB BW",
        "vcpu_count": "4",
        "ram": "16384",
        "disk": "110",
        "bandwidth": "20.00",
        "bandwidth_gb": "20480",
        "price_per_month": "120.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "117": {
        "VPSPLANID": "117",
        "name": "24576 MB RAM,3x110 GB SSD,30.00 TB BW",
        "vcpu_count": "6",
        "ram": "24576",
        "disk": "110",
        "bandwidth": "30.00",
        "bandwidth_gb": "30720",
        "price_per_month": "180.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "118": {
        "VPSPLANID": "118",
        "name": "32768 MB RAM,4x110 GB SSD,40.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "110",
        "bandwidth": "40.00",
        "bandwidth_gb": "40960",
        "price_per_month": "240.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "400": {
        "VPSPLANID": "400",
        "name": "1024 MB RAM,32 GB SSD,1.00 TB BW",
        "vcpu_count": "1",
        "ram": "1024",
        "disk": "32",
        "bandwidth": "1.00",
        "bandwidth_gb": "1024",
        "price_per_month": "6.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "401": {
        "VPSPLANID": "401",
        "name": "2048 MB RAM,64 GB SSD,2.00 TB BW",
        "vcpu_count": "1",
        "ram": "2048",
        "disk": "64",
        "bandwidth": "2.00",
        "bandwidth_gb": "2048",
        "price_per_month": "12.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "402": {
        "VPSPLANID": "402",
        "name": "4096 MB RAM,128 GB SSD,3.00 TB BW",
        "vcpu_count": "2",
        "ram": "4096",
        "disk": "128",
        "bandwidth": "3.00",
        "bandwidth_gb": "3072",
        "price_per_month": "24.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "403": {
        "VPSPLANID": "403",
        "name": "8192 MB RAM,256 GB SSD,4.00 TB BW",
        "vcpu_count": "3",
        "ram": "8192",
        "disk": "256",
        "bandwidth": "4.00",
        "bandwidth_gb": "4096",
        "price_per_month": "48.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "404": {
        "VPSPLANID": "404",
        "name": "16384 MB RAM,384 GB SSD,5.00 TB BW",
        "vcpu_count": "4",
        "ram": "16384",
        "disk": "384",
        "bandwidth": "5.00",
        "bandwidth_gb": "5120",
        "price_per_month": "96.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "405": {
        "VPSPLANID": "405",
        "name": "32768 MB RAM,512 GB SSD,6.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "512",
        "bandwidth": "6.00",
        "bandwidth_gb": "6144",
        "price_per_month": "192.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "406": {
        "VPSPLANID": "406",
        "name": "49152 MB RAM,768 GB SSD,8.00 TB BW",
        "vcpu_count": "12",
        "ram": "49152",
        "disk": "768",
        "bandwidth": "8.00",
        "bandwidth_gb": "8192",
        "price_per_month": "256.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    }
}


class rate_limited:
    """
    Decorator for retrying Vultr calls that are rate-limited.

    :param int sleep: Seconds to sleep after being rate-limited.
    :param int retries: Number of retries.
    """

    def __init__(self, sleep=0.5, retries=1):
        self.sleep = sleep
        self.retries = retries

    def __call__(self, call):
        """
        Run ``call`` method until it's not rate-limited.

        The method is invoked while it returns 503 Service Unavailable or the
        allowed number of retries is reached.

        :param callable call: Method to be decorated.
        """

        def wrapper(*args, **kwargs):
            last_exception = None

            for _ in range(self.retries + 1):
                try:
                    return call(*args, **kwargs)
                except ServiceUnavailableError as e:
                    last_exception = e
                    time.sleep(self.sleep)  # hit by rate limit, let's sleep

            if last_exception:
                raise last_exception  # pylint: disable=raising-bad-type

        update_wrapper(wrapper, call)
        return wrapper


class VultrResponse(JsonResponse):
    def parse_error(self):
        if self.status == httplib.OK:
            body = self.parse_body()
            return body
        elif self.status == httplib.FORBIDDEN:
            raise InvalidCredsError(self.body)
        elif self.status == httplib.SERVICE_UNAVAILABLE:
            raise ServiceUnavailableError(self.body)
        else:
            raise LibcloudError(self.body)


class SSHKey(object):
    def __init__(self, id, name, pub_key):
        self.id = id
        self.name = name
        self.pub_key = pub_key

    def __repr__(self):
        return (('<SSHKey: id=%s, name=%s, pub_key=%s>') %
                (self.id, self.name, self.pub_key))


class VultrConnection(ConnectionKey):
    """
    Connection class for the Vultr driver.
    """

    host = 'api.vultr.com'
    responseCls = VultrResponse
    unauthenticated_endpoints = {  # {action: methods}
        '/v1/app/list': ['GET'],
        '/v1/os/list': ['GET'],
        '/v1/plans/list': ['GET'],
        '/v1/plans/list_vc2': ['GET'],
        '/v1/plans/list_vdc2': ['GET'],
        '/v1/regions/availability': ['GET'],
        '/v1/regions/list': ['GET']
    }

    def add_default_headers(self, headers):
        """
        Adds ``API-Key`` default header.

        :return: Updated headers.
        :rtype: dict
        """

        if self.require_api_key():
            headers.update({'API-Key': self.key})
        return headers

    def encode_data(self, data):
        return urlencode(data)

    @rate_limited()
    def get(self, url):
        return self.request(url)

    @rate_limited()
    def post(self, url, data):
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        return self.request(url, data=data, headers=headers, method='POST')

    def require_api_key(self):
        """
        Check whether this call (method + action) must be authenticated.

        :return: True if ``API-Key`` header required, False otherwise.
        :rtype: bool
        """

        try:
            return self.method \
                not in self.unauthenticated_endpoints[self.action]
        except KeyError:
            return True


class VultrNodeDriverHelper(object):
    """
        VultrNode helper class.
    """

    def handle_extra(self, extra_keys, data):
        extra = {}
        for key in extra_keys:
            if key in data:
                extra[key] = data[key]
        return extra


class VultrNodeDriver(NodeDriver):
    type = Provider.VULTR
    name = 'Vultr'
    website = 'https://www.vultr.com'

    def __new__(cls, key, secret=None, secure=True, host=None, port=None,
                api_version=DEFAULT_API_VERSION, region=None, **kwargs):
        if cls is VultrNodeDriver:
            if api_version == '1':
                cls = VultrNodeDriverV1
            elif api_version == '2':
                cls = VultrNodeDriverV2
            else:
                raise NotImplementedError(
                    'No Vultr driver found for API version: %s' %
                    (api_version))
        return super().__new__(cls)


class VultrNodeDriverV1(VultrNodeDriver):
    """
    VultrNode node driver.
    """

    connectionCls = VultrConnection

    NODE_STATE_MAP = {'pending': NodeState.PENDING,
                      'active': NodeState.RUNNING}

    EX_CREATE_YES_NO_ATTRIBUTES = ['enable_ipv6',
                                   'enable_private_network',
                                   'auto_backups',
                                   'notify_activate',
                                   'ddos_protection']

    EX_CREATE_ID_ATTRIBUTES = {'iso_id': 'ISOID',
                               'script_id': 'SCRIPTID',
                               'snapshot_id': 'SNAPSHOTID',
                               'app_id': 'APPID'}

    EX_CREATE_ATTRIBUTES = ['ipxe_chain_url',
                            'label',
                            'userdata',
                            'reserved_ip_v4',
                            'hostname',
                            'tag']
    EX_CREATE_ATTRIBUTES.extend(EX_CREATE_YES_NO_ATTRIBUTES)
    EX_CREATE_ATTRIBUTES.extend(EX_CREATE_ID_ATTRIBUTES.keys())

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._helper = VultrNodeDriverHelper()

    def list_nodes(self):
        return self._list_resources('/v1/server/list', self._to_node)

    def list_key_pairs(self):
        """
        List all the available SSH keys.
        :return: Available SSH keys.
        :rtype: ``list`` of :class:`SSHKey`
        """
        return self._list_resources('/v1/sshkey/list', self._to_ssh_key)

    def create_key_pair(self, name, public_key=''):
        """
        Create a new SSH key.
        :param name: Name of the new SSH key
        :type name: ``str``

        :key public_key: Public part of the new SSH key
        :type name: ``str``

        :return: True on success
        :rtype: ``bool``
        """
        params = {'name': name, 'ssh_key': public_key}
        res = self.connection.post('/v1/sshkey/create', params)
        return res.status == httplib.OK

    def delete_key_pair(self, key_pair):
        """
        Delete an SSH key.
        :param key_pair: The SSH key to delete
        :type key_pair: :class:`SSHKey`

        :return: True on success
        :rtype: ``bool``
        """
        params = {'SSHKEYID': key_pair.id}
        res = self.connection.post('/v1/sshkey/destroy', params)
        return res.status == httplib.OK

    def list_locations(self):
        return self._list_resources('/v1/regions/list', self._to_location)

    def list_sizes(self):
        return self._list_resources('/v1/plans/list', self._to_size)

    def list_images(self):
        return self._list_resources('/v1/os/list', self._to_image)

    # pylint: disable=too-many-locals
    def create_node(self, name, size, image, location, ex_ssh_key_ids=None,
                    ex_create_attr=None):
        """
        Create a node

        :param name: Name for the new node
        :type name: ``str``

        :param size: Size of the new node
        :type size: :class:`NodeSize`

        :param image: Image for the new node
        :type image: :class:`NodeImage`

        :param location: Location of the new node
        :type location: :class:`NodeLocation`

        :param ex_ssh_key_ids: IDs of the SSH keys to initialize
        :type ex_sshkeyid: ``list`` of ``str``

        :param ex_create_attr: Extra attributes for node creation
        :type ex_create_attr: ``dict``

        The `ex_create_attr` parameter can include the following dictionary
        key and value pairs:

        * `ipxe_chain_url`: ``str`` for specifying URL to boot via IPXE
        * `iso_id`: ``str`` the ID of a specific ISO to mount,
          only meaningful with the `Custom` `NodeImage`
        * `script_id`: ``int`` ID of a startup script to execute on boot,
          only meaningful when the `NodeImage` is not `Custom`
        * 'snapshot_id`: ``str`` Snapshot ID to restore for the initial
          installation, only meaningful with the `Snapshot` `NodeImage`
        * `enable_ipv6`: ``bool`` Whether an IPv6 subnet should be assigned
        * `enable_private_network`: ``bool`` Whether private networking
          support should be added
        * `label`: ``str`` Text label to be shown in the control panel
        * `auto_backups`: ``bool`` Whether automatic backups should be enabled
        * `app_id`: ``int`` App ID to launch if launching an application,
          only meaningful when the `NodeImage` is `Application`
        * `userdata`: ``str`` Base64 encoded cloud-init user-data
        * `notify_activate`: ``bool`` Whether an activation email should be
          sent when the server is ready
        * `ddos_protection`: ``bool`` Whether DDOS protection should be enabled
        * `reserved_ip_v4`: ``str`` IP address of the floating IP to use as
          the main IP of this server
        * `hostname`: ``str`` The hostname to assign to this server
        * `tag`: ``str`` The tag to assign to this server

        :return: The newly created node.
        :rtype: :class:`Node`

        """
        params = {'DCID': location.id, 'VPSPLANID': size.id,
                  'OSID': image.id, 'label': name}

        if ex_ssh_key_ids is not None:
            params['SSHKEYID'] = ','.join(ex_ssh_key_ids)

        ex_create_attr = ex_create_attr or {}
        for key, value in ex_create_attr.items():
            if key in self.EX_CREATE_ATTRIBUTES:
                if key in self.EX_CREATE_YES_NO_ATTRIBUTES:
                    params[key] = 'yes' if value else 'no'
                else:
                    if key in self.EX_CREATE_ID_ATTRIBUTES:
                        key = self.EX_CREATE_ID_ATTRIBUTES[key]
                    params[key] = value

        result = self.connection.post('/v1/server/create', params)
        if result.status != httplib.OK:
            return False

        subid = result.object['SUBID']

        retry_count = 3
        created_node = None

        for _ in range(retry_count):
            try:
                nodes = self.list_nodes()
                created_node = [n for n in nodes if n.id == subid][0]
            except IndexError:
                time.sleep(1)
            else:
                break

        return created_node

    def reboot_node(self, node):
        params = {'SUBID': node.id}
        res = self.connection.post('/v1/server/reboot', params)

        return res.status == httplib.OK

    def destroy_node(self, node):
        params = {'SUBID': node.id}
        res = self.connection.post('/v1/server/destroy', params)

        return res.status == httplib.OK

    def _list_resources(self, url, tranform_func):
        data = self.connection.get(url).object
        sorted_key = sorted(data)
        return [tranform_func(data[key]) for key in sorted_key]

    def _to_node(self, data):
        if 'status' in data:
            state = self.NODE_STATE_MAP.get(data['status'], NodeState.UNKNOWN)
            if state == NodeState.RUNNING and \
                    data['power_status'] != 'running':
                state = NodeState.STOPPED
        else:
            state = NodeState.UNKNOWN

        if 'main_ip' in data and data['main_ip'] is not None:
            public_ips = [data['main_ip']]
        else:
            public_ips = []
        # simple check that we have ip address in value
        if len(data['internal_ip']) > 0:
            private_ips = [data['internal_ip']]
        else:
            private_ips = []
        created_at = parse_date(data['date_created'])

        # response ordering
        extra_keys = [
            "location",  # Location name
            "default_password", "pending_charges", "cost_per_month",
            "current_bandwidth_gb", "allowed_bandwidth_gb", "netmask_v4",
            "gateway_v4", "power_status", "server_state",
            "v6_networks",
            # TODO: Does we really need kvm_url?
            "kvm_url",
            "auto_backups", "tag",
            # "OSID",  # Operating system to use. See v1/os/list.
            "APPID", "FIREWALLGROUPID"
        ]
        extra = self._helper.handle_extra(extra_keys, data)

        resolve_data = VULTR_COMPUTE_INSTANCE_IMAGES.get(data["OSID"])
        if resolve_data:
            image = self._to_image(resolve_data)
        else:
            image = None

        resolve_data = VULTR_COMPUTE_INSTANCE_SIZES.get(data["VPSPLANID"])
        if resolve_data:
            size = self._to_size(resolve_data)
        else:
            size = None

        # resolve_data = VULTR_COMPUTE_INSTANCE_LOCATIONS.get(data['DCID'])
        # if resolve_data:
        #     location = self._to_location(resolve_data)
        # extra['location'] = location

        node = Node(
            id=data['SUBID'],
            name=data['label'],
            state=state,
            public_ips=public_ips,
            private_ips=private_ips,
            image=image,
            size=size,
            extra=extra,
            created_at=created_at,
            driver=self)

        return node

    def _to_location(self, data):
        extra_keys = ['continent', 'state', 'ddos_protection',
                      'block_storage', 'regioncode']
        extra = self._helper.handle_extra(extra_keys, data)

        return NodeLocation(id=data['DCID'], name=data['name'],
                            country=data['country'], extra=extra, driver=self)

    def _to_size(self, data):
        extra_keys = [
            'vcpu_count', 'plan_type', 'available_locations',
        ]
        extra = self._helper.handle_extra(extra_keys, data)

        # backward compatibility
        if extra.get('vcpu_count').isdigit():
            extra['vcpu_count'] = int(extra['vcpu_count'])

        ram = int(data['ram'])
        disk = int(data['disk'])
        # NodeSize accepted int instead float
        bandwidth = int(float(data['bandwidth']))
        price = float(data['price_per_month'])
        return NodeSize(id=data['VPSPLANID'], name=data['name'],
                        ram=ram, disk=disk,
                        bandwidth=bandwidth, price=price,
                        extra=extra, driver=self)

    def _to_image(self, data):
        extra_keys = ['arch', 'family']
        extra = self._helper.handle_extra(extra_keys, data)
        return NodeImage(id=data['OSID'], name=data['name'], extra=extra,
                         driver=self)

    def _to_ssh_key(self, data):
        return SSHKey(id=data['SSHKEYID'], name=data['name'],
                      pub_key=data['ssh_key'])


class VultrNodeDriverV2(VultrNodeDriver):
    """
    Vultr API v2 NodeDriver.
    """
    connectionCls = VultrConnectionV2
    NODE_STATE_MAP = {
        'active': NodeState.RUNNING,
        'halted': NodeState.STOPPED,
        'rebooting': NodeState.REBOOTING,
        'resizing': NodeState.RECONFIGURING,
        'pending': NodeState.PENDING,
    }

    VOLUME_STATE_MAP = {
        'active': StorageVolumeState.AVAILABLE,
        'pending': StorageVolumeState.CREATING,
    }

    SNAPSHOT_STATE_MAP = {
        'complete': VolumeSnapshotState.AVAILABLE,
        'pending': VolumeSnapshotState.CREATING,
    }

    def list_nodes(self, ex_list_bare_metals: bool = True) -> List[Node]:
        """List all nodes.

        :keyword ex_list_bare_metals: Whether to fetch bare metal nodes.
        :type    ex_list_bare_metals: ``bool``

        :return:  list of node objects
        :rtype: ``list`` of :class: `Node`
        """
        data = self._paginated_request('/v2/instances', 'instances')
        nodes = [self._to_node(item) for item in data]

        if ex_list_bare_metals:
            nodes += self.ex_list_bare_metal_nodes()
        return nodes

    def create_node(self,
                    name: str,
                    size: NodeSize,
                    location: NodeLocation,
                    image: Optional[NodeImage] = None,
                    ex_ssh_key_ids: Optional[List[str]] = None,
                    ex_private_network_ids: Optional[List[str]] = None,
                    ex_snapshot: Union[VultrNodeSnapshot, str, None] = None,
                    ex_enable_ipv6: bool = False,
                    ex_backups: bool = False,
                    ex_userdata: Optional[str] = None,
                    ex_ddos_protection: bool = False,
                    ex_enable_private_network: bool = False,
                    ex_ipxe_chain_url: Optional[str] = None,
                    ex_iso_id: Optional[str] = None,
                    ex_script_id: Optional[str] = None,
                    ex_image_id: Optional[str] = None,
                    ex_activation_email: bool = False,
                    ex_hostname: Optional[str] = None,
                    ex_tag: Optional[str] = None,
                    ex_firewall_group_id: Optional[str] = None,
                    ex_reserved_ipv4: Optional[str] = None,
                    ex_persistent_pxe: bool = False
                    ) -> Node:
        """Create a new node.

        :param name: The new node's name.
        :type name: ``str``

        :param size: The size to use to create the node.
        :type size: :class: `NodeSize`

        :param location: The location to provision the node.
        :type location: :class: `NodeLocation`

        :keyword image:  The image to use to provision the node.
        :type    image:  :class: `NodeImage`

        :keyword ex_ssh_key_ids: List of SSH keys to install on this node.
        :type    ex_ssh_key_ids: ``list`` of ``str``

        :keyword ex_private_network_ids: The network ids to attach to node.
                                         This parameter takes precedence over
                                         ex_enable_private_network (VPS only)
        :type    ex_private_network_ids: ``list`` of ``str``

        :keyword ex_snapshot: The snapshot to use when deploying the node.
                                 Mutually exclusive with image,
        :type    ex_snapshot: :class: `VultrNodeSnapshot` or ``str``

        :keyword ex_enable_ipv6: Wheteher to enable IPv6.
        :type    ex_enable_ipv6: ``bool``

        :keyword ex_backups: Enable automatic backups for the node. (VPS only)
        :type    ex_backups: ``bool``

        :keyword ex_userdata: String containing user data
        :type    ex_userdata: ``str``

        :keyword ex_ddos_protection: Enable DDoS protection (VPS only)
        :type    ex_ddos_protection: ``bool``

        :keyword ex_enable_private_network: Enable private networking.
                                            Mutually exclusive with
                                             ex_private_network_ids.
                                            (VPS only)
        :type    ex_enable_private_network: ``bool``

        :keyword ex_ipxe_chain_url: The URL location of the iPXE chainloader
                                    (VPS only)
        :type    ex_ipxe_chain_url: ``str``

        :keyword ex_iso_id: The ISO id to use when deploying this node.
                            (VPS only)
        :type    ex_iso_id: ``str``

        :keyword ex_script_id: The startup script id to use when deploying
                               this node.
        :type    ex_script_id: ``str``

        :keyword ex_image_id: The Application image_id to use when deploying
                              this node.
        :type    ex_image_id: ``str``

        :keyword ex_activation_email: Notify by email after deployment.
        :type    ex_activation_email: ``bool``

        :keyword ex_hostname: The hostname to use when deploying this node.
        :type    ex_hostname: ``str``

        :keyword ex_tag: The user-supplied tag.
        :type    ex_tag: ``str``

        :keyword ex_firewall_group_id: The Firewall Group id to attach to
                                       this node. (VPS only)
        :type    ex_firewall_group_id: ``str``

        :keyword ex_reserved_ipv4: Id of the floating IP to use as the
                                   main IP of this node.
        :type    ex_reserved_ipv4: ``str``

        :keyword ex_persistent_pxe: Enable persistent PXE (Bare Metal only)
        :type    ex_persistent_pxe: ``bool``
        """
        data = {
            'label': name,
            'region': location.id,
            'plan': size.id,
            'enable_ipv6': ex_enable_ipv6,
            'activation_email': ex_activation_email,
        }

        if image:
            data['os_id'] = image.id

        if ex_ssh_key_ids:
            data['sshkey_id'] = ex_ssh_key_ids

        if ex_snapshot:
            try:
                data['snapshot_id'] = ex_snapshot.id
            except AttributeError:
                data['snapshot_id'] = ex_snapshot

        if ex_userdata:
            data['user_data'] = base64.b64encode(
                bytes(ex_userdata, 'utf-8')).decode('utf-8')

        if ex_script_id:
            data['script_id'] = ex_script_id

        if ex_image_id:
            data['image_id'] = ex_image_id

        if ex_hostname:
            data['hostname'] = ex_hostname

        if ex_reserved_ipv4:
            data['reserved_ipv4'] = ex_reserved_ipv4

        if ex_tag:
            data['tag'] = ex_tag

        if self._is_bare_metal(size):
            if ex_persistent_pxe:
                data['persistent_pxe'] = ex_persistent_pxe
            resp = self.connection.request('/v2/bare-metals',
                                           data=json.dumps(data),
                                           method='POST')
            return self._to_node(resp.object['bare_metal'])
        else:
            if ex_private_network_ids:
                data['attach_private_network'] = ex_private_network_ids

            if ex_enable_private_network:
                data['enable_private_network'] = ex_enable_private_network

            if ex_ipxe_chain_url:
                data['ipxe_chain_url'] = ex_ipxe_chain_url

            if ex_iso_id:
                data['iso_id'] = ex_iso_id

            if ex_ddos_protection:
                data['ddos_protection'] = ex_ddos_protection

            if ex_firewall_group_id:
                data['firewall_group_id'] = ex_firewall_group_id

            if ex_backups:
                data['backups'] = ('enabled' if ex_backups is True
                                   else 'disabled')

            resp = self.connection.request('/v2/instances',
                                           data=json.dumps(data),
                                           method='POST')
            return self._to_node(resp.object['instance'])

    def reboot_node(self, node: Node) -> bool:
        """Reboot the given node.

        :param node: The node to be rebooted.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        if self._is_bare_metal(node.size):
            return self.ex_reboot_bare_metal_node(node)

        resp = self.connection.request('/v2/instances/%s/reboot' % node.id,
                                       method='POST')

        return resp.success()

    def start_node(self, node: Node) -> bool:
        """Start the given node.

        :param node: The node to be started.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        if self._is_bare_metal(node.size):
            return self.ex_start_bare_metal_node(node)

        resp = self.connection.request('/v2/instances/%s/start' % node.id,
                                       method='POST')

        return resp.success()

    def stop_node(self, node: Node) -> bool:
        """Stop the given node.

        :param node: The node to be stopped.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        if self._is_bare_metal(node.size):
            return self.ex_stop_bare_metal_node(node)

        return self.ex_stop_nodes([node])

    def destroy_node(self, node: Node) -> bool:
        """Destroy the given node.

        :param node: The node to be destroyed.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        if self._is_bare_metal(node.size):
            return self.ex_destroy_bare_metal_node(node)

        resp = self.connection.request('/v2/instances/%s' % node.id,
                                       method='DELETE')

        return resp.success()

    def list_sizes(self, ex_list_bare_metals: bool = True) -> List[NodeSize]:
        """List available node sizes.

        :keyword ex_list_bare_metals: Whether to fetch bare metal sizes.
        :type    ex_list_bare_metals: ``bool``

        :rtype: ``list`` of :class: `NodeSize`
        """
        data = self._paginated_request('/v2/plans', 'plans')
        sizes = [self._to_size(item) for item in data]

        if ex_list_bare_metals:
            sizes += self.ex_list_bare_metal_sizes()
        return sizes

    def list_images(self) -> List[NodeImage]:
        """List available node images.

        :rtype: ``list`` of :class: `NodeImage`
        """
        data = self._paginated_request('/v2/os', 'os')
        return [self._to_image(item) for item in data]

    def list_locations(self) -> List[NodeLocation]:
        """List available node locations.

        :rtype: ``list`` of :class: `NodeLocation`
        """
        data = self._paginated_request('/v2/regions', 'regions')
        return [self._to_location(item) for item in data]

    def list_volumes(self) -> List[StorageVolume]:
        """List storage volumes.

        :rtype: ``list`` of :class:`StorageVolume`
        """
        data = self._paginated_request('/v2/blocks', 'blocks')
        return [self._to_volume(item) for item in data]

    def create_volume(self,
                      size: int,
                      name: str,
                      location: Union[NodeLocation, str],
                      ) -> StorageVolume:
        """Create a new volume.

        :param size: Size of the volume in gigabytes.\
        Size may range between 10 and 10000.
        :type size: ``int``

        :param name: Name of the volume to be created.
        :type name: ``str``

        :param location: Which data center to create the volume in.
        :type location: :class:`NodeLocation` or ``str``

        :return: The newly created volume.
        :rtype: :class:`StorageVolume`
        """

        data = {
            'label': name,
            'size_gb': size,
        }
        try:
            data['region'] = location.id
        except AttributeError:
            data['region'] = location

        resp = self.connection.request('/v2/blocks',
                                       data=json.dumps(data),
                                       method='POST')
        return self._to_volume(resp.object['block'])

    def attach_volume(self,
                      node: Node,
                      volume: StorageVolume,
                      ex_live: bool = True,
                      ) -> bool:
        """Attaches volume to node.

        :param node: Node to attach volume to.
        :type node: :class:`Node`

        :param volume: Volume to attach.
        :type volume: :class:`StorageVolume`

        :param ex_live: Attach the volume without restarting the node.
        :type ex_live: ``bool``

        :rytpe: ``bool``
        """

        data = {
            'instance_id': node.id,
            'live': ex_live,
        }
        resp = self.connection.request('/v2/blocks/%s/attach' % volume.id,
                                       data=json.dumps(data),
                                       method='POST')

        return resp.success()

    def detach_volume(self,
                      volume: StorageVolume,
                      ex_live: bool = True,
                      ) -> bool:
        """Detaches a volume from a node.

        :param volume: Volume to be detached
        :type volume: :class:`StorageVolume`

        :param ex_live: Detach the volume without restarting the node.
        :type ex_live: ``bool``

        :rtype: ``bool``
        """
        data = {
            'live': ex_live
        }

        resp = self.connection.request('/v2/blocks/%s/detach' % volume.id,
                                       data=json.dumps(data),
                                       method='POST')

        return resp.success()

    def destroy_volume(self, volume: StorageVolume) -> bool:
        """Destroys a storage volume.

        :param volume: Volume to be destroyed
        :type  volume: :class:`StorageVolume`

        :rtype: ``bool``
        """

        resp = self.connection.request('/v2/blocks/%s' % volume.id,
                                       method='DELETE')

        return resp.success()

    def list_key_pairs(self) -> List[KeyPair]:
        """List all the available SSH key pair objects.

        :rtype: ``list`` of :class:`KeyPair`
        """
        data = self._paginated_request('/v2/ssh-keys', 'ssh_keys')
        return [self._to_key_pair(item) for item in data]

    def get_key_pair(self, key_id: str) -> KeyPair:
        """Retrieve a single key pair.

        :param key_id: ID of the key pair to retrieve.
        :type  key_id: ``str``

        :rtype: :class: `KeyPair`
        """
        resp = self.connection.request('/v2/ssh-keys/%s' % key_id)
        return self._to_key_pair(resp.object['ssh_key'])

    def import_key_pair_from_string(self,
                                    name: str,
                                    key_material: str
                                    ) -> KeyPair:
        """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`
        """
        data = {
            'name': name,
            'ssh_key': key_material,
        }
        resp = self.connection.request('/v2/ssh-keys',
                                       data=json.dumps(data),
                                       method='POST')
        return self._to_key_pair(resp.object['ssh_key'])

    def delete_key_pair(self, key_pair: KeyPair) -> bool:
        """Delete existing key pair.

        :param key_pair: The key pair object to delete.
        :type key_pair: :class:`.KeyPair`

        :rtype: ``bool``
        """

        resp = self.connection.request(
            '/v2/ssh-keys/%s' % key_pair.extra['id'],
            method='DELETE')

        return resp.success()

    def ex_list_bare_metal_nodes(self) -> List[Node]:
        """List all bare metal nodes.

        :return:  list of node objects
        :rtype: ``list`` of :class: `Node`
        """
        data = self._paginated_request('/v2/bare-metals', 'bare_metals')
        return [self._to_node(item) for item in data]

    def ex_reboot_bare_metal_node(self, node: Node) -> bool:
        """Reboot the given bare metal node.

        :param node: The bare metal node to be rebooted.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        resp = self.connection.request('/v2/bare-metals/%s/reboot' % node.id,
                                       method='POST')

        return resp.success()

    def ex_resize_node(self, node: Node, size: NodeSize) -> bool:
        """Change size for the given node, only applicable for VPS nodes.

        :param node: The node to be resized.
        :type node: :class: `Node`

        :param size: The new size.
        :type size: :class: `NodeSize`
        """
        data = {
            'plan': size.id
        }
        resp = self.connection.request('/v2/instances/%s' % node.id,
                                       data=json.dumps(data),
                                       method='PATCH')
        return self._to_node(resp.object['instance'])

    def ex_start_bare_metal_node(self, node: Node) -> bool:
        """Start the given bare metal node.

        :param node: The bare metal node to be started.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        resp = self.connection.request('/v2/bare-metals/%s/start' % node.id,
                                       method='POST')

        return resp.success()

    def ex_stop_bare_metal_node(self, node: Node) -> bool:
        """Stop the given bare metal node.

        :param node: The bare metal node to be stopped.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        resp = self.connection.request('/v2/bare-metals/%s/halt' % node.id,
                                       method='POST')

        return resp.success()

    def ex_destroy_bare_metal_node(self, node: Node) -> bool:
        """Destroy the given bare metal node.

        :param node: The bare metal node to be destroyed.
        :type node: :class: `Node`

        :rtype: ``bool``
        """
        resp = self.connection.request('/v2/bare-metals/%s' % node.id,
                                       method='DELETE')

        return resp.success()

    def ex_get_node(self, node_id: str) -> Node:
        """Retrieve a node object.

        :param node_id: ID of the node to retrieve.
        :type snapshot_id: ``str``

        :rtype: :class: `Node`
        """
        resp = self.connection.request('/v2/instances/%s' % node_id)
        return self._to_node(resp.object['instance'])

    def ex_stop_nodes(self, nodes: List[Node]) -> bool:
        """Stops all the nodes given.

        : param nodes: A list of the nodes to stop.
        : type node: ``list`` of: class `Node`

        : rtype: ``bool``
        """

        data = {
            "instance_ids": [node.id for node in nodes]
        }
        resp = self.connection.request('/v2/instances/halt',
                                       data=json.dumps(data),
                                       method='POST')

        return resp.success()

    def ex_list_bare_metal_sizes(self) -> List[NodeSize]:
        """List bare metal sizes.

        :rtype: ``list`` of :class: `NodeSize`
        """
        data = self._paginated_request('/v2/plans-metal', 'plans_metal')
        return [self._to_size(item) for item in data]

    def ex_list_snapshots(self) -> List[VultrNodeSnapshot]:
        """List node snapshots.

        :rtype: ``list`` of :class: `VultrNodeSnapshot`
        """
        data = self._paginated_request('/v2/snapshots', 'snapshots')
        return [self._to_snapshot(item) for item in data]

    def ex_get_snapshot(self, snapshot_id: str) -> VultrNodeSnapshot:
        """Retrieve a snapshot.

        :param snapshot_id: ID of the snapshot to retrieve.
        :type snapshot_id: ``str``

        :rtype: :class: `VultrNodeSnapshot`
        """
        resp = self.connection.request('/v2/snapshots/%s' % snapshot_id)
        return self._to_snapshot(resp.object['snapshot'])

    def ex_create_snapshot(self,
                           node: Node,
                           description: Optional[str] = None
                           ) -> VultrNodeSnapshot:
        """Create snapshot from a node.

        :param node: Node to create the snapshot from.
        :type node: :class: `Node`

        :keyword description: A description of the snapshot.
        :type    description: ``str``

        :rtype: :class: `VultrNodeSnapshot`
        """
        data = {
            'instance_id': node.id,
        }
        if description:
            data['description'] = description

        resp = self.connection.request('/v2/snapshots',
                                       data=json.dumps(data),
                                       method='POST')

        return self._to_snapshot(resp.object['snapshot'])

    def ex_delete_snapshot(self, snapshot: VultrNodeSnapshot) -> bool:
        """Delete the given snapshot.

        :param snapshot: The snapshot to delete.
        :type node: :class:`VultrNodeSnapshot`

        :rtype: ``bool``
        """

        resp = self.connection.request('/v2/snapshots/%s' % snapshot.id,
                                       method='DELETE')

        return resp.success()

    def ex_list_networks(self) -> List[VultrNetwork]:
        """List all private networks.

        :rtype: ``list`` of :class: `VultrNetwork`
        """

        data = self._paginated_request('/v2/private-networks', 'networks')
        return [self._to_network(item) for item in data]

    def ex_create_network(self,
                          cidr_block: str,
                          location: Union[NodeLocation, str],
                          description: Optional[str] = None,
                          ) -> VultrNetwork:
        """Create a private network.

        :param cidr_block: The CIDR block assigned to the network.
        :type  cidr_block: ``str``

        :param location: The location to create the network.
        :type  location: :class: `NodeLocation` or ``str``

        :keyword description: A description of the private network.
        :type    description: ``str``

        :rtype: :class: `VultrNetwork`
        """
        subnet, subnet_mask = cidr_block.split('/')
        data = {
            'v4_subnet': subnet,
            'v4_subnet_mask': int(subnet_mask),
        }

        try:
            data['region'] = location.id
        except AttributeError:
            data['region'] = location

        if description:
            data['description'] = description

        resp = self.connection.request('/v2/private-networks',
                                       data=json.dumps(data),
                                       method='POST')

        return self._to_network(resp.object['network'])

    def ex_get_network(self, network_id: str) -> VultrNetwork:
        """Retrieve a private network.

        :param network_id: ID of the network to retrieve.
        :type network_id: ``str``

        :rtype: :class: `VultrNetwork`
        """

        resp = self.connection.request('/v2/private-networks/%s' % network_id)
        return self._to_network(resp.object['network'])

    def ex_destroy_network(self, network: VultrNetwork) -> bool:
        """Delete a private network.

        :param network: The network to destroy.
        :type  network: :class: `VultrNetwork`

        :rtype: ``bool``
        """
        resp = self.connection.request('/v2/private-networks/%s' % network.id,
                                       method='DELETE')

        return resp.success()

    def ex_list_available_sizes_for_location(self,
                                             location: NodeLocation,
                                             ) -> List[str]:
        """Get a list of available sizes for the given location.

        :param location: The location to get available sizes for.
        :type location: :class: `NodeLocation`

        :return:  A list of available size IDs for the given location.
        :rtype: ``list`` of ``str``
        """
        resp = self.connection.request('/v2/regions/%s/availability'
                                       % location.id)
        return resp.object['available_plans']

    def ex_get_volume(self, volume_id: str) -> StorageVolume:
        """Retrieve a single volume.

        :param volume_id: The ID of the volume to fetch.
        :type volume_id: ``str``

        :rtype :class: `StorageVolume`
        :return: StorageVolume instance on success.
        """
        resp = self.connection.request('/v2/blocks/%s' % volume_id)

        return self._to_volume(resp.object['block'])

    def ex_resize_volume(self, volume: StorageVolume, size: int) -> bool:
        """Resize a volume.

        :param volume: The volume to resize.
        :type volume: :class:`StorageVolume`

        :param size: The new volume size in GBs.\
        Size may range between 10 and 10000.
        :type size: ``int``

        :rtype: ``bool``
        """
        data = {
            'label': volume.name,
            'size_gb': size,
        }

        resp = self.connection.request('/v2/blocks/%s' % volume.id,
                                       data=json.dumps(data),
                                       method='PATCH')
        return resp.success()

    def _is_bare_metal(self, size: Union[NodeSize, str]) -> bool:
        try:
            size_id = size.id
        except AttributeError:
            size_id = size

        return size_id.startswith('vbm')

    def _to_node(self, data: Dict[str, Any]) -> Node:
        id_ = data['id']
        name = data['label']
        public_ips = data['main_ip'].split() + data['v6_main_ip'].split()
        size = data['plan']
        image = str(data['os_id'])
        created_at = data['date_created']
        is_bare_metal = self._is_bare_metal(size)
        extra = {
            'location': data['region'],
            'ram': data['ram'],
            'disk': data['disk'],
            'netmask_v4': data['netmask_v4'],
            'gateway_v4': data['gateway_v4'],
            'v6_network': data['v6_network'],
            'v6_network_size': data['v6_network_size'],
            'app_id': data['app_id'],
            'image_id': data['image_id'],
            'features': data['features'],
            'tag': data['tag'],
            'os': data['os'],
            'is_bare_metal': is_bare_metal,
        }
        if is_bare_metal:
            state = self._get_node_state(data['status'])
            extra['cpu_count'] = data['cpu_count']
            extra['mac_address'] = data['mac_address']
            private_ips = None
        else:
            state = self._get_node_state(data['status'],
                                         power_state=data['power_status'])
            extra['vcpu_count'] = data['vcpu_count']
            extra['allowed_bandwidth'] = data['allowed_bandwidth']
            extra['power_status'] = data['power_status']
            extra['server_status'] = data['server_status']
            extra['firewall_group_id'] = data['firewall_group_id']
            private_ips = data['internal_ip'].split()

        return Node(id=id_,
                    name=name,
                    state=state,
                    public_ips=public_ips,
                    private_ips=private_ips,
                    driver=self,
                    size=size,
                    image=image,
                    extra=extra,
                    created_at=created_at)

    def _to_volume(self, data: Dict[str, Any]) -> StorageVolume:
        id_ = data['id']
        name = data['label']
        size = data['size_gb']
        try:
            state = self.VOLUME_STATE_MAP[data['status']]
        except KeyError:
            state = StorageVolumeState.UNKNOWN
        extra = {
            'date_created': data['date_created'],
            'cost': data['cost'],
            'location': data['region'],
            'attached_to_instance': data['attached_to_instance'],
            'mount_id': data['mount_id'],
        }
        return StorageVolume(id=id_,
                             name=name,
                             size=size,
                             driver=self,
                             state=state,
                             extra=extra)

    def _get_node_state(self,
                        state: str,
                        power_state: Optional[str] = None,
                        ) -> NodeState:
        try:
            state = self.NODE_STATE_MAP[state]
        except KeyError:
            state = NodeState.UNKNOWN

        if power_state is None:
            return state

        if state == NodeState.RUNNING and power_state != 'running':
            state = NodeState.STOPPED
        return state

    def _to_key_pair(self, data: Dict[str, Any]) -> KeyPair:
        name = data['name']
        public_key = data['ssh_key']
        # requires cryptography module
        try:
            fingerprint = get_pubkey_openssh_fingerprint(public_key)
        except RuntimeError:
            fingerprint = None
        extra = {
            'id': data['id'],
            'date_created': data['date_created'],
        }
        return KeyPair(name=name,
                       public_key=public_key,
                       fingerprint=fingerprint,
                       driver=self,
                       extra=extra,
                       )

    def _to_location(self, data: Dict[str, Any]) -> NodeLocation:
        id_ = data['id']
        name = data['city']
        country = data['country']
        extra = {
            'continent': data['continent'],
            'option': data['options'],
        }
        return NodeLocation(id=id_,
                            name=name,
                            country=country,
                            driver=self,
                            extra=extra
                            )

    def _to_image(self, data: Dict[str, Any]) -> NodeImage:
        id_ = data['id']
        name = data['name']
        extra = {
            'arch': data['arch'],
            'family': data['family'],
        }
        return NodeImage(id=id_,
                         name=name,
                         driver=self,
                         extra=extra)

    def _to_size(self, data: Dict[str, Any]) -> NodeSize:
        id_ = data['id']
        ram = data['ram']
        disk = data['disk']
        bandwidth = data['bandwidth']
        price = data['monthly_cost']
        is_bare_metal = self._is_bare_metal(id_)
        extra = {
            'locations': data['locations'],
            'type': data['type'],
            'disk_count': data['disk_count'],
            'is_bare_metal': is_bare_metal,
        }

        # VPS and bare metal sizes have different fields
        if is_bare_metal is False:
            extra['vcpu_count'] = data['vcpu_count']
        else:
            extra['cpu_count'] = data['cpu_count']
            extra['cpu_model'] = data['cpu_model']
            extra['cpu_threads'] = data['cpu_threads']

        return NodeSize(id=id_,
                        name=id_,
                        ram=ram,
                        disk=disk,
                        bandwidth=bandwidth,
                        price=price,
                        driver=self,
                        extra=extra)

    def _to_network(self, data: Dict[str, Any]) -> VultrNetwork:
        id_ = data['id']
        cidr_block = '%s/%s' % (data['v4_subnet'], data['v4_subnet_mask'])
        location = data['region']
        extra = {
            'description': data['description'],
            'date_created': data['date_created'],
        }
        return VultrNetwork(id=id_,
                            cidr_block=cidr_block,
                            location=location,
                            extra=extra)

    def _to_snapshot(self, data: Dict[str, Any]) -> VultrNodeSnapshot:
        id_ = data['id']
        created = data['date_created']
        # Size is returned in bytes, convert to GBs
        size = data['size'] / 1024 / 1024 / 1024
        try:
            state = self.SNAPSHOT_STATE_MAP[data['status']]
        except KeyError:
            state = VolumeSnapshotState.UNKNOWN
        extra = {
            'description': data['description'],
            'os_id': data['os_id'],
            'app_id': data['app_id'],
        }
        return VultrNodeSnapshot(id=id_,
                                 size=size,
                                 created=created,
                                 state=state,
                                 extra=extra,
                                 driver=self)

    def _paginated_request(self,
                           url: str,
                           key: str,
                           params: Optional[Dict[str, Any]] = None
                           ) -> List[Any]:
        """Perform multiple calls to get the full list of items when
        the API responses are paginated.

        :param url: API endpoint
        :type url: ``str``

        :param key: Result object key
        :type key: ``str``

        :param params: Request parameters
        :type params: ``dict``

        :return: ``list`` of API response objects
        :rtype: ``list``
        """
        params = params if params is not None else {}
        resp = self.connection.request(url, params=params).object
        data = list(resp.get(key, []))
        objects = data
        while True:
            next_page = resp['meta']['links']['next']
            if next_page:
                params['cursor'] = next_page
                resp = self.connection.request(url, params=params).object
                data = list(resp.get(key, []))
                objects.extend(data)
            else:
                return objects
