import {
  gql,
  InMemoryCache,
  Cache,
  NormalizedCacheObject,
  Reference,
  StoreObject,
  makeReference,
} from "@apollo/client/core";
import compact from "lodash/compact";
import every from "lodash/every";
import pick from "lodash/pick";
import isFunction from "lodash/isFunction";
import InvalidationPolicyManager from "../policies/InvalidationPolicyManager";
import { EntityStoreWatcher, EntityTypeMap } from "../entity-store";
import { makeEntityId, isQuery, maybeDeepClone, fieldNameFromStoreName } from "../helpers";
import { FragmentWhereFilter, InvalidationPolicyCacheConfig } from "./types";
import { CacheResultProcessor, ReadResultStatus } from "./CacheResultProcessor";
import { InvalidationPolicies, InvalidationPolicyEvent, ReadFieldOptions } from "../policies/types";
import { FragmentDefinitionNode } from 'graphql';
import { cacheExtensionsCollectionTypename, collectionEntityIdForType } from './utils';
import { initReactiveVarsCache, ReactiveVarsCache } from "./ReactiveVarsCache";

/**
 * Extension of Apollo in-memory cache which adds support for cache policies.
 */

// @ts-ignore: Private API overloads
export default class InvalidationPolicyCache extends InMemoryCache {
  // @ts-ignore: Initialize in parent constructor
  protected entityTypeMap: EntityTypeMap;
  // @ts-ignore: Initialize in parent constructor
  protected entityStoreWatcher: EntityStoreWatcher;
  protected invalidationPolicyManager!: InvalidationPolicyManager;
  protected cacheResultProcessor!: CacheResultProcessor;
  protected entityStoreRoot: any;
  protected isBroadcasting: boolean;
  protected reactiveVarsCache!: ReactiveVarsCache;
  protected invalidationPolicies: InvalidationPolicies;
  protected enableCollections: boolean;
  protected isInitialized: boolean;

  constructor(config: InvalidationPolicyCacheConfig = {}) {
    const { invalidationPolicies = {}, enableCollections = false, ...inMemoryCacheConfig } = config;
    super(inMemoryCacheConfig);

    this.enableCollections = enableCollections;
    this.invalidationPolicies = invalidationPolicies;
    this.isBroadcasting = false;
    this.isInitialized = true;
    this.reactiveVarsCache = initReactiveVarsCache(this);

    // Once the InMemoryCache has called `init()` in the super constructor, we initialize
    // the InvalidationPolicyCache objects.
    this.reinitialize();
  }

  // Whenever the InMemoryCache reinitializes the entityStore by calling `init()` again,
  // we must also reinitialize all of the InvalidationPolicyCache objects.
  private reinitialize() {
    // @ts-ignore Data is a private API
    this.entityStoreRoot = this.data;
    this.entityTypeMap = new EntityTypeMap();
    this.entityStoreWatcher = new EntityStoreWatcher({
      entityStore: this.entityStoreRoot,
      entityTypeMap: this.entityTypeMap,
      policies: this.policies,
      reactiveVarsCache: this.reactiveVarsCache,
      updateCollectionField: this.updateCollectionField.bind(this),
    });
    this.invalidationPolicyManager = new InvalidationPolicyManager({
      policies: this.invalidationPolicies,
      entityTypeMap: this.entityTypeMap,
      cacheOperations: {
        evict: (...args) => this.evict(...args),
        modify: (...args) => this.modify(...args),
        readField: (...args) => this.readField(...args),
      },
    });
    this.cacheResultProcessor = new CacheResultProcessor({
      invalidationPolicyManager: this.invalidationPolicyManager,
      // @ts-ignore This field is assigned in the parent constructor
      entityTypeMap: this.entityTypeMap,
      cache: this,
    });
    this.reactiveVarsCache.reset();
  }

  // @ts-ignore private API
  private init() {
    // @ts-ignore private API
    super.init();

    // After init is called, the entity store has been reset so we must
    // reinitialize all invalidation policy objects.
    if (this.isInitialized) {
      this.reinitialize();
    }
  }

  protected readField<T>(
    fieldNameOrOptions?: string | ReadFieldOptions,
    from?: StoreObject | Reference
  ) {
    if (!fieldNameOrOptions) {
      return;
    }

    const options = typeof fieldNameOrOptions === "string"
      ? {
        fieldName: fieldNameOrOptions,
        from,
      }
      : fieldNameOrOptions;

    if (void 0 === options.from) {
      options.from = { __ref: 'ROOT_QUERY' };
    }

    return this.policies.readField<T>(
      options,
      {
        store: this.entityStoreRoot,
      }
    );
  }

  protected broadcastWatches() {
    this.isBroadcasting = true;
    super.broadcastWatches();
    this.isBroadcasting = false;
  }

  // Determines whether the cache's data reference is set to the root store. If not, then there is an ongoing optimistic transaction
  // being applied to a new layer.
  isOperatingOnRootData() {
    // @ts-ignore
    return this.data === this.entityStoreRoot;
  }

  modify(options: Cache.ModifyOptions) {
    const modifyResult = super.modify(options);

    if (
      !this.invalidationPolicyManager.isPolicyEventActive(
        InvalidationPolicyEvent.Write
      ) ||
      !modifyResult
    ) {
      return modifyResult;
    }

    const { id = "ROOT_QUERY", fields } = options;

    if (isQuery(id)) {
      Object.keys(fields).forEach((storeFieldName) => {
        const fieldName = fieldNameFromStoreName(storeFieldName);

        const typename = this.entityTypeMap.readEntityById(
          makeEntityId(id, fieldName)
        )?.typename;

        if (!typename) {
          return;
        }

        this.invalidationPolicyManager.runWritePolicy(typename, {
          parent: {
            id,
            fieldName,
            storeFieldName,
            ref: makeReference(id),
          },
        });
      });
    } else {
      const typename = this.entityTypeMap.readEntityById(id)?.typename;

      if (!typename) {
        return modifyResult;
      }

      this.invalidationPolicyManager.runWritePolicy(typename, {
        parent: {
          id,
          ref: makeReference(id),
        },
      });
    }

    if (options.broadcast) {
      this.broadcastWatches();
    }

    return modifyResult;
  }

  write(options: Cache.WriteOptions<any, any>) {
    const writeResult = super.write(options);

    // Do not trigger a write policy if the current write is being applied to an optimistic data layer since
    // the policy will later be applied when the server data response is received.
    if (
      (!this.invalidationPolicyManager.isPolicyEventActive(
        InvalidationPolicyEvent.Write
      ) &&
        !this.invalidationPolicyManager.isPolicyEventActive(
          InvalidationPolicyEvent.Read
        )) ||
      !this.isOperatingOnRootData()
    ) {
      return writeResult;
    }

    this.cacheResultProcessor.processWriteResult(options);

    if (options.broadcast) {
      this.broadcastWatches();
    }

    return writeResult;
  }

  // Evicts all entities of the given type matching the filter criteria. Returns a list of evicted entities
  // by reference.
  evictWhere<EntityType>(filters: { __typename: string, filter?: FragmentWhereFilter<EntityType> }) {
    const { __typename, filter } = filters;

    const references = this.readReferenceWhere({
      __typename,
      filter,
    });

    references.forEach((ref) => this.evict({ id: ref.__ref, broadcast: false }));
    this.broadcastWatches();

    return references;
  }

  evict(options: Cache.EvictOptions): boolean {
    const { fieldName, args } = options;
    let { id } = options;
    if (!id) {
      if (Object.prototype.hasOwnProperty.call(options, "id")) {
        return false;
      }
      id = "ROOT_QUERY";
    }

    if (
      this.invalidationPolicyManager.isPolicyEventActive(
        InvalidationPolicyEvent.Evict
      )
    ) {
      const { typename } =
        this.entityTypeMap.readEntityById(makeEntityId(id, fieldName)) ?? {};

      if (typename) {
        const storeFieldName =
          isQuery(id) && fieldName
            ? this.policies.getStoreFieldName({
              typename,
              fieldName,
              args,
            })
            : undefined;

        this.invalidationPolicyManager.runEvictPolicy(typename, {
          parent: {
            id,
            fieldName,
            storeFieldName,
            variables: args,
            ref: makeReference(id),
          },
        });
      }
    }

    return super.evict(options);
  }

  protected updateCollectionField(typename: string, dataId: string) {
    // Since colletion support is still experimental, only record entities in collections if enabled
    if (!this.enableCollections) {
      return;
    }

    const collectionEntityId = collectionEntityIdForType(typename);
    const collectionFieldExists = !!this.readField<Record<string, any[]>>('id', makeReference(collectionEntityId));

    // If the collection field for the type does not exist in the cache, then initialize it as
    // an empty array.
    if (!collectionFieldExists) {
      this.writeFragment({
        id: collectionEntityId,
        fragment: gql`
          fragment InitializeCollectionEntity on CacheExtensionsCollectionEntity {
            data
            id
          }
        `,
        data: {
          __typename: cacheExtensionsCollectionTypename,
          id: typename,
          data: [],
        },
      });
    }

    // If the entity does not already exist in the cache, add it to the collection field policy for its type
    if (!this.entityTypeMap.readEntityById(dataId)) {
      this.modify({
        broadcast: false,
        id: collectionEntityId,
        fields: {
          data: (existing, { canRead }) => {
            return [
              ...existing.filter((ref: Reference) => canRead(ref)),
              makeReference(dataId),
            ];
          }
        }
      });
    }
  }

  // Returns all expired entities whose cache time exceeds their type's timeToLive or as a fallback
  // the global timeToLive if specified. Evicts the expired entities by default, with an option to only report
  // them.
  private _expire(reportOnly = false) {
    const { entitiesById } = this.entityTypeMap.extract();
    const expiredEntityIds: string[] = [];

    Object.keys(entitiesById).forEach((entityId) => {
      const entity = entitiesById[entityId];
      const { storeFieldNames, dataId, fieldName, typename } = entity;

      if (isQuery(dataId) && storeFieldNames) {
        Object.keys(storeFieldNames.entries).forEach((storeFieldName) => {
          const isExpired = this.invalidationPolicyManager.runReadPolicy({
            typename,
            dataId,
            fieldName,
            storeFieldName,
            reportOnly,
          });
          if (isExpired) {
            expiredEntityIds.push(makeEntityId(dataId, storeFieldName));
          }
        });
      } else {
        const isExpired = this.invalidationPolicyManager.runReadPolicy({
          typename,
          dataId,
          fieldName,
          reportOnly,
        });
        if (isExpired) {
          expiredEntityIds.push(makeEntityId(dataId));
        }
      }
    });

    if (expiredEntityIds.length > 0) {
      this.broadcastWatches();
    }

    return expiredEntityIds;
  }

  // Expires all entities still present in the cache that have exceeded their timeToLive. By default entities are evicted
  // lazily on read if their entity is expired. Use this expire API to eagerly remove expired entities.
  expire() {
    return this._expire(false);
  }

  // Returns all expired entities still present in the cache.
  expiredEntities() {
    return this._expire(true);
  }

  // Activates the provided policy events (on read, on write, on evict) or by default all policy events.
  activatePolicyEvents(...policyEvents: InvalidationPolicyEvent[]) {
    if (policyEvents.length > 0) {
      this.invalidationPolicyManager.activatePolicies(...policyEvents);
    } else {
      this.invalidationPolicyManager.activatePolicies(
        InvalidationPolicyEvent.Read,
        InvalidationPolicyEvent.Write,
        InvalidationPolicyEvent.Evict
      );
    }
  }

  // Deactivates the provided policy events (on read, on write, on evict) or by default all policy events.
  deactivatePolicyEvents(...policyEvents: InvalidationPolicyEvent[]) {
    if (policyEvents.length > 0) {
      this.invalidationPolicyManager.deactivatePolicies(...policyEvents);
    } else {
      this.invalidationPolicyManager.deactivatePolicies(
        InvalidationPolicyEvent.Read,
        InvalidationPolicyEvent.Write,
        InvalidationPolicyEvent.Evict
      );
    }
  }

  // Returns the policy events that are currently active.
  activePolicyEvents() {
    return [
      InvalidationPolicyEvent.Read,
      InvalidationPolicyEvent.Write,
      InvalidationPolicyEvent.Evict
    ].filter(policyEvent => this.invalidationPolicyManager.isPolicyEventActive(policyEvent));
  }

  read<T>(options: Cache.ReadOptions<any>): T | null {
    const result = super.read<T>(options);

    if (
      !this.invalidationPolicyManager.isPolicyEventActive(
        InvalidationPolicyEvent.Read
      )
    ) {
      return result;
    }

    const processedResult = maybeDeepClone(result);
    const processedResultStatus = this.cacheResultProcessor.processReadResult(
      processedResult,
      options
    );

    if (processedResultStatus === ReadResultStatus.Complete) {
      return result;
    }

    this.broadcastWatches();

    return processedResultStatus === ReadResultStatus.Evicted
      ? null
      : processedResult;
  }

  diff<T>(options: Cache.DiffOptions): Cache.DiffResult<T> {
    const cacheDiff = super.diff<T>(options);

    // Diff calls made by `broadcastWatches` should not trigger the read policy
    // as these are internal reads not reflective of client action and can lead to recursive recomputation of cached data which is an error.
    // Instead, diffs will trigger the read policies for client-based reads like `readCache` invocations from watched queries outside
    // the scope of broadcasts.
    if (
      !this.invalidationPolicyManager.isPolicyEventActive(
        InvalidationPolicyEvent.Read
      ) ||
      this.isBroadcasting
    ) {
      return cacheDiff;
    }

    const { result } = cacheDiff;

    const processedResult = maybeDeepClone(result);
    const processedResultStatus = this.cacheResultProcessor.processReadResult(
      processedResult,
      options
    );

    if (processedResultStatus === ReadResultStatus.Complete) {
      return cacheDiff;
    }

    this.broadcastWatches();

    cacheDiff.complete = false;
    cacheDiff.result =
      processedResultStatus === ReadResultStatus.Evicted
        ? undefined
        : processedResult;

    return cacheDiff;
  }

  extract(optimistic = false, withInvalidation = true): NormalizedCacheObject {
    const extractedCache = super.extract(optimistic);

    if (withInvalidation) {
      // The entitiesById are sufficient alone for reconstructing the type map, so to
      // minimize payload size only inject the entitiesById object into the extracted cache
      extractedCache.invalidation = pick(
        this.entityTypeMap.extract(),
        "entitiesById"
      );
    }

    return extractedCache;
  }

  // Supports reading a collection of entities by type from the cache and filtering them by the given fields. Returns
  // a list of the dereferenced matching entities from the cache based on the given fragment.
  readFragmentWhere<FragmentType, TVariables = any>(options: Cache.ReadFragmentOptions<FragmentType, TVariables> & {
    filter?: FragmentWhereFilter<FragmentType>;
  }): FragmentType[] {
    const { fragment, filter, ...restOptions } = options;
    const fragmentDefinition = fragment.definitions[0] as FragmentDefinitionNode;
    const __typename = fragmentDefinition.typeCondition.name.value;

    const matchingRefs = this.readReferenceWhere(
      {
        __typename,
        filter
      }
    );

    const matchingFragments = matchingRefs.map(ref => this.readFragment({
      ...restOptions,
      fragment,
      id: ref.__ref,
    }));

    return compact(matchingFragments);
  }

  // Supports reading a collection of references by type from the cache and filtering them by the given fields. Returns a
  // list of the matching references.
  readReferenceWhere<T>(options: {
    __typename: string,
    filter?: FragmentWhereFilter<T>;
  }) {
    const { __typename, filter } = options;
    const collectionEntityName = collectionEntityIdForType(__typename);
    const entityReferences = this.readField<Reference[]>('data', makeReference(collectionEntityName));

    if (!entityReferences) {
      return [];
    }

    if (!filter) {
      return entityReferences;
    }

    return entityReferences.filter(ref => {
      if (isFunction(filter)) {
        return filter(ref, this.readField.bind(this));
      }

      const entityFilterResults = Object.keys(filter).map(filterField => {
        // @ts-ignore
        const filterValue = filter[filterField];
        const entityValueForFilter = this.readField(filterField, ref);

        return filterValue === entityValueForFilter;
      });

      return every(entityFilterResults, Boolean);
    });
  }
}