import os
import sys
from asyncio import run, TimeoutError
from logging import Logger, getLogger
from pathlib import Path
from time import sleep
from traceback import format_exception_only
from typing import Optional

from ocpp.exceptions import OCPPError
from websockets.exceptions import WebSocketException, InvalidURI
from websockets.uri import parse_uri

from config.migration.systemdLogging import setupLogging

from rescue_ocpp_client.ChargePointInformation import ChargePointInformation
from rescue_ocpp_client.NotifierChargePoint import NotifierChargePoint

LOG_NAME = "RescueOcppClient"
"""Name of the log file in the work folder"""

INITIAL_RETRY_DELAY = 5
"""Delay before retrying to connect when encountering error. This is the delay for the first error, delay for failing retries is increased exponentially."""

MAX_RETRY_DELAY = 60 * 30
"""Maximum delay. Delay is not increased beyond this value."""

ECP_WORK_FILES_DIR_CANDIDATES = (
    os.environ.get("ECP_WORK_FILES_DIR", ""),
    "/mnt/persistent-data/evtec-ecp-data",
    # once a workdir is available on the NAND flash, add its location here
)
"""The config file is tried to be found in these folders with descending priority. The first config file found is taken."""


def findConfigFile() -> Optional[Path]:
    """Return the first readable config file found

    Iterates over the work dir candidates and tries to find a config file in it. When finding the first readable config
    file, it sets the environment variable 'ECP_WORK_FILES_DIR' to the corresponding work folder and returns a path
    object to the config file, if one has been found.

    :return: the found config file or None, if no config file could be found
    """
    CONFIG_FILE_PATH = "config/config.json"
    """Location of the config file within the workdir"""

    for workdir in ECP_WORK_FILES_DIR_CANDIDATES:
        configFile = Path(workdir) / CONFIG_FILE_PATH
        if os.access(configFile, os.R_OK):
            # The config file candidate exists and is readable. Use this one.
            os.environ["ECP_WORK_FILES_DIR"] = workdir
            return configFile
        else:
            getLogger().info("Config file '%s' is not accessible", str(configFile))
    return None


def main():
    # Find which config file to use
    if not (configFile := findConfigFile()):
        getLogger().error("No readable config found. Exiting.")
        return os.EX_CONFIG

    # setupLogging needs the ECP_WORK_FILES_DIR environment variable populated, thus it must be run after findConfigFile
    setupLogging(LOG_NAME + ".log")
    logger: Logger = getLogger(LOG_NAME)

    # Initialize object to read device config from
    logger.info("Reading config from '%s'", str(configFile))
    cpInfo = ChargePointInformation(configFile, logger)

    # Check if OCPP is enabled
    if not cpInfo.ocppIsEnabled():
        logger.warning("OCPP is disabled. Exiting...")
        return os.EX_OK

    # Ensure backend URL is a valid websocket URL
    try:
        parse_uri(cpInfo.getOcppCSEndpointUrl())
    except InvalidURI as e:
        logger.critical(
            "Found backend URL is not a valid websocket URL. Exit, do not retry."
        )
        logger.debug("Exception: ", exc_info=e)
        return os.EX_CONFIG

    # Check configured OCPP version
    if cpInfo.getOcppVersion() != "OCPP16J":
        logger.warning(
            "This client currently only supports OCPP16J (OCPP JSON version 1.6). Found %s in configuration. Will still try to contact backend.",
            cpInfo.getOcppVersion(),
        )

    # If connection aborts/exception occurs, ... try to reconnect forever.
    retryDelay: int = INITIAL_RETRY_DELAY
    while True:
        deliveredSuccessfully = run(connectToOcppBackend(cpInfo, logger))

        if deliveredSuccessfully:
            # The connection was 'successful', reset the retry delay
            retryDelay = INITIAL_RETRY_DELAY

        # Before trying to reconnect, wait the specified delay
        logger.info("Will try to reconnect after delay of %is. Waiting...", retryDelay)
        sleep(retryDelay)

        # Exponentially increase the timeout for the next retries, cap it at the specified value
        retryDelay = min(retryDelay * 2, MAX_RETRY_DELAY)


async def connectToOcppBackend(cpInfo: ChargePointInformation, logger: Logger) -> bool:
    """Start the OCPP client and send notifications to backend

    Exceptions are caught and logged if a reconnect should be attempted.

    :param cpInfo: Object to retrieve device information from
    :param logger: We'll tell it how we are doing
    :return: True if the messages have been delivered successfully
    """
    successfulDelivery: bool = False
    try:
        # Initialize OCPP client and send notifications
        async with NotifierChargePoint.notifierChargePointFactory(cpInfo, logger) as cp:
            if not hasattr(connectToOcppBackend, "bootNotificationHasBeenSent"):
                # OCPP specifies to send the boot notification only after a reboot.
                # Send it only once during the lifetime of the program.
                await cp.sendBootNotification()
                connectToOcppBackend.bootNotificationHasBeenSent = None
                logger.info("Sent boot notification")

            await cp.sendStatusNotification()
            logger.info(
                "Sent status notification. Notified CS that this CP is faulted."
            )

            # If this point is reached, boot and status notification have been delivered successfully.
            successfulDelivery = True

            await cp.clientTask
            # ocpp client should never exit. If it does exit, it most likely encountered an exception. Propagate it.
            if cp.clientTask.exception() is not None:
                raise cp.clientTask.exception()
            logger.error("OCPP client exited unexpectedly.")
    except (OSError, WebSocketException, OCPPError) as e:
        # Catch connection related errors:
        # - OSError: Error in the low level socket connection, e.g. no connectivity or name can't be resolved
        # - WebSocketException, OCPPError: Errors in the websocket/OCPP protocols/libraries
        exc_desc_lines = format_exception_only(type(e), e)
        logger.error(
            "Connection error with backend occurred: %s",
            "".join(exc_desc_lines).rstrip(),
        )
        logger.debug("", exc_info=e)
    except TimeoutError as e:
        logger.error("Timeout in connection with backend occurred.")
        logger.debug("Exception: ", exc_info=e)

    return successfulDelivery


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