import type { AdapterInstance, ResponseType } from "adapter";
import type { ResponseDetailsType, LoggerMethods } from "managers";
import type { ClientInstance } from "client";
import type {
  CacheOptionsType,
  CacheAsyncStorageType,
  CacheStorageType,
  CacheValueType,
  CacheSetState,
  RequestCacheType,
} from "cache";
import { getCacheData, getCacheEvents } from "cache";
import type { RequestInstance } from "request";
import { Request, scopeKey } from "request";
import type { ExtractAdapterType, ExtractErrorType, ExtractResponseType } from "types";
import { EventEmitter } from "utils";

/**
 * Cache class handles the data exchange with the dispatchers.
 *
 * @note
 * Keys used to save the values are created dynamically on the Request class
 *
 */
export class Cache<Adapter extends AdapterInstance> {
  public emitter = new EventEmitter();
  public events: ReturnType<typeof getCacheEvents>;

  public storage: CacheStorageType;
  public lazyStorage?: CacheAsyncStorageType;
  public version: string;
  public garbageCollectors = new Map<string, ReturnType<typeof setTimeout>>();
  private logger: LoggerMethods;
  private client: ClientInstance<{ adapter: Adapter }>;

  constructor(public options?: CacheOptionsType) {
    const { storage = new Map<string, CacheValueType>(), lazyStorage, version = "0.0.1" } = options ?? {};

    this.emitter?.setMaxListeners(1000);
    this.events = getCacheEvents(this.emitter);

    this.storage = storage;
    this.version = version;
    this.lazyStorage = lazyStorage;
  }

  /** Initialize the cache with a client reference, set up logging, and start garbage collection. */
  initialize = (client: ClientInstance<{ adapter: Adapter }>) => {
    this.client = client;
    this.logger = client.loggerManager.initialize(client, "Cache");

    // Going back from offline should re-trigger garbage collection
    client.appManager.events.onOnline(() => {
      [...this.storage.keys()].forEach(this.scheduleGarbageCollector);
    });

    [...this.storage.keys()].forEach(this.scheduleGarbageCollector);

    return this;
  };

  /**
   * Set the cache data to the storage
   * @param request
   * @param response
   * @returns
   */
  set = <Request extends RequestCacheType<RequestInstance>>(
    request: Request,
    response: CacheSetState<
      ResponseType<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>> &
        ResponseDetailsType
    > & { hydrated?: boolean },
  ): void => {
    this.logger.debug({ title: "Processing cache response", type: "system", extra: { request, response } });
    const { cacheKey, cache, staleTime, cacheTime, scope } = request;
    const storageKey = scopeKey(cacheKey, scope);
    const previousCacheData = this.storage.get<
      ExtractResponseType<Request>,
      ExtractErrorType<Request>,
      ExtractAdapterType<Request>
    >(storageKey);

    // Once refresh error occurs we don't want to override already valid data in our cache with the thrown error
    // We need to check it against cache and return last valid data we have
    const processedResponse = typeof response === "function" ? response(previousCacheData || null) : response;
    const data = getCacheData(previousCacheData, processedResponse);

    const newCacheData: CacheValueType<any, any, ExtractAdapterType<Request>> = {
      ...data,
      scope,
      staleTime,
      version: this.version,
      cacheKey,
      cacheTime,
      cached: !!request.cache,
    };

    const isServerMode = this.client.mode === "server";
    const isCachingAllowed = cache && (!isServerMode || !!(request as any).scope);

    // Only success data is valid for the cache store
    if (processedResponse.success && isCachingAllowed) {
      this.logger.debug({ title: "Saving response to cache storage", type: "system", extra: { request, data } });
      this.storage.set<Response, Error, ExtractAdapterType<Request>>(storageKey, newCacheData);
      this.lazyStorage?.set<Response, Error, ExtractAdapterType<Request>>(storageKey, newCacheData);
      this.client.triggerPlugins("onCacheItemChange", {
        cache: this,
        cacheKey,
        prevData: (previousCacheData || null) as CacheValueType<any, any, AdapterInstance>,
        newData: newCacheData as CacheValueType<any, any, AdapterInstance>,
      });

      this.scheduleGarbageCollector(storageKey);
    } else {
      // If request should not use cache - just emit response data
      this.logger.debug({ title: "Prevented saving response to cache", type: "system", extra: { request, data } });
    }

    this.logger.debug({ title: "Emitting cache response", type: "system", extra: { request, data } });
    this.events.emitCacheData<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>>(
      newCacheData,
    );
  };

  /**
   * Update the cache data with partial response data
   * @param request
   * @param partialResponse
   * @returns
   */
  update = <Request extends RequestCacheType<RequestInstance>>(
    request: Request,
    partialResponse: CacheSetState<
      Partial<
        ResponseType<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>> &
          ResponseDetailsType
      >
    >,
  ): void => {
    this.logger.debug({ title: "Processing cache update", type: "system", extra: { request, partialResponse } });
    const { cacheKey, scope } = request;
    const storageKey = scopeKey(cacheKey, scope);
    const cachedData = this.storage.get<
      ExtractResponseType<Request>,
      ExtractErrorType<Request>,
      ExtractAdapterType<Request>
    >(storageKey);

    const processedResponse =
      typeof partialResponse === "function" ? partialResponse(cachedData || null) : partialResponse;
    if (cachedData) {
      this.set(request, { ...cachedData, ...processedResponse });
    }
  };

  /**
   * Get particular record from storage by cacheKey. It will trigger lazyStorage to emit lazy load event for reading it's data.
   * @param cacheKey
   * @returns
   */
  get = <Response, Error>(cacheKey: string): CacheValueType<Response, Error, Adapter> | undefined => {
    this.getLazyResource<Response, Error>(cacheKey);
    const cachedData = this.storage.get<Response, Error, Adapter>(cacheKey);
    return cachedData;
  };

  /**
   * Get sync storage keys, lazyStorage keys will not be included
   * @returns
   */
  keys = (): string[] => {
    const values = this.storage.keys();

    return Array.from(values);
  };

  /**
   * Delete record from storages and trigger invalidation event
   * @param cacheKey
   */
  delete = (cacheKey: string): void => {
    this.logger.debug({ title: "Deleting cache element", type: "system", extra: { cacheKey } });
    this.storage.delete(cacheKey);
    this.lazyStorage?.delete(cacheKey);

    this.client.triggerPlugins("onCacheItemDelete", {
      cache: this,
      cacheKey,
    });

    this.events.emitDelete(cacheKey);
  };

  /**
   * Invalidate cache by cacheKey or partial matching with RegExp
   * It emits invalidation event for each matching cacheKey and sets staleTime to 0 to indicate out of time cache
   * @param key - cacheKey or Request instance or RegExp for partial matching
   */
  invalidate = (cacheKeys: string | RegExp | RequestInstance | Array<string | RegExp | RequestInstance>) => {
    this.logger.debug({ title: "Revalidating cache element", type: "system", extra: { cacheKeys } });

    const onInvalidate = (key: string | RegExp | RequestInstance) => {
      const keys = Array.from(this.storage.keys());
      const handleInvalidation = (invalidateStorageKey: string, notifyCacheKey: string = invalidateStorageKey) => {
        const value = this.storage.get(invalidateStorageKey);
        if (value) {
          this.storage.set(invalidateStorageKey, { ...value, staleTime: 0 });
        }

        this.client.triggerPlugins("onCacheItemInvalidate", {
          cache: this,
          cacheKey: notifyCacheKey,
        });

        this.events.emitInvalidation(notifyCacheKey);
      };

      if (key instanceof Request) {
        handleInvalidation(scopeKey(key.cacheKey, key.scope), key.cacheKey);
      } else if (typeof key === "string") {
        handleInvalidation(key);
      } else if (keys.length) {
        keys.forEach((entityKey) => {
          if (key.test(entityKey)) {
            handleInvalidation(entityKey);
          }
        });
      }
    };

    if (Array.isArray(cacheKeys)) {
      cacheKeys.forEach(onInvalidate.bind(this));
    } else {
      onInvalidate.bind(this)(cacheKeys);
    }
  };

  /**
   * Used to receive data from lazy storage
   * @param cacheKey
   */
  getLazyResource = async <Response, Error>(
    cacheKey: string,
  ): Promise<CacheValueType<Response, Error, Adapter> | undefined> => {
    const data = await this.lazyStorage?.get<Response, Error, Adapter>(cacheKey);
    const syncData = this.storage.get<Response, Error, Adapter>(cacheKey);

    // No data in lazy storage
    const hasLazyData = this.lazyStorage && data;
    if (hasLazyData) {
      const now = +new Date();
      const isNewestData = syncData ? syncData.responseTimestamp < data.responseTimestamp : true;
      const isStaleData = data.staleTime <= now - data.responseTimestamp;
      const isValidLazyData = data.version === this.version;

      if (!isValidLazyData) {
        this.lazyStorage?.delete(cacheKey);
      }
      if (isNewestData && !isStaleData && isValidLazyData) {
        this.storage.set<Response, Error, Adapter>(cacheKey, data);
        this.events.emitCacheData<Response, Error, Adapter>({ ...data, cacheKey, cached: true });
        return data;
      }
    }

    const isValidData = syncData?.version === this.version;
    if (syncData && !isValidData) {
      this.delete(cacheKey);
    }
    return syncData;
  };

  /**
   * Used to receive keys from lazy storage only
   */
  getLazyKeys = async () => {
    const keys = (await this.lazyStorage?.keys()) || [];

    return [...new Set(keys)];
  };

  /**
   * Used to receive deduplicated keys from both sync storage and lazy storage
   */
  getAllKeys = async () => {
    const keys = await this.lazyStorage?.keys();
    const asyncKeys = Array.from(keys || []);
    const syncKeys = Array.from(this.storage.keys());

    return [...new Set([...asyncKeys, ...syncKeys])];
  };

  /**
   * Schedule garbage collection for given key
   * @param cacheKey
   * @returns
   */
  scheduleGarbageCollector = (cacheKey: string) => {
    // We need to make sure that all of the values will be removed, also that we have the proper data
    const cacheData = this.storage.get(cacheKey);

    // Clear running garbage collectors for given key
    clearTimeout(this.garbageCollectors.get(cacheKey));

    // Garbage collect
    if (cacheData) {
      const timeLeft = cacheData.cacheTime + cacheData.responseTimestamp - +new Date();
      // null
      if (cacheData.cacheTime === null) {
        this.logger.debug({ title: "Cache time is null", type: "system", extra: { cacheKey } });
      }
      // Infinity
      else if (
        (cacheData.cacheTime !== null && JSON.stringify(cacheData.cacheTime) === "null") ||
        cacheData.cacheTime === Infinity
      ) {
        this.logger.debug({ title: "Cache time is Infinite", type: "system", extra: { cacheKey } });
      }
      // Run garbage collector
      else if (timeLeft >= 0) {
        this.garbageCollectors.set(
          cacheKey,
          setTimeout(() => {
            if (this.client.appManager.isOnline) {
              this.logger.info({ title: "Garbage collecting cache data", type: "system", extra: { cacheKey } });
              this.delete(cacheKey);
            }
          }, timeLeft),
        );
      }
      // Delete if value is stale and we are online
      else if (this.client.appManager.isOnline) {
        this.logger.info({ title: "Garbage collecting cache data", type: "system", extra: { cacheKey } });
        this.delete(cacheKey);
      }
    }
  };

  /**
   * Clear cache storages
   */
  clear = async (): Promise<void> => {
    this.garbageCollectors.forEach((timeout) => clearTimeout(timeout));
    this.storage.clear();
  };
}
