import { StoreObject } from "@apollo/client/core";
import EntityTypeMap from "./EntityTypeMap";
import { NormalizedCacheObjectWithInvalidation } from "./types";
import { makeEntityId, isQuery } from "../helpers";
import { Policies } from '@apollo/client/cache/inmemory/policies';
import { cacheExtensionsCollectionTypename } from '../cache/utils';
import { ReactiveVarsCache } from "../cache/ReactiveVarsCache";

interface EntityStoreWatcherConfig {
  entityStore: any;
  entityTypeMap: EntityTypeMap;
  policies: Policies;
  reactiveVarsCache: ReactiveVarsCache;
  updateCollectionField: (typename: string, dataId: string) => void;
}

type EntityStoreWatcherStoreFunctions = {
  clear: any;
  delete: any;
  merge: any;
  replace: any;
};

/**
 * Watches the EntityStore for changes and performs side-effects to keep the EntityTypeMap synchronized with the data in the EntityStore.
 */
export default class EntityStoreWatcher {
  private storeFunctions: EntityStoreWatcherStoreFunctions;

  constructor(private config: EntityStoreWatcherConfig) {
    const {
      entityStore: { clear, delete: deleteKey, merge, replace },
    } = this.config;

    this.storeFunctions = {
      clear,
      delete: deleteKey,
      merge,
      replace,
    };
    this.watch();
  }

  private delete = (
    dataId: string,
    fieldName?: string,
    args?: Record<string, any>
  ) => {
    const { entityStore, entityTypeMap, policies } = this.config;

    const result = this.storeFunctions.delete.call(
      entityStore,
      dataId,
      fieldName,
      args
    );
    const entity = entityTypeMap.readEntityById(
      makeEntityId(dataId, fieldName)
    );
    const storeFieldName =
      fieldName && args
        ? policies.getStoreFieldName({
          typename: entity ? entity.typename : undefined,
          fieldName,
          args,
        })
        : undefined;
    entityTypeMap.evict(dataId, storeFieldName || fieldName);

    return result;
  };

  private merge = (dataId: string, incomingStoreObject: StoreObject) => {
    const { entityStore, entityTypeMap } = this.config;

    if (isQuery(dataId)) {
      Object.keys(incomingStoreObject)
        .filter(
          (storeFieldName) =>
            // If there is a valid response, it will contain the type Query and then the nested response types for each requested field. We want
            // to record a map of the types for those fields to their field store names. If there is no incoming data it is because that cache entry for storeFieldName
            // is being deleted so do nothing
            storeFieldName !== "__typename" &&
            (incomingStoreObject[storeFieldName] as StoreObject)?.__typename
        )
        .forEach((storeFieldName) => {
          const entityStoreObject = incomingStoreObject[
            storeFieldName
          ] as StoreObject;
          entityTypeMap.write(
            entityStoreObject.__typename!,
            dataId,
            storeFieldName
          );
        });
    } else {
      const typename = incomingStoreObject.__typename;
      // If the incoming data is empty, the dataId entry in the cache is being deleted so do nothing
      if (dataId && typename && typename !== cacheExtensionsCollectionTypename) {
        this.config.updateCollectionField(typename, dataId);
        entityTypeMap.write(typename, dataId);
      }
    }
    return this.storeFunctions.merge.call(
      entityStore,
      dataId,
      incomingStoreObject
    );
  };

  private clear = () => {
    const {
      config: { entityStore, entityTypeMap },
      storeFunctions,
    } = this;
    entityTypeMap.clear();
    storeFunctions.clear.call(entityStore);
  };

  private replace = (data: NormalizedCacheObjectWithInvalidation | null) => {
    const {
      config: { entityStore, entityTypeMap, reactiveVarsCache, },
      storeFunctions: { replace },
    } = this;

    const invalidation = data?.invalidation;

    if (!data || !invalidation) {
      replace.call(entityStore, data);
      // After the EntityStore has been replaced, the ReactiveVarsCache should be reset to update
      // reactive vars with the new cached values.
      reactiveVarsCache.reset();
      return;
    }

    delete data.invalidation;
    entityTypeMap.restore(invalidation.entitiesById);
    // The entity type map has already been restored and the store watcher
    // does not need to run for the merges triggered by replacing the entity store.
    // Those writes would also clobber any TTLs in the entity type map from the replaced data
    // so instead we pause the store watcher until the entity store data has been replaced.
    this.pause();
    replace.call(entityStore, data);
    reactiveVarsCache.reset();
    this.watch();
  };

  private watch() {
    const { entityStore } = this.config;

    entityStore.clear = this.clear;
    entityStore.delete = this.delete;
    entityStore.merge = this.merge;
    entityStore.replace = this.replace;
  }

  private pause() {
    const { entityStore } = this.config;
    const {
      clear,
      delete: deleteFunction,
      merge,
      replace,
    } = this.storeFunctions;

    entityStore.clear = clear;
    entityStore.delete = deleteFunction;
    entityStore.merge = merge;
    entityStore.replace = replace;
  }
}