# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import pathlib
import re

from gns3.controller import Controller
from gns3.ports.ethernet_port import EthernetPort
from gns3.ports.serial_port import SerialPort
from gns3.utils.bring_to_front import bring_window_to_front_from_process_name, bring_window_to_front_from_title
from gns3.utils.normalize_filename import normalize_filename
from gns3.qt import QtGui, QtCore

from .base_node import BaseNode

import logging
log = logging.getLogger(__name__)


class Node(BaseNode):

    def __init__(self, module, compute, project):

        super().__init__(module, compute, project)

        self._node_id = None
        self._node_directory = None
        self._command_line = None
        self._always_on = False

        # minimum required base settings
        self._settings = {"name": "",
                          "x": None,
                          "y": None,
                          "z": 1,
                          "locked": False}

    def settings(self):
        """
        Returns this node settings.

        :return: settings (dictionary)
        """

        return self._settings

    def setSettingValue(self, key, value):
        """
        Set a setting on this node.

        :param key: setting key
        :param value: setting value
        """

        self._settings[key] = value

    def node_id(self):
        """
        Return the ID of this device

        :returns: identifier (string)
        """

        return self._node_id

    def setName(self, name):
        """
        Set the name for this node.

        :param name: node name
        """

        self._settings["name"] = name

    def name(self):
        """
        Returns the name of this node.

        :returns: name (string)
        """

        return self._settings["name"]

    def usage(self):
        """
        Returns the usage info for this node.

        :returns: usage (string)
        """

        return self._settings.get("usage")

    def nodeDir(self):
        """
        Return the working directory of this node

        :returns: identifier (string)
        """

        return self._node_directory

    def commandLine(self):
        """
        Return the command line used to run this node

        :returns: identifier (string)
        """

        return self._command_line

    def x(self):
        """
        Returns X coordinate

        :returns: integer
        """

        return self._settings["x"]

    def y(self):
        """
        Returns Y coordinate

        :returns: integer
        """

        return self._settings["y"]

    def z(self):
        """
        Returns Z coordinate

        :returns: integer
        """

        return self._settings["z"]

    def locked(self):
        """
        Is the node locked
        """

        return self._settings["locked"]

    def setSymbol(self, symbol):
        """
        Sets the symbol for this node.

        :param symbol: symbol path
        """

        self._settings["symbol"] = symbol

    def symbol(self):
        """
        Returns the symbol for this node.

        :returns: symbol path
        """

        return self._settings["symbol"]

    def console(self):
        """
        Returns the console port number of this node

        :returns: port number
        """

        return self.settings().get("console")

    def isStarted(self):
        """
        :returns: Boolean True if this node is running
        """

        return self.status() == Node.started

    def isAlwaysOn(self):
        """
        Whether the node is always on.

        :returns: boolean
        """

        return self._always_on

    def configFiles(self):
        """
        Name of the configuration files

        This method should be overridden in derived classes

        :returns: List of configuration files, False if no files
        """

        return None

    def configTextFiles(self):
        """
        Name of the configuration files, which are plain text files

        :returns: List of configuration files, False if no files
        """

        return self.configFiles()

    def get(self, path, *args, **kwargs):
        """
        GET on current server / project
        """

        return self.controllerHttpGet("/nodes/{node_id}{path}".format(node_id=self._node_id, path=path), *args, **kwargs)

    def post(self, path, *args, **kwargs):
        """
        POST on current server / project
        """

        return self.controllerHttpPost("/nodes/{node_id}{path}".format(node_id=self._node_id, path=path), *args, **kwargs)

    def start(self):
        """
        Starts this node instance.
        """

        if self.isStarted():
            log.debug("{} is already running".format(self.name()))
            return

        log.debug("{} is starting".format(self.name()))
        self.post("/start", self._startCallback, timeout=None, showProgress=False)

    def _startCallback(self, result, error=False, **kwargs):
        """
        Callback for start.

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            log.error("error while starting {}: {}".format(self.name(), result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])
        else:
            self._parseControllerResponse(result)

    def stop(self):
        """
        Stops this node instance.
        """

        if self.status() == Node.stopped:
            log.debug("{} is already stopped".format(self.name()))
            return

        log.debug("{} is stopping".format(self.name()))
        self.post("/stop", self._stopCallback, timeout=None, showProgress=False)

    def _stopCallback(self, result, error=False, **kwargs):
        """
        Callback for stop.

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            log.error("error while stopping {}: {}".format(self.name(), result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])
            # To avoid blocking the client we consider node as stopped if the node no longer exists
            # or the server doesn't answer
            if "status" not in result or result["status"] == 404:
                self.setStatus(Node.stopped)
        else:
            self._parseControllerResponse(result)

    def suspend(self):
        """
        Suspends this node.
        """

        if self.status() == Node.suspended:
            log.debug("{} is already suspended".format(self.name()))
            return

        log.debug("{} is being suspended".format(self.name()))
        self.post("/suspend", self._suspendCallback, timeout=None, showProgress=False)

    def _suspendCallback(self, result, error=False, **kwargs):
        """
        Callback for suspend.

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            log.error("error while suspending {}: {}".format(self.name(), result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])
        else:
            self._parseControllerResponse(result)

    def reload(self):
        """
        Reloads this node instance.
        """

        log.debug("{} is being reloaded".format(self.name()))
        self.post("/reload", self._reloadCallback, timeout=None, showProgress=False)

    def _reloadCallback(self, result, error=False, **kwargs):
        """
        Callback for reload.

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            log.error("error while reloading {}: {}".format(self.name(), result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])
        else:
            self._parseControllerResponse(result)

    def createNodeCallback(self, result):
        """
        Callback when the node has been created on the controller.

        :param result: server response
        """

        result = self._parseControllerResponse(result)
        self._createCallback(result)

        if self._loading:
            self.loaded_signal.emit()
        else:
            self.setInitialized(True)
            self.created_signal.emit(self.id())
            self._module.addNode(self)

    def _createCallback(self, result):
        """
        Create callback to be overloaded for custom handling by a node.
        """
        pass

    def update(self, new_settings, force=False):
        """
        Updates the settings for this node.

        :param new_settings: settings dictionary
        :param force: force this node to update
        """

        params = {}
        for name, value in new_settings.items():
            if name in self._settings:
                if self._settings[name] != value:
                    params[name] = value
            else:
                log.warning("'{}' setting is unknown".format(name))
        if params or force:
            self._updateOnController(params)

    def _prepareBodyForUpdate(self, params):
        """
        Prepares the body for update API call.

        :returns: body for update
        """

        assert self._node_id is not None

        # minimum required settings for update call
        body = {"properties": {},
                "node_type": self.URL_PREFIX,
                "node_id": self._node_id,
                "compute_id": self._compute.id()}

        # There are two kind of properties. The general properties common to all
        # nodes and the specific ones that need to be put in the properties field
        general_node_properties = ("name",
                                   "console",
                                   "console_type",
                                   "x",
                                   "y",
                                   "z",
                                   "locked",
                                   "symbol",
                                   "label",
                                   "port_name_format",
                                   "port_segment_size",
                                   "first_port_name",
                                   "console_auto_start")

        # No need to send this back to the server because these properties are read-only
        ignore_properties = ("console_host",
                             "symbol_url",
                             "width",
                             "height",
                             "node_id")

        for key, value in params.items():
            if key in general_node_properties:
                body[key] = value
            elif key in ignore_properties:
                pass
            else:
                body["properties"][key] = value

        return body

    def _updateOnController(self, params, timeout=60):
        """
        Update the node on the controller.
        """

        log.debug("{} is updating settings: {}".format(self.name(), params))
        body = self._prepareBodyForUpdate(params)
        self.controllerHttpPut("/nodes/{node_id}".format(node_id=self._node_id), self._updateOnControllerCallback, body=body, timeout=timeout, showProgress=False)

    def _updateOnControllerCallback(self, result, error=False, **kwargs):
        """
        Callback for update on the controller (for errors only)

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            self.server_error_signal.emit(self.id(), result["message"])
            return False

    def updateNodeCallback(self, result):
        """
        Callback when the node has been updated on the controller.

        :param result: server response (dict)
        """

        result = self._parseControllerResponse(result)
        self._updateCallback(result)
        self.updated_signal.emit()

    def _updateCallback(self, result):
        """
        Update callback to be overloaded for custom handling by a node.
        """

        pass

    def delete(self, skip_controller=False):
        """
        Deletes this node instance.

        :param skip_controller: True to not delete on the controller (often when already deleted on the server)
        """

        if not skip_controller:
            for link in self.links():
                link.setDeleting()
            self.controllerHttpDelete("/nodes/{node_id}".format(node_id=self._node_id), self._deleteCallback, showProgress=False)
        else:
            self.deleted_signal.emit()
            self._module.removeNode(self)

    def _deleteCallback(self, result, error=False, **kwargs):
        """
        Callback for delete.

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error:
            log.error("error while deleting {}: {}".format(self.name(), result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])

        # delete the node even if there is an error on server side
        self.deleted_signal.emit()
        self._module.removeNode(self)

    def duplicate(self, x, y, z):
        """
        Duplicates this node.
        """

        params = {"x": int(x),
                  "y": int(y),
                  "z": int(z)}

        self.post("/duplicate", self._duplicateCallback, body=params, timeout=None, showProgress=False)

    def _duplicateCallback(self, result, error=False, **kwargs):
        """
        Callback for duplicate (for errors only).

        :param result: server response (dict)
        :param error: indicates an error (boolean)
        """

        if error and "message" in result:
            log.error("Error while duplicating {}: {}".format(self.name(), result["message"]))

    def _parseControllerResponse(self, result):
        """
        Parse node object from controller response.

        :param result: server response (dict)
        """

        if "node_id" in result:
            self._node_id = result["node_id"]

        if "name" in result:
            self.setName(result["name"])

        if "command_line" in result:
            self._command_line = result["command_line"]

        if "node_directory" in result:
            self._node_directory = result["node_directory"]

        if "status" in result:
            if result["status"] == "started":
                self.setStatus(Node.started)
            elif result["status"] == "stopped":
                self.setStatus(Node.stopped)
            elif result["status"] == "suspended":
                self.setStatus(Node.suspended)

        if "ports" in result:
            self._updatePorts(result["ports"])

        if "properties" in result:
            for name, value in result["properties"].items():
                if name in self._settings and self._settings[name] != value:
                    log.debug("{} setting up and updating {} from '{}' to '{}'".format(self.name(), name, self._settings[name], value))
                    self._settings[name] = value

            result.update(result["properties"])
            del result["properties"]

        # Update common element of all nodes
        for key in ["x", "y", "z", "locked", "symbol", "label", "console_host", "console", "console_type", "console_auto_start", "custom_adapters", "first_port_name", "port_name_format", "port_segment_size"]:
            if key in result:
                self._settings[key] = result[key]

        return result

    def _updatePorts(self, ports):
        """
        Update the ports on this node.

        :param ports: array of Port objects
        """

        self._settings["ports"] = ports
        old_ports = self._ports.copy()
        self._ports = []
        for port in ports:
            new_port = None

            # Update port if it already exists
            for old_port in old_ports:
                if old_port.adapterNumber() == port["adapter_number"] and old_port.portNumber() == port["port_number"]:
                    new_port = old_port
                    old_port.setName(port["name"])
                    old_ports.remove(old_port)
                    break

            if new_port is None:
                if port["link_type"] == "serial":
                    new_port = SerialPort(port["name"])
                else:
                    new_port = EthernetPort(port["name"])
            new_port.setShortName(port["short_name"])
            new_port.setAdapterNumber(port["adapter_number"])
            new_port.setPortNumber(port["port_number"])
            new_port.setDataLinkTypes(port["data_link_types"])
            new_port.setStatus(self.status())
            new_port.setAdapterType(port.get("adapter_type"))
            new_port.setMacAddress(port.get("mac_address"))
            self._ports.append(new_port)

    def setGraphics(self, node_item):
        """
        Sync the remote object with the node_item
        """

        data = {"x": int(node_item.pos().x()),
                "y": int(node_item.pos().y()),
                "z": int(node_item.zValue()),
                "symbol": node_item.symbol(),
                "locked": node_item.locked()}

        if node_item.label() is not None:
            data["label"] = node_item.label().dump()

        # Only send the changes
        changed = False
        for key in data:
            if key not in self._settings or self._settings[key] != data[key]:
                changed = True

        if not changed:
            return

        self._updateOnController(data)

    def setPos(self, x, y, z=None):
        """
        Set the position for this node.

        :param x: X coordinate
        :param y: Y coordinate
        :param z: Z coordinate
        """

        self._settings["x"] = int(x)
        self._settings["y"] = int(y)
        if z is not None:
            self._settings["z"] = int(z)

    def consoleCommand(self, console_type=None):
        """
        Returns the console command line for this host

        :returns: console command line (string)
        """

        from .main_window import MainWindow
        general_settings = MainWindow.instance().settings()

        if not console_type:
            console_type = self.consoleType()
        if console_type:
            if console_type == "vnc":
                return general_settings["vnc_console_command"]
            elif console_type.startswith("spice"):
                return general_settings["spice_console_command"]
        return general_settings["telnet_console_command"]

    def consoleType(self):
        """
        Get the console type (serial, telnet or VNC)
        """

        console_type = "none"
        if "console_type" in self.settings():
            return self.settings()["console_type"]
        return console_type

    def consoleHost(self):
        """
        Returns the host to connect to the console.

        :returns: host (string)
        """

        host = self.settings()["console_host"]
        if host is None or host == "::" or host == "0.0.0.0" or host == "0:0:0:0:0:0:0:0":
            host = Controller.instance().host()
        return host

    def setStatus(self, status):
        """
        Overloaded setStatus() method for console auto start.
        """

        if self.status() == status:
            return
        super().setStatus(status)
        if status == self.started and "console_auto_start" in self.settings() and self.settings()["console_auto_start"]:
            # give the node some time to start before opening the console
            QtCore.QTimer.singleShot(1000, self.openConsole)

    def openConsole(self, command=None, aux=False):
        """
        Opens a console.

        :param command: console command line
        :param aux: indicates an auxiliary console
        """

        if command is None:
            if aux:
                command = self.consoleCommand(console_type="telnet")
            else:
                command = self.consoleCommand()

        console_type = "telnet"

        if aux:
            console_port = self.auxConsole()
            if console_port is None:
                raise ValueError("AUX console port not allocated for {}".format(self.name()))
            # AUX console is always telnet
            console_type = "telnet"
        else:
            console_port = self.console()
            if console_port is None:
                log.debug("No console port allocated for {}".format(self.name()))
                return
            if "console_type" in self.settings():
                console_type = self.consoleType()

        if console_type == "telnet":
            from .telnet_console import nodeTelnetConsole
            nodeTelnetConsole(self, console_port, command)
        elif console_type == "vnc":
            from .vnc_console import vncConsole
            vncConsole(self, console_port, command)
        elif console_type.startswith("spice"):
            from .spice_console import spiceConsole
            spiceConsole(self, console_port, command)
        elif console_type == "http" or console_type == "https":
            QtGui.QDesktopServices.openUrl(QtCore.QUrl("{console_type}://{host}:{port}{path}".format(console_type=console_type, host=self.consoleHost(), port=console_port, path=self.consoleHttpPath())))

    def bringToFront(self):
        """
        Bring the console window to front.
        """

        if self.status() == Node.started:
            console_command = self.consoleCommand()
            if console_command:
                process_name = console_command.split()[0]
                if bring_window_to_front_from_process_name(process_name, self.name()):
                    return True
                else:
                    log.debug("Could not find process name '' and window title '{}' to bring it to front".format(process_name, self.name()))

            if bring_window_to_front_from_title(self.name()):
                return True
            else:
                log.debug("Could not find window title '{}' to bring it to front".format(self.name()))
        return False

    def importFile(self, path, source_path):
        """
        Imports a file to this node.
        """

        self.post("/files/{path}".format(path=path), self._importFileCallback, body=pathlib.Path(source_path), timeout=None)

    def _importFileCallback(self, result, error=False, **kwargs):
        """
        Callback for import file.
        """

        if error:
            log.error("Error while importing file: {}".format(result["message"]))
            self.server_error_signal.emit(self.id(), result["message"])
            return False

    def exportFile(self, path, output_path):
        """
        Exports a file from this node.
        """

        self.get("/files/{path}".format(path=path), self._exportFileCallback, context={"path": output_path}, raw=True)

    def _exportFileCallback(self, result, error=False, raw_body=None, context={}, **kwargs):
        """
        Callback for export file.
        """

        if not error:
            try:
                with open(context["path"], "wb+") as f:
                    f.write(raw_body)
            except OSError as e:
                log.error("Cannot export file '{}': {}".format(context["path"], e))

    def exportConfigsToDirectory(self, directory):
        """
        Exports the configs to a directory.

        :param directory: destination directory path
        """

        if not self.configFiles():
            return False
        for file in self.configFiles():
            self.get("/files/{file}".format(file=file),
                     self._exportConfigsToDirectoryCallback,
                     context={"directory": directory, "file": file},
                     raw=True)
        return True

    def _exportConfigsToDirectoryCallback(self, result, error=False, raw_body=None, context={}, **kwargs):
        """
        Callback for exportConfigsToDirectory.

        :param result: server response
        :param error: indicates an error (boolean)
        """

        if error:
            # The file could be missing if there is no private-config for example.
            return

        export_directory = context["directory"]
        # we can have / in the case of Docker
        filename = normalize_filename(self.name()) + "_{}".format(context["file"].replace("/", "_"))
        config_path = os.path.join(export_directory, filename)
        try:
            with open(config_path, "wb") as f:
                log.debug("saving {} config to {}".format(self.name(), config_path))
                f.write(raw_body)
        except OSError as e:
            self.error_signal.emit(self.id(), "could not export config to {}: {}".format(config_path, e))

    def importConfigsFromDirectory(self, directory):
        """
        Imports configs from a directory.

        :param directory: source directory path
        """

        if not self.configFiles():
            return

        try:
            contents = os.listdir(directory)
        except OSError as e:
            self.error_signal.emit(self.id(), "Cannot list file in {}: {}".format(directory, str(e)))
            return

        for file in self.configFiles():
            # we can have / in the case of Docker
            filename = normalize_filename(self.name()) + "_{}".format(file.replace("/", "_"))
            if filename in contents:
                self.post("/files/{file}".format(file=file),
                          self._importConfigsFromDirectoryCallback,
                          pathlib.Path(os.path.join(directory, filename)))
            else:
                log.warning("{}: config file '{}' not found".format(self.name(), filename))

    def _importConfigsFromDirectoryCallback(self, result, error=False, **kwargs):
        """
        Callback for importConfigsFromDirectory.

        :param result: server response
        :param error: indicates an error (boolean)
        """

        if error and "message" in result:
            log.error("Error while import config: {}".format(result["message"]))

    @staticmethod
    def isValidRfc1123Hostname(hostname):
        """
        Validate a hostname according to RFC 1123

        Each element of the hostname must be from 1 to 63 characters long
        and the entire hostname, including the dots, can be at most 253
        characters long.  Valid characters for hostnames are ASCII
        letters from a to z, the digits from 0 to 9, and the hyphen (-).
        A hostname may not start with a hyphen.
        """

        if hostname[-1] == ".":
            hostname = hostname[:-1]  # strip exactly one dot from the right, if present

        if len(hostname) > 253:
            return False

        labels = hostname.split(".")

        # the TLD must be not all-numeric
        if re.match(r"[0-9]+$", labels[-1]):
            return False

        allowed = re.compile(r"(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$")
        return all(allowed.match(label) for label in labels)

    @staticmethod
    def onCloseOptions():
        """
        Returns the on close options.

        :returns: dict
        """

        options = {"Power off the VM": "power_off",
                   "Send the shutdown signal (ACPI)": "shutdown_signal",
                   "Save the VM state": "save_vm_state"}

        return options
