"""
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 asyncio
import ipaddress
import os
import re
import subprocess
import sys
from logging import INFO, Logger
from pathlib import Path
from string import Template
from typing import Optional, Coroutine

import dbus_next
from dbus_next import Variant

from SetNetworkConfiguration import collectStaticIpv4Addresses
from Utility import getConfigAndSetupLogging, connectSystemBus, getDbusIface

# Networkctl definitions
RUN_NCTL_FOLDER = "/run/systemd/network/"
RUN_NCTL_FILE = RUN_NCTL_FOLDER + "10-wwan0.network"
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 = "System/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"
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 "System/MobileBroadband/RouteOutgoingTrafficVia"
WWAN0_METRIC_PRIO_HIGH = 50
WWAN0_METRIC_PRIO_LOW = 150

SERVICE_LOADED = "loaded"
SERVICE_ACTIVE = "active"
MOBILE_BROADBAND = "MobileBroadband.service"
MODEM_MANAGER = "ModemManager.service"

TECH_2G = "2g"
TECH_3G = "3g"
TECH_4G = "4g"
TECH_ANY = "Any"

TECH_MAP = {TECH_2G: [2, 0], TECH_3G: [4, 0], TECH_4G: [8, 0], TECH_ANY: [14, 8]}

FAILED_REASONS = {
    0: "No error",
    1: "Unknown error",
    2: "SIM missing",
    3: "SIM unusable",
}

# there are 18 access technologies supported by ModemManager see more https://www.freedesktop.org/software/ModemManager/doc/latest/ModemManager/ref-dbus.html
RANGE_2G = range(1, 32)  # 2g: 1, 2, 4, 8, 16
RANGE_3G = range(32, 16384)  # 3g: 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192
RANGE_4G = range(16384, 262144)  # 4g: 16384, 32768, 65536, 131072
RANGE_ANY = range(262144, 262145)  # all: 262144
ACCESS_TECH_MAP = {
    TECH_2G: RANGE_2G,
    TECH_3G: RANGE_3G,
    TECH_4G: RANGE_4G,
    TECH_ANY: RANGE_ANY,
}

# 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"
MAX_SOFT_RESETS = 4  # max tries of soft reset if hard reset is allowed


# DBUS definitions
OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager"
# DBUS systemd definitions
SYSTEMD_PATH = "/org/freedesktop/systemd1"
SYSTEMD_BUS_NAME = "org.freedesktop.systemd1"
SYSTEMD_MANAGER_IFACE = SYSTEMD_BUS_NAME + ".Manager"
UNIT_INTERFACE = SYSTEMD_BUS_NAME + ".Unit"
SERVICE_IFACE = SYSTEMD_BUS_NAME + ".Service"
# DBUS networkd definitions
NETWORKD_PATH = "/org/freedesktop/network1"
NETWORKD_SERVICE = "org.freedesktop.network1"
NETWORKD_MANAGER_IFACE = NETWORKD_SERVICE + ".Manager"
NETWORKD_LINK_IFACE = NETWORKD_SERVICE + ".Link"
# DBUS modemManager definitions
MM_PATH = "/org/freedesktop/ModemManager1"
MM_SERVICE = MM_IFACE = "org.freedesktop.ModemManager1"
BEARER_IFACE = MM_IFACE + ".Bearer"
MODEM_IFACE = MM_IFACE + ".Modem"
SIMPLE_IFACE = MODEM_IFACE + ".Simple"
GPP_IFACE = MODEM_IFACE + ".Modem3gpp"

# Modem JSON definitions
JSON_ADDRESS = "address"
JSON_PREFIX = "prefix"
JSON_GATEWAY = "gateway"
JSON_DNS = "dns"

JSON_OPERATOR_CODE = "operator-code"
JSON_OPERATOR_LONG = "operator-long"
JSON_ACCESS_TECH = "access-technology"

# Modem state definitions
FAILED = -1
UNKNOWN = 0
INITIALIZING = 1
LOCKED = 2
DISABLED = 3
DISABLING = 4
ENABLING = 5
ENABLED = 6
SEARCHING = 7
REGISTERED = 8
DISCONNECTING = 9
CONNECTING = 10
CONNECTED = 11

MODEM_STATE_READABLE = {
    FAILED: "failed",
    UNKNOWN: "unknown",
    INITIALIZING: "initializing",
    LOCKED: "locked",
    DISABLED: "disabled",
    DISABLING: "disabling",
    ENABLING: "enabling",
    ENABLED: "enabled",
    SEARCHING: "searching",
    REGISTERED: "registered",
    DISCONNECTING: "disconnecting",
    CONNECTING: "connecting",
    CONNECTED: "connected",
}


# 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",
]

# Other definitions
MODEM_MAX_ENABLE_COUNTER = 4
MODEM_SEARCH_ITERATION = 21
MODEM_REGISTRATION_ITERATION = 61
OPERATOR_REGISTRATION_ITERATION = MODEM_CONNECTION_ITERATION = INIT_BEARER_CHECK = 5
OPERATOR_TIMEOUT = 2
APN_SUB = "apn"
USR_SUB = "user"
PWD_SUB = "password"

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

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}
"""


async def getModemPath(manager, _logger) -> tuple[str, int]:
    """check for an available modem and return the path when found"""
    for i in range(1, MODEM_SEARCH_ITERATION):
        if managed := await callDbusIface(manager.call_get_managed_objects(), _logger):
            for path, ifaces in managed.items():
                if MODEM_IFACE in ifaces:
                    _logger.info("Found modem interface %s", path)
                    return path, i

        _logger.info(f"{NO_MODEMS} -> sleep for 5s and try again, loop number %i", i)
        await asyncio.sleep(5)

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


async def checkCorrectModem(modemInterface, _logger) -> bool:
    """Check whether the ModemManager has detected the correct modem"""
    _logger.info("---- check if the correct modem was recognized ----")

    modemModel = await callDbusIface(modemInterface.get_model(), _logger)
    if modemModel not in [MODEM_MODEL, LEGACY_MODEM_MODEL]:
        _logger.info("Wrong modem recognized: %s -> restart", modemModel)
        return False
    _logger.info("Modem %s recognized -> continue", modemModel)
    return True


async def setTechnology(modemInterface, modemConfig: dict, _logger) -> None:
    """Check and set the configured technology as "allowed technology" on the modem"""
    _logger.info("---- Start technology selection ----")
    if not (configuredTech := modemConfig.get(TECHNOLOGY)):
        _logger.info("No allowed Technology configured!")
        return

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

    supportedModes = await callDbusIface(modemInterface.get_supported_modes(), _logger)

    if configuredTech in TECH_MAP.keys() and TECH_MAP.get(configuredTech) in supportedModes:
        await callDbusIface(modemInterface.call_set_current_modes(TECH_MAP.get(configuredTech)), _logger)
    else:
        _logger.info("setting technology failed -> ignoring this step")


async def enableModem(modemInterface, _logger) -> bool:
    """The modem is enabled and checked if it was successful"""
    _logger.info("---- Try to enable the modem ----")
    state = await callDbusIface(modemInterface.get_state(), _logger)

    if state < 0:
        failedReason = await callDbusIface(
            modemInterface.get_state_failed_reason(), _logger
        )
        _logger.info(
            f"Modem in failed state: {FAILED_REASONS.get(failedReason, FAILED_REASONS.get(1))}"
        )
        return False

    for i in range(1, MODEM_MAX_ENABLE_COUNTER):
        await callDbusIface(modemInterface.call_enable(True), _logger)
        state = await callDbusIface(modemInterface.get_state(), _logger)
        if state >= ENABLED:
            _logger.info(f"Modem in state: {MODEM_STATE_READABLE.get(state)} -> continue")
            return True
        _logger.info(f"Modem in state: {MODEM_STATE_READABLE.get(state)} -> retry")
        await asyncio.sleep(1)
    _logger.info(f"Modem in state: {MODEM_STATE_READABLE.get(state)} -> reset")
    return False


async def checkInitialBearer(gppInterface, modemConfig: dict, _logger) -> bool:
    """check if the initial bearer apn is the same as the configured apn"""
    _logger.info("---- Start initial bearer check ----")
    for i in range(INIT_BEARER_CHECK):
        initBearerSettings = await callDbusIface(gppInterface.get_initial_eps_bearer_settings(), _logger)
        if not initBearerSettings.get(APN_SUB).value or modemConfig.get(APN, "") in initBearerSettings.get(APN_SUB).value:
            _logger.info("Setting initial bearer finished or not needed")
            return True
        _logger.info(f"{modemConfig.get(APN)} != {initBearerSettings.get(APN_SUB).value} set new initial bearer apn")
        newBearerSettings = {APN_SUB: Variant("s", modemConfig.get(APN))}
        await callDbusIface(gppInterface.call_set_initial_eps_bearer_settings(newBearerSettings), _logger)
        await asyncio.sleep(1)
    _logger.warning("Setting initial bearer needed but failed, continue anyway")
    return False


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

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

    _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 = await getMCCMNC(gppInterface, operator, _logger)
        if not operator:
            return checkOperatorSelectionMode(operatorSelection, modemConfig, _logger)
    elif not isinstance(operator, str):
        operator = str(operator)

    await callDbusIface(gppInterface.call_register(operator), _logger)

    for i in range(OPERATOR_REGISTRATION_ITERATION):
        if await callDbusIface(gppInterface.get_operator_code(), _logger) == operator:
            return True
        await asyncio.sleep(OPERATOR_TIMEOUT)
    return checkOperatorSelectionMode(operatorSelection, modemConfig, _logger)


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


async def checkForModemState(modemInterface, gppInterface, _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 ----"
    )

    currentState = await callDbusIface(modemInterface.get_state(), _logger)
    if currentState < ENABLING:  # add constant 5 == state enabling
        _logger.info("modem disabled -> reset")
        return False
    enableCounter = MODEM_MAX_ENABLE_COUNTER - 1
    for i in range(1, MODEM_REGISTRATION_ITERATION):
        currentState = await callDbusIface(modemInterface.get_state(), _logger)
        if currentState == ENABLED:
            enableCounter += 1
            _logger.info(
                "Modem is %s force a search in %i loops",
                MODEM_STATE_READABLE.get(currentState),
                MODEM_MAX_ENABLE_COUNTER - enableCounter,
            )
            if enableCounter >= MODEM_MAX_ENABLE_COUNTER:
                _logger.info("Modem has registration problems -> force a search")
                # An empty string is used to register to the home networ
                await callDbusIface(gppInterface.call_register(""), _logger)
                enableCounter = 0
        elif currentState == SEARCHING:
            _logger.info("Modem is %s for an operator", MODEM_STATE_READABLE.get(currentState))
            enableCounter = 0
        if currentState > SEARCHING:
            _logger.info("Modem %s -> continue", MODEM_STATE_READABLE.get(currentState))
            return True
        _logger.info(
            "Modem not registered yet -> sleep for 10s and try again, loop number %s/60",
            i,
        )
        await asyncio.sleep(10)  # sleeping for 10 sec was tested and evaluated to be a reasonable delay at the time
    return False


async def checkConfigPlausibility(gppInterface, moblieBbConfig, _logger) -> bool:
    """Since not every operator allows every access technology this method checks if the configured params are valid"""
    _logger.info(
        "---- check config plausibility for configured operator and access technology ----"
    )
    operator = moblieBbConfig.get(OPERATOR)
    _logger.info(
        "scan all available operator and access technologies this might take a while."
    )
    if scan := await callDbusIface(gppInterface.call_scan(), _logger):
        if isinstance(operator, str) and not operator.isdigit():
            accessParam = JSON_OPERATOR_LONG
        else:
            accessParam = JSON_OPERATOR_CODE
            operator = str(operator)

        for network in scan:
            if moblieBbConfig.get(OPERATOR_SELECTION) == MANUAL:
                if network.get(accessParam).value == operator:
                    if network.get(JSON_ACCESS_TECH).value in ACCESS_TECH_MAP.get(moblieBbConfig.get(TECHNOLOGY)):
                        return True
            else:
                if network.get(JSON_ACCESS_TECH).value in ACCESS_TECH_MAP.get(moblieBbConfig.get(TECHNOLOGY)):
                    return True
    return False


async def getMCCMNC(gppInterface, configuredOperator: str, _logger) -> Optional[str]:
    """Scan for networks, compare the configured value with the found networks and return the related operator code"""
    if scan := await callDbusIface(gppInterface.call_scan(), _logger):
        # iterate through the network infos and return the operator code when the network is found
        for network in scan:
            if network.get(JSON_OPERATOR_LONG).value == configuredOperator:
                _logger.info(
                    "Operator %s found with operator code: %s",
                    configuredOperator,
                    network.get(JSON_OPERATOR_CODE).value,
                )
                return network.get(JSON_OPERATOR_CODE).value
        _logger.info("Operator %s not found", configuredOperator)
    _logger.warning("Scan failed")


async def connectModem(simpleInterface, _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.get(APN))

    connectionDict = {}
    if modemConfig.get(APN):
        connectionDict[APN_SUB] = Variant("s", modemConfig.get(APN))
    else:
        _logger.error("APN is not configured, impossible to connect modem!!!")
        return False

    if modemConfig.get(AUTH_NAME):
        connectionDict[USR_SUB] = Variant("s", modemConfig.get(AUTH_NAME))

    if modemConfig.get(AUTH_PASSWORD):
        connectionDict[PWD_SUB] = Variant("s", modemConfig.get(AUTH_PASSWORD))

    # 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):
        if bearerPath := await callDbusIface(simpleInterface.call_connect(connectionDict), _logger):
            return bearerPath

        else:
            _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."
            )
        _logger.info(
            "Could not connect to APN retry in 4s %s/%s", i, MODEM_CONNECTION_ITERATION
        )
        await asyncio.sleep(4)

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


async def getIpFromBearer(bearerInterface, _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 ----")

    ipConfig = await callDbusIface(bearerInterface.get_ip4_config(), _logger)

    dnsList = [value.value for key, value in ipConfig.items() if JSON_DNS in key]
    _logger.info(
        f"IP-address: {ipConfig.get(JSON_ADDRESS).value}/{ipConfig.get(JSON_PREFIX).value}, gateway: {ipConfig.get(JSON_GATEWAY).value}, DNS: {dnsList}"
    )
    return (
        ipConfig.get(JSON_ADDRESS).value,
        str(ipConfig.get(JSON_PREFIX).value),
        ipConfig.get(JSON_GATEWAY).value,
        dnsList,
    )


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(RUN_NCTL_FILE)

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


async def isConnected(modemInterface, _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):
        currentState = await callDbusIface(modemInterface.get_state(), _logger)
        if currentState < 11:
            _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", MODEM_STATE_READABLE.get(currentState), i)
        await asyncio.sleep(2)  # sleeping for 2 sec was tested and evaluated the optimal delay at the time
    return True


def checkGPIO() -> bool:
    """check if gpio 139 is set"""
    resetPath: Path = Path(GPIO_PATH_RESET)
    return "1" in resetPath.read_text()


async def hardReset(_logger):
    """toggles the power gpio"""
    resetPath: Path = Path(GPIO_PATH_RESET)
    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)
    await asyncio.wait_for(waitForWDM0(), timeout=60)
    writeToPath(resetPath, TURN_MODEM_ON, _logger)


async def waitForWDM0():
    """wait for the cdc-wdm0 port to disappear this might take a while when soft resetting the modem"""
    while Path(CDC_WDM0_PATH).exists():
        await asyncio.sleep(1)


async def resetCheck(serviceIface, _logger):
    """check if a reset is needed, returns true if the MobileBroadband service has failed"""
    return True if await callDbusIface(serviceIface.get_n_restarts(), _logger) > 0 else False


# Helper functions
async def reset(mobileBbServiceIface, _logger, modemInterface=None) -> bool:
    """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 ----")

    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)

    # On older board revision <= *.4A only do software reset of modem
    # This way prevent SD-Card corruption or similar failures
    hardResetAllowed = boardRev not in [BASEBOARD_10490_2A_REVISION, BASEBOARD_10490_4A_REVISION]
    numberResets = await callDbusIface(mobileBbServiceIface.get_n_restarts(), _logger)
    _logger.info(f"Number of failed restarts: {numberResets}")
    hardResetRequest = numberResets > MAX_SOFT_RESETS

    doHardReset = False
    softResetSuccess = False

    if hardResetAllowed and (not modemInterface or hardResetRequest):
        if not modemInterface:
            # when no modemIface is given a soft reset is impossible resulting in a hard reset if correct board revision
            _logger.info("No modem interface given -> hard reset")
        if hardResetRequest:
            _logger.info(f"Trigger a hard reset after {numberResets} tries")
        doHardReset = True
    else:
        _logger.info("Trigger a soft reset")
        try:
            # this is not called with callDbusIface because call_reset() returns an empty string
            await modemInterface.call_reset()
            softResetSuccess = True
        except dbus_next.errors.DBusError as e:
            _logger.warning(f"Exception caught during modem soft reset: {e}")
            if hardResetAllowed:
                doHardReset = True

    if doHardReset:
        _logger.info("Trigger a hard reset")
        await hardReset(_logger)
        return True

    if softResetSuccess:
        try:
            # it was observed to take quiet a long time to
            await asyncio.wait_for(waitForWDM0(), timeout=60)
            return True
        except asyncio.TimeoutError:
            _logger.warning("Timeout waiting for WDW")
    else:
        _logger.warning("Failed to reset the modem")
    return False


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)


async def resetModemManager(mmInterface, _logger):
    """resets the ModemManager.service"""
    await callDbusIface(mmInterface.call_restart("replace"), _logger)


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
            return stdOut, stderr.strip(), returncode

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


async def callDbusIface(dbusCall: Coroutine, _logger):
    """wrapper for dbus calls to avoid many try, catch"""
    try:
        return await dbusCall
    except dbus_next.errors.DBusError as e:
        _logger.warning(f"failed to call DBusNext method with traceback: {e}")
    return False


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,
            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)


async def waitNetplanHasSetIpAddress(
    linkInterface, 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):
        ipv4State = await linkInterface.get_i_pv4_address_state()
        onlineState = await linkInterface.get_online_state()
        if ipv4State == "routable" and onlineState == "online":
            return
        _logger.info(
            "Mobilebroadband could not find any applied address in wwan0 interface. Retry in %is. Try %i/%i",
            retryinveralSeconds,
            count,
            maxCount,
        )
        await asyncio.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))


async 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")
            await asyncio.sleep(retryDelay)
            await activateDnsStubResolver(config, logger, retryDelay * 4)
        return

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


async def quickConnect(modemConfig: dict, bus, modemPath, logger):
    """this should help standard usage of the modem to connect as quick as possible"""
    logger.info("---- check for quick connect possibility ----")
    if not modemConfig.get(TECHNOLOGY) == TECH_ANY:
        logger.info("quick connect failed due to configured technology %s", modemConfig.get(TECHNOLOGY))
        return False
    elif modemConfig.get(OPERATOR_SELECTION) != AUTO:
        logger.info("quick connect failed due to configured operator %s", modemConfig.get(OPERATOR_SELECTION))
        return False
    elif modemConfig.get(AUTH_NAME):
        logger.info("quick connect failed due to configured authName %s", modemConfig.get(AUTH_NAME))
        return False

    simpleInterface = await getDbusIface(bus, MM_SERVICE, modemPath, SIMPLE_IFACE, logger)
    simpleSettings = {APN_SUB: Variant("s", modemConfig.get(APN))}
    bearerPath = await callDbusIface(simpleInterface.call_connect(simpleSettings), logger)
    if bearerPath:
        logger.info(f"quick connect passed with bearerPath: {bearerPath}")
        await asyncio.sleep(1)  # sometimes the modem fell back to registered state when advancing to fast
    return bearerPath


async def main(workDir, config, logger) -> int:
    """Connection establishment of the modem."""
    logger.info(f"Using WorkDir: {workDir}")

    # Check if the folder /run/systemd/network/ exists if not it will be created.
    Path(RUN_NCTL_FOLDER).mkdir(exist_ok=True)
    logger.info("---- MobileBroadband enabled -> starting to connect the modem ----")

    # --------------------------- connect the system bus for dbus usage ---------------------------------------------- #
    logger.info("---- Connect system bus ----")
    if not (bus := await connectSystemBus(logger)):
        return os.EX_OSERR

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

    mobileBroadbandConfig = parseConfig(config, logger)

    if not mobileBroadbandConfig.get(APN):
        logger.error("No valid APN set, connection impossible!!!")
        return os.EX_SOFTWARE

    # --------------------------- Initialize some dbus manager and interfaces  --------------------------------------- #
    logger.info("---- Initialize dbus Ifaces ----")

    if not (systemdManager := await getDbusIface(bus, SYSTEMD_BUS_NAME, SYSTEMD_PATH, SYSTEMD_MANAGER_IFACE, logger)):
        return os.EX_OSERR

    if not (unitPath := await callDbusIface(systemdManager.call_get_unit(MOBILE_BROADBAND), logger)):
        return os.EX_OSERR

    if not (mobileBbServiceIface := await getDbusIface(bus, SYSTEMD_BUS_NAME, unitPath, SERVICE_IFACE, logger)):
        return os.EX_OSERR

    if not (mmPath := await callDbusIface(systemdManager.call_get_unit(MODEM_MANAGER), logger)):
        return os.EX_OSERR

    if not (mmIface := await getDbusIface(bus, SYSTEMD_BUS_NAME, mmPath, UNIT_INTERFACE, logger)):
        return os.EX_OSERR

    if (
        await callDbusIface(mmIface.get_load_state(), logger) != SERVICE_LOADED
        or await callDbusIface(mmIface.get_active_state(), logger) != SERVICE_ACTIVE
    ):
        logger.warning("ModemManager service was not loaded! This should not happen on a productive device")
        await resetModemManager(mmIface, logger)

    if not (objectManager := await getDbusIface(bus, MM_SERVICE, MM_PATH, OBJECT_MANAGER, logger)):
        return os.EX_OSERR

    if not (networkdIface := await getDbusIface(bus, NETWORKD_SERVICE, NETWORKD_PATH, NETWORKD_MANAGER_IFACE, logger)):
        return os.EX_OSERR

    # --------------------------- Check  -------------------------------------------------------- #
    logger.info("---- check for power and a possible reset ----")
    if not checkGPIO():
        logger.warning("Power pin gpio139 not set this should not happen during normal start-up")
        await reset(mobileBbServiceIface, logger)
        return os.EX_IOERR
    elif await resetCheck(mobileBbServiceIface, logger):
        # the reset is triggered after a failed attempt
        modemPath, loops = await getModemPath(objectManager, logger)
        if modemPath and loops <= 1:
            mIface = await getDbusIface(bus, MM_SERVICE, modemPath, MODEM_IFACE, logger)
            if not await reset(mobileBbServiceIface, logger, mIface):
                return os.EX_OSERR
            # sleep 1 sec to avoid any race condition between the kernel loosing wdw-cdm port
            # and the modemManger having a valid ModemPath
            # no time is lost as we need to wait for the ModemManager to find the Modem
            await asyncio.sleep(1)
        elif not modemPath:
            await reset(mobileBbServiceIface, logger)
            return os.EX_IOERR

    # --------------------------- Check whether the ModemManager has found the correct modem ------------------------- #
    modemPath, _ = await getModemPath(objectManager, logger)
    if not modemPath:
        return os.EX_SOFTWARE

    firstRun = await callDbusIface(mobileBbServiceIface.get_n_restarts(), logger) == 0

    if not firstRun or not (bearerPath := await quickConnect(mobileBroadbandConfig, bus, modemPath, logger)):
        modemIface = await getDbusIface(bus, MM_SERVICE, modemPath, MODEM_IFACE, logger)

        logger.info("Modem found at slot: %s", modemPath)

        if not await checkCorrectModem(modemIface, logger):
            await resetModemManager(mmIface, logger)
            return os.EX_SOFTWARE

        # --------------------------- Enabling of the modem  ------------------------------------------------------------- #

        if not await enableModem(modemIface, logger):
            return os.EX_SOFTWARE

        # ----------- Check whether the modem could register with an operator and establish a connection  ---------------- #
        gppIface = await getDbusIface(bus, MM_SERVICE, modemPath, GPP_IFACE, logger)

        await checkInitialBearer(gppIface, mobileBroadbandConfig, logger)

        if mobileBroadbandConfig.get(TECHNOLOGY) != TECH_ANY:
            if not await checkConfigPlausibility(gppIface, mobileBroadbandConfig, logger):
                if mobileBroadbandConfig.get(OPERATOR_SELECTION) == MANUAL:
                    logger.error(
                        f"Impossible configuration!!! Configured operator: {mobileBroadbandConfig.get(OPERATOR)} does not support {mobileBroadbandConfig.get(TECHNOLOGY)} technology"
                    )
                else:
                    logger.error(
                        f"Impossible configuration!!! NO available operator supports the configured technology: {mobileBroadbandConfig.get(TECHNOLOGY)}"
                    )
                logger.info("Please set technology to Any")
                return os.EX_SOFTWARE

        if mobileBroadbandConfig.get(TECHNOLOGY) != TECH_ANY:
            await setTechnology(modemIface, mobileBroadbandConfig, logger)

        if mobileBroadbandConfig.get(OPERATOR_SELECTION) != AUTO:
            if not await setOperator(gppIface, mobileBroadbandConfig, logger):
                return os.EX_SOFTWARE

        if not await checkForModemState(modemIface, gppIface, logger):
            return os.EX_SOFTWARE

        simpleInterface = await getDbusIface(bus, MM_SERVICE, modemPath, SIMPLE_IFACE, logger)

        if not (bearerPath := await connectModem(simpleInterface, logger, mobileBroadbandConfig)):
            return os.EX_SOFTWARE

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

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

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

    bearerInterface = await getDbusIface(
        bus, MM_SERVICE, bearerPath, BEARER_IFACE, logger
    )
    ipAddr, ipPrefix, gateway, dns = await getIpFromBearer(bearerInterface, 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.get(ROUTE_OUTGOING_TRAFFIC_VIA) == ETHERNET
        and doesTableExistInOs
        else WWAN0_METRIC_PRIO_HIGH
    )

    # --------------------------- Applying modem configurations  ----------------------------------------------------- #

    _, linkPath = await networkdIface.call_get_link_by_name(WWAN0)
    linkInterface = await getDbusIface(
        bus, NETWORKD_SERVICE, linkPath, NETWORKD_LINK_IFACE, logger
    )
    # Applying the configurations with networkctl
    if not checkAndSetUpNetworkFile(ipAddr, ipPrefix, gateway, dns, metric, logger):
        await callDbusIface(networkdIface.call_reload(), logger)
    await callDbusIface(linkInterface.call_reconfigure(), logger)

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

    await activateDnsStubResolver(config, logger)

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

    await waitNetplanHasSetIpAddress(
        linkInterface,
        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"""
    workingDir, conf, runMBlogger = getConfigAndSetupLogging(LOG_NAME, INFO)
    retCode = None
    try:
        retCode = asyncio.run(main(workingDir, conf, runMBlogger))
    except asyncio.CancelledError:
        runMBlogger.info("Mobile Broadband was cancelled")
        retCode = os.EX_SOFTWARE
    except Exception as err:
        runMBlogger.info(f"Unexpected exception occurred in RunMobileBroadband: {err}")
        retCode = os.EX_SOFTWARE
    finally:
        sys.exit(retCode if retCode is not None else os.EX_SOFTWARE)
