/* eslint-disable max-params */
import {Message} from '@modules/chats/interfaces';
import {MESSAGES_BATCH_SIZE} from '@modules/chats/constants';
import {
  getMessagesAboveByTs,
  getMessagesBelowByTs,
  getMessagesAroundTs,
  getMessagesWithHeight,
} from '@modules/chats/db';
import {
  fetchMessagesAbove,
  fetchMessagesBelow,
  fetchMessagesAround,
} from '@modules/chats/services';
import {mapMessageFromDB} from '@modules/chats/utils';
import {sentryLogs} from '@shared/utils';

import {MessageRangesHandler} from './MessageRangesHandler';

// Type inferred from repository function return
type DBMessageWithAssets = Awaited<
  ReturnType<typeof getMessagesAboveByTs>
>[number];

type MessageSource = 'db' | 'server' | 'mixed';

interface LoadMessagesResult {
  messages: Message[];
  hasMore: boolean;
  source: MessageSource;
  anchor?: Message;
}

// "Around" loads return a window centered on a pivot, so a single `hasMore`
// isn't meaningful — callers care about each edge independently to decide
// whether to fetch above / below from that point.
interface LoadMessagesAroundResult {
  messages: Message[];
  hasMoreAbove: boolean;
  hasMoreBelow: boolean;
  source: MessageSource;
}

class MessagesOrchestratorClass {
  /**
   * Load messages above (older than) the given anchor timestamp.
   * @throws When fetching directly from server (no DB fallback available).
   * In "mixed" mode (DB + server), server errors are caught internally
   * and only DB messages are returned.
   * Callers MUST wrap this in a try-catch to avoid runtime crashes.
   */
  async loadMessagesAbove(
    conversationId: string,
    anchorTs: string,
    options?: {limit?: number; ignoreSending?: boolean; timeout?: number},
  ): Promise<LoadMessagesResult> {
    const limit = options?.limit ?? MESSAGES_BATCH_SIZE;

    // Find the range containing the anchor
    const range = MessageRangesHandler.findRangeForTs(conversationId, anchorTs);

    const timeout = options?.timeout;

    // If no range contains this ts, we have a gap - fetch from server
    if (!range) {
      return this.fetchAndSaveMessagesAbove(
        conversationId,
        anchorTs,
        limit,
        timeout,
      );
    }

    // If anchor is at the oldest edge of the range and it's not the start,
    // we need to fetch from server (DB query would return 0 results)
    if (anchorTs === range.oldestTs && !range.isStart) {
      return this.fetchAndSaveMessagesAbove(
        conversationId,
        anchorTs,
        limit,
        timeout,
      );
    }

    // If anchor is at the oldest edge and it IS the start, no more messages
    if (anchorTs === range.oldestTs && range.isStart) {
      return {messages: [], hasMore: false, source: 'db'};
    }

    // Load from DB, but only within the current range boundaries
    // This prevents fetching messages from other disconnected ranges
    const dbMessagesRaw = await getMessagesAboveByTs({
      conversationId,
      ts: anchorTs,
      limit,
      ignoreSending: options?.ignoreSending,
      minTs: range.oldestTs,
    });

    // Map from DB format to Message interface
    const dbMessages = await this.mapAndPrepareMessagesFromDB(dbMessagesRaw);

    const isStart = range.isStart;

    // If we got less messages than requested and we're not at the start,
    // we've reached the edge of the range - need to fetch from server
    if (dbMessages.length < limit && !isStart) {
      // Use the oldest message ts as anchor for server fetch, or range boundary
      const serverAnchor =
        dbMessages.length > 0 ? dbMessages[0].ts : range.oldestTs;

      try {
        const serverResult = await this.fetchAndSaveMessagesAbove(
          conversationId,
          serverAnchor,
          limit - dbMessages.length,
          timeout,
        );

        return {
          messages: [...serverResult.messages, ...dbMessages],
          hasMore: serverResult.hasMore,
          source: 'mixed',
        };
      } catch (error) {
        sentryLogs.error({
          message:
            'Error fetching above messages from server in mixed mode, returning DB messages only',
          module: 'CHATS',
          component: 'MessagesOrchestrator.loadMessagesAbove',
          error,
          data: {
            conversationId,
            serverAnchor,
            dbMessagesCount: dbMessages.length,
          },
        });
        return {
          messages: dbMessages,
          hasMore: true,
          source: 'db',
        };
      }
    }

    return {
      messages: dbMessages,
      hasMore: dbMessages.length >= limit || !isStart,
      source: 'db',
    };
  }

  /**
   * Load messages below (newer than) the given anchor timestamp.
   * @throws When fetching directly from server (no DB fallback available).
   * In "mixed" mode (DB + server), server errors are caught internally
   * and only DB messages are returned.
   * Callers MUST wrap this in a try-catch to avoid runtime crashes.
   */
  async loadMessagesBelow(
    conversationId: string,
    anchorTs: string,
    options?: {limit?: number; ignoreSending?: boolean},
  ): Promise<LoadMessagesResult> {
    const limit = options?.limit ?? MESSAGES_BATCH_SIZE;

    // Find the range containing the anchor
    const range = MessageRangesHandler.findRangeForTs(conversationId, anchorTs);

    // If no range contains this ts, we have a gap - fetch from server
    if (!range) {
      return this.fetchAndSaveMessagesBelow(conversationId, anchorTs, limit);
    }

    // If anchor is at the newest edge of the range and it's not the end,
    // we need to fetch from server (DB query would return 0 results)
    if (anchorTs === range.newestTs && !range.isEnd) {
      return this.fetchAndSaveMessagesBelow(conversationId, anchorTs, limit);
    }

    // If anchor is at the newest edge and it IS the end, no more messages
    if (anchorTs === range.newestTs && range.isEnd) {
      return {messages: [], hasMore: false, source: 'db'};
    }

    // Load from DB, but only within the current range boundaries
    // This prevents fetching messages from other disconnected ranges
    const dbMessagesRaw = await getMessagesBelowByTs({
      conversationId,
      ts: anchorTs,
      limit,
      ignoreSending: options?.ignoreSending,
      maxTs: range.newestTs,
    });

    // Map from DB format to Message interface
    const dbMessages = await this.mapAndPrepareMessagesFromDB(dbMessagesRaw);

    const isEnd = range.isEnd;

    // If we got less messages than requested and we're not at the end,
    // we've reached the edge of the range - need to fetch from server
    if (dbMessages.length < limit && !isEnd) {
      // Use the newest message ts as anchor for server fetch, or range boundary
      const serverAnchor =
        dbMessages.length > 0
          ? dbMessages[dbMessages.length - 1].ts
          : range.newestTs;

      try {
        const serverResult = await this.fetchAndSaveMessagesBelow(
          conversationId,
          serverAnchor,
          limit - dbMessages.length,
        );

        return {
          messages: [...dbMessages, ...serverResult.messages],
          hasMore: serverResult.hasMore,
          source: 'mixed',
        };
      } catch (error) {
        sentryLogs.error({
          message:
            'Error fetching below messages from server in mixed mode, returning DB messages only',
          module: 'CHATS',
          component: 'MessagesOrchestrator.loadMessagesBelow',
          error,
          data: {
            conversationId,
            serverAnchor,
            dbMessagesCount: dbMessages.length,
          },
        });
        return {
          messages: dbMessages,
          hasMore: true,
          source: 'db',
        };
      }
    }

    return {
      messages: dbMessages,
      hasMore: dbMessages.length >= limit || !isEnd,
      source: 'db',
    };
  }

  /**
   * Load messages around a specific timestamp (for jumping to replies).
   * @throws When fetching from server (both direct and gap-fill paths).
   * Unlike loadMessagesAbove/Below, this method has no partial DB fallback.
   * Callers MUST wrap this in a try-catch to avoid runtime crashes.
   */
  async loadMessagesAroundTs(
    conversationId: string,
    targetTs: string,
    options?: {limitBefore?: number; limitAfter?: number},
  ): Promise<LoadMessagesAroundResult> {
    const limitBefore =
      options?.limitBefore ?? Math.floor(MESSAGES_BATCH_SIZE / 2);
    const limitAfter =
      options?.limitAfter ?? Math.floor(MESSAGES_BATCH_SIZE / 2);

    // Check if the target ts is within a loaded range
    const range = MessageRangesHandler.findRangeForTs(conversationId, targetTs);

    if (range) {
      // Target is in range, try to load from DB
      const dbMessagesRaw = await getMessagesAroundTs({
        conversationId,
        ts: targetTs,
        limitBefore,
        limitAfter,
        // Limit DB query to range boundaries to detect gaps
        minTs: range.oldestTs,
        maxTs: range.newestTs,
      });

      // Map from DB format to Message interface
      const dbMessages = await this.mapAndPrepareMessagesFromDB(dbMessagesRaw);

      // Check if we got enough messages in each direction, or if we're at range boundaries
      const messagesBeforeTarget = dbMessages.filter(m => m.ts < targetTs);
      const messagesAfterTarget = dbMessages.filter(m => m.ts > targetTs);

      const needMoreBefore =
        messagesBeforeTarget.length < limitBefore && !range.isStart;
      const needMoreAfter =
        messagesAfterTarget.length < limitAfter && !range.isEnd;

      // If we have gaps (not enough messages and not at range limits), fetch from server
      if (needMoreBefore || needMoreAfter) {
        return this.fetchAndSaveMessagesAroundTs(
          conversationId,
          targetTs,
          Math.max(limitBefore, limitAfter),
        );
      }

      return {
        messages: dbMessages,
        hasMoreAbove: !range.isStart,
        hasMoreBelow: !range.isEnd,
        source: 'db',
      };
    }

    // Target is not in any range, need to fetch from server
    return this.fetchAndSaveMessagesAroundTs(
      conversationId,
      targetTs,
      Math.max(limitBefore, limitAfter),
    );
  }

  /**
   * Handle a new message from websocket
   * Extends the range if needed (consolidateRanges will merge with existing ranges)
   */
  handleNewMessageFromSocket(conversationId: string, message: Message): void {
    // Find the current "live edge" range (the one with isEnd: true)
    const ranges = MessageRangesHandler.get(conversationId);
    const liveRange = ranges.find(r => r.isEnd);

    // Check if message is already within an existing range (reprocessing)
    const isAlreadyInRange = MessageRangesHandler.isInLoadedRange(
      conversationId,
      message.ts,
    );

    if (isAlreadyInRange) {
      return;
    }

    // Use the newestTs of the live range as bridge to connect the new message
    const bridgeOldestTs = liveRange?.newestTs;

    MessageRangesHandler.addMessagesAndUpdateRanges(conversationId, [message], {
      isStart: false,
      isEnd: true,
      bridgeOldestTs,
    });
  }

  private async fetchAndSaveMessagesAbove(
    conversationId: string,
    anchorTs: string,
    limit: number,
    timeout?: number,
  ): Promise<LoadMessagesResult> {
    // The service requests inclusively and returns the anchor split out so
    // MessageRangesHandler keeps its exclusive "above" invariant.
    const {messages, hasMore, anchor} = await fetchMessagesAbove({
      groupId: conversationId,
      firstEventTs: anchorTs,
      limit,
      timeout,
    });

    if (messages.length > 0) {
      // Update ranges - extend the existing range or create new one
      // Use anchorTs as bridgeNewestTs to connect with existing range
      MessageRangesHandler.addMessagesAndUpdateRanges(
        conversationId,
        messages,
        {
          isStart: !hasMore,
          isEnd: false,
          bridgeNewestTs: anchorTs,
        },
      );
    } else if (!hasMore) {
      // No more messages, mark as start
      MessageRangesHandler.markAsStart(conversationId, anchorTs);
    }

    return {messages, hasMore, source: 'server', anchor};
  }

  private async fetchAndSaveMessagesBelow(
    conversationId: string,
    anchorTs: string,
    limit: number,
  ): Promise<LoadMessagesResult> {
    // fetchMessagesBelow already saves to DB and returns the anchor split out
    // so MessageRangesHandler keeps its exclusive "below" invariant.
    const {messages, hasMore, anchor} = await fetchMessagesBelow({
      groupId: conversationId,
      firstEventTs: anchorTs,
      limit,
    });

    if (messages.length > 0) {
      // Update ranges
      // Use anchorTs as bridgeOldestTs to connect with existing range
      MessageRangesHandler.addMessagesAndUpdateRanges(
        conversationId,
        messages,
        {
          isStart: false,
          isEnd: !hasMore,
          bridgeOldestTs: anchorTs,
        },
      );
    } else if (!hasMore) {
      // No more messages, mark as end
      MessageRangesHandler.markAsEnd(conversationId, anchorTs);
    }

    return {messages, hasMore, source: 'server', anchor};
  }

  private async fetchAndSaveMessagesAroundTs(
    conversationId: string,
    targetTs: string,
    limit: number,
  ): Promise<LoadMessagesAroundResult> {
    // fetchMessagesAround already saves to DB
    const {messages, hasMoreAbove, hasMoreBelow} = await fetchMessagesAround({
      groupId: conversationId,
      targetTs,
      limit,
    });

    if (messages.length > 0) {
      // Mark range edges per-direction: when the peak window already touches
      // the conversation start, `prev_cursor` is null and we must record
      // `isStart: true` here — otherwise the next upward scroll triggers a
      // useless fetch above for the boundary message.
      MessageRangesHandler.addMessagesAndUpdateRanges(
        conversationId,
        messages,
        {
          isStart: !hasMoreAbove,
          isEnd: !hasMoreBelow,
        },
      );
    }

    return {messages, hasMoreAbove, hasMoreBelow, source: 'server'};
  }

  /**
   * Map messages from DB format to Message interface and calculate heights
   */
  private async mapAndPrepareMessagesFromDB(
    dbMessages: DBMessageWithAssets[],
  ): Promise<Message[]> {
    const mappedMessages = dbMessages.map(message =>
      mapMessageFromDB({
        message,
        assets: message.assets,
        reactions: message.reactions,
      }),
    );

    return await getMessagesWithHeight(mappedMessages);
  }
}

export const MessagesOrchestrator = new MessagesOrchestratorClass();
