"""
Author: Cooper
Date: 19/03/2019

Desc:  This script piggy backs off of OpenWRTs UCI which allows modification to system configs with ease.
Not sure if using subprocess is the best way to interact with this, but it appears to work so it is all that
matters at this moment in time.  This script allows you to set dynamic or static IP addresses.  Allows you to
enable disable DHCP server.  Note that in order for the thing to work as a DHCP server, it needs a static IP.

The whole reason this script exists is from lessons learnt from the ITxPT project preceding it. The ITxPT application
expected a DCHP server and without this nothing would work.  So this was created to give the option of a fallback
DHCP server.

This process takes about 6 seconds to process and only really ever be run once if the config never changes.  So perhaps
get a hash of the config and store it, if the config hash is the same don't bother doing this.

Added support for configuring Local Link Addressing

"""
import netifaces as ni
import subprocess
import os
import sys
import time
import logging

class NetworkConfigurator(object):
    """
    Class used to provide methods to alter the network setup.

    Uses OPenWRT's Unified Configuration Interface (UCI) https://openwrt.org/docs/guide-user/base-system/uci

    Members
    -------
    config_dict (dict): Our configuration dictionary.
    config_dir (str): Directory where configuration files are found.
    ip_address (str): Our own IP Address.
    mac_address (str): Our MAC Address.
    network_option (int): Possible network configurations:
            0: "Static",
            1: "DHCP Server",
            2: "DHCP Client",
            3: "DHCP Client w/fallback",
            4: "Local Link Addressing"
    network_status (int): Possible connection statuses:
            0: "IP obtained",
            1: "Obtaining lease",
            2: "No network/IP"

    A couple of points to consider.  On a sign the network settings will be obtained from the config only, on the console
    it will be obtained
    """

    def __init__(self, config_dict: dict, config_dir: str):
        """ Constructor.

        Args:
            config_dict (dict): Configuration dictionary.
            config_dir (str): Directory where configuration files can be found.
        """
        logging.info("Loaded NetworkConfigurator")
        self.config_dict = config_dict
        self.config_dir = config_dir

        self.ip_address = None
        self.mac_address = None
        self.hostname = ""
        self.sign_address = None

        self.network_option = "None"
        self.network_status = 2

    def option_definitions(self):
        network_option = {
            "None": "Not configured",
            0: "Static",
            1: "DHCP Server",
            2: "DHCP Client",
            3: "DHCP Client w/fallback",
            4: "Local Link Addressing"
        }

        return network_option[self.network_option]

    def status_definitions(self):
        network_status = {
            0: "Network up",
            1: "No IP",
            2: "No Network link"
        }

        return network_status[self.network_status]


    def set_static_ip(self, static_ip, netmask, gateway):
        """
        Sets the IP address for the lan to the value passed in.

        """

        commands = [
            ["uci", "set", "network.lan.proto=static"],
            ["uci", "set", "network.lan.ipaddr=%s" % static_ip],
            ["uci", "set", "network.lan.netmask=%s" % netmask],
            ["uci", "set", "network.lan.gateway=%s" % gateway],
            ["uci", "commit"],
                    ]

        logging.info("Setting static IP address %s" % static_ip)

        self.process_commands(commands)


    def set_dynamic_ip(self) -> None:
        """
        Puts the Onion in DHCP client mode
        """
        commands = [
            ["uci", "set", "network.lan.proto=dhcp"],
            ["uci", "commit"],
                    ]

        logging.info("Setting dynamic IP")

        self.process_commands(commands)


    def set_local_link_addressing(self) -> None:
        #Link local addressing requires the installation of avahi-autoipd and preconfigurations
        # https://github.com/ibrdtn/ibrdtn/wiki/HOWTO:-Auto-IP-address-configuration-on-OpenWRT

        commands = [
            ["uci", "set", "network.lan.proto=none"],
            ["uci", "set", "network.lan.autoip=yes"]
        ]

        self.process_commands(commands)

    def dhcp_server(self, state:bool) -> None:
        #dnsmasq looks after the DHCP side of things and it has some rather useful functions from what i can ascertain
        #from the documentation.
        if state:
            logging.info("Enabling DHCP Server")
            dhcp_start = self.config_dict.get("NETWORK_dhcp_start", 10)
            dhcp_pool = self.config_dict.get("NETWORK_dhcp_pool", 100)
            dhcp_force = self.config_dict.get("NETWORK_force", 0)

            commands = [
                ["uci", "set", "dhcp.lan.ignore=0"],
                ["uci", "set", "dhcp.lan.start=%s" % dhcp_start],
                ["uci", "set", "dhcp.lan.limit=%s" % dhcp_pool],
                ["uci", "set", "dhcp.lan.force=%s" % dhcp_force],
                ["uci", "commit"],
            ]

        else:
            logging.info("Disabling DHCP Server")
            commands = [
                ["uci", "set", "dhcp.lan.ignore=1"],
                ["uci", "commit"],
            ]

        self.process_commands(commands)

    def set_dns(self):
        """
        Sets the DNS server, will set the default to Google's DNS.
        :return:
        """
        dns = self.config_dict.get("NETWORK_dns_server", "8.8.8.8")

        if dns == "0.0.0.0":
            logging.info("Removing DNS")
            self.remove_dns()
            return

        logging.info("Setting DNS: %s" % dns)

        commands = [
            ["uci", "set", "network.lan.dns=%s" % dns],
            ["uci", "commit"],
        ]

        self.process_commands(commands)

    def remove_dns(self):
        """
        Removes the configured DNS server from the settings
        :return:
        """
        commands = [
            ["uci", "delete", "network.lan.dns"],
            ["uci", "commit"],
        ]

        self.process_commands(commands)

    def disable_dhcp_v6(self):
        """
        This was added because it broke FARAs SMARTHUB lol.
        """
        logging.info("Disabling DHCPv6 and RA")

        commands = [
            ["uci", "set", "dhcp.lan.ra=disabled"],
            ["uci", "set", "dhcp.lan.dhcpv6=disabled"],
            ["uci", "set", "dhcp.wan.ra=disabled"],
            ["uci", "set", "dhcp.wan.dhcpv6=disabled"],
            ["uci", "commit"],
        ]

        self.process_commands(commands)


    """
    ############################################################################################################
    NETWORK Wireless
    """

    def enable_access_point(self, enable):
        """
        This enables the wifi access point
        """
        if enable:
            ssid = self.config_dict.get("WIRELESS_ssid", self.get_hostname())
            password = self.config_dict.get("WIRELESS_password", "12345678")

            logging.info("Enabling wifi, SSID: ", ssid)
            #TODO get the UCI wireless commands
            commands = [
                ["uci", "set", "wireless.radio0.disabled=0"],
                ["uci", "set", "wireless.default_radio0.ssid=%s" % ssid],
                ["uci", "set", "wireless.default_radio0.encryption=psk2"],
                ["uci", "set", "wireless.default_radio0.key=%s" % password],
                ["uci", "commit"],
            ]
        else:
            commands = [
                ["uci", "set", "wireless.radio0.disabled=1"]
            ]

        self.process_commands(commands)

    def connect_to_access_point(self):
        """
        This enables the Onion to act as a client and connect to an access point, although I am not sure whether this
        will ever be needed in the field or for any other purpose really.
        """
        pass

    """
    ############################################################################################################
    COMMANDS
    """

    def restart_network(self):
        """
        Restarts the network interface to establish new settings
        """
        commands = [
            ["/etc/init.d/network", "restart"],
        ]

        self.process_commands(commands)

    def restart_dhcp(self):
        """
        Restarts the DHCP server to establish new settings
        """
        commands = [
            ["/etc/init.d/dnsmasq", "restart"],
        ]

        self.process_commands(commands)

    def restart_odhcpd(self):
        """
        Restarts odhcpd
        """
        commands = [
            ["/etc/init.d/odhcpd", "restart"],
        ]

        self.process_commands(commands)


    def get_link_status(self):
        """
        This obtains the current link status of the ethernet port
        """
        command = [
            "swconfig",
            "dev",
            "switch0",
            "port",
            "0",
            "get",
            "link"
        ]

        response = self.process_command(command)
        # print(response)

        if "link:up" in response:
            return True
        else:
            return False


    def process_commands(self, commands):
        """
        Invokes the commands passed in.

        Parameter
        ---------
        commands : List of str.
            Commands to be executed.

        """

        for command in commands:
            # print(command)
            if sys.platform == "linux":
                process = subprocess.Popen(command, stdout=subprocess.PIPE)
                output, error = process.communicate()
                # print(output, error)

    def process_command(self, command: list) -> str:
        """
        As above but this one returns the output
        """
        # print(command)
        process = subprocess.Popen(command, stdout=subprocess.PIPE)
        output, error = process.communicate()

        return output.decode("latin-1")

    """
    ############################################################################################################
    NETWORK config bits
    """

    def check_network_flag(self):
        #Checks if the network.done flag is present
        config_files = os.listdir(self.config_dir)
        # print("Config files ", config_files)

        if "network.done" in config_files:
            logging.info("Network flag found")
            self.network_configured = True
        else:
            logging.info("Network flag not found")
            self.network_configured = False

    def write_network_flag(self):
        try:
            networkflag = open(os.path.join(self.config_dir, "network.done"), "w")
            networkflag.close()
        except OSError:
            logging.error("Cannot set network flag...")

    def delete_network_flag(self):
        try:
            os.remove(os.path.join(self.config_dir, "network.done"))
        except OSError:
            logging.error("Cannot delete network flag")

    """
    ############################################################################################################
    Main functions
    """
    def network_truth_table(self, dhcp_server_en, static_ip):
        """
        This is the truth table for dealing with configs that may not be completely populated and hope that the
        application does something sensible to accommodate this.

        The priority is:
        - DHCP Client
        - DHCP Server (A static IP is required)
        - Static IP

        If dhcp_client_en is True then the other two do not matter.

        dhcp_server_en and dhcp_client_en are booleans whereas static_ip can be None or String.
        """
        dhcp_client_en = False  # Assume it is disabled because otherwise we wouldn't even be here in the first place.

        # turn it into a tuple for easy comparison
        network_tuple = (dhcp_server_en, static_ip)

        if network_tuple == (None, None):
            # If all the options are invalid then enable dhcp server
            logging.info("Invalid network settings, enabling DHCP Client")
            dhcp_client_en = True
        elif network_tuple == (True, None):
            # If a DHCP server is needed but no IP is supplied then use a default IP
            logging.info("No IP supplied, enabling DHCP server with IP 192.168.0.1")
            static_ip = "192.168.0.1"
        elif network_tuple == (False, None):
            # If a DHCP server is not needed but the others have not been filled in
            # When DHCP server and client are disabled but no IP is supplied
            logging.info("Invalid network settings, enabling DHCP Client")
            dhcp_client_en = True

        return dhcp_server_en, dhcp_client_en, static_ip

    def mapped_static_ip(self):
        """
        In situations where the signs are in static IP mode but one cannot be bothered to maintain separate config files
        where the only difference is the IP address then this will allow mapping based on sign address.

        Use this with caution as sometimes signs may have the same address!!!
        """
        self.enable_mapping = self.config_dict.get("MAPPED_STATIC_IP_enable", False)

        if not self.enable_mapping:
            return None
        else:
            logging.info("Network: Obtaining mapped IP for sign %s" % self.sign_address)
            try:
                mapped_ip = self.config_dict["MAPPED_STATIC_IP_sign_%s" % self.sign_address]
            except KeyError:
                mapped_ip = None

        return mapped_ip

    def get_static_ip(self):
        """
        This determines which IP to use, there is a case where both of this is none, in which case the truth table will
        decide what to do

        """

        non_mapped_ip = self.config_dict.get("NETWORK_static_ip", None)
        mapped_ip = self.mapped_static_ip()

        if mapped_ip == None:
            logging.info("Network: Using non mapped IP %s" % non_mapped_ip)
            return non_mapped_ip
        else:
            logging.info("Network: Using mapped IP %s" % mapped_ip)
            return mapped_ip



    def configure_network(self):
        """
        This is the main call for this class.
        When called, it will check whether it is appropriate to amend the network settings by looking for a flag.
        Depending on what is configured in the config file it will act accordingly.
        """
        self.check_network_flag()

        dhcp_server_en = self.config_dict.get("NETWORK_dhcp_server", None)
        dhcp_client_en = self.config_dict.get("NETWORK_dhcp_client", None)
        static_ip = self.get_static_ip()
        gateway = self.config_dict.get("NETWORK_gateway", "10.0.0.1")
        netmask = self.config_dict.get("NETWORK_subnet_mask", "255.255.255.0")
        local_link_en = self.config_dict.get("NETWORK_local_link_address", False)
        wireless_en = self.config_dict.get("WIRELESS_enable", False)
        dhcpv6_server = self.config_dict.get("NETWORK_dhcpv6_server", True)     #By default on openwrt this is enabled

        if dhcp_client_en == None:
            dhcp_server_en, dhcp_client_en, static_ip = self.network_truth_table(dhcp_server_en, static_ip)
        elif static_ip is None and dhcp_client_en is False:
            logging.info("Network: Invalid network config, enabling DHCP server")
            dhcp_client_en = True

        if self.network_configured:
            logging.info("Network setup not required")
            if dhcp_client_en and dhcp_server_en:
                self.fallback_enable = True
            else:
                self.fallback_enable = False
            return

        else:
            self.fallback_enable = False
            #With LLA in the mix, we need to decide what has priority if all are enabled
            logging.info("Setting up network")

            if dhcp_client_en and dhcp_server_en:
                self.network_option = 3
                self.fallback_enable = True
                self.dhcp_server(0)
                self.set_dynamic_ip()

            elif dhcp_client_en:
                self.network_option = 2
                self.dhcp_server(0)
                self.set_dynamic_ip()

            elif dhcp_server_en:
                self.network_option = 1
                self.set_static_ip(static_ip, netmask, gateway)
                self.dhcp_server(1)

            elif local_link_en:
                self.network_option = 4

            else:
                self.network_option = 0

                self.set_static_ip(static_ip, netmask, gateway)
                self.set_dns()
                self.dhcp_server(0)

            self.enable_access_point(wireless_en)
            self.restart_network()

            if not dhcpv6_server:
                self.disable_dhcp_v6()
                self.restart_odhcpd()

            self.restart_dhcp()

            logging.info("Waiting for network")
            time.sleep(5)

            self.write_network_flag()

    def getIPAddress(self):
        device = self.config_dict.get("NETWORK_unix_device", "br-lan")
        ni.ifaddresses(device)

        try:
            self.mac_address = ni.ifaddresses(device)[ni.AF_LINK][0]['addr']
        except KeyError:
            logging.error("Network: Cannot obtain MAC, using 40:a3:6b:00:00:00")
            self.mac_address = "40:a3:6b:00:00:00"

        try:
            self.ip_address = ni.ifaddresses(device)[ni.AF_INET][0]['addr']
            print(self.ip_address, self.mac_address)
            self.network_status = 0

        except KeyError:
            logging.error("Network: Cannot obtain IP")
            self.network_status = 1

    def get_hostname(self):
        """
        Obtains the hostname of the onion which can be used for whatever, such as the SSID if one isnt given!
        """
        commands = [
            "uci", "get", "system.@system[0].hostname"
        ]

        self.hostname = self.process_command(commands).rstrip()
        # print(self.hostname)
        return self.hostname

    def set_sign_address(self, address):
        """
        As this module is not provided a hardware dictionary, this is the means to set the address so it knows what
        IP to use
        """
        self.sign_address = address

    """
    ############################################################################################################
    Debug functions
    """
    def print_configure_network(self):
        """
        This is the main call for this class.
        When called, it will check whether it is appropriate to amend the network settings by looking for a flag.
        Depending on what is configured in the config file it will act accordingly.
        """
        self.check_network_flag()

        dhcp_server_en = self.config_dict.get("NETWORK_dhcp_server", None)
        dhcp_client_en = self.config_dict.get("NETWORK_dhcp_client", None)
        static_ip = self.get_static_ip()
        gateway = self.config_dict.get("NETWORK_gateway", "10.0.0.1")
        netmask = self.config_dict.get("NETWORK_subnet_mask", "255.255.255.0")
        local_link_en = self.config_dict.get("NETWORK_local_link_address", False)
        wireless_en = self.config_dict.get("WIRELESS_enable", False)

        if dhcp_client_en == None:
            dhcp_server_en, dhcp_client_en, static_ip = self.network_truth_table(dhcp_server_en, static_ip)
        elif static_ip is None and dhcp_client_en is False:
            print("Network: Invalid network config, enabling DHCP server")
            dhcp_client_en = True

        print("dhcp server en", dhcp_server_en)
        print("dhcp client en", dhcp_client_en)
        print("static ip", static_ip)
        print("gateway", gateway)
        print("netmask", netmask)
        print("local link", local_link_en)
        print("wireless en", wireless_en)



if __name__ == "__main__":
    from hanip.onionip import onionConfig
    oconfparse = onionConfig.OnionConfig()

    oconf = oconfparse.parse_configs_dict("/tmp", "config.cfg")


    nc = NetworkConfigurator(oconf, "/tmp")
    nc.set_sign_address("1")
    nc.print_configure_network()
    nc.get_link_status()
