# 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.
"""
GiG G8 Driver

"""
import json
from libcloud.compute.base import NodeImage, NodeSize, Node
from libcloud.compute.base import NodeDriver, UuidMixin
from libcloud.compute.base import StorageVolume, NodeAuthSSHKey
from libcloud.compute.types import Provider, NodeState
from libcloud.common.gig_g8 import G8Connection
from libcloud.common.exceptions import BaseHTTPError


class G8ProvisionError(Exception):
    pass


class G8PortForward(UuidMixin):
    def __init__(self, network, node_id, publicport,
                 privateport, protocol, driver):
        self.node_id = node_id
        self.network = network
        self.publicport = int(publicport)
        self.privateport = int(privateport)
        self.protocol = protocol
        self.driver = driver
        UuidMixin.__init__(self)

    def destroy(self):
        self.driver.ex_delete_portforward(self)


class G8Network(UuidMixin):
    """
    G8 Network object class.

    This class maps to a cloudspace

    """

    def __init__(self, id, name, cidr, publicipaddress, driver, extra=None):
        self.id = id
        self.name = name
        self._cidr = cidr
        self.driver = driver
        self.publicipaddress = publicipaddress
        self.extra = extra
        UuidMixin.__init__(self)

    @property
    def cidr(self):
        """
        Cidr is not part of the list result
        we will lazily fetch it with a get request
        """
        if self._cidr is None:
            networkdata = self.driver._api_request("/cloudspaces/get",
                                                   {"cloudspaceId": self.id})
            self._cidr = networkdata["privatenetwork"]
        return self._cidr

    def list_nodes(self):
        return self.driver.list_nodes(self)

    def destroy(self):
        return self.driver.ex_destroy_network(self)

    def list_portforwards(self):
        return self.driver.ex_list_portforwards(self)

    def create_portforward(self, node, publicport,
                           privateport, protocol='tcp'):
        return self.driver.ex_create_portforward(self, node, publicport,
                                                 privateport, protocol)


class G8NodeDriver(NodeDriver):
    """
    GiG G8 node driver

    """

    NODE_STATE_MAP = {'VIRTUAL': NodeState.PENDING,
                      'HALTED': NodeState.STOPPED,
                      'RUNNING': NodeState.RUNNING,
                      'DESTROYED': NodeState.TERMINATED,
                      'DELETED': NodeState.TERMINATED,
                      'PAUSED': NodeState.PAUSED,
                      'ERROR': NodeState.ERROR,

                      # transition states
                      'DEPLOYING': NodeState.PENDING,
                      'STOPPING': NodeState.STOPPING,
                      'MOVING': NodeState.MIGRATING,
                      'RESTORING': NodeState.PENDING,
                      'STARTING': NodeState.STARTING,
                      'PAUSING': NodeState.PENDING,
                      'RESUMING': NodeState.PENDING,
                      'RESETTING': NodeState.REBOOTING,
                      'DELETING': NodeState.TERMINATED,
                      'DESTROYING': NodeState.TERMINATED,
                      'ADDING_DISK': NodeState.RECONFIGURING,
                      'ATTACHING_DISK': NodeState.RECONFIGURING,
                      'DETACHING_DISK': NodeState.RECONFIGURING,
                      'ATTACHING_NIC': NodeState.RECONFIGURING,
                      'DETTACHING_NIC': NodeState.RECONFIGURING,
                      'DELETING_DISK': NodeState.RECONFIGURING,
                      'CHANGING_DISK_LIMITS': NodeState.RECONFIGURING,
                      'CLONING': NodeState.PENDING,
                      'RESIZING': NodeState.RECONFIGURING,
                      'CREATING_TEMPLATE': NodeState.PENDING,
                      }

    name = "GiG G8 Node Provider"
    website = 'https://gig.tech'
    type = Provider.GIG_G8
    connectionCls = G8Connection

    def __init__(self, user_id, key, api_url):
        # type (int, str, str) -> None
        """
        :param  key: Token to use for api (jwt)
        :type   key: ``str``
        :param  user_id: Id of the account to connect to (accountId)
        :type   user_id: ``int``
        :param  api_url: G8 api url
        :type   api_url: ``str``

        :rtype: ``None``
        """
        self._apiurl = api_url.rstrip("/")
        super(G8NodeDriver, self).__init__(key=key)
        self._account_id = user_id
        self._location_data = None

    def _ex_connection_class_kwargs(self):
        return {"url": self._apiurl}

    def _api_request(self, endpoint, params=None):
        return self.connection.request(endpoint.lstrip("/"),
                                       data=json.dumps(params),
                                       method="POST").object

    @property
    def _location(self):
        if self._location_data is None:
            self._location_data = self._api_request("/locations/list")[0]
        return self._location_data

    def create_node(self, name, image, ex_network, ex_description,
                    size=None, auth=None, ex_create_attr=None,
                    ex_expose_ssh=False):
        # type (str, Image, G8Network, str, Size,
        #       Optional[NodeAuthSSHKey], Optional[Dict], bool) -> Node
        """
        Create a node.

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

        * `memory`: ``int`` Memory in MiB
                    (only used if size is None and vcpus is passed
        * `vcpus`: ``int`` Amount of vcpus
                   (only used if size is None and memory is passed)
        * `disk_size`: ``int`` Size of bootdisk
                       defaults to minimumsize of the image
        * `user_data`: ``str`` for cloud-config data
        * `private_ip`: ``str`` Private Ip inside network
        * `data_disks`: ``list(int)`` Extra data disks to assign
                        to vm list of disk sizes in GiB

        :param name: the name to assign the vm
        :type  name: ``str``

        :param size: the plan size to create
                       mutual exclusive with `memory` `vcpus`
        :type  size: :class:`NodeSize`

        :param image: which distribution to deploy on the vm
        :type  image: :class:`NodeImage`

        :param network: G8 Network to place vm in
        :type  size: :class:`G8Network`

        :param ex_description: Descripton of vm
        :type  size: : ``str``

        :param auth: an SSH key
        :type  auth: :class:`NodeAuthSSHKey`

        :param ex_create_attr: A dictionary of optional attributes for
                                 vm creation
        :type  ex_create_attr: ``dict``

        :param ex_expose_ssh: Create portforward for ssh port
        :type  ex_expose_ssh: int

        :return: The newly created node.
        :rtype: :class:`Node`
        """
        params = {"name": name,
                  "imageId": int(image.id),
                  "cloudspaceId": int(ex_network.id),
                  "description": ex_description}

        ex_create_attr = ex_create_attr or {}
        if size:
            params["sizeId"] = int(size.id)
        else:
            params["memory"] = ex_create_attr["memory"]
            params["vcpus"] = ex_create_attr["vcpus"]
        if "user_data" in ex_create_attr:
            params["userdata"] = ex_create_attr["user_data"]
        if "data_disks" in ex_create_attr:
            params["datadisks"] = ex_create_attr["data_disks"]
        if "private_ip" in ex_create_attr:
            params["privateIp"] = ex_create_attr["private_ip"]
        if "disk_size" in ex_create_attr:
            params["disksize"] = ex_create_attr["disk_size"]
        else:
            params["disksize"] = image.extra["min_disk_size"]
        if auth and isinstance(auth, NodeAuthSSHKey):
            userdata = params.setdefault("userdata", {})
            users = userdata.setdefault("users", [])
            root = None
            for user in users:
                if user["name"] == "root":
                    root = user
                    break
            else:
                root = {"name": "root", "shell": "/bin/bash"}
                users.append(root)
            keys = root.setdefault("ssh-authorized-keys", [])
            keys.append(auth.pubkey)
        elif auth:
            error = "Auth type {} is not implemented".format(type(auth))
            raise NotImplementedError(error)

        machineId = self._api_request("/machines/create", params)
        machine = self._api_request("/machines/get",
                                    params={"machineId": machineId})
        node = self._to_node(machine, ex_network)
        if ex_expose_ssh:
            port = self.ex_expose_ssh_node(node)
            node.extra["ssh_port"] = port
            node.extra["ssh_ip"] = ex_network.publicipaddress
        return node

    def _find_ssh_ports(self, ex_network, node):
        forwards = ex_network.list_portforwards()
        usedports = []
        result = {"node": None, "network": usedports}
        for forward in forwards:
            usedports.append(forward.publicport)
            if forward.node_id == node.id and forward.privateport == 22:
                result["node"] = forward.privateport
        return result

    def ex_expose_ssh_node(self, node):
        """
        Create portforward for ssh purposed

        :param node: Node to expose ssh for
        :type  node: ``Node``

        :rtype: ``int``
        """

        network = node.extra["network"]
        ports = self._find_ssh_ports(network, node)
        if ports["node"]:
            return ports["node"]
        usedports = ports["network"]
        sshport = 2200
        endport = 3000
        while sshport < endport:
            while sshport in usedports:
                sshport += 1
            try:
                network.create_portforward(node, sshport, 22)
                node.extra["ssh_port"] = sshport
                node.extra["ssh_ip"] = network.publicipaddress
                break
            except BaseHTTPError as e:
                if e.code == 409:
                    # port already used maybe raise let's try next
                    usedports.append(sshport)
                raise
        else:
            raise G8ProvisionError("Failed to create portforward")
        return sshport

    def ex_create_network(self, name, private_network="192.168.103.0/24",
                          type="vgw"):
        # type (str, str, str) -> G8Network
        """
        Create network also known as cloudspace

        :param name: the name to assing to the network
        :type  name: ``str``

        :param private_network: subnet used as private network
        :type  private_network: ``str``

        :param type: type of the gateway vgw or routeros
        :type  type: ``str``
        """
        userinfo = self._api_request("../system/usermanager/whoami")
        params = {"accountId": self._account_id,
                  "privatenetwork": private_network,
                  "access": userinfo["name"],
                  "name": name,
                  "location": self._location["locationCode"],
                  "type": type}
        networkid = self._api_request("/cloudspaces/create", params)
        network = self._api_request("/cloudspaces/get",
                                    {"cloudspaceId": networkid})
        return self._to_network(network)

    def ex_destroy_network(self, network):
        # type (G8Network) -> bool
        self._api_request("/cloudspaces/delete",
                          {"cloudspaceId": int(network.id)})
        return True

    def stop_node(self, node):
        # type (Node) -> bool
        """
        Stop virtual machine
        """
        node.state = NodeState.STOPPING
        self._api_request("/machines/stop", {"machineId": int(node.id)})
        node.state = NodeState.STOPPED
        return True

    def ex_list_portforwards(self, network):
        # type (G8Network) -> List[G8PortForward]
        data = self._api_request("/portforwarding/list",
                                 {"cloudspaceId": int(network.id)})
        forwards = []
        for forward in data:
            forwards.append(self._to_port_forward(forward, network))
        return forwards

    def ex_create_portforward(self, network, node, publicport,
                              privateport, protocol="tcp"):
        # type (G8Network, Node, int, int, str) -> G8PortForward
        params = {"cloudspaceId": int(network.id),
                  "machineId": int(node.id),
                  "localPort": privateport,
                  "publicPort": publicport,
                  "publicIp": network.publicipaddress,
                  "protocol": protocol}
        self._api_request("/portforwarding/create", params)
        return self._to_port_forward(params, network)

    def ex_delete_portforward(self, portforward):
        # type (G8PortForward) -> bool
        params = {"cloudspaceId": int(portforward.network.id),
                  "publicIp": portforward.network.publicipaddress,
                  "publicPort": portforward.publicport,
                  "proto": portforward.protocol}
        self._api_request("/portforwarding/deleteByPort", params)
        return True

    def start_node(self, node):
        # type (Node) -> bool
        """
        Start virtual machine
        """
        node.state = NodeState.STARTING
        self._api_request("/machines/start", {"machineId": int(node.id)})
        node.state = NodeState.RUNNING
        return True

    def ex_list_networks(self):
        # type () -> List[G8Network]
        """
        Return the list of networks.

        :return: A list of network objects.
        :rtype: ``list`` of :class:`G8Network`
        """
        networks = []
        for network in self._api_request("/cloudspaces/list"):
            if network["accountId"] == self._account_id:
                networks.append(self._to_network(network))
        return networks

    def list_sizes(self):
        # type () -> List[Size]
        """
        Returns a list of node sizes as a cloud provider might have

        """
        location = self._location["locationCode"]

        sizes = []
        for size in self._api_request("/sizes/list", {"location": location}):
            sizes.extend(self._to_size(size))
        return sizes

    def list_nodes(self, ex_network=None):
        # type (Optional[G8Network]) -> List[Node]
        """
        List the nodes known to a particular driver;
        There are two default nodes created at the beginning
        """
        def _get_ssh_port(forwards, node):
            for forward in forwards:
                if forward.node_id == node.id and forward.privateport == 22:
                    return forward
        if ex_network:
            networks = [ex_network]
        else:
            networks = self.ex_list_networks()
        nodes = []
        for network in networks:
            nodes_list = self._api_request("/machines/list",
                                           params={"cloudspaceId": network.id})
            forwards = network.list_portforwards()
            for nodedata in nodes_list:
                node = self._to_node(nodedata, network)
                sshforward = _get_ssh_port(forwards, node)
                if sshforward:
                    node.extra["ssh_port"] = sshforward.publicport
                    node.extra["ssh_ip"] = network.publicipaddress
                nodes.append(node)
        return nodes

    def reboot_node(self, node):
        # type (Node) -> bool
        """
        Reboot node
        returns True as if the reboot had been successful.
        """
        node.state = NodeState.REBOOTING
        self._api_request("/machines/reboot", {"machineId": int(node.id)})
        node.state = NodeState.RUNNING
        return True

    def destroy_node(self, node):
        # type (Node) -> bool
        """
        Destroy node
        """
        self._api_request("/machines/delete", {"machineId": int(node.id)})
        return True

    def list_images(self):
        # type () -> List[Image]
        """
        Returns a list of images as a cloud provider might have

        @inherits: :class:`NodeDriver.list_images`
        """
        images = []
        for image in self._api_request("/images/list",
                                       {"accountId": self._account_id}):
            images.append(self._to_image(image))
        return images

    def list_volumes(self):
        # type () -> List[StorageVolume]
        volumes = []
        for disk in self._api_request("/disks/list",
                                      {"accountId": self._account_id}):
            if disk["status"] not in ["ASSIGNED", "CREATED"]:
                continue
            volumes.append(self._to_volume(disk))
        return volumes

    def create_volume(self, size, name, ex_description, ex_disk_type="D"):
        # type (int, str, str, Optional[str]) -> StorageVolume
        """
        Create volume

        :param size: Size of the volume to create in GiB
        :type  size: ``int``

        :param name: Name of the volume
        :type  name: ``str``

        :param description: Descripton of the volume
        :type  description: ``str``

        :param disk_type: Type of the disk depending on the G8
                            D for datadisk is always available
        :type  disk_type: ``str``

        :rtype: class:`StorageVolume`
        """
        params = {"size": size,
                  "name": name,
                  "type": ex_disk_type,
                  "description": ex_description,
                  "gid": self._location["gid"],
                  "accountId": self._account_id
                  }
        diskId = self._api_request("/disks/create", params)
        disk = self._api_request("/disks/get", {"diskId": diskId})
        return self._to_volume(disk)

    def destroy_volume(self, volume):
        # type (StorageVolume) -> bool
        self._api_request("/disks/delete", {"diskId": int(volume.id)})
        return True

    def attach_volume(self, node, volume):
        # type (Node, StorageVolume) -> bool
        params = {"machineId": int(node.id),
                  "diskId": int(volume.id)}
        self._api_request("/machines/attachDisk", params)
        return True

    def detach_volume(self, node, volume):
        # type (Node, StorageVolume) -> bool
        params = {"machineId": int(node.id),
                  "diskId": int(volume.id)}
        self._api_request("/machines/detachDisk", params)
        return True

    def _to_volume(self, data):
        # type (dict) -> StorageVolume
        extra = {"type": data["type"], "node_id": data.get("machineId")}
        return StorageVolume(id=str(data["id"]), size=data["sizeMax"],
                             name=data["name"], driver=self,
                             extra=extra)

    def _to_node(self, nodedata, ex_network):
        # type (dict) -> Node
        state = self.NODE_STATE_MAP.get(nodedata["status"], NodeState.UNKNOWN)
        public_ips = []
        private_ips = []
        nics = nodedata.get("nics", [])
        if not nics:
            nics = nodedata.get("interfaces", [])
        for nic in nics:
            if nic["type"] == "PUBLIC":
                public_ips.append(nic["ipAddress"].split("/")[0])
            else:
                private_ips.append(nic["ipAddress"])
        extra = {"network": ex_network}
        for account in nodedata.get("accounts", []):
            extra["password"] = account["password"]
            extra["username"] = account["login"]

        return Node(id=str(nodedata['id']), name=nodedata['name'],
                    driver=self, public_ips=public_ips,
                    private_ips=private_ips, state=state, extra=extra)

    def _to_network(self, network):
        # type (dict) -> G8Network
        return G8Network(str(network["id"]), network["name"], None,
                         network["externalnetworkip"], self)

    def _to_image(self, image):
        # type (dict) -> Image
        extra = {"min_disk_size": image["bootDiskSize"],
                 "min_memory": image["memory"],
                 }
        return NodeImage(id=str(image["id"]), name=image["name"],
                         driver=self, extra=extra)

    def _to_size(self, size):
        # type (dict) -> Size
        sizes = []
        for disk in size["disks"]:
            sizes.append(NodeSize(id=str(size["id"]), name=size["name"],
                                  ram=size["memory"], disk=disk,
                                  driver=self, extra={"vcpus": size["vcpus"]},
                                  bandwidth=0, price=0))
        return sizes

    def _to_port_forward(self, data, ex_network):
        # type (dict, G8Network) -> G8PortForward
        return G8PortForward(ex_network, str(data["machineId"]),
                             data["publicPort"], data["localPort"],
                             data["protocol"], self)


if __name__ == "__main__":
    import doctest
    doctest.testmod()
