#!/usr/bin/env python3
# -*- python -*-
#
#  File: fuss-server-config
#
#  Copyright (C) 2007-2016 Christopher R. Gabriel <cgabriel@truelite.it>,
#                          Elena Grandi <elena@truelite.it>,
#                          Progetto Fuss <info@fuss.bz.it>
#
#  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 2 of the License, or
#  (at your option) any later version.

import argparse
import logging
import os
import re
import shutil
import socket
import sys

from gettext import gettext as _

import apt
import netaddr
import netifaces
import ruamel.yaml
import urwid

ansible_data_path = "/usr/share/fuss-server/"
conf_file = '/etc/fuss-server/fuss-server.yaml'
clean_config_file = '/usr/share/doc/fuss-server/examples/fuss-server.yaml.example'  # noqa: E501

try:
    VERSION = apt.cache.Cache().get('fuss-server').installed.version
except AttributeError:
    VERSION = 'dev'


class ConfigFrame(urwid.Frame):
    def __init__(self, *args, ui, **kw):
        super().__init__(*args, **kw)
        self.ui = ui

    def keypress(self, size: tuple[int, int], key: str) -> str | None:
        key = super().keypress(size, key)
        match key:
            case "esc":
                # completely abort running fuss-server
                sys.exit(1)
            case "f10":
                # save all data and leave the UI
                if self.validate():
                    for n, w in self.ui.widgets.items():
                        self.ui.config.data[n] = w.get_edit_text()
                    raise urwid.ExitMainLoop
            case "f11":
                # save all data and leave the UI
                if self.validate():
                    for n, w in self.ui.widgets.items():
                        self.ui.config.data[n] = w.get_edit_text()
                    raise urwid.ExitMainLoop
        return key

    def validate(self):
        valid = []
        # first validate each field
        for w in self.ui.widgets.values():
            valid.append(w.validate())
        # and then perform cross validation
        valid.append(self._crosscheck_network())
        valid.append(self._crosscheck_guest())
        valid.append(self._crosscheck_devices())

        return all(valid)

    def _crosscheck_network(self):
        wrong = []
        localnet_w = self.ui.widgets['localnet']
        internal_ifaces_w = self.ui.widgets['internal_ifaces']
        dhcp_range_w = self.ui.widgets['dhcp_range']
        all_networks = {
            netaddr.IPNetwork(x['addr']+'/'+x['netmask']): iface
            for iface in netifaces.interfaces()
            for x in netifaces.ifaddresses(iface).get(
                netifaces.AF_INET,
                [])
            }
        localnet = netaddr.IPNetwork(localnet_w.get_edit_text())
        internal_ifaces_list = internal_ifaces_w.get_edit_text()
        if not internal_ifaces_list:
            internal_ifaces_w.mark_invalid(
                "No internal interfaces are configured"
            )
            wrong = ["internal_ifaces"]
        try:
            if all_networks[localnet] not in internal_ifaces_list:
                internal_ifaces_w.mark_invalid()
                localnet_w.mark_invalid(_(
                    "The value for local network {localnet} is not " +
                    "configured on any local interface ({ifaces})"
                    ).format(
                        localnet=str(localnet),
                        ifaces=str(internal_ifaces_list)
                        )
                )
                wrong = ["internal_ifaces", "localnet"]
        except KeyError:
            localnet_w.mark_invalid(
                "No interface found for localnet {}".format(
                    str(localnet)
                )
            )
            wrong = ["localnet"]
        try:
            range_addrs = [
                netaddr.IPAddress(ip)
                for ip in dhcp_range_w.get_edit_text().split()
            ]
        except ValueError:
            dhcp_range_w.mark_invalid(_(
                "The DHCP range should list valid IP addresses"
            ))
            wrong.append("dhcp_range")
        for addr in range_addrs:
            if addr not in localnet:
                dhcp_range_w.mark_invalid(
                    "The DHCP range should be part of the LAN address"
                )
                wrong.append("dhcp_range")
                break
        if range_addrs[1] == localnet[-1]:
                dhcp_range_w.mark_invalid(
                    "The DHCP range should not include the broadcast address"
                )
                wrong.append("dhcp_range")
        return not bool(wrong)

    def _crosscheck_guest(self):
        guest_iface_w = self.ui.widgets['guest_iface']
        guest_network_w = self.ui.widgets['guest_network']
        widgets = (guest_iface_w, guest_network_w)
        filled = [bool(w.get_edit_text()) for w in widgets]
        if self.ui.config.wifi_guest:
            # if the guest wifi is going to be configured, all related
            # fields should be set
            if not all(filled):
                for i in range(2):
                    if not filled[i]:
                        widgets[i].mark_invalid(_(
                            "Guest network configuration is required"
                        ))
                return False
        else:
            # if the guest wifi should not be configured, all related
            # fields should be empty
            if any(filled):
                for i in range(2):
                    if filled[i]:
                        widgets[i].mark_invalid(_(
                            "Guest network configuration "
                            "should not be present"
                        ))
                return False
            else:
                # and if they are, we are done with the checks
                return True

        internal_ifaces_w = self.ui.widgets['internal_ifaces']
        external_ifaces_w = self.ui.widgets['external_ifaces']
        guest_iface = guest_iface_w.get_edit_text()
        internal_ifaces = internal_ifaces_w.get_edit_text()
        external_ifaces = external_ifaces_w.get_edit_text()
        if guest_iface in internal_ifaces:
            guest_iface_w.mark_invalid(
                _("Guest interface cannot be the same as a LAN interface")
            )
            return False
        if guest_iface in external_ifaces:
            guest_iface_w.mark_invalid(
                _("Guest interface cannot be the same as a WAN interface")
            )
            return False
        if 'tun' in guest_iface_w.get_edit_text():
            guest_iface_w.mark_invalid(
                _("Guest interface cannot be a tunnel interface")
            )
            return False

# TODO: fare controllo solo sulle "altre" interfacce (da internal_ifaces)
#        ip_route = subprocess.check_output(['ip', 'route'])
#        hotspot_net = netaddr.IPNetwork(self.data['hotspot_network'])
#        hs_net_s = str(hotspot_net.network).encode('utf-8')
#
#        for line in ip_route.split(b'\n'):
#            if line.strip().startswith(hs_net_s) and b'tun' not in line:
#                logging.warning((
#                    "Network {} already used\n" +
#                    "Please choose another one"
#                    ).format(str(hotspot_net.network)))
#                return ['hotspot_network']

        return True

    def _crosscheck_devices(self):
        devices_iface_w = self.ui.widgets['devices_iface']
        devices_network_w = self.ui.widgets['devices_network']
        widgets = (devices_iface_w, devices_network_w)
        filled = [bool(w.get_edit_text()) for w in widgets]
        if self.ui.config.wifi_devices:
            # if the devices network is going to be configured, all
            # related fields should be set
            if not all(filled):
                for i in range(2):
                    if not filled[i]:
                        widgets[i].mark_invalid(_(
                            "Devices network configuration is required"
                        ))
                return False
        else:
            # if the devices network should not be configured, all
            # related fields should be empty
            if any(filled):
                for i in range(2):
                    if filled[i]:
                        widgets[i].mark_invalid(_(
                            "Devices network configuration "
                            "should not be present"
                        ))
                return False
            else:
                # and if they are, we are done with the checks
                return True

        internal_ifaces_w = self.ui.widgets['internal_ifaces']
        external_ifaces_w = self.ui.widgets['external_ifaces']
        devices_iface = devices_iface_w.get_edit_text()
        internal_ifaces = internal_ifaces_w.get_edit_text()
        external_ifaces = external_ifaces_w.get_edit_text()
        if devices_iface in internal_ifaces:
            devices_iface_w.mark_invalid(
                _("Devices interface cannot be the same as a LAN interface")
            )
            return False
        if devices_iface in external_ifaces:
            devices_iface_w.mark_invalid(
                _("Devices interface cannot be the same as a WAN interface")
            )
            return False
        if 'tun' in devices_iface_w.get_edit_text():
            devices_iface_w.mark_invalid(
                _("Devices interface cannot be a tunnel interface")
            )
            return False

        return True


class ConfigEdit(urwid.PopUpLauncher):
    def __init__(self, *args, name, data, ui, edit=None, **kw):
        kw["allow_tab"] = kw.get("allow_tab", False)
        kw["multiline"] = kw.get("multiline", False)
        self.name = name
        self.data = data
        self.ui = ui
        if edit:
            self.edit = edit
        else:
            self.edit = urwid.Edit(("bold", data["label"] + ": "), *args, **kw)
        super().__init__(self.edit)

    def create_pop_up(self):
        pop_up = PopUpHelp(data=self.data)
        urwid.connect_signal(
            pop_up, "close", lambda button: self.close_pop_up()
        )
        return pop_up

    def get_pop_up_parameters(self):
        return {"left": 0, "top": 1, "overlay_width": 64, "overlay_height": 8}

    def keypress(self, size: tuple[int, int], key: str) -> str | None:
        key = super().keypress(size, key)
        match key:
            case "enter":
                if self.validate():
                    return "down"
            case "tab":
                if self.validate():
                    return "down"
            case "f1":
                self.open_pop_up()
            case "f2":
                self.open_pop_up()
            case "up":
                self.validate()
            case "down":
                self.validate()
        return key

    def get_edit_text(self, *args, **kw):
        return self.edit.get_edit_text(*args, **kw)

    def mark_invalid(self, text=None):
        if not self.edit.caption.startswith("(*) "):
            self.edit.set_caption(("bold", "(*) " + self.edit.caption))
        if text:
            old_help = self.data["help"]
            self.data["help"] = text
            self.open_pop_up()
            self.data["help"] = old_help

    def mark_valid(self):
        if self.edit.caption.startswith("(*) "):
            self.edit.set_caption(("bold", self.edit.caption[4:]))

    def validate(self):
        if not self.get_edit_text():
            self.mark_invalid(_("Mandatory field should not be empty."))
            return False
        else:
            self.mark_valid()
            return True


class ConfigListEdit(ConfigEdit):
    def __init__(self, *args, edit_text, **kw):
        super().__init__(*args, edit_text=", ".join(edit_text), **kw)

    def get_edit_text(self, *args, **kw):
        text = super().get_edit_text(*args, **kw)
        return [s.strip() for s in text.split(",")]


class LocalnetEdit(ConfigEdit):
    def validate(self):
        """
        Localnet should be a valid address in CIDR format
        """
        value = self.get_edit_text()
        if not value:
            self.mark_invalid(_("Localnet field should not be empty"))
            return False
        if not len(value.split('/')) == 2:
            self.mark_invalid(_(
                "Localnet should be a valid address in CIDR format"
            ))
            return False
        try:
            netaddr.IPNetwork(value)
        except netaddr.AddrFormatError:
            self.mark_invalid(_(
                "Localnet should be a valid address in CIDR format"
            ))
            return False
        self.mark_valid()
        return True


class DomainEdit(ConfigEdit):
    def validate(self):
        """
        domain should be made up of two alphanumeric names separated by
        one dot.
        The TLD .local isn't allowed, because it's reserved to mDNS
        """
        value = self.get_edit_text()
        if not value:
            self.mark_invalid(_(
                "Domain should not be empty"
            ))
            return False
        allowed = re.compile(r"^[\w]+\.[\w]+$")
        if not allowed.match(value):
            self.mark_invalid(
                "domain should be made up of two "
                "alphanumeric names separated by one dot."
            )
            return False
        if value.endswith('.local'):
            self.mark_invalid(text=".local domains are not allowed")
            return False
        self.mark_valid()
        return True


class PassEdit(ConfigEdit):
    def validate(self):
        r"""
        pass should not contain any of &, \, /, $ chars, nor be composed
        of just numbers
        """
        value = self.get_edit_text()
        if not value:
            self.mark_invalid(_(
                "Pass should not be empty"
            ))
            return False
        try:
            int(value)
        except ValueError:
            # if we can't get a number out of the password everything is
            # fine
            pass
        else:
            self.mark_invalid("password must not be composed by just numbers")
            return False
        # add more forbidden char if neeeded
        forbiddenchars = set(r'$\/&')
        if any((c in forbiddenchars) for c in value):
            self.mark_invalid("password must not contain &, \\, /, or $")
            return False
        else:
            self.mark_valid()
            return True


class GeoplaceEdit(ConfigEdit):
    def validate(self):
        self.mark_valid()
        return True


class FussZoneSelector(urwid.Pile):
    def __init__(self, *args, ui, parent, **kw):
        self.ui = ui
        self.parent = parent
        yaml = ruamel.yaml.YAML()
        try:
            with open("fuss_zones.yaml") as fp:
                fuss_zones = yaml.load(fp)
        except FileNotFoundError:
            with open("/usr/share/fuss-server/fuss_zones.yaml") as fp:
                fuss_zones = yaml.load(fp)
        choices = [
            urwid.Text(self.parent.data["help"]),
            urwid.Divider(),
        ]
        for t in fuss_zones["fuss_zones"].keys():
            b = urwid.Button(t)
            urwid.connect_signal(b, "click", self.zone_selected)
            choices.append(b)
        b = urwid.Button("")
        urwid.connect_signal(b, "click", self.zone_selected)
        choices.append(b)
        super().__init__(choices)

    def keypress(self, size: tuple[int, int], key: str) -> str | None:
        key = super().keypress(size, key)
        match key:
            case "esc":
                self.ui.placeholder.original_widget = self.ui.main_screen
                return None
        return key

    def zone_selected(self, button, user_data=None):
        self.parent.edit.set_edit_text(button.get_label())
        self.ui.placeholder.original_widget = self.ui.main_screen
        self.parent.keypress((0, 0), "tab")


class FUSSZoneEdit(ConfigEdit):
    def keypress(self, size: tuple[int, int], key: str) -> str | None:
        # we need to act on the "enter" key before the parent class
        # turns it into a "down"
        match key:
            case "enter":
                self.open_zone_selector()
        key = super().keypress(size, key)
        return key

    def open_zone_selector(self):
        w = urwid.Padding(
            FussZoneSelector(ui=self.ui, parent=self),
            ('fixed left', 2), ('fixed right', 2)
        )
        w = urwid.LineBox(w)
        w = urwid.Filler(w, ('fixed top', 2))
        self.ui.placeholder.original_widget = w

    def get_edit_text(self, *args, **kw):
        return self.edit.edit_text


class WorkgroupEdit(ConfigEdit):
    def validate(self):
        """
        workgroup should be made of alphanumeric
        """
        value = self.get_edit_text()
        if not value:
            self.mark_invalid(_(
                "Workgroup should not be empty"
            ))
            return False
        allowed = re.compile(r"^[\w]+$")
        if not allowed.match(value):
            self.mark_invalid("Domain must contains only alphanumeric")
            return False
        self.mark_valid()
        return True


class DHCPRangeEdit(ConfigEdit):
    def validate(self):
        """
        dhcp_range should be made of valid ips
        """
        value = self.get_edit_text()
        if not value:
            self.mark_invalid(_(
                "dhcp range should not be empty"
            ))
            return False
        ips = value.split(' ')
        if len(ips) != 2:
            self.mark_invalid(
                "The DHCP range should be made of two IP addresses "
                "separated by a space."
            )
            return False
        for i, ip in enumerate(ips):
            try:
                ips[i] = netaddr.IPAddress(ip)
            except (netaddr.AddrFormatError, ValueError):
                self.mark_invalid(
                    "The addresses for the DHCP Range should be valid "
                    "IP addresses"
                )
                return False
        if not ips[0] < ips[1]:
            self.mark_invalid(
                "The first address should be lower than the second one."
            )
            return False
        self.mark_valid()
        return True


class ExternalIfacesEdit(ConfigListEdit):
    def validate(self):
        value = self.get_edit_text()
        if not isinstance(value, list):
            self.mark_invalid(_(
                "External Interfaces should not be empty"
            ))
            return False
        for iface in value:
            if iface not in netifaces.interfaces():
                self.mark_invalid(
                    "Interface {} is not available".format(iface)
                )
                return False
        self.mark_valid()
        return True


class InternalIfacesEdit(ExternalIfacesEdit):
    pass


class GuestIfaceEdit(ConfigEdit):
    def validate(self):
        value = self.get_edit_text()
        if not value:
            # we don't have the value of wifi_guest here, so we're going to
            # check whether this field should be filled or empty in the
            # crosscheck validation.
            self.mark_valid()
            return True
        if value not in netifaces.interfaces():
            self.mark_invalid("Interface {} is not available".format(
                value
            ))
            return False
        return True


class GuestNetworkEdit(ConfigEdit):
    def validate(self):
        """
        Guest network should be a valid address in CIDR format
        """
        value = self.get_edit_text()
        if not value:
            # we don't have the value of wifi_guest here, so we're going
            # to check whether this field should be filled or empty in
            # the crosscheck validation.
            self.mark_valid()
            return True
        try:
            netaddr.IPNetwork(value)
        except netaddr.AddrFormatError:
            self.mark_invalid(_(
                "Guest network should be a valid address in CIDR format"
            ))
            return False
        return True


class DevicesIfaceEdit(ConfigEdit):
    def validate(self):
        value = self.get_edit_text()
        if not value:
            # we don't have the value of wifi_guest here, so we're going
            # to check whether this field should be filled or empty in
            # the crosscheck validation.
            self.mark_valid()
            return True
        if value not in netifaces.interfaces():
            self.mark_invalid("Interface {} is not available".format(
                value
            ))
            return False
        return True


class DevicesNetworkEdit(ConfigEdit):
    def validate(self):
        """
        Devices network should be a valid address in CIDR format
        """
        value = self.get_edit_text()
        if not value:
            # we don't have the value of wifi_devices here, so we're
            # going to check whether this field should be filled or
            # empty in the crosscheck validation.
            self.mark_valid()
            return True
        try:
            netaddr.IPNetwork(value)
        except netaddr.AddrFormatError:
            self.mark_invalid(_(
                "Devices network should be a valid address in CIDR format"
            ))
            return False
        return True


class PopUpHelp(urwid.WidgetWrap):
    signals = ["close"]

    def __init__(self, *args, data, **kw):
        self.data = data
        w = urwid.Padding(
            urwid.Text(data["help"]),
            ('fixed left', 2), ('fixed right', 2)
        )
        w = urwid.Filler(w, ('fixed top', 2))
        w = urwid.Frame(
            w,
            footer=urwid.Text(
                "ESC to close"
            ),
        )
        w = urwid.LineBox(w)
        super().__init__(w)

    def keypress(self, size: tuple[int, int], key: str) -> str | None:
        key = super().keypress(size, key)
        match key:
            case "esc":
                self._emit("close")
            case "enter":
                self._emit("close")
        return key


class UI:
    known = {
        "localnet": {
            "label": _("Local network address"),
            "help": _("The format is netaddr/cidr, ex. 192.168.1.0/24"),
            "widget": LocalnetEdit,
        },
        "domain": {
            "label": _("Domain name"),
            "help": _("The domain for this network, ex.  'institute.lan'"),
            "widget": DomainEdit,
        },
        "pass": {
            "label": _("Master password"),
            "help": _("The master password for this server"),
            "widget": PassEdit,
        },
        "fuss_zone": {
            "label": _("Fuss Zone"),
            "help": _(
                "Select a FUSS-Zone (it can be changed afterwards). \n"
                "Please be aware that by selecting one of the preset "
                "values technical data from the clients may be collected "
                "and sent to a central server.\n"
                "More details are available at "
                "https://fuss-tech-guide.readthedocs.io/it/latest/server/fuss_zone.html "
            ),
            "widget": FUSSZoneEdit,
        },
        "workgroup": {
            "label": _("Windows Workgroup"),
            "help": _(
                "The Windows WorkGroup for this network, ex. 'institute'"
            ),
            "widget": WorkgroupEdit,
        },
        "dhcp_range": {
            "label": _("DHCP Server Range"),
            "help": _(
                "The IP range of address given by the DHCP Server, "
                "ex. '192.168.1.10 192.168.1.100'"
            ),
            "widget": DHCPRangeEdit,
        },
        "external_ifaces": {
            "label": _("WAN Interface"),
            "help": _("The WAN interface(s) of the server, ex. 'eth0'"),
            "widget": ExternalIfacesEdit,
        },
        "internal_ifaces": {
            "label": _("LAN Interfaces"),
            "help": _("The LAN interface(s) of the server, ex. 'eth1, eth2'"),
            "widget": InternalIfacesEdit,
        },
        "guest_iface": {
            "label": _("Guest network Interface"),
            "help": _(
                "The Guest network interface of the server, ex.  'eth3'"
            ),
            "widget": GuestIfaceEdit,
        },
        "guest_network": {
            "label": _("Guest Network (CIDR)"),
            "help": _("Guest network of the server, ex.  '10.1.0.0/24'"),
            "widget": GuestNetworkEdit,
        },
        "devices_iface": {
            "label": _("Devices network Interface"),
            "help": _(
                "The Devices network interface of the server, ex.  'eth4'"
            ),
            "widget": DevicesIfaceEdit,
        },
        "devices_network": {
            "label": _("Devices Network (CIDR)"),
            "help": _("The Devices network of the server, ex.  '10.4.0.0/24'"),
            "widget": DevicesNetworkEdit,
        },
    }

    def __init__(self, config):
        self.config = config

        self.widgets = {}
        for name, data in self.known.items():
            if self.config.data[name] is None:
                edit_text = ""
            else:
                edit_text = self.config.data[name]
            self.widgets[name] = data["widget"](
                edit_text=edit_text,
                name=name,
                data=data,
                ui=self,
            )

        urwid.set_encoding("utf8")
        self.main = urwid.Pile(self.widgets.values())
        w = urwid.Padding(self.main, ('fixed left', 2), ('fixed right', 2))
        self.main_screen = urwid.Filler(w, ('fixed top', 2))
        # w = urwid.Scrollable(w)
        self.placeholder = urwid.WidgetPlaceholder(self.main_screen)
        self.frame = ConfigFrame(
            self.placeholder,
            header=urwid.Text(
                ("bold", "FUSS Server Configuration"),
                align="center"
            ),
            footer=urwid.Text(
                "F1 help - enter validate - F10 save and continue - ESC abort"
                "\n(*) invalid fields"
            ),
            ui=self,
        )

    def run(self):
        w = urwid.LineBox(self.frame)
        self.loop = urwid.MainLoop(
            w,
            [
                ("bold", "white, bold", "black"),
            ],
            handle_mouse=False,
            pop_ups=True
        )
        self.loop.run()


class Configuration:
    def __init__(
            self,
            c_file=conf_file,
            reconf_all=False,
            wifi_guest=False,
            wifi_devices=False,
    ):
        self.c_file = c_file
        self.reconf_all = reconf_all
        self.wifi_guest = wifi_guest
        self.wifi_devices = wifi_devices

    def _list_has_data(self, li):
        if not li:
            return False
        return any(li)

    def load_guessed_data(self):
        """
        Try to guess missing data from the OS state.
        """
        # if we set some value, we also set self.reconf_all, so that we
        # are sure that the self-detected values are shown to the user
        if not self.data["domain"]:
            self.data["domain"] = socket.getfqdn().partition('.')[-1]
            self.reconf_all = True
        if not self.data["workgroup"]:
            self.data["workgroup"] = self.data["domain"].partition('.')[0]
            self.reconf_all = True
        interfaces = netifaces.interfaces()
        try:
            interfaces.remove("lo")
        except ValueError:
            pass
        if not self._list_has_data(self.data["external_ifaces"]):
            self.data["external_ifaces"] = [interfaces[0]]
            self.reconf_all = True
        for iface in self.data["external_ifaces"]:
            try:
                interfaces.remove(iface)
            except ValueError:
                pass
        if not self._list_has_data(self.data["guest_iface"]) \
                and self.wifi_guest \
                and len(interfaces) > 2:
            if self.data.get("hotspot_iface"):
                self.data["guest_iface"] = self.data["hotspot_iface"]
            else:
                self.data["guest_iface"] = interfaces[-1]
            self.reconf_all = True
        try:
            interfaces.remove(self.data["guest_iface"])
        except (ValueError, KeyError):
            pass
        if not self._list_has_data(self.data["devices_iface"]) \
                and self.wifi_devices \
                and len(interfaces) > 2:
            self.data["devices_iface"] = interfaces[-1]
            self.reconf_all = True
        try:
            interfaces.remove(self.data["devices_iface"])
        except (ValueError, KeyError):
            pass
        if not self._list_has_data(self.data["internal_ifaces"]):
            self.data["internal_ifaces"] = interfaces
            self.reconf_all = True
        if not self.data["localnet"]:
            addresses = netifaces.ifaddresses(self.data["internal_ifaces"][0])
            addresses = addresses.get(netifaces.AF_INET)
            if addresses:
                self.data["localnet"] = str(netaddr.IPNetwork(
                    addresses[0]['addr']+'/'+addresses[0]['netmask']
                ))
                self.reconf_all = True
        if not self.data["dhcp_range"]:
            ln = netaddr.IPNetwork(self.data["localnet"])
            start = netaddr.IPAddress(ln[min(ln.size/4, 357)])
            end = netaddr.IPAddress(ln[-5])
            self.data["dhcp_range"] = "{} {}".format(start, end)
        if not self.data["guest_network"] and self.wifi_guest:
            if self.data.get("hotspot_network"):
                self.data["guest_network"] = self.data["hotspot_network"]
            else:
                self.data["guest_network"] = "172.16.0.0/19"
            self.reconf_all = True
        if not self.data["devices_network"] and self.wifi_devices:
            self.data["devices_network"] = "10.10.0.0/20"
            self.reconf_all = True

    def load(self, bootstrap=False):
        """
        Load configuration data from a file.
        """
        if bootstrap or not os.path.exists(self.c_file):
            logging.info("Creating a new configuration file with empty values")
            confdir = os.path.dirname(os.path.realpath(self.c_file))
            if not os.path.isdir(confdir):
                os.makedirs(confdir)
            shutil.copyfile(clean_config_file, os.path.realpath(self.c_file))
        yaml = ruamel.yaml.YAML(typ="rt")
        with open(self.c_file) as fp:
            self.data = yaml.load(fp)
        if not self.data:
            logging.error(
                "The configuration file seems to be empty.\n" +
                "Please delete it to restart from a new valid one."
                )
            # TODO: this should probably raise an exception
            sys.exit(3)
        for f in UI.known:
            if f not in self.data:
                self.data[f] = None
        self.load_guessed_data()

    def save(self):
        """
        Save configuration data to file, setting safe permissions.
        """
        os.chmod(self.c_file, 0o640)
        os.umask(0o27)
        yaml = ruamel.yaml.YAML(typ="rt")
        with open(self.c_file, "w") as fp:
            yaml.dump(
                self.data,
                stream=fp,
                )
        os.umask(0o22)

    def ask(self):
        """
        Ask for the configuration until we have a valid one.

        force=True enforces a reconfiguration even if we already have a
        valid one.

        """
        logging.info("Asking for configuration")
        ui = UI(self)
        if not ui.frame.validate() or self.reconf_all:
            ui.run()
        self.save()


def fail_if_not_root():
    if os.getuid() > 0:
        logging.error("Can't execute fuss-server - Are you root?")
        sys.exit(5)


def _config(c, bootstrap=False):
    c.load(bootstrap)
    c.ask()


def configure(args):
    logging.info("Asking for missing configuration")
    if args.configuration_file == conf_file:
        # Usually we can't work except as root, but when working on a
        # different configuration file it is convenient to allow to
        # check and set the configuration as a normal user.
        fail_if_not_root()
    c = Configuration(
        reconf_all=args.reconfigure_all,
        c_file=args.configuration_file,
        wifi_guest=args.wifi_guest,
        wifi_devices=args.wifi_devices,
        )
    _config(c, bootstrap=args.bootstrap)


def create(args):
    logging.info("Applying configuration")
    fail_if_not_root()
    wifi_guest = args.wifi_guest
    wifi_devices = args.wifi_devices
    # if create has already been run with one of the wifi
    # options set, we should keep that setting.
    # fuss_server_hotspot was used up to bookworm and should be kept in
    # trixie, but can be removed in forky
    if os.path.exists("/etc/ansible/facts.d/fuss_server_wifi_guest.fact") \
            or os.path.exists("/etc/ansible/facts.d/fuss_server_hotspot.fact"):
        wifi_guest = True
    if os.path.exists("/etc/ansible/facts.d/fuss_server_wifi_devices.fact"):
        wifi_devices = True
    c = Configuration(
        wifi_guest=wifi_guest,
        wifi_devices=wifi_devices,
    )
    _config(c)
    os.chdir(ansible_data_path)
    os.execvp(os.path.join(ansible_data_path, 'create.yml'), [
        'fuss-server',
        '-i', 'localhost,',
        '-c', 'local',
        '--force-handlers',
        '-e', f'fuss_server_version={VERSION}',
        '-e', '{{wifi_guest: {}}}'.format(
            "true" if wifi_guest else "false"
        ),
        '-e', '{{wifi_devices: {}}}'.format(
            "true" if wifi_devices else "false"
        ),
        ])


def upgrade(args):
    logging.info("Upgrading configuration")
    fail_if_not_root()
    if args.wifi_guest or args.wifi_devices:
        logging.error(_(
            "fuss-server upgrade does not support adding "
            "--wifi-guest or --wifi-devices. "
            "Please use fuss-server configure or fuss-server create "
            "to add a wifi network."
        ))
        sys.exit(1)
    # if a wifi network has already been configured, we also include
    # its roles. refs: #977, #996
    wifi_guest = False
    wifi_devices = False
    # fuss_server_hotspot was used up to bookworm and should be kept in
    # trixie, but can be removed in forky
    if os.path.exists("/etc/ansible/facts.d/fuss_server_wifi_guest.fact") \
            or os.path.exists("/etc/ansible/facts.d/fuss_server_hotspot.fact"):
        wifi_guest = True
    if os.path.exists("/etc/ansible/facts.d/fuss_server_wifi_devices.fact"):
        wifi_devices = True
    c = Configuration(
        wifi_guest=wifi_guest,
        wifi_devices=wifi_devices,
    )
    _config(c)
    os.chdir(ansible_data_path)
    os.execvp(os.path.join(ansible_data_path, 'create.yml'), [
        'fuss-server',
        '-i', 'localhost,',
        '-c', 'local',
        '--force-handlers',
        '-e', f'fuss_server_version={VERSION}',
        '-e', '{{wifi_guest: {}}}'.format(
            "true" if wifi_guest else "false"
        ),
        '-e', '{{wifi_devices: {}}}'.format(
            "true" if wifi_devices else "false"
        ),
        ])


def purge(args):
    logging.info("Purging")
    fail_if_not_root()
    c = Configuration()
    _config(c)
    os.chdir(ansible_data_path)
    os.execvp(os.path.join(ansible_data_path, 'purge.yml'), [
        'fuss-server',
        '-i', 'localhost,',
        '-c', 'local',
        '--force-handlers',
        ])


def test(args):
    logging.info("Testing the server")
    fail_if_not_root()
    os.chdir(ansible_data_path)
    os.execvp(os.path.join(ansible_data_path, 'test.sh'), [
        'fuss-server',
        ])


def self_test(args):

    import unittest

    class TestCheck(unittest.TestCase):
        MOCK_DATA = {
            "label": ".",
            "help": ".",
        }

        def setUp(self):
            self.c = Configuration()

        def test_localnet(self):
            w = LocalnetEdit(
                edit_text='192.168.5.23/24',
                name="localnet",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = LocalnetEdit(
                edit_text='',
                name="localnet",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = LocalnetEdit(
                edit_text='192.168.5.23 255.255.255.0',
                name="localnet",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())

        def test_dhcp_range(self):
            w = DHCPRangeEdit(
                edit_text='192.168.5.23 192.168.5.42',
                name="dhcp_range",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = DHCPRangeEdit(
                edit_text='192.168.5.23',
                name="dhcp_range",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = DHCPRangeEdit(
                edit_text='192.168.5.0/24',
                name="dhcp_range",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())

        def test_check_domain(self):
            w = DomainEdit(
                edit_text='scuola.lan',
                name="domain",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = DomainEdit(
                edit_text='local.lan',
                name="domain",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = DomainEdit(
                edit_text='this.is.not.valid',
                name="domain",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = DomainEdit(
                edit_text='scuola.local',
                name="domain",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())

        def test_check_workgroup(self):
            w = WorkgroupEdit(
                edit_text='workgroup',
                name="workgroup",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = WorkgroupEdit(
                edit_text='scuola.lan',
                name="workgroup",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())

        """
        def test_crosscheck_hotspot(self):
            w = WorkgroupEdit(
                edit_text='',
                name="workgroup",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            self.c.data = {
                'external_ifaces': ['eth0'],
                'internal_ifaces': ['eth1', 'eth2'],
                'hotspot_iface': 'eth3',
                'hotspot_network': '192.168.5.0/24',
                }

            # All valid values
            self.assertEqual(self.c._crosscheck_hotspot(), [])

            # both hotspot variables empty: valid
            self.c.data['hotspot_iface'] = ''
            self.c.data['hotspot_network'] = ''
            self.assertEqual(self.c._crosscheck_hotspot(), [])

            # only one hotspot variabile empty: invalid
            self.c.data['hotspot_iface'] = 'eth3'
            self.c.data['hotspot_network'] = ''
            self.assertEqual(self.c._crosscheck_hotspot(), ['hotspot_network'])

            self.c.data['hotspot_iface'] = ''
            self.c.data['hotspot_network'] = '192.168.5.0/24'
            self.assertEqual(self.c._crosscheck_hotspot(), ['hotspot_iface'])

            # hotspot interface can't be the same as an internal or
            # external one
            self.c.data['hotspot_iface'] = 'eth0'
            self.assertEqual(self.c._crosscheck_hotspot(), ['hotspot_iface'])
            self.c.data['hotspot_iface'] = 'eth1'
            self.assertEqual(self.c._crosscheck_hotspot(), ['hotspot_iface'])

            # hotspot interface can't be a tun one
            self.c.data['hotspot_iface'] = 'eth1'
            self.assertEqual(self.c._crosscheck_hotspot(), ['hotspot_iface'])
        """

        def test_password(self):
            w = PassEdit(
                edit_text='abcdefg',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertTrue(w.validate())
            w = PassEdit(
                edit_text='abcd&',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = PassEdit(
                edit_text='abcd\\',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = PassEdit(
                edit_text='abcd/',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = PassEdit(
                edit_text='abcd$',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())
            w = PassEdit(
                edit_text='1234',
                name="password",
                data=self.MOCK_DATA,
            )
            self.assertFalse(w.validate())

    suite = unittest.TestLoader().loadTestsFromTestCase(TestCheck)
    unittest.TextTestRunner(verbosity=1).run(suite)


def main():
    parser = argparse.ArgumentParser(
        description='Configure a FUSS server.'
        )
    parser.set_defaults(func=create)

    parser.add_argument(
        '--wifi-guest',
        help="Also setup a guest network",
        action="store_true",
        )
    parser.add_argument(
        '--wifi-devices',
        help="Also setup a devices network",
        action="store_true",
        )

    subparser = parser.add_subparsers(
        title='subcommands',
        description="Run fuss-server <command> -h "
                    "for help on the subcommands.",
        dest='create'
        )

    create_parser = subparser.add_parser(
        'create',
        help='install dependencies and configuration'
        )
    create_parser.add_argument(
        '--limit',
        help="Ignored for compatibility"
        )
    create_parser.add_argument(
        '--wifi-guest',
        help="Also setup a guest network",
        action="store_true",
        # if the option is not used, do not override the same option
        # used for the main command / parser
        default=argparse.SUPPRESS,
        )
    create_parser.add_argument(
        '--wifi-devices',
        help="Also setup a devices network",
        action="store_true",
        # if the option is not used, do not override the same option
        # used for the main command / parser
        default=argparse.SUPPRESS,
        )
    create_parser.set_defaults(func=create)

    upgrade_parser = subparser.add_parser(
        'upgrade',
        help='apply a new configuration to an existing fuss-server'
        )
    upgrade_parser.add_argument(
        '--limit',
        help="Ignored for compatibility"
        )
    upgrade_parser.set_defaults(func=upgrade)

    purge_parser = subparser.add_parser(
        'purge',
        help='clean configuration'
        )
    purge_parser.add_argument(
        '--limit',
        help="Ignored for compatibility"
        )
    purge_parser.set_defaults(func=purge)

    configure_parser = subparser.add_parser(
        'configure',
        help='configure configuration'
        )
    configure_parser.add_argument(
        '--wifi-guest',
        help="Also setup a guest network",
        action="store_true",
        # if the option is not used, do not override the same option
        # used for the main command / parser
        default=argparse.SUPPRESS,
        )
    configure_parser.add_argument(
        '--wifi-devices',
        help="Also setup a devices network",
        action="store_true",
        # if the option is not used, do not override the same option
        # used for the main command / parser
        default=argparse.SUPPRESS,
        )
    configure_parser.add_argument(
        '-r', '--reconfigure-all',
        action="store_true",
        help="Reconfigure all options"
        )
    configure_parser.add_argument(
        '-b', '--bootstrap',
        action="store_true",
        help="Delete all current configuration and start with a new empty file"
        )
    configure_parser.add_argument(
        '-f', '--configuration-file',
        help="Use a different configuration file (for testing)",
        default=conf_file
        )
    configure_parser.set_defaults(func=configure)

    test_parser = subparser.add_parser(
        'test',
        help='test the server configuration'
        )
    test_parser.set_defaults(func=test)

    self_test_parser = subparser.add_parser(
        'selftest',
        help='run tests on this script'
        )
    self_test_parser.set_defaults(func=self_test)

    args = parser.parse_args()
    args.func(args)


if __name__ == '__main__':
    main()
