import {
  ApolloClient,
  ApolloClientOptions,
  DocumentNode,
  ObservableQuery,
} from '@apollo/client/core';
import { Policies } from '@apollo/client/cache/inmemory/policies';
import { buildWatchFragmentQuery, buildWatchFragmentWhereQuery } from './utils';
import { InvalidationPolicyCache } from '../cache';
import { WatchFragmentOptions, WatchFragmentWhereOptions } from './types';
import { generateFragmentFieldName } from '../helpers';

// An extension of the Apollo client that add support for watching updates to entities
// and collections of entities based on the provided filters.
export default class ApolloExtensionsClient<TCacheShape> extends ApolloClient<TCacheShape> {
  protected policies: Policies;

  constructor(config: ApolloClientOptions<TCacheShape>) {
    super(config);

    // @ts-ignore
    this.policies = this.cache.policies;
  }

  // Watches the data in the cache similarly to watchQuery and additionally extracts the given fieldName from the watch query
  // subscription and returns a subscription that emits that field data.
  private watchQueryForField(query: DocumentNode, fieldName: string): ObservableQuery {
    const obsQuery = this.watchQuery({
      fetchPolicy: 'cache-only',
      query: query,
    });

    const subscribe = obsQuery.subscribe.bind(obsQuery);

    obsQuery.subscribe = (observer, ...rest) => {
      // This check is modeled after the Zen Observable observer check:
      // https://github.com/zenparsing/zen-observable/blob/master/src/Observable.js#L211
      if (typeof observer !== 'object') {
        observer = {
          next: observer,
          error: rest[0] as (error: any) => void,
          complete: rest[1] as () => void,
        };
      }

      const observerNext = observer.next;

      // The observer maps the value emitted from the observable to the data at the
      // given field name.
      observer.next = (value: Record<string, any>) => {
        if (observerNext) {
          observerNext(value?.data?.[fieldName]);
        }
      }

      const subscription = subscribe(observer);
      const unsubscribe = subscription.unsubscribe.bind(subscription);

      subscription.unsubscribe = () => {
        // @ts-ignore typePolicies is private. Delete the field name from the type policies
        // after the subscription has been cleaned up.
        delete this.cache.policies.typePolicies.Query.fields[fieldName];
        unsubscribe();
      }

      return subscription;
    }

    return obsQuery;
  }

  // Watches the data in the cache similarly to watchQuery for a given fragment.
  watchFragment(
    options: WatchFragmentOptions,
  ): ObservableQuery {
    const fieldName = generateFragmentFieldName();
    const query = buildWatchFragmentQuery({
      ...options,
      fieldName,
      policies: this.policies,
    });

    return this.watchQueryForField(query, fieldName);
  }

  // Watches the data in the cache similarly to watchQuery for all entities int he cache
  // matching the given filter.
  watchFragmentWhere<FragmentType>(options: WatchFragmentWhereOptions<FragmentType>) {
    const fieldName = generateFragmentFieldName();
    const query = buildWatchFragmentWhereQuery({
      ...options,
      fieldName,
      cache: this.cache as unknown as InvalidationPolicyCache,
      policies: this.policies,
    });

    return this.watchQueryForField(
      query,
      fieldName,
    );
  }
}