import ipaddress
import logging
import os
import subprocess
import sys
from enum import Enum
from logging import Logger, getLogger
from pathlib import Path
from typing import Dict, Any, List, Tuple

import yaml

from Utility import getConfigAndSetupLogging
from config.ConfigSimpleReadOnly import configSimpleReadOnly

# Netplan definitions
NETPLAN_FOLDER = "/run/netplan/"
NETPLAN = "/usr/sbin/netplan"
APPLY = "apply"

# Config definitions
REMOTE_DISPLAY_1 = "RemoteDisplay1"
REMOTE_HMI_1 = "RemoteHMI1"
REMOTE_HMI_IP_ADDITION_100 = 100
REMOTE_HMI_2 = "RemoteHMI2"
REMOTE_HMI_IP_ADDITION_200 = 200
POWER_SINK = "PowerSinkControl"
SECONDARY_SINK = "CC6-secondary"
SECONDARY_SINK_LOW_BYTE = 100
SECONDARY_SINK_HIGH_BYTE = 200


NETWORK_PATH = "Interfaces/Network/"
ENABLE = "Enable"
IP = "IP"
NETMASK = "Netmask"
GATEWAY = "Gateway"
DNS_SERVERS = "DNS-Servers"
CONFIG_FILE_PATH = "config/config.json"
ECP_WORK_FILES_DIR_CANDIDATES = (
    os.environ.get("ECP_WORK_FILES_DIR", ""),
    "/mnt/persistent-data/evtec-ecp-data",
)


STATIC_CONFIG = NETWORK_PATH + "LAN-STATIC"
MANAGEMENT_CONFIG = NETWORK_PATH + "LAN-MANAGEMENT"
DHCP_CONFIG = NETWORK_PATH + "DHCP"
DHCPV6_CONFIG = NETWORK_PATH + "DHCPv6"
DEVICE_CONFIG = "Device/"
SERIAL = DEVICE_CONFIG + "Serial/Serial"
ROLE = DEVICE_CONFIG + "Role"  # different roles have different IP-schemes
VARIANT = (
    DEVICE_CONFIG + "Variant"
)  # distinction for the primary and secondary PowerSinkControl.

STATIC_METRIC_VALUE = 80


# iptables definitions
PORT_FORWARDING_TO_DISPLAY1 = "8890"
PORT_FORWARDING_TO_DISPLAY2 = "8891"
PORT_FORWARDING_TO_DISPLAY_API1 = "5050"
PORT_FORWARDING_TO_DISPLAY_API2 = "5051"
PORT_8888 = "8888"
PORT_5005 = "5005"
# enables IPv4 forwarding on system. Required for Network Address Translation (NAT) to work.
IP4_FORWARDING_COMMAND = ["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"]
IPTABLES_MASQUERADE_COMMAND = [
    "/usr/sbin/iptables",
    "-t",
    "nat",
    "-A",
    "POSTROUTING",
    "-j",
    "MASQUERADE",
    "-o",
]
""" iptables command to add post-routing masquerade to an interface. The interface needs to be added as last element """
EXTERNAL_INTERFACES = ["eth0", "wwan0"]
""" list of interfaces which could be connected to the internet """

# Netplan dict definitions
NETWORK = "network"
ETHERNETS = "ethernets"
RENDERER = "renderer"
NETWORKD = "networkd"
VERSION = "version"
VERSION2 = 2
ETH0 = "eth0"
DHCP4 = "dhcp4"
DHCP6 = "dhcp6"
ADDRESSES = "addresses"
ROUTES = "routes"
DNS = "nameservers"
DNS_DICT = {ADDRESSES: []}


class LanTypes(Enum):
    LAN_MANAGEMENT = 20
    LAN_STATIC = 40
    DHCP = 60

    def __str__(self):
        return self.name.replace("_", "-")


def iPv4ValidatorStrict(ipAddress: str) -> bool:
    """Check if an IPv4-address string has the correct format.

    :param ipAddress: ip-address as string
    """
    try:
        ipObject = ipaddress.ip_address(ipAddress)
    except ValueError:
        return False
    return isinstance(ipObject, ipaddress.IPv4Address)


# Fallback definitions
LAN_FALLBACK_CONFIG = {
    STATIC_CONFIG: {ENABLE: False},
    MANAGEMENT_CONFIG: {
        ENABLE: True,
        IP: "10.2.0.100",
        NETMASK: "255.255.0.0",
        GATEWAY: "",
        DNS_SERVERS: ["1.1.1.1", "8.8.8.8"],
    },
    DHCP_CONFIG: {ENABLE: False},
    DHCPV6_CONFIG: {ENABLE: False},
    DEVICE_CONFIG: {SERIAL: 0, ROLE: "ControllerRemoteDisplay", VARIANT: ""},
}

# Other definitions
DUMMY_IP = "0.0.0.0"
SUCCESS = "successful"
NO_SUCCESS = "NOT successful"
ENABLED = "enabled"
DISABLED = "disabled"
PATH = "Path"

LOG_NAME = "SetNetworkConfiguration"


# Functions


def writeNetworkConfig(
    config: Dict[str, Any],
    netplanFile: Path,
    isStatic: bool,
    _logger: Logger,
    lanType: LanTypes,
) -> None:
    """Writes .yaml file according specified configuration. If the LAN is disabled, the specific .yaml file is deleted.

    :param config: Contains all relevant information to configure the LAN
    :param netplanFile: File where to write the config for Netplan into
    :param isStatic: Writes the given configuration as static LAN. Otherwise DHCP.
    :param _logger: logger instance
    """
    dictTemplate = {
        NETWORK: {ETHERNETS: {ETH0: {}}, RENDERER: NETWORKD, VERSION: VERSION2}
    }

    if not (lanEnabled := config[ENABLE]):
        successful = disableLan(netplanFile)

    else:
        if isStatic:
            dictTemplate[NETWORK][ETHERNETS][ETH0][ADDRESSES] = [
                f"{(lanIP := config[IP])}/{calcNetMask((lanNetmask := config[NETMASK]))}"
            ]
            if lanGateway := config[GATEWAY]:
                dictTemplate[NETWORK][ETHERNETS][ETH0][ROUTES] = [
                    {"to": "default", "via": lanGateway, "metric": STATIC_METRIC_VALUE}
                ]

            _logger.info(
                f"Static with IP: {lanIP}, Netmask: {lanNetmask}, Gateway: {lanGateway}"
            )

        else:
            dictTemplate[NETWORK][ETHERNETS][ETH0][DHCP4] = True

        # Set DHCPv6 Enable from configuration
        # Only write the dhcp settings in LAN_STATIC or DHCP file and skip double config with LAN_MANAGEMENT
        if lanType != LanTypes.LAN_MANAGEMENT:
            dictTemplate[NETWORK][ETHERNETS][ETH0][DHCP6] = config[DHCP6]

        if dns := config.get(DNS_SERVERS, []):
            dnsConfig = DNS_DICT
            dnsConfig[ADDRESSES] = dns
            dictTemplate[NETWORK][ETHERNETS][ETH0][DNS] = dnsConfig
            _logger.info(f"with DNS-Servers: {dns}")

        successful = bool(netplanFile.write_text(yaml.safe_dump(dictTemplate)))

    _logger.info(
        f"Configuration as {ENABLED if lanEnabled else DISABLED} was {SUCCESS if successful else NO_SUCCESS}"
    )


def disableLan(filePath: Path) -> bool:
    """
    Deactivating LAN configurations by deleting the .yaml file at the given file path

    :param filePath: path to .yaml file which should be deleted.
    :return: True is deletion was successful.
    """
    Path.unlink(filePath, missing_ok=True)
    return not Path.exists(filePath)


def calcIPFromSerial(serial: int, role: str, variant: str = "") -> str:
    """
    Calculation of the 10.2 IP address with the serial number according to the MSI IP-Scheme on the Wiki

    :param serial: serial number of the device.
    :param role: config param Device/Role
    :param variant: config param Device/Variant
    :return: calculated IP as string.
    """
    highByte = int(((serial - 1) / 100) % 100)
    lowByte = int(((serial - 1) % 100) + 1)
    if role in [REMOTE_DISPLAY_1, REMOTE_HMI_1]:
        highByte += REMOTE_HMI_IP_ADDITION_100
    elif role in [REMOTE_HMI_2]:
        highByte += REMOTE_HMI_IP_ADDITION_200
    elif role in [POWER_SINK] and variant in [SECONDARY_SINK]:
        lowByte += SECONDARY_SINK_LOW_BYTE
        highByte += SECONDARY_SINK_HIGH_BYTE
    return f"10.2.{highByte}.{lowByte}"


def calcNetMask(netMask: str) -> int:
    """
    Conversion of the given netmask from the format '255.255.0.0' to '/16'

    :param netMask: Netmask to recalculate.
    :return: Netmask as CIDR notation.
    """
    return ipaddress.IPv4Network(DUMMY_IP + "/" + netMask).prefixlen


def checkLanSettings(lanConfig: Dict, _logger: Logger) -> bool:
    """
    Checks the given configuration for its validity and corrects it if necessary.

    :param lanConfig: the configuration to be checked.
    :param _logger: logger instance
    :return: True if all parameters are in a correct format.
    """
    configValid: bool = True

    # Check if at least the IP and the netmask are given
    if not lanConfig.get(IP) or not lanConfig.get(NETMASK):
        _logger.info("No IP or netmask specified. Check config!")
        configValid = False

    # Check if all parameters are in correct IP format
    for configKey in [IP, NETMASK, GATEWAY]:
        if lanConfig.get(configKey) and not iPv4ValidatorStrict(lanConfig[configKey]):
            _logger.info(f"Wrong IP format in parameter {configKey}. Check config!")
            configValid = False

    # Check given DNS servers for correct IP format
    if not all(iPv4ValidatorStrict(dns) for dns in lanConfig.get(DNS_SERVERS, [])):
        _logger.info("Wrong DNS server IP format. Check config!")
        configValid = False

    # If the configured IP is the same as the default fallback IP, there must be something wrong!
    if lanConfig.get(IP) == LAN_FALLBACK_CONFIG[MANAGEMENT_CONFIG][IP]:
        _logger.info("IP same as fallback. Reconfigure!")
        configValid = False

    return configValid


def configureNetwork(
    lanType: LanTypes,
    configFile: Path,
    logger: Logger,
    isStatic: bool = True,
    deviceConfig: dict = {},
) -> None:
    """
    Configures the network settings according to the specified configuration.

    :param lanType: LAN type to be configured
    :param configFile: Path to the config.json
    :param logger: Logger instance
    :param isStatic: Set to False if the network should be configured with DHCP
    :param deviceConfig: configuration to retrieve the serial number and device role.
    """
    lanConfig = getIpConfig(lanType, configFile, logger, isStatic, deviceConfig)

    # Add the information
    netplanFile = f"{lanType.value}-{str(lanType)}-CONFIG.yaml"
    netplanPath = Path(NETPLAN_FOLDER + netplanFile)
    logger.info(f"Configuring {netplanFile}")

    # Write network configurations to YAML file
    writeNetworkConfig(lanConfig, netplanPath, isStatic, logger, lanType)


def getIpConfig(
    lanType: LanTypes,
    configFile: Path,
    logger: Logger,
    isStatic: bool = True,
    deviceConfig: dict = {},
) -> dict[str, Any]:
    """
    Returns the IP configuration for a particular lanType

    :param lanType: LAN type to be configured
    :param configFile: Path to the config.json
    :param logger: Logger instance
    :param isStatic: Set to False if the network should be configured with DHCP
    :param deviceConfig: configuration to retrieve the serial number and device role.
    :return: dict with all values for IP configuration for the specified lanType
    """
    configKey = NETWORK_PATH + str(lanType)
    fallbackConfig = LAN_FALLBACK_CONFIG[configKey]

    # Load configuration from file with read only. Create new dictionary in order to prevent overwriting the fallback.
    retVal = configSimpleReadOnly((configKey,), LAN_FALLBACK_CONFIG, configFile)

    # Load  dhcpv6 configuration
    retValDhcpv6 = configSimpleReadOnly(
        (DHCPV6_CONFIG,), LAN_FALLBACK_CONFIG, configFile
    )

    lanConfig = dict(retVal.get(configKey))

    # Add settings for DHCPv6 from the config
    lanConfig[DHCP6] = retValDhcpv6.get(DHCPV6_CONFIG).get(ENABLE)

    # Set config to the fallback values if LAN is disabled or the settings aren't valid.
    if isStatic and (
        not lanConfig.get(ENABLE, False) or not checkLanSettings(lanConfig, logger)
    ):
        lanConfig.update(fallbackConfig)

        # Overwrite default fallback IP if a serial number is available
        if serialNumber := deviceConfig.get(SERIAL):
            logger.info(
                f"recalculating IP-address with the serial number: {serialNumber}, "
                f"because there is a serial number available."
            )
            lanConfig[IP] = calcIPFromSerial(
                serialNumber, deviceConfig[ROLE], deviceConfig[VARIANT]
            )
            logger.info(f"new IP-address: {lanConfig[IP]}")

    return lanConfig


def portForwardingForDisplays(deviceConfig: dict, logger: Logger) -> None:
    """Add port forwarding so the display ports 8888 and 5050 is accessible via the baseboard. Since there can be two
    displays we create 4 forwarding rules:
    baseboard:8890 -> display1:8888
    baseboard:5050 -> display1:5005
    baseboard:8891 -> display2:8888
    baseboard:5051 -> display2:5005
    """
    portForwardingCommands = [IP4_FORWARDING_COMMAND]

    displayIp1 = calcIPFromSerial(deviceConfig.get(SERIAL), REMOTE_HMI_1)
    displayIp2 = calcIPFromSerial(deviceConfig.get(SERIAL), REMOTE_HMI_2)
    if iPv4ValidatorStrict(displayIp1):
        portForwardingCommands.append(
            createPortForwardingCommand(PORT_FORWARDING_TO_DISPLAY1, displayIp1, PORT_8888)
        )
        portForwardingCommands.append(
            createPortForwardingCommand(
                PORT_FORWARDING_TO_DISPLAY_API1, displayIp1, PORT_5005
            )
        )
    else:
        logger.warning("Invalid IP address for display 1: %s", displayIp1)
    if iPv4ValidatorStrict(displayIp2):
        portForwardingCommands.append(
            createPortForwardingCommand(PORT_FORWARDING_TO_DISPLAY2, displayIp2, PORT_8888)
        )
        portForwardingCommands.append(
            createPortForwardingCommand(
                PORT_FORWARDING_TO_DISPLAY_API2, displayIp2, PORT_5005
            )
        )
    else:
        logger.warning("Invalid IP address for display 2: %s", displayIp2)

    for interface in EXTERNAL_INTERFACES:
        command = IPTABLES_MASQUERADE_COMMAND + [interface]
        portForwardingCommands.append(command)

    for command in portForwardingCommands:
        stdOut, stderr = executeCommand(command, logger)
        if logger.level <= logging.DEBUG:
            logger.debug(
                f'execution of subprocess: {" ".join(command)}, stdOut: {stdOut}, stderr: {stderr}'
            )


def createPortForwardingCommand(ownPort: str, extIP: str, extPort: str) -> List[str]:
    """creates and returns the port forwarding command for the given ip and ports"""

    return [
        "/usr/sbin/iptables",
        "-t",
        "nat",
        "-A",
        "PREROUTING",
        "-p",
        "tcp",
        "--dport",
        ownPort,
        "-j",
        "DNAT",
        "--to-destination",
        f"{extIP}:{extPort}",
    ]


def executeCommand(command: List[str], _logger) -> Tuple[str, str]:
    """Execute a bash command with popen"""
    try:
        with subprocess.Popen(
            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
        ) as executed:
            stdOut, stderr = executed.communicate()

    except OSError as error:
        _logger.warning(f'failed to execute: {" ".join(command)}, error: {error}')
        sys.exit(os.EX_OSERR)
    return stdOut, stderr


def main() -> int:
    """Read out the configuration and configure the network accordingly.

    :return: 1 if netplan could not be applied, 0 on success
    """
    workDir, config, logger, configFile = getConfigAndSetupLogging(
        LOG_NAME, logging.INFO
    )
    logger.info(f"Using WorkDir: {workDir}")

    # Check if the folder /run/netplan exists if not it will be created.
    Path(NETPLAN_FOLDER).mkdir(exist_ok=True)

    # --------------------------- Gather device information ---------------------------------------------------------- #

    fallback = LAN_FALLBACK_CONFIG[DEVICE_CONFIG]
    deviceConfig = configSimpleReadOnly(tuple(fallback.keys()), fallback, configFile)

    # --------------------------- Evaluate and write the LAN-Management configuration to file  ----------------------- #
    logger.info("Start network configuration")

    configureNetwork(
        LanTypes.LAN_MANAGEMENT, configFile, logger, deviceConfig=deviceConfig
    )

    # --------------------------- Evaluate and write the LAN configuration to file  ---------------------------------- #

    configureNetwork(LanTypes.LAN_STATIC, configFile, logger)

    # --------------------------- Evaluate and write the DHCP configuration to file  --------------------------------- #

    configureNetwork(LanTypes.DHCP, configFile, logger, isStatic=False)

    # --------------------------- Applying all LAN configurations  --------------------------------------------------- #

    # Applying the configurations with netplan apply
    stdOut, stdErr = executeCommand([NETPLAN, APPLY], logger)
    if stdErr:
        logger.warning(f"Netplan apply failed with error: {stdErr}")
        return os.EX_OSERR
    logger.debug(
        f'execution of subprocess: {" ".join([NETPLAN, APPLY])}, stdOut: {stdOut}, stderr: {stdErr}'
    )

    # --------------------------- Applying port forwarding for display  ---------------------------------------------- #

    portForwardingForDisplays(deviceConfig, logger)

    return os.EX_OK


if __name__ == "__main__":
    """Main entrypoint, any unhandled exceptions will lead to non zero exit code"""
    retCode = main()
    sys.exit(retCode)
