import asyncio
from contextlib import asynccontextmanager
from logging import Logger
from random import uniform
from ssl import SSLContext, TLSVersion, PROTOCOL_TLS_CLIENT, CERT_NONE
from typing import ContextManager, Optional
from urllib.parse import quote
import base64

import websockets
from ocpp.routing import on, after
from ocpp.v16 import ChargePoint, call, call_result
from ocpp.v16.enums import (
    RegistrationStatus,
    ChargePointErrorCode,
    ChargePointStatus,
    Action,
    MessageTrigger,
    TriggerMessageStatus,
)
from websockets.uri import parse_uri

from rescue_ocpp_client.ChargePointInformation import ChargePointInformation


class NotifierChargePoint(ChargePoint):
    """
    Implements an OCPP client (charge point)

    An ocpp.ChargePoint class extended with some methods for the specific usecase

    Note that it is not implemented to send heartbeats. This is not required for OCPP-J 1.6, as websockets implement
    already their own heartbeat. It is still recommended in order to synchronize system time, which does not apply here.

    Note that for every received call for which no handler is implemented, the library returns a CallError
    NotImplemented, as suggested in the OCPP-J 1.6 specification
    """

    MAXIMUM_BOOT_NOTIFICATION_RETRIES: int = 10
    """Number of times to try to resend the boot notification, if it has not already been accepted, before raising a MessageRejected exception."""

    chargePointInformation: ChargePointInformation
    """Object from which to retrieve information about the charge point."""

    clientTask: asyncio.Task
    """Task which responds to server requests"""

    logger: Logger = None

    def __init__(
        self,
        chargePointInformation: ChargePointInformation,
        id: str,
        connection: websockets.ClientConnection,
        logger: Logger,
        response_timeout: int = 30,
    ):
        """Instantiate an OCPP client

        :param chargePointInformation: Object from which to retrieve information about the charge point.
        :param id: Charge point identity
        :param connection: Websocket client connection to the central system
        :param response_timeout: When no response on a request is received within this interval, an asyncio.TimeoutError is raised.
        """
        super().__init__(id, connection, response_timeout)
        self.chargePointInformation = chargePointInformation
        self.logger = logger

    class MessageRejected(Exception):
        """Exception raised when a message can't be sent successfully"""

        pass

    @staticmethod
    @asynccontextmanager
    async def notifierChargePointFactory(
        chargePointInformation: ChargePointInformation, logger: Logger
    ) -> ContextManager["NotifierChargePoint"]:
        """Instantiates a NotifierChargePoint

        Is to be used with a context manager

        :param chargePointInformation: Object from which to retrieve information about the charge point.
        :param logger: Logger to use
        :return: A notifierChargePoint wrapped in a context manager
        :raises: All exceptions that websockets.connect, WebSocketClientProtocol.recv (see https://websockets.readthedocs.io/en/15.0.1/) or the ocpp library raise
        """
        cpId = chargePointInformation.getOcppCPIdentity()
        connectionUrl = (
            chargePointInformation.getOcppCSEndpointUrl() + "/" + quote(cpId)
        )
        parsedConnectionUrl = parse_uri(connectionUrl)
        # Don't log the full URL. Some backends (including Gridware) allow authentication via a specific endpoint URL,
        # thus it must be considered a secret.
        logger.info("Connecting to backend at %s", parsedConnectionUrl.host)

        authKey = chargePointInformation.getOcppAuthorizationKey()
        extraHeader = ()
        if authKey:
            # HTTP Basic authentication as described in OCPP-J 1.6 section 6.2.2 is to be used
            if not parsedConnectionUrl.secure:
                # Authentication is used over an unencrypted channel. Horrible!
                logger.warning(
                    "An Authorization Key is configured for the OCPP connection, but an unencrypted CsAddress is used, which renders authentication useless. Please use an encrypted CsAddress starting with 'wss://'."
                )
            # Fix user and password into the connection URL
            basicAuthCred = f"{quote(cpId)}:{quote(authKey)}"
            basicAuthCredBytes = basicAuthCred.encode("utf-8")
            encodedCred = base64.b64encode(basicAuthCredBytes).decode()
            extraHeader: list[tuple[str, str]] = [
                (
                    "Authorization",
                    f"Basic {encodedCred}",
                )
            ]

        if parsedConnectionUrl.secure:
            # Connection is TLS encrypted
            # Create SSL context with maximum compatibility and no validations at all.
            # While this might be a security risk, it is desired in this use case,
            # as it is more important to be able to deliver the notifications at all than to deliver them for sure to
            # the right backend. Also, possibly custom root CAs have been installed to the productive system, which
            # might not be available in the rescue OS
            sslContext = SSLContext(PROTOCOL_TLS_CLIENT)
            sslContext.check_hostname = False
            sslContext.verify_mode = CERT_NONE
            sslContext.minimum_version = TLSVersion.SSLv3
        else:
            # No connection encryption used.
            sslContext = None

        # use rather long timeouts, in favour of stability; take in account slow mobile connections
        async with websockets.connect(
            connectionUrl,
            subprotocols=["ocpp1.6"],
            ssl=sslContext,
            open_timeout=60,
            ping_interval=90,
            ping_timeout=60,
            close_timeout=10,  # reduce timeout, to reduce maximum time required for closing application
            additional_headers=extraHeader,  # basic auth credentials are given with this argument
        ) as ws:
            cp = NotifierChargePoint(
                chargePointInformation, cpId, ws, logger, response_timeout=60
            )

            # start the library, let the client do its work, stop & clean up after that
            cp.clientTask = asyncio.create_task(cp.start(), name="OCPP Client Task")
            logger.info("Connection established")
            yield cp
            logger.info("Closing connection to backend...")
            cp.clientTask.cancel()

    async def sendBootNotification(
        self,
        delay: float = 0,
        failCount: int = 0,
    ) -> None:
        """
        Sends the boot notification to the Central System

        If the boot notification is not accepted, retries and only completes when a response with status accepted is
        received.

        :param bootNotificationInfo: The boot notification to send
        :param delay: Delay before sending boot notification in seconds
        :param failCount: Number of times sending the boot notification has already failed
        :raises: MessageRejected: If the boot notification is not accepted repeatedly
        """
        # delay the call, if it is not the first try
        await asyncio.sleep(delay)

        bootNotificationInfo = (
            await self.chargePointInformation.getBootNotificationInfo()
        )
        self.logger.debug("Send boot notification info: %s", str(bootNotificationInfo))

        response = await self.call(bootNotificationInfo)

        if response.status != RegistrationStatus.accepted:
            failCount += 1
            # The response status was either Rejected or Pending
            if failCount >= self.MAXIMUM_BOOT_NOTIFICATION_RETRIES:
                msg = f"Boot notification was not accepted for {failCount} times."
                self.logger.error(msg)
                raise self.MessageRejected(msg)

            # Retry to send the boot notification after the stipulated timeout.
            if response.interval > 0:
                nextDelay = response.interval
            elif delay <= 0:
                # it is the first failure and interval is not set, use a random timeout between 2 and 6 seconds
                nextDelay = uniform(2, 6)
            else:
                # it is not the first failure and interval is not set, use an exponential backoff delay
                nextDelay = 2 * delay

            self.logger.warning(
                "Sending boot notification failed with status %s. Retry to send after %is.",
                response.status,
                nextDelay,
            )
            await self.sendBootNotification(nextDelay, failCount)

    async def sendStatusNotification(self) -> None:
        """Sends a status notification to the Central System

        CS is notified that this CP is faulted and has booted to the rescue OS."""
        statusNotification = call.StatusNotification(
            connector_id=0,
            error_code=ChargePointErrorCode.internal_error,
            info="Booted to rescue OS",  # max 50 chars!
            status=ChargePointStatus.faulted,
            # timestamp= is omitted, thus time of message reception is assumed
            vendor_id=self.chargePointInformation.getOcppVendorId(),
            # vendor_error_code= not used, max 50 chars
            # TODO: Try to provide some information why booted to rescue OS (e.g. if SD card is bricked, RAUC slots are bad, ...)
        )
        await self.call(statusNotification)

    async def sendHeartbeat(self) -> None:
        """Sends a heartbeat message to the Central System"""
        await self.call(call.Heartbeat())

    @on(Action.trigger_message)
    def onTriggerMessage(
        self, requested_message: MessageTrigger, connector_id: Optional[int]
    ) -> call_result.TriggerMessage:
        """Responds to the Trigger Messages from the CS

        Sends the requested message, if implemented, otherwise responds with not implemented.
        """
        match requested_message:
            case (
                MessageTrigger.boot_notification
                | MessageTrigger.status_notification
                | MessageTrigger.heartbeat
            ):
                # these messages are implemented and will be responded to
                return call_result.TriggerMessage(TriggerMessageStatus.accepted)
            case _:
                # all other messages are not implemented
                return call_result.TriggerMessage(TriggerMessageStatus.not_implemented)

    @after(Action.trigger_message)
    async def afterTriggerMessage(
        self, requested_message: MessageTrigger, connector_id: Optional[int]
    ) -> None:
        """Sends the requested message, if implemented

        This method is called after the TriggerMessage.conf PDU has been sent. It tries to send the requested message
        """
        match requested_message:
            case MessageTrigger.boot_notification:
                await self.sendBootNotification()
            case MessageTrigger.status_notification:
                await self.sendStatusNotification()
            case MessageTrigger.heartbeat:
                await self.sendHeartbeat()
            case _:
                # all other messages are not implemented, do nothing
                pass
