import {ApiErrors} from '@config/api';
import {
  BlockMessageForwarded,
  BlockMessageReplied,
  BlockType,
  MediaType,
  Message,
  MessageStatus,
} from '@modules/chats/interfaces';
import {getPendingMessages} from '@modules/chats/db';
import {forwardMessage, postMessage} from '@modules/chats/services';
import {fileHandler} from '@modules/chats/instances/FileHandler';
import {compareUlid} from '@shared/utils';

import {OrchestratorUpdaterService} from './updaters';
import {PendingAssets} from './PendingAssets';
import {uploadAudioFile} from '../utils/files';
import {mapMediaToService} from '../utils/mappers/assetMapper';
import {conversationMarker} from './ConversationMarker';

class MessagesQueue {
  private static instance: MessagesQueue;
  private queue: Message[] = [];
  private isRunning = false;
  private isPendingLoaded = false;
  private sendPendingMsgTimerId: Nullable<NodeJS.Timeout> = null;

  static getInstance() {
    if (!MessagesQueue.instance) {
      MessagesQueue.instance = new MessagesQueue();
    }
    return MessagesQueue.instance;
  }

  /**
   * Add message to queue to be sent sequentially
   */
  addMessage = (message: Message) => {
    this.queue.push(message);
    if (this.isPendingLoaded) {
      this.runQueue();
    }
  };

  onConnectionLost = () => {
    this.isPendingLoaded = false;
  };

  /**
   * Remove message from queue
   */
  removeMessage = (id: string) => {
    this.queue = this.queue.filter(m => m.client_msg_id !== id);
  };

  /**
   * Check if the queue is empty
   * @returns true if the queue is empty, false otherwise
   */
  isQueueEmpty = () => {
    return this.queue.length === 0;
  };

  /**
   * Get pending messages from DB and start sending them sequentially
   */
  sendPendingMessages = async () => {
    try {
      if (this.sendPendingMsgTimerId) {
        clearTimeout(this.sendPendingMsgTimerId);
        this.sendPendingMsgTimerId = null;
      }
      const pendingFromDB = await getPendingMessages();

      // Filter out messages that already exist in the queue based on client_msg_id
      const uniquePendingMessages = pendingFromDB.reduce((acc, current) => {
        if (
          !this.queue.some(
            queueMsg => queueMsg.client_msg_id === current.client_msg_id,
          )
        ) {
          acc.push(PendingAssets.setMessage(current));
        }
        return acc;
      }, [] as Message[]);
      this.queue = [...uniquePendingMessages, ...this.queue].sort((a, b) =>
        compareUlid(a.ts, b.ts),
      );

      this.isPendingLoaded = true;
      this.runQueue();
    } catch (error) {
      // If there is an error, retry the function after 2 seconds
      this.sendPendingMsgTimerId = setTimeout(() => {
        this.sendPendingMessages();
      }, 2000);
    }
  };

  /**
   * Run queue of messages sequentially. It will run the queue only if there is no other running queue
   */
  private runQueue = async () => {
    if (this.isRunning) {
      return;
    }

    this.isRunning = true;

    try {
      /**
       * Wait until all pending messages are loaded from DB to be sent first ( to keep the right order)
       */
      while (this.queue.length > 0 && this.isPendingLoaded) {
        const message = this.queue.shift();

        if (message) {
          const forwardBlock = message.blocks?.find(
            block => block.type === BlockType.MessageForwarded,
          ) as BlockMessageForwarded | undefined;

          const isForwardedMessage =
            forwardBlock?.original_channel_ts &&
            forwardBlock.original_message_ts;

          if (isForwardedMessage) {
            await this.forwardMessage(message, forwardBlock);
          } else {
            await this.sendMessage(message);
          }
        }
      }
    } finally {
      this.isRunning = false;
    }
  };

  /**
   * Send message to server. Update message status (sent, error) in local state and DB
   */
  private sendMessage = async (param: Message) => {
    const message = {...param};

    try {
      // Upload pending assets if any
      if (message.pendingAssets) {
        for (const pendingAsset of message.pendingAssets) {
          if (pendingAsset.type === MediaType.AUDIO) {
            const asset = await uploadAudioFile({
              uri: pendingAsset.localUri,
              durationMs: pendingAsset.metadata?.durationInMs ?? 0,
            });

            // Update asset item and replace the temp id with the real one
            await fileHandler.updateAsset({
              assetId: pendingAsset.id,
              asset: {
                key: Number(asset.id),
                remoteUrl: asset.fileAsset!.url,
              },
              withNewAssetId: asset.id,
            });

            message.attachments = mapMediaToService([asset]);
          }
        }
      }
      // Send to server
      const {data} = await postMessage({
        channel: message.channel,
        text: message.text,
        generated_by: message.generated_by,
        idempotencyKey: message.client_msg_id,
        messageId: message.client_msg_id,
        attachments: message.attachments || undefined,
        reply_ts:
          (
            message.blocks?.find(
              block => block.type === BlockType.MessageReplied,
            ) as BlockMessageReplied
          )?.message_replied_id || undefined,
      });

      conversationMarker.markConversationAsRead(message.channel, data.ts);
      // Update local state and DB
      OrchestratorUpdaterService.processMessage({
        ...data,
        status: MessageStatus.Sent,
        // if the server does not return generated_by, keep the existing one
        generated_by: data.generated_by ?? message.generated_by,
      });

      return;
    } catch (error) {
      // If the error is a network error, do not update the message status (it will be retried later)
      if (
        (error as Error).message === ApiErrors.NETWORK_ERROR ||
        (error as Error).message === ApiErrors.TIMEOUT_ERROR
      ) {
        // stop the queue processing and add this message back to the queue at index 0;
        this.onConnectionLost();
        this.queue.unshift(message);
        return;
      }

      // Unknown error. Update the message status to error
      OrchestratorUpdaterService.processMessage({
        ...message,
        status: MessageStatus.Error,
      });
    }
  };

  private forwardMessage = async (
    message: Message,
    forwardedBlock: BlockMessageForwarded,
  ) => {
    try {
      const {data} = await forwardMessage({
        channel: forwardedBlock.original_channel_ts!,
        messageId: forwardedBlock.original_message_ts!,
        destinationChannelIds: [message.channel],
        destinationUserIds: [],
        idempotencyKey: message.client_msg_id,
        text: message.formatted_text || message.text,
        attachments: message.attachments,
      });

      for (const item of data) {
        OrchestratorUpdaterService.processMessage({
          ...item,
          ts: message.ts,
          client_msg_id: message.client_msg_id,
          status: MessageStatus.Sent,
        });
      }
    } catch (error) {
      if (
        (error as Error).message === ApiErrors.NETWORK_ERROR ||
        (error as Error).message === ApiErrors.TIMEOUT_ERROR
      ) {
        // stop the queue processing and add this message back to the queue at index 0;
        this.onConnectionLost();
        this.queue.unshift(message);
        return;
      }

      // Unknown error. Update the message status to error
      OrchestratorUpdaterService.processMessage({
        ...message,
        status: MessageStatus.Error,
      });
    }
  };
}

export const messagesQueue = MessagesQueue.getInstance();
