# 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.

import os
import re
import time
import platform
import mimetypes
import subprocess
from os.path import join as pjoin
from collections import defaultdict

from libcloud.utils.py3 import ET, ensure_string
from libcloud.compute.base import Node, NodeState, NodeDriver
from libcloud.compute.types import Provider
from libcloud.utils.networking import is_public_subnet

try:
    import libvirt

    have_libvirt = True
except ImportError:
    have_libvirt = False


class LibvirtNodeDriver(NodeDriver):
    """
    Libvirt (http://libvirt.org/) node driver.

    To enable debug mode, set LIBVIR_DEBUG environment variable.
    """

    type = Provider.LIBVIRT
    name = "Libvirt"
    website = "http://libvirt.org/"

    NODE_STATE_MAP = {
        0: NodeState.TERMINATED,  # no state
        1: NodeState.RUNNING,  # domain is running
        2: NodeState.PENDING,  # domain is blocked on resource
        3: NodeState.TERMINATED,  # domain is paused by user
        4: NodeState.TERMINATED,  # domain is being shut down
        5: NodeState.TERMINATED,  # domain is shut off
        6: NodeState.UNKNOWN,  # domain is crashed
        7: NodeState.UNKNOWN,  # domain is suspended by guest power management
    }

    def __init__(self, uri, key=None, secret=None):
        """
        :param  uri: Hypervisor URI (e.g. vbox:///session, qemu:///system,
                     etc.).
        :type   uri: ``str``

        :param  key: the username for a remote libvirtd server
        :type   key: ``str``

        :param  secret: the password for a remote libvirtd server
        :type   key: ``str``
        """
        if not have_libvirt:
            raise RuntimeError("Libvirt driver requires 'libvirt' Python " + "package")

        self._uri = uri
        self._key = key
        self._secret = secret
        if uri is not None and "+tcp" in self._uri:
            if key is None and secret is None:
                raise RuntimeError(
                    "The remote Libvirt instance requires "
                    + "authentication, please set 'key' and "
                    + "'secret' parameters"
                )
            auth = [
                [libvirt.VIR_CRED_AUTHNAME, libvirt.VIR_CRED_PASSPHRASE],
                self._cred_callback,
                None,
            ]
            self.connection = libvirt.openAuth(uri, auth, 0)
        else:
            self.connection = libvirt.open(uri)
        if uri is None:
            self._uri = self.connection.getInfo()

    def _cred_callback(self, cred, user_data):
        """
        Callback for the authentication scheme, which will provide username
        and password for the login. Reference: ( http://bit.ly/1U5yyQg )

        :param  cred: The credentials requested and the return
        :type   cred: ``list``

        :param  user_data: Custom data provided to the authentication routine
        :type   user_data: ``list``

        :rtype: ``int``
        """
        for credential in cred:
            if credential[0] == libvirt.VIR_CRED_AUTHNAME:
                credential[4] = self._key
            elif credential[0] == libvirt.VIR_CRED_PASSPHRASE:
                credential[4] = self._secret
        return 0

    def list_nodes(self):
        domains = self.connection.listAllDomains()
        nodes = self._to_nodes(domains=domains)
        return nodes

    def reboot_node(self, node):
        domain = self._get_domain_for_node(node=node)
        return domain.reboot(flags=0) == 0

    def destroy_node(self, node):
        domain = self._get_domain_for_node(node=node)
        return domain.destroy() == 0

    def start_node(self, node):
        domain = self._get_domain_for_node(node=node)
        return domain.create() == 0

    def stop_node(self, node):
        domain = self._get_domain_for_node(node=node)
        return domain.shutdown() == 0

    def ex_start_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
        """
        Start a stopped node.

        :param  node: Node which should be used
        :type   node: :class:`Node`

        :rtype: ``bool``
        """
        return self.start_node(node=node)

    def ex_shutdown_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
        """
        Shutdown a running node.

        Note: Usually this will result in sending an ACPI event to the node.

        :param  node: Node which should be used
        :type   node: :class:`Node`

        :rtype: ``bool``
        """
        return self.stop_node(node=node)

    def ex_suspend_node(self, node):
        """
        Suspend a running node.

        :param  node: Node which should be used
        :type   node: :class:`Node`

        :rtype: ``bool``
        """
        domain = self._get_domain_for_node(node=node)
        return domain.suspend() == 0

    def ex_resume_node(self, node):
        """
        Resume a suspended node.

        :param  node: Node which should be used
        :type   node: :class:`Node`

        :rtype: ``bool``
        """
        domain = self._get_domain_for_node(node=node)
        return domain.resume() == 0

    def ex_get_node_by_uuid(self, uuid):
        """
        Retrieve Node object for a domain with a provided uuid.

        :param  uuid: Uuid of the domain.
        :type   uuid: ``str``
        """
        domain = self._get_domain_for_uuid(uuid=uuid)
        node = self._to_node(domain=domain)
        return node

    def ex_get_node_by_name(self, name):
        """
        Retrieve Node object for a domain with a provided name.

        :param  name: Name of the domain.
        :type   name: ``str``
        """
        domain = self._get_domain_for_name(name=name)
        node = self._to_node(domain=domain)
        return node

    def ex_take_node_screenshot(self, node, directory, screen=0):
        """
        Take a screenshot of a monitoring of a running instance.

        :param node: Node to take the screenshot of.
        :type node: :class:`libcloud.compute.base.Node`

        :param directory: Path where the screenshot will be saved.
        :type directory: ``str``

        :param screen: ID of the monitor to take the screenshot of.
        :type screen: ``int``

        :return: Full path where the screenshot has been saved.
        :rtype: ``str``
        """
        if not os.path.exists(directory) or not os.path.isdir(directory):
            raise ValueError("Invalid value for directory argument")

        domain = self._get_domain_for_node(node=node)
        stream = self.connection.newStream()
        mime_type = domain.screenshot(stream=stream, screen=0)
        extensions = mimetypes.guess_all_extensions(type=mime_type)

        if extensions:
            extension = extensions[0]
        else:
            extension = ".png"

        name = "screenshot-{}{}".format(int(time.time()), extension)
        file_path = pjoin(directory, name)

        with open(file_path, "wb") as fp:

            def write(stream, buf, opaque):
                fp.write(buf)

            stream.recvAll(write, None)

        try:
            stream.finish()
        except Exception:
            # Finish is not supported by all backends
            pass

        return file_path

    def ex_get_hypervisor_hostname(self):
        """
        Return a system hostname on which the hypervisor is running.
        """
        hostname = self.connection.getHostname()
        return hostname

    def ex_get_hypervisor_sysinfo(self):
        """
        Retrieve hypervisor system information.

        :rtype: ``dict``
        """
        xml = self.connection.getSysinfo()
        etree = ET.XML(xml)

        attributes = ["bios", "system", "processor", "memory_device"]

        sysinfo = {}
        for attribute in attributes:
            element = etree.find(attribute)
            entries = self._get_entries(element=element)
            sysinfo[attribute] = entries

        return sysinfo

    def _to_nodes(self, domains):
        nodes = [self._to_node(domain=domain) for domain in domains]
        return nodes

    def _to_node(self, domain):
        state, max_mem, memory, vcpu_count, used_cpu_time = domain.info()
        state = self.NODE_STATE_MAP.get(state, NodeState.UNKNOWN)

        public_ips, private_ips = [], []

        ip_addresses = self._get_ip_addresses_for_domain(domain)

        for ip_address in ip_addresses:
            if is_public_subnet(ip_address):
                public_ips.append(ip_address)
            else:
                private_ips.append(ip_address)

        extra = {
            "uuid": domain.UUIDString(),
            "os_type": domain.OSType(),
            "types": self.connection.getType(),
            "used_memory": memory / 1024,
            "vcpu_count": vcpu_count,
            "used_cpu_time": used_cpu_time,
        }

        node = Node(
            id=domain.ID(),
            name=domain.name(),
            state=state,
            public_ips=public_ips,
            private_ips=private_ips,
            driver=self,
            extra=extra,
        )
        node._uuid = domain.UUIDString()  # we want to use a custom UUID
        return node

    def _get_ip_addresses_for_domain(self, domain):
        """
        Retrieve IP addresses for the provided domain.

        Note: This functionality is currently only supported on Linux and
        only works if this code is run on the same machine as the VMs run
        on.

        :return: IP addresses for the provided domain.
        :rtype: ``list``
        """
        result = []

        if platform.system() != "Linux":
            # Only Linux is supported atm
            return result

        if "///" not in self._uri:
            # Only local libvirtd is supported atm
            return result

        mac_addresses = self._get_mac_addresses_for_domain(domain=domain)

        arp_table = {}
        try:
            cmd = ["arp", "-an"]
            child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            stdout, _ = child.communicate()
            arp_table = self._parse_ip_table_arp(arp_output=stdout)
        except OSError as e:
            if e.errno == 2:
                cmd = ["ip", "neigh"]
                child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
                stdout, _ = child.communicate()
                arp_table = self._parse_ip_table_neigh(ip_output=stdout)

        for mac_address in mac_addresses:
            if mac_address in arp_table:
                ip_addresses = arp_table[mac_address]
                result.extend(ip_addresses)

        return result

    def _get_mac_addresses_for_domain(self, domain):
        """
        Parses network interface MAC addresses from the provided domain.
        """
        xml = domain.XMLDesc()
        etree = ET.XML(xml)
        elems = etree.findall("devices/interface[@type='network']/mac")

        result = []
        for elem in elems:
            mac_address = elem.get("address")
            result.append(mac_address)

        return result

    def _get_domain_for_node(self, node):
        """
        Return libvirt domain object for the provided node.
        """
        domain = self.connection.lookupByUUIDString(node.uuid)
        return domain

    def _get_domain_for_uuid(self, uuid):
        """
        Return libvirt domain object for the provided uuid.
        """
        domain = self.connection.lookupByUUIDString(uuid)
        return domain

    def _get_domain_for_name(self, name):
        """
        Return libvirt domain object for the provided name.
        """
        domain = self.connection.lookupByName(name)
        return domain

    def _get_entries(self, element):
        """
        Parse entries dictionary.

        :rtype: ``dict``
        """
        elements = element.findall("entry")

        result = {}
        for element in elements:
            name = element.get("name")
            value = element.text
            result[name] = value

        return result

    def _parse_ip_table_arp(self, arp_output):
        """
        Sets up the regexp for parsing out IP addresses from the 'arp -an'
        command and pass it along to the parser function.

        :return: Dictionary from the parsing function
        :rtype: ``dict``
        """
        arp_regex = re.compile(r".*?\((.*?)\) at (.*?)\s+")
        return self._parse_mac_addr_table(arp_output, arp_regex)

    def _parse_ip_table_neigh(self, ip_output):
        """
        Sets up the regexp for parsing out IP addresses from the 'ip neighbor'
        command and pass it along to the parser function.

        :return: Dictionary from the parsing function
        :rtype: ``dict``
        """
        ip_regex = re.compile(r"(.*?)\s+.*lladdr\s+(.*?)\s+")
        return self._parse_mac_addr_table(ip_output, ip_regex)

    def _parse_mac_addr_table(self, cmd_output, mac_regex):
        """
        Parse the command output and return a dictionary which maps mac address
        to an IP address.

        :return: Dictionary which maps mac address to IP address.
        :rtype: ``dict``
        """
        lines = ensure_string(cmd_output).split("\n")

        arp_table = defaultdict(list)
        for line in lines:
            match = mac_regex.match(line)

            if not match:
                continue

            groups = match.groups()
            ip_address = groups[0]
            mac_address = groups[1]
            arp_table[mac_address].append(ip_address)

        return arp_table
