import {decodeTime} from 'ulid';
import {getConversations} from '@modules/chats/services';
import {unreadsStore} from '@modules/chats/store';
import {LOCAL_INITIAL_VERSION} from '@modules/chats/constants';
import {Conversation} from '@modules/chats/interfaces';
import {Storage} from '@services/mmkvFactory';
import {getUlid, sentryLogs, wait} from '@shared/utils';

/**
 * =========================================
 * UnreadsHandlerNew - Chat Unread Messages Management
 * =========================================
 *
 * PURPOSE:
 * This class manages unread message counts for chat conversations, synchronizing state
 * between the server, local storage, and real-time socket updates while preventing
 * race conditions.
 *
 * KEY CONCEPTS:
 *
 * 1. THREE DATA SOURCES:
 *    - Server: Source of truth (via periodic sync)
 *    - Socket: Real-time increments for new messages
 *    - Local Storage: Persisted state across app restarts (MMKV)
 *
 * 2. PENDING MECHANISM (Race Condition Prevention):
 *    When sync is running and socket events arrive, we can't immediately apply them
 *    because the server response might overwrite them. Solution:
 *    - Store incoming socket events in "pending" queue during sync
 *    - After server response is applied, merge pending events
 *    - Only count pending messages that are newer than server's latest_message_ts
 *
 * 3. TIMESTAMP-BASED CONFLICT RESOLUTION:
 *    Each unread state has:
 *    - count: Number of unread messages
 *    - latest_message_ts: ULID timestamp of the most recent unread message
 *
 *    Rules for applying server updates:
 *    - If server timestamp > local timestamp: Replace with server value (newer data)
 *    - If timestamps match AND server count = 0: User read on another device, reset local
 *    - If timestamps match AND local count = 0: User marked as read locally, keep local
 *
 * 4. DATA FLOW:
 *    App Start → init() → Load from MMKV → runSync() → Apply server data + pending → Update UI
 *    Socket Event → incrementUnread() → If syncing: Add to pending, else: Update directly
 *    User Reads → markAsRead() → Set count to 0, update timestamp
 *
 * 5. PERSISTENCE:
 *    Uses two separate MMKV stores:
 *    - UNREADS_COUNTS_STORE: Channel → count mapping
 *    - UNREADS_LATEST_MSG_STORE: Channel → latest_message_ts mapping
 *    This allows independent reads/writes and prevents data corruption
 */

type Channel = string;

export type UnreadState = {
  /**
   * The ts of latest message when this unread state was last updated
   */
  latest_message_ts: string;
  /**
   * The number of unread messages
   */
  count: number;
  /**
   * The ts of the last read message
   */
  last_read: Nullable<string>;
  /**
   * Server-provided membership version. Monotonically increasing on the server
   * for each read-state mutation. Used to discard stale channel_marked events.
   * Local-only mutations (incrementUnread, local markAsRead) preserve it.
   */
  version: number;
};

type UnreadCountsMap = {[channel: string]: number};

export const UNREAD_HANDLER_STORES = {
  counts: '__CHAT_UNREADS_COUNTS_STORE__',
  latestMsg: '__CHAT_UNREADS_LATEST_MSG_STORE__',
  lastRead: '__CHAT_UNREADS_LAST_READ_STORE__',
  version: '__CHAT_UNREADS_VERSION_STORE__',
  // Store to keep track of all store ids per user for cleanup
  storeNamespaces: '__CHAT_UNREADS_NAMESPACES_STORE__',
};

/**
 * Transforms internal unread state to UI-friendly format
 *
 * The UI store only needs counts, not timestamps. This function strips
 * away the timestamp data to create a simpler object for the UI layer.
 *
 * @param unreads - Full unread state map with counts and timestamps
 * @returns Simplified map with only channel IDs and counts
 *
 * @example
 * Input:  { 'channel-1': { count: 5, latest_message_ts: '01HXYZ...' } }
 * Output: { 'channel-1': 5 }
 */
function getPreparedDataForUIStore(
  unreads: Map<Channel, UnreadState>,
): UnreadCountsMap {
  const acc: UnreadCountsMap = {};
  for (const [channel, data] of unreads.entries()) {
    acc[channel] = data.count;
  }
  return acc;
}

class UnreadsUpdaterServiceClass {
  private isSyncRunning = false;

  private unreads: Map<Channel, UnreadState>;
  private pendingTs: Map<Channel, Set<string>>;

  private storeUnreadsCount: Storage;
  private storeLatestMsg: Storage;
  private storeLastRead: Storage;
  private storeVersion: Storage;
  private storeNamespaces: Storage; // to save stores ids for each user for cleanup

  constructor() {
    this.storeNamespaces = new Storage({
      id: UNREAD_HANDLER_STORES.storeNamespaces,
    });

    // Default initialization, will be replaced on init()
    this.storeUnreadsCount = new Storage({id: UNREAD_HANDLER_STORES.counts});
    this.storeLatestMsg = new Storage({id: UNREAD_HANDLER_STORES.latestMsg});
    this.storeLastRead = new Storage({id: UNREAD_HANDLER_STORES.lastRead});
    this.storeVersion = new Storage({id: UNREAD_HANDLER_STORES.version});

    this.pendingTs = new Map<Channel, Set<string>>();
    this.unreads = new Map<Channel, UnreadState>();
  }

  private static readonly UNREADS_RESET_VERSION = 1;
  private static readonly UNREADS_RESET_KEY = '__UNREADS_RESET_VERSION__';

  init = async (user_id: number) => {
    const storeIds = this.getStoreIdsByUser(user_id);

    // Init stores for the current user_id
    this.storeUnreadsCount = new Storage({id: storeIds.counts});
    this.storeLatestMsg = new Storage({id: storeIds.latestMsg});
    this.storeLastRead = new Storage({id: storeIds.lastRead});
    this.storeVersion = new Storage({id: storeIds.version});

    // One-time reset to clear potentially corrupted unread state
    // caused by stale channel_marked events overriding correct data.
    // Can be removed once all users have run this version.
    this.resetStorageIfNeeded();

    const countKeys = this.storeUnreadsCount.getAllKeys();
    const latestKeys = this.storeLatestMsg.getAllKeys();
    const lastReadKeys = this.storeLastRead.getAllKeys();
    const versionKeys = this.storeVersion.getAllKeys();
    const keys = [
      ...new Set([
        ...countKeys,
        ...latestKeys,
        ...lastReadKeys,
        ...versionKeys,
      ]),
    ];

    try {
      keys.forEach(key => {
        const count = this.storeUnreadsCount.getNumber(key);
        const latestTs = this.storeLatestMsg.getString(key);
        const lastRead = this.storeLastRead.getString(key);
        const version = this.storeVersion.getNumber(key);
        this.unreads.set(key, {
          count: count ?? 0,
          latest_message_ts: latestTs ?? '',
          last_read: lastRead ?? null,
          version: version ?? LOCAL_INITIAL_VERSION,
        });
      });

      if (this.unreads.size > 0) {
        const unreadsCountMap = getPreparedDataForUIStore(this.unreads);

        unreadsStore.getState().update(unreadsCountMap);
      }
    } catch (error) {
      sentryLogs.error({
        message: 'Error initializing unreads handler',
        module: 'CHATS',
        component: 'UnreadsHandler',
        error,
      });
    }

    await this.runSync();
  };

  private resetStorageIfNeeded = () => {
    const currentVersion =
      this.storeNamespaces.getNumber(
        UnreadsUpdaterServiceClass.UNREADS_RESET_KEY,
      ) ?? 0;

    if (currentVersion < UnreadsUpdaterServiceClass.UNREADS_RESET_VERSION) {
      this.storeUnreadsCount.clearAll();
      this.storeLatestMsg.clearAll();
      this.storeLastRead.clearAll();
      this.storeVersion.clearAll();
      this.storeNamespaces.set(
        UnreadsUpdaterServiceClass.UNREADS_RESET_KEY,
        UnreadsUpdaterServiceClass.UNREADS_RESET_VERSION,
      );
      sentryLogs.info({
        message: 'Unreads storage reset due to version upgrade',
        module: 'CHATS',
        component: 'UnreadsUpdaterService.resetStorageIfNeeded',
      });
    }
  };

  /**
   * Increments unread count for a channel when a new message arrives via socket.
   *
   * Behavior:
   * - If channel has no unread state yet, initializes it with count = 1
   * - If message timestamp is older or equal to the current latest_message_ts,
   *   the event is ignored to prevent double-counting
   *
   * Sync handling:
   * - If a server sync is running, message timestamps are added to a pending buffer
   * - If no sync is running, unread count and timestamp are updated immediately
   *
   * Pending messages are merged after server data is applied.
   */
  public incrementUnread = (channel: string, messageTs: string) => {
    const current = this.unreads.get(channel);

    if (!current) {
      // First last read will be 1 ms before the message ts
      const decodedTs = decodeTime(messageTs);
      const lastRead = getUlid(decodedTs - 1);
      this.updateData(channel, {
        latest_message_ts: messageTs,
        count: 1,
        last_read: lastRead,
        version: LOCAL_INITIAL_VERSION,
      });

      this.updateUIStore(channel);
      return;
    }

    // if the event is older or equal to current, ignore
    if (current?.latest_message_ts >= messageTs) {
      return;
    }

    // If is syncing, use current unreads + accumulate pending then replace the current with the server if needed and add pending
    if (this.isSyncRunning) {
      const currentPending = this.pendingTs.get(channel) ?? new Set<string>();
      currentPending.add(messageTs);
      this.pendingTs.set(channel, currentPending);
    } else {
      this.updateData(channel, {
        latest_message_ts: messageTs,
        count: current.count + 1,
        last_read: current.last_read,
        version: current.version,
      });
    }

    this.updateUIStore(channel);
  };

  /**
   * Marks a channel as read.
   *
   * Resets unread count to 0 and updates latest_message_ts to the latest
   * known message. This ensures that older messages will not be counted
   * as unread again during future syncs.
   *
   * If `version` is provided (server-driven channel_marked), it overrides the
   * stored version. Local marks omit it and preserve the current version.
   */
  public markAsRead = (
    channel: string,
    latest_message_ts: string,
    version?: number,
  ) => {
    const current = this.unreads.get(channel);
    if (current) {
      this.updateData(channel, {
        latest_message_ts,
        count: 0,
        last_read: latest_message_ts,
        version: version ?? current.version,
      });

      this.updateUIStore(channel);
    }
  };

  // Get unreads by channel
  public get = (channel: string) => {
    return this.unreads.get(channel) || null;
  };

  /**
   * Clean up all unread data (reset to initial state)
   */
  public cleanup = () => {
    this.isSyncRunning = false;
    this.unreads.clear();
    this.pendingTs.clear();
    unreadsStore.getState().clear();
  };

  /**
   * Delete item on chat deletion
   * @param channel
   */
  public deleteItem = (channel: string) => {
    this.unreads.delete(channel);
    this.pendingTs.delete(channel);
    this.storeUnreadsCount.remove(channel);
    this.storeLatestMsg.remove(channel);
    this.storeLastRead.remove(channel);
    this.storeVersion.remove(channel);

    unreadsStore.getState().update({
      [channel]: 0,
    });
  };

  /**
   * Updates UI store with the current unread count for a channel.
   *
   * If there are pending socket events (during sync),
   * their effect is included in the displayed count.
   *
   * This method does not persist data.
   */
  private updateUIStore = (channel: string) => {
    const current = this.unreads.get(channel);
    if (!current) {
      return;
    }

    const mergedData = this.getMergedDataWithPending(channel);

    unreadsStore.getState().update({
      [channel]: mergedData.count,
    });
  };

  /**
   * Applies server unread state if it should override local state.
   *
   * Called during sync for each conversation.
   *
   * Conflict resolution rules:
   * 1. If no local state exists, accept server data
   * 2. If server timestamp is newer, accept server data
   * 3. If timestamps match and server count is 0, accept server data
   *    (messages were read on another device)
   * 4. If timestamps match, server count > 0 and local count is 0,
   *    keep local state (user marked as read locally during sync)
   * 5. Otherwise, keep local state
   *
   * Persistence and UI updates are handled by the caller.
   *
   * @param channel - The channel ID
   * @param data - Server unread state (count + timestamp)
   */
  private applyServerUnreadStateIfNeeded = (
    channel: string,
    data: UnreadState,
  ) => {
    const current = this.unreads.get(channel);

    if (!current) {
      this.unreads.set(channel, data);
      return;
    }

    const isNewer = data.latest_message_ts > current.latest_message_ts;
    const isSameButWasRead =
      current.latest_message_ts === data.latest_message_ts && data.count === 0;

    if (isNewer || isSameButWasRead) {
      this.updateData(channel, data);
    } else if (data.version > current.version) {
      // Server has a newer membership version but we keep local count/ts because
      // they reflect a local-only state (e.g. local markAsRead during sync).
      // Still bump the version so subsequent channel_marked events compare
      // against the latest server version.
      this.updateData(channel, {...current, version: data.version});
    }
  };

  /**
   * Merges pending socket message timestamps into the current unread state.
   *
   * Only pending messages newer than the current latest_message_ts are counted.
   * After merging, the pending buffer for the channel is cleared.
   */
  private flushPending = (channel: Channel) => {
    const current = this.unreads.get(channel);
    const pending = this.pendingTs.get(channel);

    if (!pending || !current) {
      return;
    }

    // Use current value and apply pending if they are newer
    // so it will be current count + number of pending ts that are newer than current latest_message_ts
    // Use the latest ts form either current or pending
    const mergedData = this.getMergedDataWithPending(channel);

    this.updateData(channel, {
      latest_message_ts: mergedData.latest_message_ts,
      count: mergedData.count,
      last_read: mergedData.last_read,
      version: mergedData.version,
    });

    this.pendingTs.delete(channel);
  };

  /**
   * Synchronizes unread state with the server.
   *
   * While sync is running:
   * - socket events are buffered instead of applied immediately
   *
   * For each conversation returned by the server:
   * - server unread state is applied using timestamp-based conflict resolution
   * - pending socket events are merged
   * - final state is persisted and pushed to UI store
   *
   * Uses cursor-based pagination and continues until all pages are fetched.
   */
  runSync = async () => {
    if (this.isSyncRunning) {
      return;
    }

    this.isSyncRunning = true;

    // eslint-disable-next-line no-constant-condition
    while (true) {
      try {
        let data: Conversation[] = [];
        let nextCursor: Nullable<string> = null;
        let tryCount = 0;

        try {
          const response = await getConversations({
            cursor: nextCursor,
            unread: true,
          });

          data = response.data;
          nextCursor = response.nextCursor;
        } catch (error) {
          tryCount++;
          if (tryCount >= 3) {
            throw error;
          }
          await wait(5000);
          continue;
        }

        const newStoreData: UnreadCountsMap = {};

        data.forEach(conversation => {
          if (conversation.readMembership) {
            // replace the current value with the server value if needed
            this.applyServerUnreadStateIfNeeded(conversation.id, {
              latest_message_ts: conversation.latest?.ts ?? getUlid(),
              count: conversation.readMembership.unread_count,
              last_read: conversation.readMembership.last_read,
              version: conversation.readMembership.version,
            });

            // apply pending
            this.flushPending(conversation.id);

            const current = this.unreads.get(conversation.id);

            if (!current) {
              return;
            }

            // collect the data for the UI store update
            // current count is already merged with pending events
            newStoreData[conversation.id] = current.count;

            // All data updated, persist and update UI
            this.updatePersistStore(conversation.id, current);
          }
        });

        unreadsStore.getState().update(newStoreData);

        if (!nextCursor) {
          break;
        }
      } catch (error) {
        sentryLogs.error({
          message: 'Unreads sync failed',
          module: 'CHATS',
          component: 'UnreadsHandler',
          error,
        });
        break;
      }
    }

    this.isSyncRunning = false;
  };

  /**
   * Calculates unread state by merging current data with pending socket events.
   *
   * Pending timestamps newer than the current latest_message_ts
   * increment the unread count and may update the latest timestamp.
   *
   * This method does not mutate internal state.
   */
  private getMergedDataWithPending = (channel: string): UnreadState => {
    const current = this.unreads.get(channel);
    const pending = this.pendingTs.get(channel) || new Set<string>();

    if (!current) {
      return {
        latest_message_ts: '',
        count: 0,
        last_read: null,
        version: LOCAL_INITIAL_VERSION,
      };
    }

    return [...pending].reduce<UnreadState>(
      (acc, pendingTs) => {
        // Check if the pending ts is newer than current latest
        if (pendingTs > current.latest_message_ts) {
          acc.count += 1;
        }

        // Update latest_message_ts if needed
        if (pendingTs > acc.latest_message_ts) {
          acc.latest_message_ts = pendingTs;
        }

        return acc;
      },
      {
        latest_message_ts: current.latest_message_ts,
        count: current.count,
        last_read: current.last_read,
        version: current.version,
      },
    );
  };

  private updateData = (channel: string, unreadState: UnreadState) => {
    this.unreads.set(channel, unreadState);
    this.updatePersistStore(channel, unreadState);
  };

  private updatePersistStore = (channel: string, unreadState: UnreadState) => {
    this.storeUnreadsCount.set(channel, unreadState.count);
    this.storeLatestMsg.set(channel, unreadState.latest_message_ts);
    this.storeLastRead.set(channel, unreadState.last_read ?? '');
    this.storeVersion.set(channel, unreadState.version);
  };

  private getStoreIdsByUser = (user_id: number) => {
    const storeIdsByUser = {
      counts: `${UNREAD_HANDLER_STORES.counts}:${user_id}`,
      latestMsg: `${UNREAD_HANDLER_STORES.latestMsg}:${user_id}`,
      lastRead: `${UNREAD_HANDLER_STORES.lastRead}:${user_id}`,
      version: `${UNREAD_HANDLER_STORES.version}:${user_id}`,
    };

    try {
      // Save namespace to be able to clean all data later if needed
      if (!this.storeNamespaces.contains(`${user_id}`)) {
        this.storeNamespaces.set(`${user_id}`, JSON.stringify(storeIdsByUser));
      }
    } catch {
      //
    }
    return storeIdsByUser;
  };
}

export const UnreadsUpdaterService = new UnreadsUpdaterServiceClass();
