import {AppState, AppStateStatus, NativeEventSubscription} from 'react-native';
import {eventsProcessor} from '@config/socket/EventsProcessor';
import {EventsSocketsType} from '@config/constants';
import {
  getMostRecentProcessedEventTs,
  getOldestNotProcessedEventTs,
  removeAllEvents,
} from '@modules/chats/db';
import {messagesQueue} from '@modules/chats/instances/MessagesQueue';
import {ChatEvent} from '@modules/chats/interfaces';
import {getEvents} from '@modules/chats/services';
import {mapEventsToSocket} from '@modules/chats/utils';
import {Storage} from '@services/mmkvFactory';
import {ACTIVE, BACKGROUND} from '@shared/constants';
import {getUlid, maxUlid, sentryLogs, wait} from '@shared/utils';

import {useGroups} from '../store/zustand/useGroups';
import {unprocessedEventsService} from './UnprocessedEventsService';

type PendingSocketEvent = {
  name: EventsSocketsType;
  data: Record<string, unknown>;
  retries?: number;
};

/**
 * @deprecated Use lastFetchedEventStore instead
 */
const processEventsStore = new Storage({id: 'process_events_store'});

const LAST_EVENT_TS_KEY = 'last_event_ts';
const lastFetchedEventStore = new Storage({id: 'last_fetched_event_store'});
const MIGRATED_KEY = 'status';
const migratedEventsStore = new Storage({id: 'migrated_events_store'});

const parseEventData = (data: Record<string, unknown>) => {
  if (typeof data.json_event !== 'string') {
    return data;
  }

  try {
    return JSON.parse(data.json_event) as Record<string, unknown>;
  } catch {
    return data;
  }
};

const getPendingEventLogData = (event: PendingSocketEvent) => {
  const data = parseEventData(event.data);
  const huData =
    data.hu_data && typeof data.hu_data === 'object'
      ? (data.hu_data as Record<string, unknown>)
      : {};

  return {
    eventName: event.name,
    eventTs: event.data.ts,
    channel: data.channel,
    clientMsgId: data.client_msg_id,
    senderId: huData.sender_id,
    messageTs: huData.message_ts,
    version: huData.version,
    retries: event.retries ?? 0,
    attachmentsCount: Array.isArray(data.attachments)
      ? data.attachments.length
      : undefined,
  };
};

class SyncEventsClass {
  /**
   * Batch size to get events from the server.
   */
  private batchSize: number = 100;

  /**
   * Whether the sync is running.
   */
  private isRunning: boolean = false;

  /**
   * Events received while syncing. These events will be reprocessed after sync is done.
   */
  private pendingEvents: PendingSocketEvent[] = [];

  /**
   * The timestamp(ULID) to catch up from. This is to ensure getting all events from last sync to socket connection.
   */
  private catchUpFrom: string | null = null;

  /**
   * Delay to show the fetching events indicator after 3 seconds.
   */
  private fetchingTimeoutDelay: number = 3000;

  /**
   * Timeout ref
   */
  private fetchingTimeout: ReturnType<typeof setTimeout> | null = null;

  /**
   * AppState subscription ref to detect background → foreground transitions and trigger a resync.
   */
  private appStateSubscription: NativeEventSubscription | null = null;

  /**
   * Last known AppState. Used to detect background → foreground transitions.
   */
  private lastAppState: AppStateStatus = AppState.currentState;

  /**
   * Subscribe to AppState changes to trigger a resync every time the app comes
   * back to foreground from background. `run()` already guards against
   * concurrent execution via `isRunning`.
   */
  subscribeAppState() {
    if (this.appStateSubscription) {
      return;
    }

    this.appStateSubscription = AppState.addEventListener(
      'change',
      nextAppState => {
        const wasBackground = BACKGROUND.test(this.lastAppState);
        const isActive = nextAppState === ACTIVE;
        this.lastAppState = nextAppState;

        if (wasBackground && isActive) {
          this.run();
        }
      },
    );
  }

  /**
   * Start syncing process.
   * @param skipPullEvents - If true, skip fetching events from the server and run only pending events and post-sync actions.
   * @param catchUpFrom - The timestamp(ULID) to catch up from. Ensures all events between the last HTTP sync and socket connection are fetched.
   */
  async run({
    skipPullEvents = false,
    catchUpFrom,
  }: {skipPullEvents?: boolean; catchUpFrom?: string} = {}) {
    // Check if sync is already running.
    if (this.isRunning) {
      // Always prefer the newer catchUpFrom to avoid missing events between two timestamps
      if (
        catchUpFrom &&
        (!this.catchUpFrom || catchUpFrom > this.catchUpFrom)
      ) {
        this.catchUpFrom = catchUpFrom;
      }

      return;
    }

    // If skipPullEvents is true, run only the pending events and post actions.
    if (skipPullEvents) {
      this.onSyncDone();
      return;
    }

    this.isRunning = true;

    this.fetchingTimeout = setTimeout(() => {
      if (this.isRunning) {
        useGroups.getState().setIsFetchingEvents(true);
      }
    }, this.fetchingTimeoutDelay);

    try {
      // Run on app opening
      await this.processUnprocessedEvents();
      await this.getEvents();
    } finally {
      this.isRunning = false;
      this.onSyncDone();
    }
  }

  /**
   * Process a single event. If the sync is running, we will add the event to the pending events to be re-processed later.
   * We re-process since it could fail if that event depends on other events that are not processed yet (e.g. message reaction).
   * @param event - The event to process.
   */
  async processEvent(
    event: {
      name: EventsSocketsType;
      data: Record<string, unknown>;
    },
    shouldInformEvent: boolean = true,
  ) {
    try {
      const eventTs = event.data.ts as string | undefined;
      if (this.isRunning) {
        await eventsProcessor.processEvents([event], {
          onError: (unprocessedEvent, error) => {
            sentryLogs.error({
              message: 'Socket event failed during sync — queued to pending',
              module: 'CHATS',
              component: 'SyncEvents:processEvent',
              data: {
                eventName: unprocessedEvent.name,
                eventTs: unprocessedEvent.data.ts,
              },
              error,
            });

            // Only persist to unprocessedEventsService when the event is part
            // of the cursor flow. Socket-only events (shouldInformEvent=false)
            // are best-effort: if they fail, the next HTTP sync will refetch
            // them via the lastFetchedEventStore cursor.
            if (shouldInformEvent) {
              unprocessedEventsService.onError(unprocessedEvent);
            }

            this.pendingEvents.push(unprocessedEvent);
          },
          onSuccess: () => {
            if (eventTs && shouldInformEvent) {
              unprocessedEventsService.onSuccess(eventTs);
            }
          },
        });
      } else {
        await eventsProcessor.processEvents([event], {
          onSuccess: () => {
            if (eventTs && shouldInformEvent) {
              unprocessedEventsService.onSuccess(eventTs);
            }
          },
          onError: (unprocessedEvent, error) => {
            sentryLogs.error({
              message: shouldInformEvent
                ? 'Socket event failed outside of sync — queued for retry'
                : 'Socket event failed outside of sync',
              module: 'CHATS',
              component: 'SyncEvents:processEvent',
              data: {
                eventName: unprocessedEvent.name,
                eventTs: unprocessedEvent.data.ts,
                error: error instanceof Error ? error.message : String(error),
              },
            });

            if (shouldInformEvent) {
              unprocessedEventsService.onError(unprocessedEvent);
            }
          },
        });
      }
    } catch (error) {
      sentryLogs.error({
        message: 'Unhandled error processing socket event',
        module: 'CHATS',
        component: 'SyncEvents:processEvent',
        data: {
          eventName: event.name,
          eventTs: event.data.ts,
          error: error instanceof Error ? error.message : String(error),
        },
      });
    }
  }

  /**
   * Get lost events from the server and process them.
   */
  private async getEvents() {
    try {
      sentryLogs.debug({
        message: 'Getting lost events',
        module: 'CHATS',
        component: 'SyncEvents',
      });

      const promises: Promise<void>[] = [];

      const startTime = Date.now();
      const lastFetchedStoredEventTs = await this.getLastFetchedEventTs();
      let lastEventReceived: ChatEvent | undefined;
      let totalFetched = 0;
      let totalProcessed = 0;
      let totalRequestErrors = 0;

      const eventsStartTime = Date.now();

      // eslint-disable-next-line no-constant-condition
      while (true) {
        let events: ChatEvent[] = [];
        let nextCursor: Nullable<string> = null;
        let requestId: string | undefined;

        // Try to get events from the server. If error, wait 5 seconds and try again
        try {
          const response = await getEvents({
            oldest:
              lastEventReceived?.ts ?? lastFetchedStoredEventTs ?? undefined,
            limit: this.batchSize,
            inclusive: false,
          });
          events = response.data;
          nextCursor = response.nextCursor || null;
          lastEventReceived = events.at(-1);
          requestId = response.requestId;
        } catch (error) {
          sentryLogs.error({
            message: 'Error getting events from server',
            module: 'CHATS',
            component: 'SyncEvents:getEvents',
            data: {
              requestId,
              totalFetched,
              totalRequestErrors,
              error: error instanceof Error ? error.message : String(error),
            },
          });
          totalRequestErrors++;
          await wait(5000);
          continue;
        }

        if (lastEventReceived?.ts) {
          this.setLastFetchedEventTs(lastEventReceived.ts);
        }

        totalFetched += events.length;

        const runProcessing = async (data: ChatEvent[]) => {
          if (data.length > 0) {
            await eventsProcessor.processEvents(mapEventsToSocket(data), {
              onSuccess: event => {
                totalProcessed++;

                unprocessedEventsService.onSuccess(event.data.ts as string);
              },
              onError: (event, error) => {
                sentryLogs.error({
                  message: 'Batch event failed during sync — queued to pending',
                  module: 'CHATS',
                  component: 'SyncEvents:getEvents',
                  data: {
                    eventName: event.name,
                    eventTs: event.data.ts,
                    error:
                      error instanceof Error ? error.message : String(error),
                  },
                });

                unprocessedEventsService.onError(event);

                this.pendingEvents.push(event);
              },
            });
          }
        };

        promises.push(runProcessing(events));

        // Check if there is a case when the it has a next cursor but no events
        if (nextCursor && events.length === 0) {
          sentryLogs.error({
            message: 'Next cursor exists but no events fetched',
            module: 'CHATS',
            component: 'SyncEvents:getEvents',
            data: {
              requestId,
              nextCursor,
              totalFetched,
              totalRequestErrors,
            },
          });
        }

        // If no cursor, means we have synced all events. Break the loop
        if (!nextCursor) {
          break;
        }
      }

      const eventsEndTime = Date.now();

      const dbProcessingStart = Date.now();
      // use Promise.allSettled to process all batches and catch if some of them failed without failing the whole syncing process
      const batchResults = await Promise.allSettled(promises);
      const dbProcessingEnd = Date.now();

      const failedBatches = batchResults.filter(r => r.status === 'rejected');
      if (failedBatches.length > 0) {
        sentryLogs.error({
          message: 'Some event batches failed to process',
          module: 'CHATS',
          component: 'SyncEvents:getEvents',
          data: {
            failedBatches: failedBatches.length,
            totalBatches: batchResults.length,
            errors: JSON.stringify(
              failedBatches.map(
                r => (r as PromiseRejectedResult).reason?.message,
              ),
            ),
          },
        });
      }

      // Catch up from the catchUpFrom timestamp if it is set
      const catchUpFrom = this.catchUpFrom;
      this.catchUpFrom = null;
      let catchupDuration = null;
      if (
        catchUpFrom &&
        lastEventReceived &&
        lastEventReceived.ts < catchUpFrom
      ) {
        const start = Date.now();
        await this.catchUp(lastEventReceived.ts);
        catchupDuration = Date.now() - start;
      }

      sentryLogs.info({
        message: 'Lost events fully processed',
        module: 'CHATS',
        component: 'SyncEvents',
        data: {
          catchUpFrom,
          lastEventReceived: lastEventReceived?.ts,
          fullDuration: Date.now() - startTime,
          getEventsDuration: eventsEndTime - eventsStartTime,
          dbProcessingDuration: dbProcessingEnd - dbProcessingStart,
          getLastEventTsDuration: eventsStartTime - startTime,
          catchupDuration,
          totalFetched,
          totalProcessed,
          initialEvent: lastFetchedStoredEventTs,
          totalRequestErrors,
          failedBatches: failedBatches.length,
        },
      });
    } catch (error) {
      sentryLogs.error({
        message: 'Error getting events',
        module: 'CHATS',
        component: 'SyncEvents',
        data: {
          error: error instanceof Error ? error.message : String(error),
        },
      });
    }
  }

  /**
   * Catch up from the given timestamp to the latest event (from server).
   * Covers the small gap between the last HTTP sync request and socket connection time.
   * @param from - The timestamp(ULID) to catch up from.
   */
  private async catchUp(from: string) {
    let reqId: string | undefined;
    try {
      const {data, requestId} = await getEvents({
        oldest: from,
        limit: this.batchSize,
        inclusive: false,
      });
      reqId = requestId;

      if (data.length > 0) {
        const lastEventReceived = data.at(-1);
        if (lastEventReceived?.ts) {
          this.setLastFetchedEventTs(lastEventReceived.ts);
        }

        await eventsProcessor.processEvents(mapEventsToSocket(data), {
          onSuccess: event => {
            unprocessedEventsService.onSuccess(event.data.ts as string);
          },
          onError: (unprocessedEvent, error) => {
            sentryLogs.error({
              message: 'CatchUp event failed — queued to pending',
              module: 'CHATS',
              component: 'SyncEvents:catchUp',
              data: {
                eventName: unprocessedEvent.name,
                eventTs: unprocessedEvent.data.ts,
                requestId: reqId,
                from,
                error: error instanceof Error ? error.message : String(error),
              },
            });

            unprocessedEventsService.onError(unprocessedEvent);

            this.pendingEvents.push(unprocessedEvent);
          },
        });
      }
    } catch (error) {
      sentryLogs.error({
        message: 'Error catching up events',
        module: 'CHATS',
        component: 'SyncEvents:catchUp',
        data: {
          from,
          requestId: reqId,
          error: error instanceof Error ? error.message : String(error),
        },
      });
    }
  }

  /**
   * Process pending events stored in the instance.
   */
  private async processPendingEvents() {
    if (this.pendingEvents.length === 0) {
      return;
    }

    const eventsToProcess = this.pendingEvents;
    this.pendingEvents = [];

    sentryLogs.debug({
      message: 'Processing pending events',
      module: 'CHATS',
      component: 'SyncEvents',
      data: {count: eventsToProcess.length},
    });

    try {
      await eventsProcessor.processEvents(eventsToProcess, {
        onSuccess: processedEvent => {
          const ts = processedEvent.data.ts as string | undefined;
          if (ts) {
            unprocessedEventsService.onSuccess(ts);
          }
        },
        onError: (failedEvent, error) => {
          const pendingEvent = failedEvent as PendingSocketEvent;
          const logData = getPendingEventLogData(pendingEvent);

          sentryLogs.error({
            message: 'Pending event failed to process — queued for retry',
            module: 'CHATS',
            component: 'SyncEvents:processPendingEvents',
            data: logData,
            error,
          });
        },
      });
    } catch (error) {
      sentryLogs.error({
        message:
          'Unhandled error processing pending events — all pending events lost',
        module: 'CHATS',
        component: 'SyncEvents:processPendingEvents',
        data: {
          pendingCount: eventsToProcess.length,
          error: error instanceof Error ? error.message : String(error),
        },
      });
    }
  }

  /**
   * @deprecated Kept for backward compatibility. Called only when `lastFetchedEventStore`
   * is empty (first launch after the migration to the new cursor model). Runs the one-time
   * SQLite migration and returns its result if available; otherwise falls back to the
   * most-recent ts in `processEventsStore`, or a fresh ULID for brand-new installs.
   */
  async getLastEventTs(): Promise<string> {
    // Migration just ran — return the ts it resolved directly.
    const migratedTs = await this.migrateDBEventsToMMKVIfNeeded();
    if (migratedTs) {
      return migratedTs;
    }

    let latestTs: string | null = null;

    processEventsStore.getAllKeys().forEach(ts => {
      if (!latestTs || ts > latestTs) {
        latestTs = ts;
      }
    });

    if (latestTs) {
      return latestTs;
    }

    // No events in store — brand-new install, start from a fresh ULID.
    return getUlid();
  }

  private async getLastFetchedEventTs() {
    const lastFetchedEventTs =
      lastFetchedEventStore.getString(LAST_EVENT_TS_KEY);

    if (lastFetchedEventTs) {
      return lastFetchedEventTs;
    }

    // First-time fallback: run migration and seed lastFetchedEventStore.
    const lastEventTs = await this.getLastEventTs();

    const saved = this.setLastFetchedEventTs(lastEventTs);
    if (saved) {
      processEventsStore.clearAll();
    }

    return lastEventTs;
  }

  private setLastFetchedEventTs(ts: string) {
    try {
      lastFetchedEventStore.set(LAST_EVENT_TS_KEY, ts);
      return true;
    } catch (error) {
      sentryLogs.error({
        message: 'Error setting last fetched event ts',
        module: 'CHATS',
        component: 'SyncEvents:setLastFetchedEventTs',
        error: error instanceof Error ? error.message : String(error),
      });
    }
    return false;
  }

  /**
   * Callback to be called when the sync is done, and start running post actions.
   * We are using it to:
   * - Process pending events (events received while syncing).
   * - Send pending messages (saved in DB) to the server. This is because we want to get all lost events first to avoid sorting inconsistencies.
   * - Update chat store as sync is done.
   */
  private onSyncDone() {
    sentryLogs.debug({
      message: 'Sync done called',
      module: 'CHATS',
      component: 'SyncEvents',
      data: {
        fetchingTimeout: !!this.fetchingTimeout,
        pendingEvents: this.pendingEvents.length,
      },
    });

    if (this.fetchingTimeout) {
      clearTimeout(this.fetchingTimeout);
      this.fetchingTimeout = null;
    }
    this.processPendingEvents();
    messagesQueue.sendPendingMessages();
    useGroups.getState().setIsFetchingEvents(false);
  }

  // One-time migration: reads the most recent event ts from the old SQLite DB and
  // saves it to lastFetchedEventStore, then removes all SQLite event rows.
  private migrateDBEventsToMMKVIfNeeded = async () => {
    if (!migratedEventsStore.getBoolean(MIGRATED_KEY)) {
      try {
        const [oldestUnprocessedEventTs, mostRecentProcessedEventTs] =
          await Promise.all([
            getOldestNotProcessedEventTs(),
            getMostRecentProcessedEventTs(),
          ]);

        const mostRecentTs = maxUlid(
          oldestUnprocessedEventTs ?? '',
          mostRecentProcessedEventTs ?? '',
        );

        if (mostRecentTs) {
          this.setLastFetchedEventTs(mostRecentTs);
        }

        await removeAllEvents();
        migratedEventsStore.set(MIGRATED_KEY, true);

        return mostRecentTs;
      } catch (error) {
        sentryLogs.error({
          message: 'Events migration failed',
          module: 'CHATS',
          component: 'SyncEvents:migrateDBEventsToMMKVIfNeeded',
          data: {error: error instanceof Error ? error.message : String(error)},
        });
      }
    }
  };

  private processUnprocessedEvents = async () => {
    try {
      const unprocessed = unprocessedEventsService.getAll();
      const eventsToProcess = Object.values(unprocessed);
      if (eventsToProcess.length === 0) {
        return;
      }

      await eventsProcessor.processEvents(eventsToProcess, {
        onSuccess: event => {
          const eventTs = event.data.ts as string;
          unprocessedEventsService.onSuccess(eventTs);
        },
        onError: (event, error) => {
          sentryLogs.error({
            message: 'Unprocessed event retry failed',
            module: 'CHATS',
            component: 'SyncEvents:processUnprocessedEvent',
            data: {
              eventName: event.name,
              eventTs: event.data.ts,
              error: error instanceof Error ? error.message : String(error),
            },
          });

          if (AppState.currentState === 'active') {
            unprocessedEventsService.onError(event, true);
          }
        },
      });
    } catch (e) {
      sentryLogs.error({
        message: 'Unhandled error processing unprocessed events',
        module: 'CHATS',
        component: 'SyncEvents:processUnprocessedEvent',
        data: {error: e instanceof Error ? e.message : String(e)},
      });
    }
  };
}

export const SyncEvents = new SyncEventsClass();
SyncEvents.subscribeAppState();
