import os
from logging import Logger
from typing import Optional, List

import asyncio
from dbus_next import (
    DBusError,
    BusType,
    InvalidBusNameError,
    InvalidObjectPathError,
    InterfaceNotFoundError,
)
from dbus_next.aio import ProxyInterface, MessageBus
from ocpp.v16 import call
from ocpp.v16.enums import ChargePointErrorCode, ChargePointStatus


class ChargePointInformation:
    """Class which collects all information about the charge point needed for OCPP"""

    # Config keys
    KEY_SECTION_OCPP = "OCPP"
    KEY_SECTION_CONNECTION_PARAMETERS = "ConnectionParameters"
    KEY_SECTION_DEVICE_PARAMETERS = "DeviceParameters"
    KEY_SECTION_CONFIG_PARAMETERS = "ConfigurationParameters"
    # main section
    KEY_OCPP_ENABLE = "Enable"
    KEY_OCPP_VERSION = "Version"
    # connection section
    KEY_CS_ADDRESS = "CsAddress"
    # device section
    KEY_CP_ID = "chargeBoxIdentity"
    KEY_CP_VENDOR = "chargePointVendor"
    KEY_CP_MODEL = "chargePointModel"
    KEY_VENDOR_ID = "vendorId"
    # config section
    KEY_OCPP_SECURITY = "SecurityParameters"
    KEY_AUTH_KEY = "AuthorizationKey"

    KEY_DEVICE_ROLE = "Role"
    KEY_DEVICE_SECTION = "Device"
    KEY_DEVICE_SERIAL = "Serial"
    KEY_DEVICE_YEAR = "Year"
    KEY_DEVICE_BATCH = "Batch"
    KEY_DEVICE_ART_NR = "Art"
    KEY_DEVICE_MAJ_REF = "MajRev"
    KEY_DEVICE_MIN_REF = "MinRev"
    OCPP_ROLES = ["ControllerRemoteDisplay", "ControllerLocalHMI"]

    def __init__(self, config: dict, logger: Logger):
        self._logger: Logger = logger
        self._deviceSection: dict = config.get(self.KEY_DEVICE_SECTION, {})
        self._ocppSection: dict = config.get(self.KEY_SECTION_OCPP, {})
        self._ocppConnectionSection: dict = self._ocppSection.get(
            self.KEY_SECTION_CONNECTION_PARAMETERS, {}
        )
        self._ocppDeviceSection: dict = self._ocppSection.get(
            self.KEY_SECTION_DEVICE_PARAMETERS, {}
        )
        self._ocppConfigSection: dict = self._ocppSection.get(
            self.KEY_SECTION_CONFIG_PARAMETERS, {}
        )
        self._ocppSecuritySection: dict = self._ocppConfigSection.get(
            self.KEY_OCPP_SECURITY, {}
        )

    def ocppIsEnabled(self) -> bool:
        """Returns True if OCPP would be enabled in the primary OS

        Two conditions must be satisfied, for OCPP to be enabled:
        - OCPP must be enabled in the config file
        - The device role in the config must be either 'ControllerRemoteDisplay' or 'ControllerLocalHMI'.
          This is because:
            ECP.service is only started by the service supervisor if this is the case
            only then the ECP program is started with the system type ChargeboxControl
            only then the auxiliary process is started
            only then the OCPP client is started
        """

        deviceRole = self._deviceSection.get(self.KEY_DEVICE_ROLE, "")
        ocppEnabled = self._ocppSection.get(self.KEY_OCPP_ENABLE, False)
        return ocppEnabled and deviceRole in self.OCPP_ROLES

    def getOcppVersion(self) -> str:
        """Gets the configured OCPP version

        If no version is configured, 'OCPP16J' is assumed."""
        return self._ocppSection.get(self.KEY_OCPP_VERSION, "OCPP16J")

    def getOcppCSEndpointUrl(self) -> Optional[str]:
        """Gets the OCPP central system endpoint URL from the configuration

        e.g. ws://ocpp.andcharge.com:8080/steve/websocket/CentralSystemService
        Note that potential trailing slashes found in the config value are removed!
        """
        url: str = self._ocppConnectionSection.get(self.KEY_CS_ADDRESS, "")
        if url:
            url = url.strip("/")
        return url

    def getOcppCPIdentity(self) -> str:
        """Gets the charge point identity to use with the OCPP backend"""
        id = self._deviceSection.get(self.KEY_CP_ID, "")
        if not id or id == "":
            # Use the default ID derived from model and serial
            cbModel, cbSerial = self._getChargeBoxModelAndSerial()
            id = "{}_{}".format(cbSerial, cbModel)
        return id

    def getOcppAuthorizationKey(self) -> Optional[str]:
        """Gets the basic authentication password from the OCPP configuration

        If the field is not set or empty, None is returned."""
        return self._ocppSecuritySection.get(self.KEY_AUTH_KEY, None)

    async def getBootNotificationInfo(self) -> call.BootNotification:
        """
        Extract information for boot notification from the config

        Mimics the behaviour of the ECP OCPP implementation

        :return: An OCPP BootNotification call object, ready to be sent to the backend
        """
        # Code adapted from method ocpp.ocpp16.OCPPConfiguration.__init__ in ECP
        # The default values are taken from BASE_CONFIG.json
        cbModel, cbSerial = self._getChargeBoxModelAndSerial()
        charge_point_vendor: str = self._deviceSection.get(self.KEY_VENDOR_ID, "EVTEC")
        charge_point_model: str = self._deviceSection.get(self.KEY_CP_MODEL, "")
        if charge_point_model == "":
            charge_point_model = cbModel

        charge_box_serial_number: str = "{} {}".format(cbSerial, cbModel)
        charge_point_serial_number: str = charge_box_serial_number

        # In the OCPP implementation of ECP, the CP model is assigned to the Meter SN and vice versa. While this seems odd, mimic the ECP OCPP implementation blindly for now
        meter_serial_number: str = charge_point_model
        meter_type: str = charge_box_serial_number

        # In ECP, the firmware version is set to the ECP version, which is not available in rescue OS
        # Set it to a prefix and the version ID. It is suggested to load /etc/os-release as an environment file to load the Version ID variable
        firmware_version: str = "rescue-os " + os.environ.get("VERSION_ID", "")

        # Get SIM IMSI and ICCID via DBus
        imsi, iccid = await self._getSimInfo()

        return call.BootNotification(
            charge_point_model=charge_point_model,
            charge_point_vendor=charge_point_vendor,
            charge_box_serial_number=charge_box_serial_number,
            charge_point_serial_number=charge_point_serial_number,
            firmware_version=firmware_version,
            iccid=iccid,
            imsi=imsi,
            meter_serial_number=meter_serial_number,
            meter_type=meter_type,
        )

    def getOcppVendorId(self) -> str:
        """Gets the OCPP vendor id from the configuration"""
        return self._deviceSection.get(self.KEY_VENDOR_ID, "EVTEC")

    def _getChargeBoxModelAndSerial(self) -> tuple[str, str]:
        """Get the serial number and model of the charge box from the config to be used in serial number and ID of OCPP messages

        :param config: Config file as dictionary
        """
        configKeys = {
            self.KEY_DEVICE_SERIAL: 0,
            self.KEY_DEVICE_YEAR: 0,
            self.KEY_DEVICE_BATCH: 0,
            self.KEY_DEVICE_ART_NR: 10000,
            self.KEY_DEVICE_MIN_REF: "A",
            self.KEY_DEVICE_MAJ_REF: 0,
        }

        if self._deviceSection:
            configKeys.update(self._deviceSection)

        year = configKeys.get(self.KEY_DEVICE_YEAR)
        batch = configKeys.get(self.KEY_DEVICE_BATCH)
        serial = configKeys.get(self.KEY_DEVICE_SERIAL)
        art = configKeys.get(self.KEY_DEVICE_ART_NR)
        majRev = configKeys.get(self.KEY_DEVICE_MAJ_REF)
        minRev = configKeys.get(self.KEY_DEVICE_MIN_REF)
        cbModel = "{:0>5}.{}{}".format(art, majRev, minRev)
        cbSerial = "{:0>2}{:0>2}{:0>4}".format(year, batch, serial)

        return cbModel, cbSerial

    async def _getSimInfo(self) -> tuple[str, str]:
        """
        Retrieves IMSI and ICCID of the SIM via DBus

        If there are several Modems registered, get the information from the first Modem which successfully returns the
        information.

        :return: Tuple of IMSI and ICCID
        """
        try:
            simObjects = await asyncio.wait_for(self._getSimInterfaces(), 5)
        except asyncio.TimeoutError:
            self._logger.info("Timed out waiting for DBus ModemManager interfaces")
            return ("", "")

        for sim in simObjects:
            try:
                imsi = await sim.get_imsi()
                iccid = await sim.get_sim_identifier()
                return (imsi, iccid)
            except DBusError:
                self._logger.info("Failed to read SIM properties via DBus")
                self._logger.debug("Exception info:", exc_info=True)
                pass
        return ("", "")

    async def _getSimInterfaces(self) -> List[ProxyInterface]:
        """
        Returns a list of the Sim interfaces of all /org/freedesktop/ModemManager/SIMs DBus objects

        Every registered modem object contributes one entry in the list. If no modem objects are found, return an empty list.

        :return: Proxy inteface on which properties of the SIM objects can be gotten from
        """

        # This is the minimized description of the DBus objects, everything not needed here was removed.
        # Get the whole description with 'dbus-send --system --print-reply --dest=org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1 org.freedesktop.DBus.Introspectable.Introspect'
        MODEM_MANAGER_INTROSPECTION = """\
        <!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
        <node><interface name="org.freedesktop.DBus.ObjectManager">
            <method name="GetManagedObjects"><arg type="a{oa{sa{sv}}}" name="object_paths_interfaces_and_properties" direction="out"/></method>
        </interface></node>
        """
        # Get the whole description with 'dbus-send --system --print-reply --dest=org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1/SIM/0 org.freedesktop.DBus.Introspectable.Introspect'
        SIM_INTROSPECTION = """\
        <!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
        <node><interface name="org.freedesktop.ModemManager1.Sim">
            <property type="s" name="SimIdentifier" access="read"/>
            <property type="s" name="Imsi" access="read"/>
        </interface></node>
        """
        bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
        # get a list of all registered modem objects via the standard DBus Object Manager interface
        try:
            modemObjects = (
                await bus.get_proxy_object(
                    "org.freedesktop.ModemManager1",
                    "/org/freedesktop/ModemManager1",
                    MODEM_MANAGER_INTROSPECTION,
                )
                .get_interface(name="org.freedesktop.DBus.ObjectManager")
                .call_get_managed_objects()
            )
        except (
            DBusError,
            InvalidBusNameError,
            InvalidObjectPathError,
            InterfaceNotFoundError,
        ):
            self._logger.info("Could not find any modem objects on the system DBus")
            self._logger.debug("Exception info:", exc_info=True)
            return []
        # get the object paths of the SIM object of the modems
        simObjectPaths = [
            modem["org.freedesktop.ModemManager1.Modem"]["Sim"].value
            for modem in modemObjects.values()
        ]
        # get the SIM interface of the SIM objects
        simInterfaces: List[ProxyInterface] = []
        for path in simObjectPaths:
            try:
                simInterfaces.append(
                    bus.get_proxy_object(
                        bus_name="org.freedesktop.ModemManager1",
                        path=path,
                        introspection=SIM_INTROSPECTION,
                    ).get_interface(name="org.freedesktop.ModemManager1.Sim")
                )
            except (
                DBusError,
                InvalidBusNameError,
                InvalidObjectPathError,
                InterfaceNotFoundError,
            ):
                self._logger.info("Could not retrieve Sim interface of path %s", path)
                self._logger.debug("Exception info:", exc_info=True)
        return simInterfaces
