// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import type {
  BootstrapConfig,
  Call,
  CallAction,
  CallError,
  CallResult,
  ICache,
  IMessage,
  IMessageConfirmation,
  IMessageHandler,
  IMessageRouter,
  IMessageSender,
  OcppRequest,
  OcppResponse,
  OCPPVersionType,
  SystemConfig,
} from '@citrineos/base';
import {
  AbstractMessageRouter,
  AbstractModule,
  BOOT_STATUS,
  CacheNamespace,
  createIdentifier,
  ErrorCode,
  EventGroup,
  getStationIdFromIdentifier,
  getTenantIdFromIdentifier,
  mapToCallAction,
  MessageOrigin,
  MessageState,
  MessageTypeId,
  NO_ACTION,
  OCPP2_1,
  OCPP_CallAction,
  OcppError,
  OCPPValidator,
  OCPPVersion,
  RequestBuilder,
  RetryMessageError,
} from '@citrineos/base';
import type { ILocationRepository } from '@dal/interfaces/repositories.js';
import { sequelize } from '@dal/index.js';
import { OidcTokenProvider } from '@util/authorization/index.js';
import type { ILogObj } from 'tslog';
import { Logger } from 'tslog';
import { v4 as uuidv4 } from 'uuid';
import { WebhookDispatcher } from './webhook.dispatcher.js';

/**
 * Implementation of the ocpp router
 */
export class MessageRouterImpl extends AbstractMessageRouter implements IMessageRouter {
  /**
   * Fields
   */

  protected _webhookDispatcher: WebhookDispatcher;
  protected _cache: ICache;
  protected _sender: IMessageSender;
  protected _handler: IMessageHandler;
  protected _networkHook: (identifier: string, message: string) => Promise<void>;
  protected _locationRepository: ILocationRepository;
  protected readonly _oidcTokenProvider?: OidcTokenProvider;

  /**
   * Constructor for the class.
   *
   * @param {BootstrapConfig & SystemConfig} config - the system configuration
   * @param {ICache} cache - the cache object
   * @param {IMessageSender} [sender] - the message sender
   * @param {IMessageHandler} [handler] - the message handler
   * @param {WebhookDispatcher} [dispatcher] - the webhook dispatcher
   * @param {Function} networkHook - the network hook needed to send messages to chargers
   * @param {ILocationRepository} [locationRepository] - An optional parameter of type {@link ILocationRepository} which
   * represents a repository for accessing and manipulating variable data.
   * If no `locationRepository` is provided, a default {@link locationRepository} instance is created and used.
   * @param {Logger<ILogObj>} [logger] - the logger object (optional)
   * @param {OCPPValidator} [ocppValidator] - the OCPPValidator instance, for message validation (optional)
   */
  constructor(
    config: BootstrapConfig & SystemConfig,
    cache: ICache,
    sender: IMessageSender,
    handler: IMessageHandler,
    dispatcher: WebhookDispatcher,
    networkHook: (identifier: string, message: string) => Promise<void>,
    logger?: Logger<ILogObj>,
    ocppValidator?: OCPPValidator,
    locationRepository?: ILocationRepository,
  ) {
    super(config, cache, handler, sender, networkHook, logger, ocppValidator);

    this._cache = cache;
    this._sender = sender;
    this._handler = handler;
    this._webhookDispatcher = dispatcher;
    this._networkHook = networkHook;
    this._locationRepository =
      locationRepository || new sequelize.SequelizeLocationRepository(config, logger);
    if (this._config.oidcClient) {
      this._oidcTokenProvider = new OidcTokenProvider(this._config.oidcClient, this._logger);
    }
  }

  async doesChargingStationExistByStationId(
    tenantId: number,
    ocppConnectionName: string,
  ): Promise<boolean> {
    return await this._locationRepository.doesChargingStationExistByStationId(
      tenantId,
      ocppConnectionName,
    );
  }

  // TODO: Below method should lock these tables so that a rapid connect-disconnect cannot result in race condition.
  async registerConnection(
    tenantId: number,
    ocppConnectionName: string,
    protocol: OCPPVersion,
  ): Promise<boolean> {
    const dispatcherRegistration = this._webhookDispatcher.register(tenantId, ocppConnectionName);

    const connectionIdentifier = createIdentifier(tenantId, ocppConnectionName);
    const requestSubscription = this._handler.subscribe(connectionIdentifier, undefined, {
      tenantId: tenantId.toString(),
      ocppConnectionName,
      state: MessageState.Request.toString(),
      origin: MessageOrigin.ChargingStationManagementSystem.toString(),
    });

    const responseSubscription = this._handler.subscribe(connectionIdentifier, undefined, {
      tenantId: tenantId.toString(),
      ocppConnectionName,
      state: MessageState.Response.toString(),
      origin: MessageOrigin.ChargingStationManagementSystem.toString(),
    });

    const onlineCharger = this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(
      tenantId,
      ocppConnectionName,
      true,
      protocol,
    );

    return Promise.all([
      dispatcherRegistration,
      requestSubscription,
      responseSubscription,
      onlineCharger,
    ])
      .then((resolvedArray) => resolvedArray[1] && resolvedArray[2])
      .catch((error) => {
        this._logger.error(`Error registering connection for ${connectionIdentifier}: ${error}`);
        return false;
      });
  }

  async deregisterConnection(tenantId: number, ocppConnectionName: string): Promise<boolean> {
    this._webhookDispatcher.deregister(tenantId, ocppConnectionName).catch((err) => {
      this._logger.error('_webhookDispatcher deregister failed', err);
    });

    let protocol: OCPPVersion | null = null;
    try {
      const chargingStation = await this._locationRepository.readChargingStationByStationId(
        tenantId,
        ocppConnectionName,
      );
      if (chargingStation?.protocol) {
        protocol = chargingStation.protocol as OCPPVersion;
      }
    } catch (e: any) {
      this._logger?.warn?.(
        `Could not read charging station ${ocppConnectionName} of tenant ${tenantId} to determine protocol: ${e.message}`,
      );
    }

    await this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(
      tenantId,
      ocppConnectionName,
      false,
      protocol,
    );

    const connectionIdentifier = createIdentifier(tenantId, ocppConnectionName);
    // TODO: ensure that all queue implementations in 02_Util only unsubscribe 1 queue per call
    // ...which will require refactoring this method to unsubscribe request and response queues separately
    return await this._handler.unsubscribe(connectionIdentifier);
  }

  async onMessage(
    identifier: string,
    message: string,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<boolean> {
    const tenantId = getTenantIdFromIdentifier(identifier);
    const ocppConnectionName = getStationIdFromIdentifier(identifier);
    let success = true;
    let rpcMessage: any;
    let messageTypeId: MessageTypeId | undefined = undefined;
    let messageId: string = '-1'; // OCPP 2.0.1 part 4, section 4.2.3, When also the MessageId cannot be read, the CALLERROR SHALL contain "-1" as MessageId.

    try {
      try {
        rpcMessage = JSON.parse(message);
      } catch (error) {
        this._logger.error(
          `Error parsing ${message} from websocket, unable to reply: ${JSON.stringify(error)}`,
        );
        throw error;
      }
      messageTypeId = rpcMessage[0];
      messageId = rpcMessage[1];
      switch (messageTypeId) {
        case MessageTypeId.Call: {
          await this._onCall(identifier, rpcMessage as Call, timestamp, protocol);
          break;
        }
        case MessageTypeId.CallResult: {
          await this._onCallResult(identifier, rpcMessage as CallResult, timestamp, protocol);
          break;
        }
        case MessageTypeId.CallError: {
          await this._onCallError(identifier, rpcMessage as CallError, timestamp, protocol);
          break;
        }
        default: {
          let errorCode;
          switch (protocol) {
            case OCPPVersion.OCPP1_6:
            case OCPPVersion.OCPP2_0_1:
            case OCPPVersion.OCPP2_1: {
              errorCode = ErrorCode.FormatViolation;
              break;
            }
            default: {
              throw new Error('Unknown protocol: ' + protocol);
            }
          }
          throw new OcppError(
            messageId,
            errorCode,
            'Unknown message type id: ' + messageTypeId,
            {},
          );
        }
      }
    } catch (error) {
      success = false; // ensure we return false in case of an error
      this._logger.error('Error processing message:', message, error);
      const action = this.getActionFromIncompletelyParsedRpcMessage(rpcMessage, messageTypeId);
      if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) {
        const callError =
          error instanceof OcppError
            ? error.asCallError()
            : [
                MessageTypeId.CallError,
                messageId,
                ErrorCode.InternalError,
                'Unable to process message',
                { error: error },
              ];
        const rawMessage = JSON.stringify(callError);
        await this._sendMessage(
          identifier,
          protocol,
          action,
          MessageState.Response,
          rawMessage,
          callError,
        );
      }
      let state = MessageState.Unknown;
      switch (messageTypeId) {
        case MessageTypeId.Call:
          state = MessageState.Request;
          break;
        case MessageTypeId.CallResult:
        case MessageTypeId.CallError:
          state = MessageState.Response;
          break;
        default: // keep as Unknown
          break;
      }
      await this._webhookDispatcher.dispatchMessageReceivedUnparsed(
        tenantId,
        ocppConnectionName,
        message,
        timestamp.toISOString(),
        protocol,
        action,
        state,
      );
    }

    // Update latestOcppMessageTimestamp for any incoming OCPP message (non-blocking, single query)
    this._locationRepository
      .updateChargingStationTimestamp(tenantId, ocppConnectionName, timestamp.toISOString())
      .catch((error: any) => {
        this._logger.error(`Failed to update latestOcppMessageTimestamp for ${identifier}:`, error);
      });

    return success;
  }

  /**
   * Sends a Call message to a charging station with given identifier.
   *
   * @param ocppConnectionName - The connection name of the charging station
   * @param {number} tenantId - The identifier of the tenant.
   * @param {OCPPVersionType} protocol The OCPP protocol version of the message.
   * @param {CallAction} action - The action to be called.
   * @param {OcppRequest} payload - The payload of the call.
   * @param {string} correlationId - The correlation ID of the message.
   * @param {MessageOrigin} _origin - The origin of the message.
   * @return {Promise<boolean>} A promise that resolves to a boolean indicating if the call was sent successfully.
   */
  async sendCall(
    ocppConnectionName: string,
    tenantId: number,
    protocol: OCPPVersionType,
    action: CallAction,
    payload: OcppRequest,
    correlationId = uuidv4(),
    _origin?: MessageOrigin,
  ): Promise<IMessageConfirmation> {
    const identifier = createIdentifier(tenantId, ocppConnectionName);
    const transactionNamespace = CacheNamespace.Transactions + identifier;

    const message: Call = [MessageTypeId.Call, correlationId, action, payload];
    if (await this._sendCallIsAllowed(identifier, protocol, message)) {
      if (!(await this._cache.existsAnyInNamespace(transactionNamespace))) {
        const cacheTimestamp = new Date();
        await this._cache.set(
          correlationId,
          `${action}@${cacheTimestamp.toISOString()}`,
          transactionNamespace,
          this._config.maxCallLengthSeconds,
        );
        const rawMessage = JSON.stringify(message);
        const successTimestamp = await this._sendMessage(
          identifier,
          protocol,
          action,
          MessageState.Request,
          rawMessage,
          message,
        );
        if (successTimestamp != undefined) {
          this._logger.debug(
            `Call sent successfully with ${
              successTimestamp.getTime() - cacheTimestamp.getTime()
            } ms of lag between cache and send ${correlationId}`,
            identifier,
            message,
          );
        } else {
          const removed = await this._cache.remove(correlationId, transactionNamespace);
          this._logger.warn(
            `Failed to send call, removed from cache: ${removed}`,
            identifier,
            message,
          );
        }
        return { success: !!successTimestamp };
      } else {
        this._logger.info(
          'Call already in progress, throwing retry exception',
          identifier,
          message,
        );
        throw new RetryMessageError('Call already in progress');
      }
    } else {
      this._logger.info('RegistrationStatus Rejected, unable to send', identifier, message);
      return { success: false };
    }
  }

  /**
   * Sends the CallResult to a charging station with given identifier.
   *
   * @param {string} correlationId - The correlation ID of the message.
   * @param ocppConnectionName - The connection name of the charging station
   * @param {number} tenantId - The identifier of the tenant.
   * @param {OCPPVersionType} protocol The OCPP protocol version of the message.
   * @param {CallAction} action - The action to be called.
   * @param {OcppRequest} payload - The payload of the call.
   * @param {MessageOrigin} _origin - The origin of the message.
   * @return {Promise<boolean>} A promise that resolves to true if the call result was sent successfully, or false otherwise.
   */
  async sendCallResult(
    correlationId: string,
    ocppConnectionName: string,
    tenantId: number,
    protocol: OCPPVersionType,
    action: CallAction,
    payload: OcppResponse,
    _origin?: MessageOrigin,
  ): Promise<IMessageConfirmation> {
    const message: CallResult = [MessageTypeId.CallResult, correlationId, payload];
    const identifier = createIdentifier(tenantId, ocppConnectionName);

    const cachedActionTimestamp = await this._cache.get<string>(
      correlationId,
      CacheNamespace.Transactions + identifier,
    );
    if (!cachedActionTimestamp) {
      this._logger.error(
        'Failed to send callResult due to missing message id',
        identifier,
        message,
      );
      return { success: false };
    }
    const [cachedAction, cachedTimestamp] = cachedActionTimestamp?.split(/@(.*)/) ?? []; // Returns all characters after first '@'
    if (cachedAction === action) {
      const rawMessage = JSON.stringify(message);
      const success = await Promise.all([
        this._sendMessage(
          identifier,
          protocol,
          cachedAction,
          MessageState.Response,
          rawMessage,
          message,
          cachedTimestamp,
        ),
        this._cache.remove(correlationId, CacheNamespace.Transactions + identifier),
      ]).then((successes) => successes.every(Boolean));
      this._logger.debug(`CallResult sent successfully ${correlationId}`, identifier, message);
      return { success };
    } else {
      this._logger.error(
        'Failed to send callResult due to mismatched action',
        identifier,
        cachedActionTimestamp,
        message,
      );
      return { success: false };
    }
  }

  /**
   * Sends a CallError message to a charging station with given identifier.
   *
   * @param {string} correlationId - The correlation ID of the message.
   * @param ocppConnectionName - The connection name of the charging station
   * @param {number} tenantId - The identifier of the tenant.
   * @param {OCPPVersionType} protocol The OCPP protocol version of the message.
   * @param {CallAction} _action - The action to be called.
   * @param {OcppError} error - The error of the call.
   * @param {MessageOrigin} _origin - The origin of the message.
   * @return {Promise<boolean>} - A promise that resolves to true if the message was sent successfully.
   */
  async sendCallError(
    correlationId: string,
    ocppConnectionName: string,
    tenantId: number,
    protocol: OCPPVersionType,
    action: CallAction,
    error: OcppError,
    _origin?: MessageOrigin | undefined,
  ): Promise<IMessageConfirmation> {
    const message: CallError = error.asCallError();
    const identifier = createIdentifier(tenantId, ocppConnectionName);

    const cachedActionTimestamp = await this._cache.get<string>(
      correlationId,
      CacheNamespace.Transactions + identifier,
    );
    if (!cachedActionTimestamp) {
      this._logger.error('Failed to send callError due to missing message id', identifier, message);
      return { success: false };
    }
    const [cachedAction, cachedTimestamp] = cachedActionTimestamp?.split(/@(.*)/) ?? []; // Returns all characters after first '@'
    if (cachedAction === action) {
      const rawMessage = JSON.stringify(message);
      const success = await Promise.all([
        this._sendMessage(
          identifier,
          protocol,
          cachedAction,
          MessageState.Response,
          rawMessage,
          message,
          cachedTimestamp,
        ),
        this._cache.remove(correlationId, CacheNamespace.Transactions + identifier),
      ]).then((successes) => successes.every(Boolean));
      return { success };
    } else {
      this._logger.error(
        'Failed to send callError due to mismatched action',
        identifier,
        cachedActionTimestamp,
        cachedAction,
        message,
      );
      return { success: false };
    }
  }

  async shutdown(): Promise<void> {
    await this._sender.shutdown();
    await this._handler.shutdown();
  }

  /**
   * Private Methods
   */

  /**
   * Handles an incoming Call message from a client connection.
   *
   * @param {string} identifier - The client identifier.
   * @param {Call} message - The Call message received.
   * @param {Date} timestamp Time at which the message was received from the charger.
   * @param {string} protocol The OCPP protocol version of the message
   * @return {void}
   */
  async _onCall(
    identifier: string,
    message: Call,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<void> {
    const messageId = message[1];
    const tenantId = getTenantIdFromIdentifier(identifier);
    const ocppConnectionName = getStationIdFromIdentifier(identifier);

    let action = message[2];
    this._logger.debug('_onCall:', identifier, message, timestamp.toISOString(), protocol);

    action = mapToCallAction(protocol, action);
    const isAllowed = await this._onCallIsAllowed(action, identifier);
    if (!isAllowed) {
      throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`);
    }
    // Run schema validation for incoming Call message
    const { isValid, errors } = this._validateCall(identifier, message, protocol);

    if (!isValid || errors) {
      throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', {
        errors: errors,
      });
    }

    this._cache
      .existsAnyInNamespace(CacheNamespace.Transactions + identifier)
      .then((exists) => {
        if (exists) {
          this._logger.debug(
            'Another call is already in progress, processing call anyways',
            identifier,
            message,
          );
        }
      })
      .catch((error) => {
        this._logger.error(
          'Failed to check if another call is in progress:',
          identifier,
          message,
          error,
        );
      });
    this._cache
      .setIfNotExist(
        messageId,
        `${action}@${timestamp.toISOString()}`,
        CacheNamespace.Transactions + identifier,
        this._config.maxCallLengthSeconds,
      )
      .then((success) => {
        if (!success) {
          this._logger.debug(
            'Another call with same messageId is already in progress, processing call anyways',
            identifier,
            message,
          );
        }
      })
      .catch((error) => {
        this._logger.error('Failed to set call in cache:', identifier, message, error);
      });

    try {
      // Route call
      const confirmation = await this._routeCall(identifier, message, timestamp, protocol);

      if (!confirmation.success) {
        throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', {
          details: confirmation.payload,
        });
      }
    } catch (error) {
      const callError =
        error instanceof OcppError
          ? error
          : new OcppError(messageId, ErrorCode.InternalError, 'Call failed', {
              details: error,
            });

      this.sendCallError(messageId, ocppConnectionName, tenantId, protocol, action, callError)
        .catch((err) => {
          this._logger.error('sendCallError failed', err);
        })
        .finally(() => {
          this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => {
            this._logger.error('cache remove failed', err);
          });
        });
    }
  }

  /**
   * Handles a CallResult made by the client.
   *
   * @param {string} identifier - The client identifier that made the call.
   * @param {CallResult} message - The OCPP CallResult message.
   * @param {Date} timestamp Time at which the message was received from the charger.
   * @param {OCPPVersionType} protocol The OCPP protocol version of the message
   */
  async _onCallResult(
    identifier: string,
    message: CallResult,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<void> {
    const messageId = message[1];

    this._logger.debug('_onCallResult:', identifier, message, timestamp.toISOString(), protocol);

    const cachedActionTimestamp = await this._cache.get<string>(
      messageId,
      CacheNamespace.Transactions + identifier,
    );

    await this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => {
      this._logger.error('_onCallResult cache remove failed', err);
    });

    if (!cachedActionTimestamp) {
      throw new OcppError(
        messageId,
        ErrorCode.InternalError,
        'MessageId not found, call may have timed out',
        { maxCallLengthSeconds: this._config.maxCallLengthSeconds },
      );
    }

    const [action, cachedTimestamp] = cachedActionTimestamp.split(/@(.*)/); // Returns all characters after first '@'
    this._logger.debug(
      `Message received. Time taken since sent: ${
        timestamp.getTime() - new Date(cachedTimestamp).getTime()
      } ms`,
      identifier,
      message,
    );

    // Run schema validation for incoming CallResult message
    const { isValid, errors } = this._validateCallResult(
      identifier,
      mapToCallAction(protocol, action),
      message,
      protocol,
    );

    if (!isValid || errors) {
      throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', {
        errors: errors,
      });
    }

    // Route call result
    const confirmation = await this._routeCallResult(
      identifier,
      message,
      mapToCallAction(protocol, action),
      timestamp,
      protocol,
    );

    if (!confirmation.success) {
      throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', {
        details: confirmation.payload,
      });
    }
  }

  /**
   * Handles the CallError that may have occured during a Call exchange.
   *
   * @param {string} identifier - The client identifier.
   * @param {CallError} message - The error message.
   * @param {Date} timestamp Time at which the message was received from the charger.
   * @param {OCPPVersionType} protocol The OCPP protocol version of the message
   */
  async _onCallError(
    identifier: string,
    message: CallError,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<void> {
    const messageId = message[1];

    this._logger.debug('_onCallError:', identifier, message, timestamp.toISOString(), protocol);

    const cachedActionTimestamp = await this._cache.get<string>(
      messageId,
      CacheNamespace.Transactions + identifier,
    );

    // Always remove pending call transaction
    await this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => {
      this._logger.error('_onCallError cache remove failed', err);
    });

    if (!cachedActionTimestamp) {
      throw new OcppError(
        messageId,
        ErrorCode.InternalError,
        'MessageId not found, call may have timed out',
        { maxCallLengthSeconds: this._config.maxCallLengthSeconds },
      );
    }

    const [action, cachedTimestamp] = cachedActionTimestamp.split(/@(.*)/); // Returns all characters after first '@'
    this._logger.debug(
      `Message received. Time taken since sent: ${
        timestamp.getTime() - new Date(cachedTimestamp).getTime()
      } ms`,
      identifier,
      message,
    );

    const confirmation = await this._routeCallError(
      identifier,
      message,
      mapToCallAction(protocol, action),
      timestamp,
      protocol,
    );

    if (!confirmation.success) {
      // Below code commented out with debug log because currently there is no error routing implemented, so this block will always be reached for CallErrors.
      // Once error routing is implemented, this block can be uncommented to throw an error if the CallError routing fails.
      this._logger.debug('Unable to route call error: ', confirmation);
      // throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', {
      //   details: confirmation.payload,
      // });
    }
  }

  /**
   * Determine if the given action for identifier is allowed.
   *
   * @param {CallAction} action - The action to be checked.
   * @param {string} identifier - The identifier to be checked.
   * @return {Promise<boolean>} A promise that resolves to a boolean indicating if the action and identifier are allowed.
   */
  private _onCallIsAllowed(action: CallAction, identifier: string): Promise<boolean> {
    return this._cache.exists(action, identifier).then((blacklisted) => !blacklisted);
  }

  /**
   *
   * @param {string} identifier - The identifier of the client, e.g. "tenantId:ocppConnectionName".
   * @param {OCPPVersionType} protocol - The OCPP protocol version.
   * @param {string} action - The OCPP CallAction to be sent. See {@link CallAction}.
   * @param {MessageState} state - The state of the message. Used for dispatching in webhook.
   * @param {string} rawMessage - The raw message string to be sent, i.e. the stringified version of the rpc message. Used for sending in webhook and logging.
   * @param {any} rpcMessage - the rpc message json object, i.e. [MessageTypeId, messageId, action, payload] for Call or [MessageTypeId, messageId, payload] for CallResult. Used for logging and dispatching in webhook.
   * @param {string} receivedIsoTimestamp - The ISO timestamp of when the Call was received, if this is a response to a Call. Used for logging the time taken for the message to be sent since it was received.
   * @returns {Promise<Date | undefined>} A promise that resolves to the timestamp of when the message was sent or undefined if the message failed to send.
   */
  private async _sendMessage(
    identifier: string,
    protocol: OCPPVersionType,
    action: string,
    state: MessageState,
    rawMessage: string,
    rpcMessage: any,
    receivedIsoTimestamp?: string,
  ): Promise<Date | undefined> {
    try {
      await this._networkHook(identifier, rawMessage); // Throws an error if the message is not sent, or returns void
    } catch (error) {
      this._logger.error('Failed to send message:', identifier, rawMessage, error);
      // Don't dispatch if the message was not sent
      return undefined;
    }
    const sentTimestamp = new Date();
    if (receivedIsoTimestamp) {
      const receivedTimestamp = new Date(receivedIsoTimestamp);
      this._logger.debug(
        `Message sent successfully. Time taken since received: ${
          sentTimestamp.getTime() - receivedTimestamp.getTime()
        } ms`,
        identifier,
        rpcMessage,
      );
    }
    this._webhookDispatcher
      .dispatchMessageSent(
        identifier,
        action,
        state,
        sentTimestamp.toISOString(),
        protocol,
        rpcMessage,
      )
      .catch((err) => {
        this._logger.error('dispatchMessageSent failed', err);
      });
    return sentTimestamp;
  }

  private async _sendCallIsAllowed(
    identifier: string,
    protocol: OCPPVersionType,
    message: Call,
  ): Promise<boolean> {
    const status = await this._cache.get<string>(BOOT_STATUS, identifier);
    if (
      status === OCPP2_1.RegistrationStatusEnumType.Rejected &&
      // TriggerMessage<BootNotification> is the only message allowed to be sent during Rejected BootStatus B03.FR.08
      !(
        mapToCallAction(protocol, message[2]) === OCPP_CallAction.TriggerMessage &&
        (message[3] as OCPP2_1.TriggerMessageRequest).requestedMessage ==
          OCPP2_1.MessageTriggerEnumType.BootNotification
      )
    ) {
      return false;
    }
    return true;
  }

  private async _routeCall(
    connectionIdentifier: string,
    message: Call,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<IMessageConfirmation> {
    const messageId = message[1];
    const action = mapToCallAction(protocol, message[2]);
    const payload = message[3] as OcppRequest;
    const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
    const ocppConnectionName = getStationIdFromIdentifier(connectionIdentifier);

    const _message: IMessage<OcppRequest> = RequestBuilder.buildCall(
      ocppConnectionName,
      messageId,
      tenantId,
      action,
      payload,
      EventGroup.Router,
      MessageOrigin.ChargingStation,
      protocol,
      timestamp,
    );

    return this.emitMessage(_message, message);
  }

  private async _routeCallResult(
    connectionIdentifier: string,
    message: CallResult,
    action: CallAction,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<IMessageConfirmation> {
    const messageId = message[1];
    const payload = message[2] as OcppResponse;
    const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
    const ocppConnectionName = getStationIdFromIdentifier(connectionIdentifier);

    const _message: IMessage<OcppResponse> = RequestBuilder.buildCallResult(
      ocppConnectionName,
      messageId,
      tenantId,
      action,
      payload,
      EventGroup.Router,
      MessageOrigin.ChargingStation,
      protocol,
      timestamp,
    );

    return this.emitMessage(_message, message);
  }

  private async _routeCallError(
    connectionIdentifier: string,
    message: CallError,
    action: CallAction,
    timestamp: Date,
    protocol: OCPPVersionType,
  ): Promise<IMessageConfirmation> {
    const messageId = message[1];
    const payload = new OcppError(messageId, message[2], message[3], message[4]);
    const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
    const ocppConnectionName = getStationIdFromIdentifier(connectionIdentifier);

    const _message: IMessage<OcppError> = RequestBuilder.buildCallError(
      ocppConnectionName,
      messageId,
      tenantId,
      action,
      payload,
      EventGroup.Router,
      MessageOrigin.ChargingStation,
      protocol,
      timestamp,
    );

    // Fulfill callback for api, if needed
    this._handleMessageApiCallback(_message).catch((err) => {
      this._logger.error('_handleMessageApiCallback failed', err);
    });

    return this.emitMessage(_message, message);
  }

  private async emitMessage(
    message: IMessage<any>,
    rpcMessage: any,
  ): Promise<IMessageConfirmation> {
    let confirmation: IMessageConfirmation;
    if (message.payload instanceof OcppError) {
      // No error routing currently done
      this._logger.warn('OCPP Error routing not implemented');
      confirmation = { success: false };
    } else {
      confirmation = await this._sender.send(message);
    }

    await this._webhookDispatcher.dispatchMessageReceived(
      message.context.tenantId,
      message.context.ocppConnectionName,
      message.context.timestamp,
      message.protocol,
      message.action,
      message.state,
      rpcMessage,
    );

    return confirmation;
  }

  private async _handleMessageApiCallback(message: IMessage<OcppError>): Promise<void> {
    const url: string | null = await this._cache.get(
      message.context.correlationId,
      AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.ocppConnectionName,
    );
    if (url) {
      const headers: { [key: string]: string } = {
        'Content-Type': 'application/json',
      };

      if (this._oidcTokenProvider) {
        try {
          const token = await this._oidcTokenProvider.getToken();
          headers['Authorization'] = `Bearer ${token}`;
        } catch (error) {
          this._logger.error('Failed to get OIDC token for callback:', error);
          return;
        }
      }

      await fetch(url, {
        method: 'POST',
        headers,
        body: JSON.stringify(message.payload),
      });
    }
  }

  private getActionFromIncompletelyParsedRpcMessage(
    rpcMessage: any,
    messageTypeId?: MessageTypeId,
  ) {
    let action;
    switch (messageTypeId) {
      case MessageTypeId.Call:
        action = rpcMessage && rpcMessage.length > 2 ? rpcMessage[2] : NO_ACTION;
        break;
      case MessageTypeId.CallResult:
      case MessageTypeId.CallError:
      default:
        action = NO_ACTION;
        break;
    }
    return action;
  }
}
