# Network configuration spoke classes
#
# Copyright (C) 2013  Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#
import gi
gi.require_version("NM", "1.0")
from gi.repository import NM

import socket

from pyanaconda import network
from pyanaconda.core.configuration.anaconda import conf
from pyanaconda.modules.common.constants.services import NETWORK
from pyanaconda.modules.common.structures.network import NetworkDeviceConfiguration
from pyanaconda.flags import flags
from pyanaconda.ui.categories.system import SystemCategory
from pyanaconda.ui.tui.spokes import NormalTUISpoke
from pyanaconda.ui.tui.tuiobject import Dialog, report_if_failed
from pyanaconda.ui.common import FirstbootSpokeMixIn
from pyanaconda.core.i18n import N_, _
from pyanaconda.core.regexes import IPV4_PATTERN_WITH_ANCHORS, IPV4_NETMASK_WITH_ANCHORS, IPV4_OR_DHCP_PATTERN_WITH_ANCHORS
from pyanaconda.core.constants import ANACONDA_ENVIRON
from pyanaconda.anaconda_loggers import get_module_logger

from simpleline.render.containers import ListColumnContainer
from simpleline.render.prompt import Prompt
from simpleline.render.screen import InputState
from simpleline.render.screen_handler import ScreenHandler
from simpleline.render.widgets import TextWidget, CheckboxWidget, EntryWidget

log = get_module_logger(__name__)

# This will be used in decorators in ConfigureDeviceSpoke.
# The decorators are processed before the class is created so you can have this as a variable there.
IP_ERROR_MSG = N_("Bad format of the IP address")
NETMASK_ERROR_MSG = N_("Bad format of the netmask")

__all__ = ["NetworkSpoke"]


# TODO: use our own datastore?
class WiredTUIConfigurationData():
    """Holds tui input configuration data of wired device."""
    def __init__(self):
        self.ip = "dhcp"
        self.netmask = ""
        self.gateway = ""
        self.ipv6 = "auto"
        self.ipv6gateway = ""
        self.nameserver = ""
        self.ipv6addrgenmode = NM.SettingIP6ConfigAddrGenMode.EUI64
        self.onboot = False

    def set_from_connection(self, connection):
        """Set the object from NM RemoteConnection.

        :param connection: connection to be used to set the object
        :type connection: NM.RemoteConnection
        """
        connection_uuid = connection.get_uuid()

        ip4_config = connection.get_setting_ip4_config()
        ip4_method = ip4_config.get_method()
        if ip4_method == NM.SETTING_IP4_CONFIG_METHOD_AUTO:
            self.ip = "dhcp"
        elif ip4_method == NM.SETTING_IP4_CONFIG_METHOD_MANUAL:
            if ip4_config.get_num_addresses() > 0:
                addr = ip4_config.get_address(0)
                self.ip = addr.get_address()
                self.netmask = network.prefix_to_netmask(addr.get_prefix())
            else:
                log.error("No ip4 address found for manual method in %s", connection_uuid)
        elif ip4_method == NM.SETTING_IP4_CONFIG_METHOD_DISABLED:
            self.ip = ""
        else:
            log.error("Unexpected ipv4 method %s found in connection %s", ip4_method, connection_uuid)
            self.ip = "dhcp"
        self.gateway = ip4_config.get_gateway() or ""

        ip6_config = connection.get_setting_ip6_config()
        self.ipv6addrgenmode = ip6_config.get_addr_gen_mode()
        ip6_method = ip6_config.get_method()
        if ip6_method == NM.SETTING_IP6_CONFIG_METHOD_AUTO:
            self.ipv6 = "auto"
        elif ip6_method == NM.SETTING_IP6_CONFIG_METHOD_IGNORE:
            self.ipv6 = "ignore"
        elif ip6_method == NM.SETTING_IP6_CONFIG_METHOD_DHCP:
            self.ipv6 = "dhcp"
        elif ip6_method == NM.SETTING_IP6_CONFIG_METHOD_MANUAL:
            if ip6_config.get_num_addresses() > 0:
                addr = ip6_config.get_address(0)
                self.ipv6 = "{}/{}".format(addr.get_address(), addr.get_prefix())
            else:
                log.error("No ip6 address found for manual method in %s", connection_uuid)
        else:
            log.error("Unexpected ipv6 method %s found in connection %s", ip6_method, connection_uuid)
            self.ipv6 = "auto"
        self.ipv6gateway = ip6_config.get_gateway() or ""

        nameservers = []
        for i in range(0, ip4_config.get_num_dns()):
            nameservers.append(ip4_config.get_dns(i))
        for i in range(0, ip6_config.get_num_dns()):
            nameservers.append(ip6_config.get_dns(i))
        self.nameserver = ",".join(nameservers)

        self.onboot = connection.get_setting_connection().get_autoconnect()

    def update_connection(self, connection):
        """Update NM RemoteConnection from the object.

        :param connection: connection to be updated from the object
        :type connection: NM.RemoteConnection
        """
        # ipv4 settings
        if self.ip == "dhcp":
            method4 = NM.SETTING_IP4_CONFIG_METHOD_AUTO
        elif self.ip:
            method4 = NM.SETTING_IP4_CONFIG_METHOD_MANUAL
        else:
            method4 = NM.SETTING_IP4_CONFIG_METHOD_DISABLED

        connection.remove_setting(NM.SettingIP4Config)
        s_ip4 = NM.SettingIP4Config.new()
        s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, method4)
        if method4 == NM.SETTING_IP4_CONFIG_METHOD_MANUAL:
            prefix4 = network.netmask_to_prefix(self.netmask)
            addr4 = NM.IPAddress.new(socket.AF_INET, self.ip, prefix4)
            s_ip4.add_address(addr4)
            if self.gateway:
                s_ip4.props.gateway = self.gateway
        connection.add_setting(s_ip4)

        # ipv6 settings
        if self.ipv6 == "ignore":
            method6 = NM.SETTING_IP6_CONFIG_METHOD_IGNORE
        elif not self.ipv6 or self.ipv6 == "auto":
            method6 = NM.SETTING_IP6_CONFIG_METHOD_AUTO
        elif self.ipv6 == "dhcp":
            method6 = NM.SETTING_IP6_CONFIG_METHOD_DHCP
        else:
            method6 = NM.SETTING_IP6_CONFIG_METHOD_MANUAL

        connection.remove_setting(NM.SettingIP6Config)
        s_ip6 = NM.SettingIP6Config.new()
        s_ip6.set_property(NM.SETTING_IP6_CONFIG_ADDR_GEN_MODE, self.ipv6addrgenmode)
        s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, method6)
        if method6 == NM.SETTING_IP6_CONFIG_METHOD_MANUAL:
            addr6, _slash, prefix6 = self.ipv6.partition("/")
            if prefix6:
                prefix6 = int(prefix6)
            else:
                prefix6 = 64
            addr6 = NM.IPAddress.new(socket.AF_INET6, addr6, prefix6)
            s_ip6.add_address(addr6)
            if self.ipv6gateway:
                s_ip6.props.gateway = self.ipv6gateway
        connection.add_setting(s_ip6)

        # nameservers
        if self.nameserver:
            for ns in [str.strip(i) for i in self.nameserver.split(",")]:
                if NM.utils_ipaddr_valid(socket.AF_INET6, ns):
                    s_ip6.add_dns(ns)
                elif NM.utils_ipaddr_valid(socket.AF_INET, ns):
                    s_ip4.add_dns(ns)
                else:
                    log.error("IP address %s is not valid", ns)

        s_con = connection.get_setting_connection()
        s_con.set_property(NM.SETTING_CONNECTION_AUTOCONNECT, self.onboot)

    def __str__(self):
        return "WiredTUIConfigurationData ip:{} netmask:{} gateway:{} ipv6:{} ipv6gateway:{} " \
            "nameserver:{} onboot:{} addr-gen-mode:{}".format(self.ip, self.netmask, self.gateway,
                                                              self.ipv6, self.ipv6gateway,
                                                              self.nameserver, self.onboot,
                                                              self.ipv6addrgenmode)


class NetworkSpoke(FirstbootSpokeMixIn, NormalTUISpoke):
    """ Spoke used to configure network settings.

       .. inheritance-diagram:: NetworkSpoke
          :parts: 3
    """
    category = SystemCategory
    configurable_device_types = [
        NM.DeviceType.ETHERNET,
        NM.DeviceType.INFINIBAND,
    ]

    @staticmethod
    def get_screen_id():
        """Return a unique id of this UI screen."""
        return "network-configuration"

    def __init__(self, data, storage, payload):
        NormalTUISpoke.__init__(self, data, storage, payload)
        self.title = N_("Network configuration")
        self._network_module = NETWORK.get_proxy()

        self.nm_client = network.get_nm_client()
        if not self.nm_client and conf.system.provides_system_bus:
            self.nm_client = NM.Client.new(None)

        self._container = None
        self.hostname = self._network_module.Hostname
        self.editable_configurations = []
        self.errors = []
        self._apply = False

    @classmethod
    def should_run(cls, environment, data):
        """Should the spoke run?"""
        if not FirstbootSpokeMixIn.should_run(environment, data):
            return False

        return conf.system.can_configure_network

    def initialize(self):
        self.initialize_start()
        NormalTUISpoke.initialize(self)
        self._update_editable_configurations()
        self._network_module.DeviceConfigurationChanged.connect(self._device_configurations_changed)
        self.initialize_done()

    def _device_configurations_changed(self, device_configurations):
        log.debug("device configurations changed: %s", device_configurations)
        self._update_editable_configurations()

    def _update_editable_configurations(self):
        device_configurations = NetworkDeviceConfiguration.from_structure_list(
            self._network_module.GetDeviceConfigurations()
        )
        self.editable_configurations = [dc for dc in device_configurations
                                        if dc.device_type in self.configurable_device_types]

    @property
    def completed(self):
        """ Check whether this spoke is complete or not."""
        # If we can't configure network, don't require it
        return (not conf.system.can_configure_network
                or self._network_module.GetActivatedInterfaces())

    @property
    def mandatory(self):
        # the network spoke should be mandatory only if it is running
        # during the installation and if the installation source requires network
        return ANACONDA_ENVIRON in flags.environs and self.payload.needs_network

    @property
    def status(self):
        """ Short msg telling what devices are active. """
        return network.status_message(self.nm_client)

    def _summary_text(self):
        """Devices cofiguration shown to user."""
        msg = ""
        activated_devs = self._network_module.GetActivatedInterfaces()
        for device_configuration in self.editable_configurations:
            name = device_configuration.device_name
            if name in activated_devs:
                msg += self._activated_device_msg(name)
            else:
                msg += _("Wired (%(interface_name)s) disconnected\n") \
                       % {"interface_name": name}
        return msg

    def _activated_device_msg(self, devname):
        msg = _("Wired (%(interface_name)s) connected\n") \
              % {"interface_name": devname}

        device = self.nm_client.get_device_by_iface(devname)
        if device:
            addr_str = dnss_str = gateway_str = netmask_str = ""
            ipv4config = device.get_ip4_config()
            if ipv4config:
                addresses = ipv4config.get_addresses()
                if addresses:
                    a0 = addresses[0]
                    addr_str = a0.get_address()
                    prefix = a0.get_prefix()
                    netmask_str = network.prefix_to_netmask(prefix)
                gateway_str = ipv4config.get_gateway() or ''
                dnss_str = ",".join(ipv4config.get_nameservers())
            msg += _(" IPv4 Address: %(addr)s Netmask: %(netmask)s Gateway: %(gateway)s\n") % \
                {"addr": addr_str, "netmask": netmask_str, "gateway": gateway_str}
            msg += _(" DNS: %s\n") % dnss_str

            ipv6config = device.get_ip6_config()
            if ipv6config:
                for address in ipv6config.get_addresses():
                    addr_str = address.get_address()
                    prefix = address.get_prefix()
                    # Do not display link-local addresses
                    if not addr_str.startswith("fe80:"):
                        msg += _(" IPv6 Address: %(addr)s/%(prefix)d\n") % \
                            {"addr": addr_str, "prefix": prefix}
        return msg

    def refresh(self, args=None):
        """ Refresh screen. """
        NormalTUISpoke.refresh(self, args)

        self._container = ListColumnContainer(1, columns_width=78, spacing=1)

        if not self.nm_client:
            self.window.add_with_separator(TextWidget(_("Network configuration is not available.")))
            return

        summary = self._summary_text()
        self.window.add_with_separator(TextWidget(summary))

        hostname = _("Host Name: %s\n") % self._network_module.Hostname
        self.window.add_with_separator(TextWidget(hostname))
        current_hostname = _("Current host name: %s\n") % self._network_module.GetCurrentHostname()
        self.window.add_with_separator(TextWidget(current_hostname))

        # if we have any errors, display them
        while len(self.errors) > 0:
            self.window.add_with_separator(TextWidget(self.errors.pop()))

        dialog = Dialog(_("Host Name"))
        self._container.add(TextWidget(_("Set host name")), callback=self._set_hostname_callback, data=dialog)

        for device_configuration in self.editable_configurations:
            iface = device_configuration.device_name
            text = (_("Configure device %s") % iface)
            self._container.add(TextWidget(text), callback=self._ensure_connection_and_configure,
                                data=iface)

        self.window.add_with_separator(self._container)

    def _set_hostname_callback(self, dialog):
        self.hostname = dialog.run()
        self.redraw()
        self.apply()

    def _ensure_connection_and_configure(self, iface):
        for device_configuration in self.editable_configurations:
            if device_configuration.device_name == iface:
                connection_uuid = device_configuration.connection_uuid
                if connection_uuid:
                    self._configure_connection(iface, connection_uuid)
                else:
                    device_type = self.nm_client.get_device_by_iface(iface).get_device_type()
                    connection = get_default_connection(iface, device_type)
                    connection_uuid = connection.get_uuid()
                    log.debug("adding default connection %s for %s", connection_uuid, iface)
                    data = (iface, connection_uuid)
                    self.nm_client.add_connection2(
                        connection.to_dbus(NM.ConnectionSerializationFlags.ALL),
                        (NM.SettingsAddConnection2Flags.TO_DISK |
                         NM.SettingsAddConnection2Flags.BLOCK_AUTOCONNECT),
                        None,
                        False,
                        None,
                        self._default_connection_added_cb,
                        data
                    )
                return
        log.error("device configuration for %s not found", iface)

    def _default_connection_added_cb(self, client, result, data):
        iface, connection_uuid = data
        try:
            _connection, result = client.add_connection2_finish(result)
        except Exception as e:  # pylint: disable=broad-except
            msg = "adding default connection {} from {} failed: {}".format(
                connection_uuid, iface, str(e))
            log.error(msg)
            self.errors.append(msg)
            self.redraw()
        else:
            log.debug("added default connection %s for %s: %s", connection_uuid, iface, result)
            self._configure_connection(iface, connection_uuid)

    def _configure_connection(self, iface, connection_uuid):
        connection = self.nm_client.get_connection_by_uuid(connection_uuid)

        new_spoke = ConfigureDeviceSpoke(self.data, self.storage, self.payload,
                                         self._network_module, iface, connection)
        ScreenHandler.push_screen_modal(new_spoke)

        if new_spoke.errors:
            self.errors.extend(new_spoke.errors)
            self.redraw()
            return

        if new_spoke.apply_configuration:
            self._apply = True

        self._network_module.LogConfigurationState(
            "Settings of {} updated in TUI.".format(iface)
        )

        self.redraw()
        self.apply()

    def input(self, args, key):
        """ Handle the input. """
        if self._container.process_user_input(key):
            return InputState.PROCESSED
        else:
            return super().input(args, key)

    def apply(self):
        """Apply all of our settings."""
        # Inform network module that device configurations might have been changed
        # and we want to generate kickstart from device configurations
        # (persistent NM / config files configuration), instead of using original kickstart.
        self._network_module.NetworkDeviceConfigurationChanged()

        (valid, error) = network.is_valid_hostname(self.hostname, local=True)
        if not self.hostname or valid:
            self._network_module.Hostname = self.hostname
        else:
            self.errors.append(_("Host name is not valid: %s") % error)
            self.hostname = self._network_module.Hostname

        if self._apply:
            self._apply = False
            if ANACONDA_ENVIRON in flags.environs:
                from pyanaconda.payload.manager import payloadMgr
                payloadMgr.restart_thread(self.payload, try_media=False)


class ConfigureDeviceSpoke(NormalTUISpoke):
    """ Spoke to set various configuration options for net devices. """
    category = "network"

    def __init__(self, data, storage, payload, network_module, iface, connection):
        super().__init__(data, storage, payload)
        self.title = N_("Device configuration")

        self._network_module = network_module
        self._container = None
        self._connection = connection
        self._iface = iface
        self._connection_uuid = connection.get_uuid()
        self.errors = []
        self.apply_configuration = False

        self._data = WiredTUIConfigurationData()
        self._data.set_from_connection(self._connection)

        log.debug("Configure iface %s: connection %s -> %s", self._iface, self._connection_uuid,
                  self._data)

    def refresh(self, args=None):
        """ Refresh window. """
        super().refresh(args)

        self._container = ListColumnContainer(1)

        dialog = Dialog(title=(_('IPv4 address or %s for DHCP') % '"dhcp"'),
                        conditions=[self._check_ipv4_or_dhcp])
        self._container.add(EntryWidget(dialog.title, self._data.ip), self._set_ipv4_or_dhcp, dialog)

        dialog = Dialog(title=_("IPv4 netmask"), conditions=[self._check_netmask])
        self._container.add(EntryWidget(dialog.title, self._data.netmask), self._set_netmask, dialog)

        dialog = Dialog(title=_("IPv4 gateway"), conditions=[self._check_ipv4])
        self._container.add(EntryWidget(dialog.title, self._data.gateway), self._set_ipv4_gateway, dialog)

        msg = (_('IPv6 address[/prefix] or %(auto)s for automatic, %(dhcp)s for DHCP, '
                 '%(ignore)s to turn off')
               % {"auto": '"auto"', "dhcp": '"dhcp"', "ignore": '"ignore"'})
        dialog = Dialog(title=msg, conditions=[self._check_ipv6_config])
        self._container.add(EntryWidget(dialog.title, self._data.ipv6), self._set_ipv6, dialog)

        dialog = Dialog(title=_("IPv6 default gateway"), conditions=[self._check_ipv6])
        self._container.add(EntryWidget(dialog.title, self._data.ipv6gateway), self._set_ipv6_gateway, dialog)

        dialog = Dialog(title=_("Nameservers (comma separated)"), conditions=[self._check_nameservers])
        self._container.add(EntryWidget(dialog.title, self._data.nameserver), self._set_nameservers, dialog)

        msg = _("Connect automatically after reboot")
        w = CheckboxWidget(title=msg, completed=self._data.onboot)
        self._container.add(w, self._set_onboot_handler)

        msg = _("Apply configuration in installer")
        w = CheckboxWidget(title=msg, completed=self.apply_configuration)
        self._container.add(w, self._set_apply_handler)

        self.window.add_with_separator(self._container)

        message = _("Configuring device %s.") % self._iface
        self.window.add_with_separator(TextWidget(message))

    @report_if_failed(message=IP_ERROR_MSG)
    def _check_ipv4_or_dhcp(self, user_input, report_func):
        return IPV4_OR_DHCP_PATTERN_WITH_ANCHORS.match(user_input) is not None

    @report_if_failed(message=IP_ERROR_MSG)
    def _check_ipv4(self, user_input, report_func):
        return IPV4_PATTERN_WITH_ANCHORS.match(user_input) is not None

    @report_if_failed(message=NETMASK_ERROR_MSG)
    def _check_netmask(self, user_input, report_func):
        return IPV4_NETMASK_WITH_ANCHORS.match(user_input) is not None

    @report_if_failed(message=IP_ERROR_MSG)
    def _check_ipv6(self, user_input, report_func):
        return network.check_ip_address(user_input, version=6)

    @report_if_failed(message=IP_ERROR_MSG)
    def _check_ipv6_config(self, user_input, report_func):
        if user_input in ["auto", "dhcp", "ignore"]:
            return True
        addr, _slash, prefix = user_input.partition("/")
        if prefix:
            try:
                if not 1 <= int(prefix) <= 128:
                    return False
            except ValueError:
                return False
        return network.check_ip_address(addr, version=6)

    @report_if_failed(message=IP_ERROR_MSG)
    def _check_nameservers(self, user_input, report_func):
        if user_input.strip():
            addresses = [str.strip(i) for i in user_input.split(",")]
            for ip in addresses:
                if not network.check_ip_address(ip):
                    return False
        return True

    def _set_ipv4_or_dhcp(self, dialog):
        self._data.ip = dialog.run()

    def _set_netmask(self, dialog):
        self._data.netmask = dialog.run()

    def _set_ipv4_gateway(self, dialog):
        self._data.gateway = dialog.run()

    def _set_ipv6(self, dialog):
        self._data.ipv6 = dialog.run()

    def _set_ipv6_gateway(self, dialog):
        self._data.ipv6gateway = dialog.run()

    def _set_nameservers(self, dialog):
        self._data.nameserver = dialog.run()

    def _set_apply_handler(self, args):
        self.apply_configuration = not self.apply_configuration

    def _set_onboot_handler(self, args):
        self._data.onboot = not self._data.onboot

    def input(self, args, key):
        if self._container.process_user_input(key):
            return InputState.PROCESSED_AND_REDRAW
        else:
            if key.lower() == Prompt.CONTINUE:
                if self._data.ip != "dhcp" and not self._data.netmask:
                    self.errors.append(_("Configuration not saved: netmask missing in static configuration"))
                else:
                    self.apply()
                return InputState.PROCESSED_AND_CLOSE
            else:
                return super().input(args, key)

    @property
    def indirect(self):
        return True

    def apply(self):
        """Apply changes to NM connection."""
        log.debug("updating connection %s:\n%s", self._connection_uuid,
                  self._connection.to_dbus(NM.ConnectionSerializationFlags.ALL))

        updated_connection = NM.SimpleConnection.new_clone(self._connection)
        self._data.update_connection(updated_connection)

        # Commit the changes
        self._connection.update2(
            updated_connection.to_dbus(NM.ConnectionSerializationFlags.ALL),
            NM.SettingsUpdate2Flags.TO_DISK | NM.SettingsUpdate2Flags.BLOCK_AUTOCONNECT,
            None,
            None,
            self._connection_updated_cb,
            self._connection_uuid
        )

    def _connection_updated_cb(self, connection, result, connection_uuid):
        connection.update2_finish(result)
        log.debug("updated connection %s:\n%s", connection_uuid,
                  connection.to_dbus(NM.ConnectionSerializationFlags.ALL))
        if self.apply_configuration:
            nm_client = network.get_nm_client()
            device = nm_client.get_device_by_iface(self._iface)
            log.debug("activating connection %s with device %s",
                      connection_uuid, self._iface)
            nm_client.activate_connection_async(connection, device, None, None)


def get_default_connection(iface, device_type):
    """Get default connection to be edited by the UI."""
    connection = NM.SimpleConnection.new()
    s_con = NM.SettingConnection.new()
    s_con.props.uuid = NM.utils_uuid_generate()
    s_con.props.autoconnect = True
    s_con.props.id = iface
    s_con.props.interface_name = iface
    if device_type == NM.DeviceType.ETHERNET:
        s_con.props.type = "802-3-ethernet"
        s_wired = NM.SettingWired.new()
        connection.add_setting(s_wired)
    elif device_type == NM.DeviceType.INFINIBAND:
        s_con.props.type = "infiniband"
        s_ib = NM.SettingInfiniband.new()
        s_ib.props.transport_mode = "datagram"
        connection.add_setting(s_ib)
    connection.add_setting(s_con)
    return connection
