import subprocess
from ipaddress import ip_address, IPv4Network, IPv4Address
from enum import IntEnum
from logging import Logger, WARNING
from os import EX_OK, EX_OSERR
from pathlib import Path
from sys import exit

import yaml

from Utility import getConfigAndSetupLogging

# 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

CONFIG_SYSTEM_SECTION = "System"
CONFIG_NETWORK_SECTION = "LAN"
CONFIG_LAN_STATIC_SECTION = "LAN-STATIC"
CONFIG_LAN_MANAGEMENT_SECTION = "LAN-MANAGEMENT"
CONFIG_DHCP_SECTION = "DHCP"
CONFIG_DHCPV6_SECTION = "DHCPv6"
CONFIG_DEVICE_SECTION = "Device"
CONFIG_SERIAL_NUMBER = "Serial"
CONFIG_ROLE = "Role"  # different roles have different IP-schemes
CONFIG_VARIANT = (
    "Variant"  # distinction for the primary and secondary PowerSinkControl.
)

ENABLE = "Enable"
IP = "IP"
NETMASK = "Netmask"
GATEWAY = "Gateway"
DNS_SERVERS = "DNS-Servers"
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(IntEnum):
    LAN_MANAGEMENT = 20
    LAN_STATIC = 40
    DHCP = 60
    DHCPv6 = 80

    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 = ip_address(ipAddress)
    except ValueError:
        return False
    return isinstance(ipObject, IPv4Address)


DEFAULT_LAN_IP = "10.2.0.100"
DEFAULT_LAN_MASK = "255.255.0.0"
DEFAULT_LAN_DNS_SERVERS = ["1.1.1.1", "8.8.8.8"]
DEFAULT_GATEWAY = ""
DEFAULT_NETWORK_CONFIG = {
    CONFIG_SYSTEM_SECTION: {
        CONFIG_NETWORK_SECTION: {
            CONFIG_DHCP_SECTION: {ENABLE: False},
            CONFIG_DHCPV6_SECTION: {ENABLE: False},
            CONFIG_LAN_MANAGEMENT_SECTION: {
                ENABLE: True,
                IP: DEFAULT_LAN_IP,
                NETMASK: DEFAULT_LAN_MASK,
                GATEWAY: DEFAULT_GATEWAY,
                DNS_SERVERS: DEFAULT_LAN_DNS_SERVERS,
            },
            CONFIG_LAN_STATIC_SECTION: {
                ENABLE: False,
                IP: "",
                NETMASK: "",
                GATEWAY: "",
                DNS_SERVERS: [],
            },
        }
    }
}
DEFAULT_DEVICE_CONFIG = {
    CONFIG_SERIAL_NUMBER: 0,
    CONFIG_ROLE: "ControllerRemoteDisplay",
    CONFIG_VARIANT: "",
}

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


def writeNetworkConfig(
    config: dict[str, dict | str | int | float | bool],
    netplanFile: Path,
    _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}
    }

    settingEnabled = config[ENABLE]
    if not settingEnabled:
        successful = disableLan(netplanFile)
        removeSuccess = "succeeded" if successful else "failed"
        _logger.info(f"Removing {lanType} as it is disabled, delete {removeSuccess}")
    else:
        if lanType in [LanTypes.LAN_STATIC, LanTypes.LAN_MANAGEMENT]:
            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}"
            )

            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}")
        else:
            if lanType == LanTypes.DHCP:
                dictTemplate[NETWORK][ETHERNETS][ETH0][DHCP4] = settingEnabled
            elif lanType == LanTypes.DHCPv6:
                dictTemplate[NETWORK][ETHERNETS][ETH0][DHCP6] = settingEnabled

    successful = bool(netplanFile.write_text(yaml.safe_dump(dictTemplate)))
    _logger.info(
        f"Configuration as {ENABLED if settingEnabled 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 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) == DEFAULT_LAN_IP:
        _logger.info("IP same as fallback. Reconfigure!")
        configValid = False

    return configValid


def configureNetwork(
    lanType: LanTypes,
    config: dict[str, dict | str | int | float | bool],
    logger: Logger,
    deviceConfig: dict[str, dict | str | int | float | bool] = {},
) -> None:
    """
    Configures the network settings according to the specified configuration.

    :param lanType: LAN type to be configured
    :param config: The config file as dict
    :param logger: Logger instance
    :param deviceConfig: configuration to retrieve the serial number and device role.
    """
    lanConfig = getIpConfig(lanType, config, logger, 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, logger, lanType)


def getIpConfig(
    lanType: LanTypes,
    config: dict[str, dict | str | int | float | bool],
    logger: Logger,
    deviceConfig: dict = {},
) -> dict[str, dict | str | int | float | bool]:
    """
    Returns the IP configuration for a particular lanType

    :param lanType: LAN type to be configured
    :param config: The config file as dict
    :param logger: Logger instance
    :param deviceConfig: configuration to retrieve the serial number and device role.
    :return: dict with all values for IP configuration for the specified lanType
    """
    systemSection = config.get(CONFIG_SYSTEM_SECTION, {})
    networkSection = systemSection.get(CONFIG_NETWORK_SECTION, {})
    networkSettings = networkSection.get(str(lanType), {})
    defaultNetworkSettings = (
        DEFAULT_NETWORK_CONFIG.get(CONFIG_SYSTEM_SECTION)
        .get(CONFIG_NETWORK_SECTION)
        .get(str(lanType))
    )
    serialNumber = deviceConfig.get(
        CONFIG_SERIAL_NUMBER, DEFAULT_DEVICE_CONFIG.get(CONFIG_SERIAL_NUMBER)
    )
    ipfromSerial = calcIPFromSerial(
        serialNumber,
        systemSection.get(CONFIG_ROLE, DEFAULT_DEVICE_CONFIG.get(CONFIG_ROLE)),
        deviceConfig.get(CONFIG_VARIANT, DEFAULT_DEVICE_CONFIG.get(CONFIG_VARIANT)),
    )
    lanConfig = {
        ENABLE: networkSettings.get(ENABLE, defaultNetworkSettings.get(ENABLE)),
        IP: networkSettings.get(IP, ipfromSerial),
        NETMASK: networkSettings.get(NETMASK, ""),
        GATEWAY: networkSettings.get(GATEWAY, ""),
        DNS_SERVERS: networkSettings.get(DNS_SERVERS, []),
    }
    logger.info(f"Network settings for {lanType}: {lanConfig}")
    if lanType in [LanTypes.LAN_STATIC, LanTypes.LAN_MANAGEMENT]:
        if not checkLanSettings(lanConfig, logger):
            logger.info(f"Network settings validity check failed, use fallback")
            # Set config to the fallback values if the settings aren't valid.
            lanConfig.update(defaultNetworkSettings)
            if serialNumber != 0:
                logger.info(
                    f"Serial number {ipfromSerial} is valid, use {ipfromSerial} instead for IP"
                )
                lanConfig[IP] = ipfromSerial
            logger.info(f"Network settings fallback: {lanConfig}")

    return lanConfig


def collectStaticIpv4Addresses(config: dict, logger: Logger) -> list[str]:
    """
    Returns a list with all static ipv4 addresses configured on the system

    This is the management IP (derived from the serial number), and, if configured, the static IP set in the config of
    ECP.

    :param config: The config as dict
    :param logger: The logger instance
    :return: all static IPv4 addresses
    """

    deviceSection = config.get(CONFIG_DEVICE_SECTION, {})
    systemSection = config.get(CONFIG_SYSTEM_SECTION, {})

    deviceConfig = {
        CONFIG_SERIAL_NUMBER: deviceSection.get(
            CONFIG_SERIAL_NUMBER, DEFAULT_DEVICE_CONFIG.get(CONFIG_SERIAL_NUMBER)
        ),
        CONFIG_ROLE: systemSection.get(
            CONFIG_ROLE, DEFAULT_DEVICE_CONFIG.get(CONFIG_ROLE)
        ),
        CONFIG_VARIANT: deviceSection.get(
            CONFIG_VARIANT, DEFAULT_DEVICE_CONFIG.get(CONFIG_VARIANT)
        ),
    }

    lanConfigs = [
        getIpConfig(LanTypes.LAN_MANAGEMENT, config, logger, deviceConfig=deviceConfig),
        getIpConfig(LanTypes.LAN_STATIC, config, logger),
    ]
    ipAddresses = []
    # only return address which are enabled and remove possible duplicates
    for lanConfig in lanConfigs:
        if lanConfig[IP] and lanConfig[ENABLE] and lanConfig[IP] not in ipAddresses:
            ipAddresses.append(lanConfig[IP])
    return ipAddresses


def portForwardingForDisplays(
    deviceConfig: dict[str, dict | str | int | float | bool], logger: Logger
) -> tuple[list[str], ...]:
    """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(CONFIG_SERIAL_NUMBER), REMOTE_HMI_1)
    displayIp2 = calcIPFromSerial(deviceConfig.get(CONFIG_SERIAL_NUMBER), 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)

    stdOuts = []
    stdErrs = []
    for command in portForwardingCommands:
        stdOut, stderr = executeCommand(command, logger)
        stdOuts.append(stdOut)
        stdErrs.append(stderr)
        logger.warning(
            f'execution of subprocess: {" ".join(command)}, stdOut: {stdOut}, stderr: {stderr}'
        )
    return stdOuts, stdErrs


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}')
    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 = getConfigAndSetupLogging(LOG_NAME, WARNING)
    logger.info(f"Using WorkDir: {workDir}")
    if not config:
        # use fallback values
        # device section
        deviceConfig = DEFAULT_DEVICE_CONFIG
        config = DEFAULT_NETWORK_CONFIG
    else:
        # device section
        deviceSection = config.get(CONFIG_DEVICE_SECTION, {})
        systemSection = config.get(CONFIG_SYSTEM_SECTION, {})
        # use a fallback value when not defined
        deviceConfig = {
            CONFIG_SERIAL_NUMBER: deviceSection.get(CONFIG_SERIAL_NUMBER, 0),
            CONFIG_ROLE: systemSection.get(CONFIG_ROLE, "ControllerRemoteDisplay"),
            CONFIG_VARIANT: deviceSection.get(CONFIG_VARIANT, ""),
        }

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

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

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

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

    configureNetwork(LanTypes.LAN_STATIC, config, logger)

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

    configureNetwork(LanTypes.DHCP, config, logger)

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

    configureNetwork(LanTypes.DHCPv6, config, logger)

    # --------------------------- 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 EX_OSERR

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

    stdOuts, stdErrs = portForwardingForDisplays(deviceConfig, logger)
    errors = [error for error in stdErrs if error]
    if errors:
        logger.warning(f"Port forwarding failed with errors: {errors}")
        return EX_OSERR
    return EX_OK


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