import { StateStore } from './store';
import { throttle } from './utils';

import type { StreamChat } from './client';
import type { Thread } from './thread';
import type { DefaultGenerics, Event, ExtendableGenerics, OwnUserResponse, QueryThreadsOptions } from './types';

const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000;
const MAX_QUERY_THREADS_LIMIT = 25;
export const THREAD_MANAGER_INITIAL_STATE = {
  active: false,
  isThreadOrderStale: false,
  threads: [],
  unreadThreadCount: 0,
  unseenThreadIds: [],
  lastConnectionDropAt: null,
  pagination: {
    isLoading: false,
    isLoadingNext: false,
    nextCursor: null,
  },
  ready: false,
};

export type ThreadManagerState<SCG extends ExtendableGenerics = DefaultGenerics> = {
  active: boolean;
  isThreadOrderStale: boolean;
  lastConnectionDropAt: Date | null;
  pagination: ThreadManagerPagination;
  ready: boolean;
  threads: Thread<SCG>[];
  unreadThreadCount: number;
  /**
   * List of threads that haven't been loaded in the list, but have received new messages
   * since the latest reload. Useful to display a banner prompting to reload the thread list.
   */
  unseenThreadIds: string[];
};

export type ThreadManagerPagination = {
  isLoading: boolean;
  isLoadingNext: boolean;
  nextCursor: string | null;
};

export class ThreadManager<SCG extends ExtendableGenerics = DefaultGenerics> {
  public readonly state: StateStore<ThreadManagerState<SCG>>;
  private client: StreamChat<SCG>;
  private unsubscribeFunctions: Set<() => void> = new Set();
  private threadsByIdGetterCache: {
    threads: ThreadManagerState<SCG>['threads'];
    threadsById: Record<string, Thread<SCG> | undefined>;
  };

  constructor({ client }: { client: StreamChat<SCG> }) {
    this.client = client;
    this.state = new StateStore<ThreadManagerState<SCG>>(THREAD_MANAGER_INITIAL_STATE);

    this.threadsByIdGetterCache = { threads: [], threadsById: {} };
  }

  public get threadsById() {
    const { threads } = this.state.getLatestValue();

    if (threads === this.threadsByIdGetterCache.threads) {
      return this.threadsByIdGetterCache.threadsById;
    }

    const threadsById = threads.reduce<Record<string, Thread<SCG>>>((newThreadsById, thread) => {
      newThreadsById[thread.id] = thread;
      return newThreadsById;
    }, {});

    this.threadsByIdGetterCache.threads = threads;
    this.threadsByIdGetterCache.threadsById = threadsById;

    return threadsById;
  }

  public resetState = () => {
    this.state.next(THREAD_MANAGER_INITIAL_STATE);
  };

  public activate = () => {
    this.state.partialNext({ active: true });
  };

  public deactivate = () => {
    this.state.partialNext({ active: false });
  };

  public registerSubscriptions = () => {
    if (this.unsubscribeFunctions.size) return;

    this.unsubscribeFunctions.add(this.subscribeUnreadThreadsCountChange());
    this.unsubscribeFunctions.add(this.subscribeManageThreadSubscriptions());
    this.unsubscribeFunctions.add(this.subscribeReloadOnActivation());
    this.unsubscribeFunctions.add(this.subscribeNewReplies());
    this.unsubscribeFunctions.add(this.subscribeRecoverAfterConnectionDrop());
    this.unsubscribeFunctions.add(this.subscribeChannelDeleted());
  };

  private subscribeUnreadThreadsCountChange = () => {
    // initiate
    const { unread_threads: unreadThreadCount = 0 } = (this.client.user as OwnUserResponse<SCG>) ?? {};
    this.state.partialNext({ unreadThreadCount });

    const unsubscribeFunctions = [
      'health.check',
      'notification.mark_read',
      'notification.thread_message_new',
      'notification.channel_deleted',
    ].map(
      (eventType) =>
        this.client.on(eventType, (event) => {
          const { unread_threads: unreadThreadCount } = event.me ?? event;
          if (typeof unreadThreadCount === 'number') {
            this.state.partialNext({ unreadThreadCount });
          }
        }).unsubscribe,
    );

    return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
  };

  private subscribeChannelDeleted = () =>
    this.client.on('notification.channel_deleted', (event) => {
      const { cid } = event;
      const { threads } = this.state.getLatestValue();

      const newThreads = threads.filter((thread) => thread.channel.cid !== cid);
      this.state.partialNext({ threads: newThreads });
    }).unsubscribe;

  private subscribeManageThreadSubscriptions = () =>
    this.state.subscribeWithSelector(
      (nextValue) => ({ threads: nextValue.threads }),
      ({ threads: nextThreads }, prev) => {
        const { threads: prevThreads = [] } = prev ?? {};
        // Thread instance was removed if there's no thread with the given id at all,
        // or it was replaced with a new instance
        const removedThreads = prevThreads.filter((thread) => thread !== this.threadsById[thread.id]);

        nextThreads.forEach((thread) => thread.registerSubscriptions());
        removedThreads.forEach((thread) => thread.unregisterSubscriptions());
      },
    );

  private subscribeReloadOnActivation = () =>
    this.state.subscribeWithSelector(
      (nextValue) => ({ active: nextValue.active }),
      ({ active }) => {
        if (active) this.reload();
      },
    );

  private subscribeNewReplies = () =>
    this.client.on('notification.thread_message_new', (event: Event<SCG>) => {
      const parentId = event.message?.parent_id;
      if (!parentId) return;

      const { unseenThreadIds, ready } = this.state.getLatestValue();
      if (!ready) return;

      if (this.threadsById[parentId]) {
        this.state.partialNext({ isThreadOrderStale: true });
      } else if (!unseenThreadIds.includes(parentId)) {
        this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) });
      }
    }).unsubscribe;

  private subscribeRecoverAfterConnectionDrop = () => {
    const unsubscribeConnectionDropped = this.client.on('connection.changed', (event) => {
      if (event.online === false) {
        this.state.next((current) =>
          current.lastConnectionDropAt
            ? current
            : {
                ...current,
                lastConnectionDropAt: new Date(),
              },
        );
      }
    }).unsubscribe;

    const throttledHandleConnectionRecovered = throttle(
      () => {
        const { lastConnectionDropAt } = this.state.getLatestValue();
        if (!lastConnectionDropAt) return;
        this.reload({ force: true });
      },
      DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION,
      { trailing: true },
    );

    const unsubscribeConnectionRecovered = this.client.on('connection.recovered', throttledHandleConnectionRecovered)
      .unsubscribe;

    return () => {
      unsubscribeConnectionDropped();
      unsubscribeConnectionRecovered();
    };
  };

  public unregisterSubscriptions = () => {
    this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions());
    this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction());
    this.unsubscribeFunctions.clear();
  };

  public reload = async ({ force = false } = {}) => {
    const { threads, unseenThreadIds, isThreadOrderStale, pagination, ready } = this.state.getLatestValue();
    if (pagination.isLoading) return;
    if (!force && ready && !unseenThreadIds.length && !isThreadOrderStale) return;
    const limit = threads.length + unseenThreadIds.length;

    try {
      this.state.next((current) => ({
        ...current,
        pagination: {
          ...current.pagination,
          isLoading: true,
        },
      }));

      const response = await this.queryThreads({
        limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) || MAX_QUERY_THREADS_LIMIT,
      });

      const currentThreads = this.threadsById;
      const nextThreads: Thread<SCG>[] = [];

      for (const incomingThread of response.threads) {
        const existingThread = currentThreads[incomingThread.id];

        if (existingThread) {
          // Reuse thread instances if possible
          nextThreads.push(existingThread);
          if (existingThread.hasStaleState) {
            existingThread.hydrateState(incomingThread);
          }
        } else {
          nextThreads.push(incomingThread);
        }
      }

      this.state.next((current) => ({
        ...current,
        threads: nextThreads,
        unseenThreadIds: [],
        isThreadOrderStale: false,
        pagination: {
          ...current.pagination,
          isLoading: false,
          nextCursor: response.next ?? null,
        },
        ready: true,
      }));
    } catch (error) {
      this.client.logger('error', (error as Error).message);
      this.state.next((current) => ({
        ...current,
        pagination: {
          ...current.pagination,
          isLoading: false,
        },
      }));
    }
  };

  public queryThreads = (options: QueryThreadsOptions = {}) => {
    return this.client.queryThreads({
      limit: 25,
      participant_limit: 10,
      reply_limit: 10,
      watch: true,
      ...options,
    });
  };

  public loadNextPage = async (options: Omit<QueryThreadsOptions, 'next'> = {}) => {
    const { pagination } = this.state.getLatestValue();

    if (pagination.isLoadingNext || !pagination.nextCursor) return;

    try {
      this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } });

      const response = await this.queryThreads({
        ...options,
        next: pagination.nextCursor,
      });

      this.state.next((current) => ({
        ...current,
        threads: response.threads.length ? current.threads.concat(response.threads) : current.threads,
        pagination: {
          ...current.pagination,
          nextCursor: response.next ?? null,
          isLoadingNext: false,
        },
      }));
    } catch (error) {
      this.client.logger('error', (error as Error).message);
      this.state.next((current) => ({
        ...current,
        pagination: {
          ...current.pagination,
          isLoadingNext: false,
        },
      }));
    }
  };
}
