"""
The RunMobileBroadband establishes a connection from the modem. This is mainly done with the mmcli commands.
These mmcli commands can be written directly to the command line to communicate with the modem manager.

The full man-pages can be found in the following link.
https://www.freedesktop.org/software/ModemManager/man/1.0.0/mmcli.8.html
"""

import contextlib
import ipaddress
import json
import logging
import os
import psutil
import re
import socket
import yaml
import subprocess
import sys
import time
from logging import INFO, Logger
from pathlib import Path
from string import Template
from typing import Optional

from SetNetworkConfiguration import collectStaticIpv4Addresses
from Utility import getConfigAndSetupLogging

# Networkctl definitions
NCTL_FOLDER = "/run/systemd/network/"
NCTL_FILE = "/run/systemd/network/10-wwan0.network"
NCTL = "/bin/networkctl"
IP_TOOL = "/sbin/ip"
NETPLAN_IS_APPLIED_MAX_RETRIES = 15
NETPLAN_IS_APPLIED_INTERVAL = 3  # 1 second

# systemd-resolved definitions
SYSTEMD_RESOLVED_RELOAD_CMD = ["/bin/systemctl", "restart", "systemd-resolved"]
DNS_STUB_CONFIG_FILE = "/run/systemd/resolved.conf.d/80-ECP-DNS-stub.conf"
DNS_RESOLVED_SECTION_KEY = "Resolve"
DNS_STUB_EXTRA_IP_KEY = "DNSStubListenerExtra"
DNS_SERVICE_RELOAD_MIN_BACKOFF = 2  # If reloading systemd-resolved fails, delays next attempt with an exponential backoff delay, starting with this delay, multiplying the delay by 4 every attempt [seconds]
DNS_SERVICE_RELOAD_MAX_BACKOFF = 512  # If the reload delay exceeds this parameter, retrying is aborted. Results in a maximum of 5 attempts, with a maximum delay of 8.5mins

# Config definitions
BASE_CONF_PATH = "Interfaces/MobileBroadband/"
AUTH_TYPE = BASE_CONF_PATH + "AuthType"
AUTH_NAME = BASE_CONF_PATH + "AuthName"
MOBILE_BROADBAND_ENABLED = BASE_CONF_PATH + "Enable"
AUTH_PASSWORD = BASE_CONF_PATH + "AuthPassword"
APN = BASE_CONF_PATH + "APN"
PIN = BASE_CONF_PATH + "PIN"
OPERATOR_SELECTION = BASE_CONF_PATH + "OperatorSelection"
OPERATOR = BASE_CONF_PATH + "Operator"
TECHNOLOGY = BASE_CONF_PATH + "Technology"
ROUTE_OUTGOING_TRAFFIC_VIA = BASE_CONF_PATH + "RouteOutgoingTrafficVia"
LOG_NAME = "RunMobileBroadband"
# Priority for the interface. Decision if outgoing traffic should go via Modem or Ethernet
# Depends on value from config "Interfaces/MobileBroadband/RouteOutgoingTrafficVia"
WWAN0_METRIC_PRIO_HIGH = 50
WWAN0_METRIC_PRIO_LOW = 150

# Routing table definitions
PATH_ROUTINGTABLE_OS = "/etc/iproute2/rt_tables"
WWAN0_TABLE = "twwan"
WWAN0 = "wwan0"
# Hardware definitions
CDC_WDM0_PATH = "/sys/class/usbmisc/cdc-wdm0"
GPIO_PATH_BOARD_REV_9 = "/sys/class/gpio/gpio9/value"
GPIO_PATH_BOARD_REV_144 = "/sys/class/gpio/gpio144/value"
GPIO_PATH_BOARD_REV_146 = "/sys/class/gpio/gpio146/value"
GPIO_PATH_RESET = "/sys/class/gpio/gpio139/value"
BASEBOARD_10490_2A_REVISION = 7
BASEBOARD_10490_4A_REVISION = 0
MODEM_MODEL = "QUECTEL Mobile Broadband Module"
LEGACY_MODEM_MODEL = "0"
NO_MODEMS = "No modems were found"
WAS_SUCCESSFULLY = "successfully"

# Modem JSON definitions
JSON_MODEM = "modem"
JSON_GENERIC = "generic"
JSON_STATE = "state"
JSON_STATE_FAILED = "state-failed-reason"
JSON_MODEL = "model"
JSON_SIM = "sim"
JSON_BEARERS = "bearers"
JSON_BEARER = "bearer"
JSON_IPV4 = "ipv4-config"
JSON_MODEM_LIST = "modem-list"
JSON_ADDRESS = "address"
JSON_PREFIX = "prefix"
JSON_GATEWAY = "gateway"
JSON_DNS = "dns"
JSON_3GPP = "3gpp"
JSON_EPS = "eps"
JSON_INITIAL_BEARER = "initial-bearer"
JSON_SETTINGS = "settings"
JSON_OPERATOR_CODE = "operator-code"
JSON_OPERATOR_NAME = "operator-name"
JSON_SCAN_NETWORKS = "scan-networks"

# Modem state definitions
REGISTERED = "registered"
CONNECTED = "connected"
ENABLED = "enabled"
DISABLED = "disabled"
SEARCHING = "searching"
STATE_REGISTERED_OR_HIGHER = [REGISTERED, CONNECTED]

# Bash command definitions
MMCLI = "mmcli"
SCAN_MODEM = "-L"
MODEM_OPTION = "-m"
BEARER_OPTION = "-b"
SIM_OPTION = "-i"
MODEM_ENABLE = "-e"
JSON_FORMAT = "-J"
RESET_OPTION = "--reset"
SCAN_COMMAND = [MMCLI, SCAN_MODEM]
SCAN_COMMAND_JSON = [MMCLI, SCAN_MODEM, JSON_FORMAT]
MODEM_SOFT_RESET = [MMCLI, MODEM_OPTION, RESET_OPTION]

# enables IPv4 forwarding on system. Required for Network Address Translation (NAT) to work.
IP4_FORWARDING_COMMAND = ["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"]
# set up NAT masquerading using iptables. It performs masquerading for packets leaving the wwan0 interface
IPTABLES_MASQUERADE_COMMAND = [
    "/usr/sbin/iptables",
    "-t",
    "nat",
    "--append",
    "POSTROUTING",
    "-o",
    "wwan0",
    "-j",
    "MASQUERADE",
]
NCTL_RELOAD = [NCTL, "reload"]
NCTL_RECONFIGURE_WWAN0 = [NCTL, "reconfigure", "wwan0"]

RESTART_MODEM_MANAGER_COMMAND = ["systemctl", "restart", "ModemManager"]

# Other definitions
MODEM_MAX_ENABLE_COUNTER = 4
MODEM_SEARCH_ITERATION = 21
MODEM_REGISTRATION_ITERATION = 61
MODEM_CONNECTION_ITERATION = 5
OPERATOR_REGISTRATION_ITERATION = 5
OPERATOR_TIMEOUT = 2
PIN_SUB = "pin"
SIM_PIN = f"--pin=${PIN_SUB}"
TECH_SUB = "tech"
OP_SUB = "op"
APN_SUB = "apn"
USR_SUB = "user"
PWD_SUB = "password"
SET_TECHNOLOGY = f"--set-allowed-modes=${TECH_SUB}"
SET_OPERATOR = f"--3gpp-register-in-operator=${OP_SUB}"
SIMPLE_CONNECT = f"--simple-connect=apn=${APN_SUB},user=${USR_SUB},password=${PWD_SUB}"
REGISTER_HOME = "--3gpp-register-home"
SCAN_FOR_OPERATORS = "--3gpp-scan"
SET_INITIAL_BEARER = f"--3gpp-set-initial-eps-bearer-settings=apn=${APN_SUB}"
TIMEOUT_300 = "--timeout=300"

AUTO = "Auto"
MANUAL = "Manual"
MANUAL_AUTO_FALLBACK = "Manual-AutoFallback"

NO_MODEM_MANAGER = "couldn't find the ModemManager process in the bus"
ACTIVATION_REJECTED = "Activation rejected"
UNKNOWN_EQUIPMENT = "Error.MobileEquipment.Unknown"
TURN_MODEM_OFF = "0"
TURN_MODEM_ON = "1"

IP_SUP = "ip"
DNS_SUP = "dns"
GATEWAY_SUP = "gateway"
METRIC = "metric"
ETHERNET = "Ethernet"

NCTL_TEMPLATE = f"""
[Match]
Name=wwan0

[Network]
LinkLocalAddressing=ipv6
Address=${IP_SUP}
DNS=${DNS_SUP}
LLDP=false

[Route]
Destination=0.0.0.0/0
Gateway=${GATEWAY_SUP}
Metric=${METRIC}
"""


# Main functions
def checkAvailability(_logger) -> bool:
    """this for-loop checks 20 times whether the ModemManager has detected a modem
    with a sleep delay of 5 seconds between each run.
    If nothing is detected, a reset is performed before the script is terminated as failure.
    todo: The number of runs and delay can be adjusted when we have more experience with the ModemManager.
    """
    _logger.info("---- Start to check if the ModemManager recognizes a modem ----")
    for i in range(1, MODEM_SEARCH_ITERATION):
        availableModems = availableModem(_logger)
        if NO_MODEMS not in availableModems:
            _logger.info("a modem was recognized -> continue")
            return True

        _logger.info(
            "%s -> sleep for 5s and try again, loop number %i", availableModems, i
        )
        time.sleep(
            5
        )  # sleeping for 5 sec was tested and evaluated the optimal delay at the time

    _logger.info("%s -> reset", NO_MODEMS)
    return False


def readModemNumber(_logger) -> str | None:
    """When the modem is detected, a string is returned containing information about the location of the modem,
    which is filtered out here
    """
    replyMsg, errorMsg, _ = executeCommand(SCAN_COMMAND_JSON, _logger)
    if errorMsg:
        _logger.info(
            "Error happened while scanning for modems with traceback: %s", errorMsg
        )

    scannedModems = json.loads(replyMsg)
    # return None when scannedModem["modem-list"] is an empty list
    return scannedModems[JSON_MODEM_LIST][0] if scannedModems[JSON_MODEM_LIST] else None


def checkCorrectModem(modemNumber: str, _logger) -> bool:
    """Check whether the ModemManager has detected the correct modem,
    if not, a reset is performed before the script is terminated as failure.
    """
    _logger.info("---- check if the correct modem was recognized ----")

    modemModel = getModemProperties(modemNumber, _logger)[JSON_MODEM][JSON_GENERIC][
        JSON_MODEL
    ]
    if modemModel not in [MODEM_MODEL, LEGACY_MODEM_MODEL]:
        _logger.info("Wrong modem recognized: %s -> reset", modemModel)
        resetModemManager(_logger)
        return False
    _logger.info("Modem %s recognized -> continue", modemModel)
    return True


def setTechnology(modemNumber: str, modemConfig: dict, _logger) -> None:
    """Check and set the configured technology as "allowed technology" on the modem"""
    _logger.info("---- Start technology selection ----")
    if not (allowedTech := modemConfig[TECHNOLOGY]):
        _logger.info("No allowed Technology configured!")
        return

    _logger.info(f"Attempting to set allowed Technology to: {allowedTech}")

    command = [
        MMCLI,
        MODEM_OPTION,
        modemNumber,
        Template(SET_TECHNOLOGY).substitute({TECH_SUB: allowedTech}),
    ]
    techMsg, errorMsg, _ = executeCommand(command, _logger)
    if errorMsg:
        _logger.info(
            "Error happened while setting technology with traceback: %s", errorMsg
        )
    _logger.info(techMsg.rstrip("\n"))

    if WAS_SUCCESSFULLY not in techMsg:
        _logger.info("setting technology failed -> ignoring this step")


def enableModem(modemNumber: str, _logger) -> bool:
    """The modem is enabled and checked if it was successful,
    if not a reset is performed before the script is terminated as failure.
    """
    _logger.info("---- Try to enable the modem ----")

    replyMsg, errorMsg, _ = executeCommand(
        [MMCLI, MODEM_OPTION, modemNumber, MODEM_ENABLE], _logger
    )
    if errorMsg:
        _logger.info(
            "Error happened while enabling the modem with traceback: %s", errorMsg
        )
    enableMsg = replyMsg.strip()

    if WAS_SUCCESSFULLY not in enableMsg:
        modemProperties = getModemProperties(modemNumber, _logger)
        currentState = modemProperties[JSON_MODEM][JSON_GENERIC][JSON_STATE]
        stateReason = modemProperties[JSON_MODEM][JSON_GENERIC][JSON_STATE_FAILED]
        _logger.info(
            "Enable the modem failed at state: %s reason: %s  -> reset",
            currentState,
            stateReason,
        )
        return False
    _logger.info(enableMsg)
    return True


def sendSIMPin(modemNumber: str, modemConfig: dict, _logger) -> bool:
    """The SIM location is parsed out and the PIN is sent
    if this was not successful a reset is performed before the script is terminated as failure.
    """
    _logger.info("---- Start to enable SIM-card ----")
    if not (pin := modemConfig[PIN]):
        _logger.info("No PIN configured for the SIM-card")
        return True

    _logger.info("Try to send the PIN for the SIM-card")

    SIMNumber = getSimNumber(modemNumber, _logger)

    simMsg, errorMsg, _ = executeCommand(
        [MMCLI, SIM_OPTION, SIMNumber, Template(SIM_PIN).substitute({PIN_SUB: pin})],
        _logger,
    )
    if errorMsg:
        _logger.info(
            "Error happened sending the PIN to the SIM-card with traceback: %s",
            errorMsg,
        )
    _logger.info(simMsg)

    if WAS_SUCCESSFULLY not in simMsg:
        _logger.info("sending the pin failed -> reset")
        return False
    return True


def setOperator(modemNumber: str, modemConfig: dict, _logger) -> bool:
    """Check and set the configured operator"""
    _logger.info("---- Start operator selection ----")

    def checkOperatorSelectionMode():
        """Depending on configured operatorSelection exit or continue RunMobileBroadband"""
        if operatorSelection == MANUAL:
            _logger.info(f"register to {modemConfig[OPERATOR]} failed -> reset")
            return False
        else:
            # operatorSelection is set to Manual-AutoFallback
            _logger.info(f"register to {modemConfig[OPERATOR]} failed -> use Automatic")
            return True

    operatorSelection = modemConfig[OPERATOR_SELECTION]
    if not (operator := modemConfig[OPERATOR]):
        _logger.info("No operator configured")
        return checkOperatorSelectionMode()

    _logger.info(f"operator selection try to register to operator: {operator}")
    if isinstance(operator, str) and not operator.isdigit():
        _logger.info("Scanning now for operators this may take up to 180 seconds")
        operator = getMCCMNC(modemNumber, operator, _logger)
        if not operator:
            return checkOperatorSelectionMode()

    opMsg, errorMsg, _ = executeCommand(
        [
            MMCLI,
            MODEM_OPTION,
            modemNumber,
            Template(SET_OPERATOR).substitute({OP_SUB: operator}),
        ],
        _logger,
    )

    if errorMsg:
        _logger.info(
            "Error happened while register to operator with traceback: %s", errorMsg
        )
    _logger.info(opMsg.rstrip("\n"))
    if WAS_SUCCESSFULLY in opMsg:
        for i in range(OPERATOR_REGISTRATION_ITERATION):
            modemProperties = getModemProperties(modemNumber, _logger)
            modemOperator = (
                modemProperties.get(JSON_MODEM, {})
                .get(JSON_3GPP, {})
                .get(JSON_OPERATOR_CODE, "")
            )
            _logger.info(
                f"check if the registered operator is correct: {modemOperator} == {operator}"
            )

            if modemOperator == operator:
                return True
            time.sleep(OPERATOR_TIMEOUT)
    return checkOperatorSelectionMode()


def checkForModemState(modemNumber: str, _logger) -> bool:
    """After the modem has been enabled, it normally goes straight into the registered state.
    If this is not the case, it has difficulty finding an operator,
    so it checks here every 10s the state until the modem has found an operator
    """
    _logger.info(
        "---- check whether the modem was able to register or is still searching for an operator ----"
    )

    modemProperties = getModemProperties(modemNumber, _logger)
    currentState = modemProperties[JSON_MODEM][JSON_GENERIC][JSON_STATE]
    if currentState == DISABLED:
        _logger.info("modem disabled -> reset")
        return False
    enableCounter = MODEM_MAX_ENABLE_COUNTER - 1
    for i in range(1, MODEM_REGISTRATION_ITERATION):
        modemProperties = getModemProperties(modemNumber, _logger)
        currentState = modemProperties[JSON_MODEM][JSON_GENERIC][JSON_STATE]
        if currentState == ENABLED:
            enableCounter += 1
            _logger.info(
                "Modem is %s force a search in %i loops",
                currentState,
                MODEM_MAX_ENABLE_COUNTER - enableCounter,
            )
            if enableCounter >= MODEM_MAX_ENABLE_COUNTER:
                _logger.info("Modem has registration problems -> force a search")
                registerToHomeNetwork(modemNumber, _logger)
                enableCounter = 0
        elif currentState == SEARCHING:
            _logger.info("Modem is %s for an operator", currentState)
            enableCounter = 0
        if currentState in STATE_REGISTERED_OR_HIGHER:
            _logger.info("Modem %s -> continue", currentState)
            return True
        _logger.info(
            "Modem not registered yet -> sleep for 10s and try again, loop number %s/60",
            i,
        )
        time.sleep(
            10
        )  # sleeping for 10 sec was tested and evaluated the optimal delay at the time
    return False


def getMCCMNC(modemNumber: str, configuredOperator: str, _logger) -> Optional[str]:
    """Scan for networks, compare the configured value with the found networks and return the related operator code"""
    scanMsg, scanErrorMsg, _ = executeCommand(
        [
            MMCLI,
            MODEM_OPTION,
            modemNumber,
            SCAN_FOR_OPERATORS,
            JSON_FORMAT,
            TIMEOUT_300,
        ],
        _logger,
    )
    networks = (
        json.loads(scanMsg)
        .get(JSON_MODEM, {})
        .get(JSON_3GPP, {})
        .get(JSON_SCAN_NETWORKS, [])
    )
    # networks is a list of strings with all network infos. In the following line we convert the infos into a dict
    scanDictList = [
        {item.split(": ")[0]: item.split(": ")[1] for item in network.split(", ")}
        for network in networks
    ]
    # iterate through the network infos and return the operator code when the network is found
    for network in scanDictList:
        if network.get(JSON_OPERATOR_NAME) == configuredOperator:
            _logger.info(
                "Operator %s found with operator code: %s",
                configuredOperator,
                network.get(JSON_OPERATOR_CODE),
            )
            return network.get(JSON_OPERATOR_CODE)
    _logger.info("Operator %s not found", configuredOperator)


def connectModem(modemNumber: str, _logger, modemConfig: dict) -> bool:
    """Connects the modem with simple-connect and needs an APN
    if this was not successful a reset is performed before the script is terminated as failure.
    """
    _logger.info("---- Try to establish a connection to apn: %s ----", modemConfig[APN])

    connectionDict = {
        APN_SUB: modemConfig[APN],
        USR_SUB: modemConfig[AUTH_NAME],
        PWD_SUB: modemConfig[AUTH_PASSWORD],
    }
    simpleConnectCommand = Template(SIMPLE_CONNECT).substitute(connectionDict)

    # for a more complex connection with password use the command below
    # connectMessage=$(mmcli -m "$modemNumber" --create-bearer=['apn=$apn,user=$authName,password=$authPassword']
    for i in range(MODEM_CONNECTION_ITERATION):
        replyMsg, errorMsg, _ = executeCommand(
            [MMCLI, MODEM_OPTION, modemNumber, simpleConnectCommand], _logger
        )
        if errorMsg:
            _logger.info(
                "Error happened while connecting the modem with traceback: %s", errorMsg
            )
            if ACTIVATION_REJECTED in errorMsg:
                _logger.info(
                    "Although the modem is registered with a provider, it cannot establish a connection."
                    "\n    - Check the config is a valid APN configured?"
                    "\n    - The SIM card may be blocked or your data volume may be exhausted."
                )
            if UNKNOWN_EQUIPMENT in errorMsg:
                """Edge case, if the device is commissioned in Switzerland with a whereever SIM card and
                installed abroad with a different SIM card and APN, the initial bearer APN will be set to wm,
                causing the Internet connection to take much longer to establish."""
                replyMsgInfo, _, _ = executeCommand(
                    [MMCLI, MODEM_OPTION, modemNumber, JSON_FORMAT], _logger
                )
                initBearerSettings = (
                    json.loads(replyMsgInfo)
                    .get(JSON_MODEM, {})
                    .get(JSON_3GPP, {})
                    .get(JSON_EPS, {})
                    .get(JSON_INITIAL_BEARER, {})
                    .get(JSON_SETTINGS, {})
                )
                if initBearerSettings.get(APN_SUB) != modemConfig[APN]:
                    # Initial bearer apn differs from the configured apn -> set new initial bearer
                    _logger.info(
                        "initial-bearer apn: %s != %s current apn -> configure modem with new initial bearer %s",
                        initBearerSettings.get(APN_SUB),
                        modemConfig[APN],
                        modemConfig[APN],
                    )
                    setInitialBearerCommand = Template(SET_INITIAL_BEARER).substitute(
                        connectionDict
                    )
                    replyMsgInit, _, _ = executeCommand(
                        [MMCLI, MODEM_OPTION, modemNumber, setInitialBearerCommand],
                        _logger,
                    )
                    _logger.info(replyMsgInit)

        connectMsg = replyMsg.strip()

        if WAS_SUCCESSFULLY in connectMsg:
            _logger.info(connectMsg)
            return True
        _logger.info(
            "Could not connect to APN retry in 4s %s/%s", i, MODEM_CONNECTION_ITERATION
        )
        time.sleep(4)

    _logger.info("connecting the modem failed -> reset")
    return False


def readBearerNumber(modemNumber: str, _logger) -> str:
    """Retrieve the bearer number, the bearer contains connection information, including the assigned ip address."""
    _logger.info("---- retrieve bearer number ----")
    replyMsg, errorMsg, _ = executeCommand(
        [MMCLI, MODEM_OPTION, modemNumber, JSON_FORMAT], _logger
    )
    if errorMsg:
        _logger.info(
            "Error happened while connecting the modem with traceback: %s", errorMsg
        )
    modemProperties = json.loads(replyMsg)
    return modemProperties[JSON_MODEM][JSON_GENERIC][JSON_BEARERS][0]


def getIpFromBearer(bearerNumber: str, _logger) -> tuple[str, str, str, list[str]]:
    """Retrieve all needed bearer information such as ip-address, ip-prefix, gateway and dns"""
    _logger.info("---- Get the IP from the bearer ----")

    replyMsg, errorMsg, _ = executeCommand(
        [MMCLI, BEARER_OPTION, bearerNumber, JSON_FORMAT], _logger
    )
    if errorMsg:
        _logger.info(
            "Error happened while getting bearer properties with traceback: %s",
            errorMsg,
        )
    bearerProperties = json.loads(replyMsg)
    ipConfig = bearerProperties[JSON_BEARER][JSON_IPV4]

    _logger.info(
        # for readability is this log statement kept an f-string
        f"IP-address: {ipConfig[JSON_ADDRESS]}/{ipConfig[JSON_PREFIX]}, gateway: {ipConfig[JSON_GATEWAY]}, DNS: {ipConfig[JSON_DNS]}"
    )
    return (
        ipConfig[JSON_ADDRESS],
        ipConfig[JSON_PREFIX],
        ipConfig[JSON_GATEWAY],
        ipConfig[JSON_DNS],
    )


def checkAndSetUpNetworkFile(ipAddr, prefix, gateway, dns, metric, _logger) -> bool:
    """write the connection details to 10-wwan0.network in /run/systemd/network/"""
    _logger.info("---- Set up internet connection ----")

    filePath = Path(NCTL_FILE)

    nctlDict = {
        IP_SUP: f"{ipAddr}/{prefix}",
        DNS_SUP: " ".join(dns),
        GATEWAY_SUP: gateway,
        METRIC: metric,
    }
    simpleConnectCommand = Template(NCTL_TEMPLATE).substitute(nctlDict)
    if filePath.is_file() and simpleConnectCommand == filePath.read_text():
        _logger.info("The content of wwan0 config file matches the new config")
        return True
    elif not bool(filePath.write_text(simpleConnectCommand)):
        _logger.info("nothing was written to the networkctl config")
        return False
    _logger.info(f"written to networkctl {simpleConnectCommand}")
    return False


def isConnected(modemNumber: str, _logger) -> bool:
    """It has been observed that the modem can fall back into the registered state after connection,
    therefore the connected status is checked here
    """
    _logger.info("connection should be established checking now if it did not fallback")
    for i in range(1, MODEM_CONNECTION_ITERATION):
        modemProperties = getModemProperties(modemNumber, _logger)
        currentState = modemProperties[JSON_MODEM][JSON_GENERIC][JSON_STATE]

        if currentState != CONNECTED:
            _logger.info(
                "Modem should be in state connected but fell back to: %s -> reset",
                currentState,
            )
            return False
        _logger.info("%s -> next check in 2s, loop number %i/4", currentState, i)
        time.sleep(
            2
        )  # sleeping for 2 sec was tested and evaluated the optimal delay at the time
    return True


# Helper functions
def reset(_logger, modemNumber: str| None) -> None:
    """the reset function toggles a GPIO pin to remove power from the modem.

    Do static reset only if it is not the old board revision
    On old boards the reset disrupts 3.3V supply and makes SD-Card unavailable
    """
    _logger.info("---- RESET STARTED ----")

    resetPath: Path = Path(GPIO_PATH_RESET)
    value139 = int(resetPath.read_text())

    gpio9Path = Path(GPIO_PATH_BOARD_REV_9)
    gpio144Path = Path(GPIO_PATH_BOARD_REV_144)
    gpio146Path = Path(GPIO_PATH_BOARD_REV_146)

    value9 = int(gpio9Path.read_text())
    value144 = int(gpio144Path.read_text())
    value146 = int(gpio146Path.read_text())

    boardRev = value9 + (value144 << 1) + (value146 << 2)

    if boardRev in [BASEBOARD_10490_2A_REVISION, BASEBOARD_10490_4A_REVISION]:
        # On older board revision <= *.4A only do software reset of modem
        # This way prevent SD-Card corruption or similar failures
        if  modemNumber is not None:
            # If the modemNumber is 'None', the modem manager could not find a modem
            # and a soft reset is therefore impossible.
            MODEM_SOFT_RESET.insert(2, modemNumber)
            _logger.info(f"ResetCommand: {MODEM_SOFT_RESET}")
            replyMsg, errorMsg, _ = executeCommand(MODEM_SOFT_RESET, _logger)
            if errorMsg:
                _logger.info(
                    "Error happened while resetting modem with traceback: %s", errorMsg
                )
            _logger.info("Software reset triggered wait until path disappears")
            while Path(CDC_WDM0_PATH).exists():
                time.sleep(0.02)
    else:
        # on newer boards revision >= *.5A do hard reset. Display boards do not have a modem slot and there is no effect there.
        _logger.info(f"Do hard reset by toggling {GPIO_PATH_RESET} with value {value139}")
        writeToPath(resetPath, TURN_MODEM_OFF, _logger)
        # if you put only 2s the kernel will not unregister the ports and drivers but it might still work(needs testing)
        time.sleep(4)
        writeToPath(resetPath, TURN_MODEM_ON, _logger)


def writeToPath(path: Path, message: str, _logger) -> None:
    """write a message to a file at the given path"""
    if not bool(path.write_text(message)):
        _logger.info("%s was not written to %s", message, path)
        return
    _logger.info("%s was written to %s", message, path)


def availableModem(_logger) -> str:
    """mmcli -L checks if the ModemManager has recognised any modems"""
    replyMsg, errorMsg, _ = executeCommand(SCAN_COMMAND, _logger)
    if errorMsg:
        _logger.info(
            "Error happened while scanning for modems with traceback: %s", errorMsg
        )
    return replyMsg.strip()


def resetModemManager(_logger) -> str:
    """resets the ModemManager.service"""
    replyMsg, errorMsg, _ = executeCommand(RESTART_MODEM_MANAGER_COMMAND, _logger)
    if errorMsg:
        _logger.info(
            "Error happened while scanning for modems with traceback: %s", errorMsg
        )
    return replyMsg.strip()


def getModemProperties(modemNumber: str, _logger) -> dict:
    """returns all properties of the modem"""
    replyMsg, errorMsg, _ = executeCommand(
        [MMCLI, MODEM_OPTION, modemNumber, JSON_FORMAT], _logger
    )
    if errorMsg:
        _logger.info(
            "Error happened while getting modem properties with traceback: %s", errorMsg
        )
    modemProperties = json.loads(replyMsg)
    return modemProperties


def getSimNumber(modemNumber: str, _logger) -> str:
    """returns the SIM slot number"""
    return getModemProperties(modemNumber, _logger)[JSON_MODEM][JSON_GENERIC][JSON_SIM]


def registerToHomeNetwork(modemNumber: str, _logger) -> None:
    """Sets the modem to searching state, which actively tries to register to a network"""
    _logger.info("trying to register modem")
    replyMsg, errorMsg, _ = executeCommand(
        [MMCLI, MODEM_OPTION, modemNumber, REGISTER_HOME], _logger
    )
    if errorMsg:
        _logger.info(
            "Error happened trying to register modem to network with traceback: %s",
            errorMsg,
        )


def executeCommand(command: list[str], _logger) -> tuple[str, str, int]:
    """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()
            returncode = executed.returncode

            if NO_MODEM_MANAGER in stderr:
                _logger.info(
                    "couldn't find the ModemManager process in the bus -> restart ModemManager"
                )
                executeCommand(RESTART_MODEM_MANAGER_COMMAND, _logger)
                sys.exit(os.EX_OSERR)
            return stdOut, stderr.strip(), returncode

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


def parseConfig(loadedConfig: dict, _logger) -> dict[str, str | int | bool]:
    """Parse the network relevant keys from ECP configuration
    Config file location is determined by environment variable `ECP_WORK_FILES_DIR`.

    :return: parsed network config params
    """

    parsedConf = {
        key: getConfigKey(loadedConfig, key) or None
        for key in (
            AUTH_TYPE,
            AUTH_NAME,
            MOBILE_BROADBAND_ENABLED,
            AUTH_PASSWORD,
            APN,
            PIN,
            OPERATOR_SELECTION,
            OPERATOR,
            TECHNOLOGY,
            ROUTE_OUTGOING_TRAFFIC_VIA,
        )
    }

    return parsedConf


def getConfigKey(config: dict, key: str) -> str | bool | int | None:
    """Get the configuration key for a given slash separated config path

    :param config: the configuration dictionary
    :param key: the slash separated config path
    :return: config value or `None` if the key is missing
    """
    if not config:
        return None
    subkey, _, remainingKey = key.partition("/")
    return (
        getConfigKey(config.get(subkey), remainingKey)
        if remainingKey
        else config.get(subkey)
    )


def setupDefaultGatewayWithTable(
    gateway: str, interface: str, table: str, _logger: Logger
) -> None:
    """
    Declares the default gateway of the interface wwan0 via a custom table.
    This ensures that the OS is not forgetful like a human and forwards the TCP/UDP packets back
    the way they came in (same interface)

    :param gateway: the default gateway of the interface wwan0
    :param interface: the interface name
    :param table: the table number for the interface routing
    :param _logger: the logger object
    """
    _logger.info(
        f"MobileBroadband defines the default gateway via table. Table name is: {table}."
    )
    # Create routing table
    _, errorMsgForwarding, _ = executeCommand(
        [
            IP_TOOL,
            "route",
            "add",
            "default",
            "via",
            gateway,
            "dev",
            interface,
            "table",
            table,
        ],
        _logger,
    )
    if errorMsgForwarding:
        _logger.info(
            "Error happened while creation of the new default gateway with table: %s",
            errorMsgForwarding,
        )
        sys.exit(os.EX_OSERR)


def addRoutingPolicy(network: str, netmask: str, table: str, _logger: Logger) -> None:
    """
    Adds a network to a table for policy based routing rule.

    :param network: the network address
    :param table: the table name for the interface routing
    :param _logger: the logger object
    """
    _logger.info(
        f"MobileBroadband adds the network {network}/{netmask} as rule to route via table: {table}."
    )
    # Add policy routing rule
    _, errorMsgForwarding, _ = executeCommand(
        [IP_TOOL, "rule", "add", "from", f"{network}/{netmask}", "table", table],
        _logger,
    )
    if errorMsgForwarding:
        _logger.info(
            "Error happened while adding of the policy routing rule: %s",
            errorMsgForwarding,
        )
        sys.exit(os.EX_OSERR)


def getNetworkAddressFromIp(ipAddress: str, netmask: str, _logger: Logger) -> str:
    """
    Return the network address from an IP address.

    :param ipAddress: the IP address for lookup the network address.
    :return: the network address from an IP address.
    """
    try:
        host = ipaddress.IPv4Interface(f"{ipAddress}/{netmask}")
        return host.network.network_address.compressed
    except ValueError as error:
        _logger.info(
            "Error happened while getting network address. Given ipAddress: %s/%s is not valid. Error: %s",
            ipAddress,
            netmask,
            error,
        )
        sys.exit(os.EX_OSERR)


def waitNetplanHasSetIpAddress(
    maxCount: int, retryinveralSeconds: int, _logger: Logger
) -> None:
    """
    Checks after netplan apply if the changes where set. The operating system schedules the tasks by himself
    it's not predictable possible to get the confirmation of apply.

    :param maxCount: the maximum number of retrieving action.
    :param retryinveralSeconds: the number of seconds to wait for the tasks to complete.
    :param _logger: the logger object
    """
    _logger.info(
        f"MobileBroadband waits for network to be applied in the operating system."
    )

    for count in range(1, maxCount + 1):
        with contextlib.suppress(KeyError):
            for addr in psutil.net_if_addrs()[WWAN0]:
                if addr.family == socket.AF_INET:
                    return
        _logger.info(
            "Mobilebroadband could not find any applied address in wwan0 interface. Retry in %is. Try %i/%i",
            retryinveralSeconds,
            count,
            maxCount,
        )
        time.sleep(retryinveralSeconds)

    _logger.info(
        "Netplan was applied but No IP is for the OS visible. Something went wrong."
    )
    sys.exit(os.EX_OSERR)


def checkRoutingTable(targetTableName: str, _logger: Logger) -> bool:
    """
    Checks if a tablename exist in the preconfigured os.

    :param targetTableName: the name of the table to check
    :param _logger: the logger object
    :return: True if the table exist, False otherwise
    """
    try:
        pattern = re.compile(rf"^\d+\s+{targetTableName}", re.MULTILINE)
        content = Path(PATH_ROUTINGTABLE_OS).read_text()
        if pattern.search(content):
            return True

        _logger.info(
            "Table '%s'  does not exist. Please upgrade firmware. Modem is always prioritized.",
            targetTableName,
        )
        return False

    except FileNotFoundError:
        _logger.info(
            "Oh no, the file '%s' is not there. Something went wrong. Modem is always prioritized.",
            PATH_ROUTINGTABLE_OS,
        )
        return False


def writeDnsStubConfig(staticIpv4s: list[str], logger: Logger):
    """
    Writes the config for systemd-resolved to listen for DNS queries on the specified IPv4 addresses

    :param staticIpv4s: list of static IPv4 addresses on which DNS queries shall be served
    :param logger: the logger to use
    """

    # Add the section to the conf file
    configFile = f"[{DNS_RESOLVED_SECTION_KEY}]\n"

    # Add all static IPs
    for ip in staticIpv4s:
        configFile += f"{DNS_STUB_EXTRA_IP_KEY}={ip}\n"

    # Create the directory for the config file if it doesn't exist, write the file
    Path(DNS_STUB_CONFIG_FILE).parent.mkdir(parents=True, exist_ok=True)
    Path(DNS_STUB_CONFIG_FILE).write_text(configFile)

    logger.info("Starting the DNS stub resolver on the IPs %s", str(staticIpv4s))


def activateDnsStubResolver(
    config: dict, logger, retryDelay: float = DNS_SERVICE_RELOAD_MIN_BACKOFF
):
    """
    Activates the DNS stub resolver on the static IP addresses

    Writes config and reloads the service systemd-resolved. If reloading fails, waits the retry delay and retries with a delay
    multiplied by four, up to the maximum delay.

    :param config: ECP configuration dict
    :param logger: the logger to use
    :param retryDelay: delay in seconds before the next attempt if reloading fails
    """
    # Write config for systemd-resolved that activates the DNS stub
    staticIpv4s = collectStaticIpv4Addresses(config, logger)
    writeDnsStubConfig(staticIpv4s, logger)

    # Reload the configuration of systemd-resolved
    stdOut, stdErr, returncode = executeCommand(SYSTEMD_RESOLVED_RELOAD_CMD, logger)
    logger.debug(
        "execution of subprocess: %s, stdOut: %s, stderr: %s",
        " ".join(SYSTEMD_RESOLVED_RELOAD_CMD),
        stdOut,
        stdErr,
    )
    if returncode:
        # Encountered error -> retry or abort
        logger.warning(f"reloading systemd-resolved failed with error: {stdErr}")
        if retryDelay > DNS_SERVICE_RELOAD_MAX_BACKOFF:
            logger.error("Failed to activate DNS server. Not trying further.")
        else:
            logger.warning(f"Retry in {retryDelay}s")
            time.sleep(retryDelay)
            activateDnsStubResolver(config, logger, retryDelay * 4)
        return

    logger.info("Reloaded systemd-resolved successfully. DNS server activated.")


def main() -> int:
    """Connection establishment of the modem."""
    workDir, config, logger = getConfigAndSetupLogging(LOG_NAME, INFO)
    logger.info(f"Using WorkDir: {workDir}")
    resetGPIOPath = Path(GPIO_PATH_RESET)
    # Check if the folder /run/systemd/network/ exists if not it will be created.
    Path(NCTL_FOLDER).mkdir(exist_ok=True)
    logger.info("---- MobileBroadband enabled -> starting to connect the modem ----")

    # --------------------------- Gather config.json information ----------------------------------------------------- #

    mobileBroadbandConfig = parseConfig(config, logger)

    # --------------------------- Check modem number since uboot starts Modem  --------------------------------------- #
    modemNumber = readModemNumber(logger)
    logger.info("Modem found at slot: %s", modemNumber)

    # --------------------------- Set the GPIO reset pin to 1 -------------------------------------------------------- #
    logger.info("Reset modem and start connection establishment")
    reset(logger, modemNumber)

    # --------------------------- Check whether the ModemManager has found the correct modem ------------------------- #

    if not checkAvailability(logger):
        return os.EX_SOFTWARE

    modemNumber = readModemNumber(logger)
    if modemNumber is None:
        logger.error("No modem found!! checkAvailability should have not succeeded!")
        return os.EX_SOFTWARE
    logger.info("Modem found at slot: %s", modemNumber)

    if not checkCorrectModem(modemNumber, logger):
        return os.EX_SOFTWARE

    # --------------------------- Enabling of the modem  ------------------------------------------------------------- #
    setTechnology(modemNumber, mobileBroadbandConfig, logger)
    if not enableModem(modemNumber, logger):
        return os.EX_SOFTWARE

    # ----------- Check whether the modem could register with an operator and establish a connection  ---------------- #

    if not sendSIMPin(
        modemNumber, mobileBroadbandConfig, logger
    ):  # Not needed for us right now
        return os.EX_SOFTWARE

    if mobileBroadbandConfig[OPERATOR_SELECTION] != AUTO:
        if not setOperator(modemNumber, mobileBroadbandConfig, logger):
            return os.EX_SOFTWARE

    if not checkForModemState(modemNumber, logger):
        return os.EX_SOFTWARE

    if not connectModem(modemNumber, logger, mobileBroadbandConfig):
        return os.EX_SOFTWARE

    # ------------------ check if the modem still is connected  ------------------------------------------------------ #

    if not isConnected(modemNumber, logger):
        return os.EX_SOFTWARE

    # ------------------ Retrieving bearer information and establishing an internet connection  ---------------------- #

    bearerNumber = readBearerNumber(modemNumber, logger)
    ipAddr, ipPrefix, gateway, dns = getIpFromBearer(bearerNumber, logger)

    # Important for not having and self locked out situation
    # If the routing table does not exist in the OS, it cannot be enforced the traffic via ethernet
    # Otherwise you cannot reach anymore the device remotely via modem

    doesTableExistInOs = checkRoutingTable(WWAN0_TABLE, logger)

    # Decides if the outgoing tcp/udp traffic goes via wwan0 or eth0
    # Lower metric equals higher priority
    # Access via Modem will always be possible
    # Table must exist in /etc/iproute2/rt_tables for enabling Ethernet as primary out of traffic

    metric = (
        WWAN0_METRIC_PRIO_LOW
        if mobileBroadbandConfig[ROUTE_OUTGOING_TRAFFIC_VIA] == ETHERNET
        and doesTableExistInOs
        else WWAN0_METRIC_PRIO_HIGH
    )

    # --------------------------- Applying modem configurations  ----------------------------------------------------- #
    # Applying the configurations with networkctl
    if not checkAndSetUpNetworkFile(ipAddr, ipPrefix, gateway, dns, metric, logger):
        _, errorMsg, _ = executeCommand(NCTL_RELOAD, logger)
        if errorMsg:
            logger.info(
                "Error happened while trying to reload networkctl with traceback: %s",
                errorMsg,
            )
            return os.EX_OSERR

        _, errorMsg, _ = executeCommand(NCTL_RECONFIGURE_WWAN0, logger)
        if errorMsg:
            logger.info(
                "Error happened while trying to reconfigure networkctl with traceback: %s",
                errorMsg,
            )
            return os.EX_OSERR

    logger.info(
        "MobileBroadband active apply IPv4 forwarding and masquerading to Base- or Controllerboard."
    )
    # Enable IPv4 forwarding
    _, errorMsgForwarding, _ = executeCommand(IP4_FORWARDING_COMMAND, logger)
    if errorMsgForwarding:
        logger.info("Error happened while IPv4 forwarding: %s", errorMsgForwarding)
        return os.EX_OSERR

    # Masquerade
    _, errorMsgMasquerading, _ = executeCommand(IPTABLES_MASQUERADE_COMMAND, logger)
    if errorMsgMasquerading:
        logger.info("Error happened while masquerading: %s", errorMsgMasquerading)
        return os.EX_OSERR

    # --------------------------- Start DNS stub resolver on fixed IP addresses  ------------------------------------- #

    activateDnsStubResolver(config, logger)

    # ------------------ Wait to netplan apply changes takes effect  ------------------------------------------------- #

    waitNetplanHasSetIpAddress(
        maxCount=NETPLAN_IS_APPLIED_MAX_RETRIES,
        retryinveralSeconds=NETPLAN_IS_APPLIED_INTERVAL,
        _logger=logger,
    )

    # ------------------ setup policy bsed routing for the modem  ---------------------------------------------------- #

    if doesTableExistInOs:
        # Setup and ensure the traffic goes back via same interface it comes in.
        # When tcp/udp comes in via wwan0, responses again back via wwan0.
        network = getNetworkAddressFromIp(
            ipAddress=ipAddr, netmask=ipPrefix, _logger=logger
        )
        setupDefaultGatewayWithTable(
            gateway=gateway, interface=WWAN0, table=WWAN0_TABLE, _logger=logger
        )
        addRoutingPolicy(
            network=network, netmask=ipPrefix, table=WWAN0_TABLE, _logger=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)
