from asyncio import run, TimeoutError
from json import loads, JSONDecodeError
from logging import getLogger, Logger, StreamHandler, INFO, basicConfig
from logging.handlers import RotatingFileHandler
from os import environ, chown, EX_CONFIG
from pathlib import Path
from sys import exit, stdout
from time import sleep
from traceback import format_exception_only

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

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

APPLICATION_DEFAULT_PATH = "/mnt/persistent-data/evtec-ecp-data"
RESCUE_DEFAULT_PATH = "/mnt/rescue-data/evtec-ecp-data"
ECP_WORK_FILES_DIR_CANDIDATES = (
    environ.get("ECP_WORK_FILES_DIR", ""),
    APPLICATION_DEFAULT_PATH,
    RESCUE_DEFAULT_PATH,
)
"""The config is tried to be found in these folders with descending priority. The first config file found is taken.
Corresponding to the folder of the config file, the log file path is chosen.
"""


LOG_DIR = "log"
CONFIG_DIR = "config"
CONFIG_FILE = "config.json"
LOGGING_FORMAT = "%(asctime)s [%(levelname)s] %(message)s"
# defined by the YOCTO build
LOGFILE_OWNER_UID: int = 1000
LOGFILE_OWNER_GID: int = 1000
LOG_NAME = "RescueOcppClient"


def createLogFile(logFilePath: Path):
    """Create the given path and change ownership to xfc user.

    :param logFilePath: Path to the log file
    """
    if not logFilePath.exists():
        logFilePath.touch()

    if (
        logFilePath.stat().st_uid != LOGFILE_OWNER_UID
        or logFilePath.stat().st_gid != LOGFILE_OWNER_GID
    ):
        chown(logFilePath, LOGFILE_OWNER_UID, LOGFILE_OWNER_GID)


def findPath() -> tuple[Path | None, dict]:
    """This function returns the first working path directory. It does so by iterating over the work dir candidates
    ECP_WORK_FILES_DIR_CANDIDATES and tries to find a working config file in its subpath. Returns the working file path
    when the config file is readable and can be json parsed otherwise return None.

    :return: the working path or None
    """
    workingDirectory = None
    config = {}
    for workdir in ECP_WORK_FILES_DIR_CANDIDATES:
        configFile = Path(workdir) / CONFIG_DIR / CONFIG_FILE
        if configFile.is_file():
            # Check readability
            try:
                config: dict = loads(configFile.read_text())
                isReadable = True
            except (
                FileNotFoundError,
                PermissionError,
                IsADirectoryError,
                JSONDecodeError,
            ):
                isReadable = False
            if isReadable:
                workingDirectory = workdir
                break
    return workingDirectory, config


def getConfigAndSetupLogging(
    logName: str, logLevel: int = INFO
) -> tuple[Path | None, dict, Logger]:
    """Check for a working directory and get its config file and do create a valid file rotation of the given log name.
    When there is no working directory found, only a logger is created to stdout.

    :param logName: The name of the logger to initialize
    """
    workPath, config = findPath()
    handlers = []
    streamHandler = StreamHandler(stream=stdout)
    streamHandler.setLevel(logLevel)
    handlers.append(streamHandler)
    if workPath:
        logFilePath = Path(workPath) / LOG_DIR / (logName + ".log")
        createLogFile(logFilePath)
        # (more or less 105 lines of logs) 4094 chars === 4kB / 64000 === 64kB
        # / 64kB * (1 + 2) = 192kb disk size
        fileHandler = RotatingFileHandler(logFilePath, maxBytes=64000, backupCount=2)
        fileHandler.setLevel(logLevel)
        handlers.append(fileHandler)
    basicConfig(level=logLevel, format=LOGGING_FORMAT, handlers=handlers)

    return workPath, config, getLogger(logName)


def main():
    # Find which config file to use
    _, config, logger = getConfigAndSetupLogging(LOG_NAME, INFO)
    if config is None:
        logger.info("No config file found! Exiting...")
        return EX_CONFIG

    # Initialize object to read device config from
    logger.info(f"Reading config {config}", )
    cpInfo = ChargePointInformation(config, logger)

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

    # 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 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"""
    exit(main())
