import {
  ApolloServerPlugin,
  GraphQLRequestListener,
} from 'apollo-server-plugin-base';
import { GraphQLRequestContext, GraphQLResponse } from 'apollo-server-types';
import { KeyValueCache, PrefixingKeyValueCache } from 'apollo-server-caching';
import { ValueOrPromise } from 'apollo-server-types';
import { CacheHint, CacheScope } from 'apollo-cache-control';

// XXX This should use createSHA from apollo-server-core in order to work on
// non-Node environments. I'm not sure where that should end up ---
// apollo-server-sha as its own tiny module? apollo-server-env seems bad because
// that would add sha.js to unnecessary places, I think?
import { createHash } from 'crypto';

interface Options<TContext = Record<string, any>> {
  // Underlying cache used to save results. All writes will be under keys that
  // start with 'fqc:' and are followed by a fixed-size cryptographic hash of a
  // JSON object with keys representing the query document, operation name,
  // variables, and other keys derived from the sessionId and extraCacheKeyData
  // hooks. If not provided, use the cache in the GraphQLRequestContext instead
  // (ie, the cache passed to the ApolloServer constructor).
  cache?: KeyValueCache;

  // Define this hook if you're setting any cache hints with scope PRIVATE.
  // This should return a session ID if the user is "logged in", or null if
  // there is no "logged in" user.
  //
  // If a cachable response has any PRIVATE nodes, then:
  // - If this hook is not defined, a warning will be logged and it will not be cached.
  // - Else if this hook returns null, it will not be cached.
  // - Else it will be cached under a cache key tagged with the session ID and
  //   mode "private".
  //
  // If a cachable response has no PRIVATE nodes, then:
  // - If this hook is not defined or returns null, it will be cached under a cache
  //   key tagged with the mode "no session".
  // - Else it will be cached under a cache key tagged with the mode
  //   "authenticated public".
  //
  // When reading from the cache:
  // - If this hook is not defined or returns null, look in the cache under a cache
  //   key tagged with the mode "no session".
  // - Else look in the cache under a cache key tagged with the session ID and the
  //   mode "private". If no response is found in the cache, then look under a cache
  //   key tagged with the mode "authenticated public".
  //
  // This allows the cache to provide different "public" results to anonymous
  // users and logged in users ("no session" vs "authenticated public").
  //
  // A common implementation of this hook would be to look in
  // requestContext.request.http.headers for a specific authentication header or
  // cookie.
  //
  // This hook may return a promise because, for example, you might need to
  // validate a cookie against an external service.
  sessionId?(
    requestContext: GraphQLRequestContext<TContext>,
  ): ValueOrPromise<string | null>;

  // Define this hook if you want the cache key to vary based on some aspect of
  // the request other than the query document, operation name, variables, and
  // session ID. For example, responses that include translatable text may want
  // to return a string derived from
  // requestContext.request.http.headers.get('Accept-Language'). The data may
  // be anything that can be JSON-stringified.
  extraCacheKeyData?(
    requestContext: GraphQLRequestContext<TContext>,
  ): ValueOrPromise<any>;

  // If this hook is defined and returns false, the plugin will not read
  // responses from the cache.
  shouldReadFromCache?(
    requestContext: GraphQLRequestContext<TContext>,
  ): ValueOrPromise<boolean>;

  // If this hook is defined and returns false, the plugin will not write the
  // response to the cache.
  shouldWriteToCache?(
    requestContext: GraphQLRequestContext<TContext>,
  ): ValueOrPromise<boolean>;
}

enum SessionMode {
  NoSession,
  Private,
  AuthenticatedPublic,
}

function sha(s: string) {
  return createHash('sha256')
    .update(s)
    .digest('hex');
}

interface BaseCacheKey {
  source: string;
  operationName: string | null;
  variables: { [name: string]: any };
  extra: any;
}

interface ContextualCacheKey {
  sessionMode: SessionMode;
  sessionId?: string | null;
}

interface CacheValue {
  // Note: we only store data responses in the cache, not errors.
  //
  // There are two reasons we don't cache errors. The user-level reason is that
  // we think that in general errors are less cacheable than real results, since
  // they might indicate something transient like a failure to talk to a
  // backend. (If you need errors to be cacheable, represent the erroneous
  // condition explicitly in data instead of out-of-band as an error.) The
  // implementation reason is that this lets us avoid complexities around
  // serialization and deserialization of GraphQL errors, and the distinction
  // between formatted and unformatted errors, etc.
  data: Record<string, any>;
  cachePolicy: Required<CacheHint>;
  cacheTime: number; // epoch millis, used to calculate Age header
}

type CacheKey = BaseCacheKey & ContextualCacheKey;

function cacheKeyString(key: CacheKey) {
  return sha(JSON.stringify(key));
}

function isGraphQLQuery(requestContext: GraphQLRequestContext<any>) {
  return (
    requestContext.operation && requestContext.operation.operation === 'query'
  );
}

export default function plugin(
  options: Options = Object.create(null),
): ApolloServerPlugin {
  return {
    requestDidStart(
      outerRequestContext: GraphQLRequestContext<any>,
    ): GraphQLRequestListener<any> {
      const cache = new PrefixingKeyValueCache(
        options.cache || outerRequestContext.cache!,
        'fqc:',
      );

      let sessionId: string | null = null;
      let baseCacheKey: BaseCacheKey | null = null;
      let age: number | null = null;

      return {
        async responseForOperation(
          requestContext,
        ): Promise<GraphQLResponse | null> {
          requestContext.metrics.responseCacheHit = false;

          if (!isGraphQLQuery(requestContext)) {
            return null;
          }

          async function cacheGet(
            contextualCacheKeyFields: ContextualCacheKey,
          ): Promise<GraphQLResponse | null> {
            const key = cacheKeyString({
              ...baseCacheKey!,
              ...contextualCacheKeyFields,
            });
            const serializedValue = await cache.get(key);
            if (serializedValue === undefined) {
              return null;
            }

            const value: CacheValue = JSON.parse(serializedValue);
            // Use cache policy from the cache (eg, to calculate HTTP response
            // headers).
            requestContext.overallCachePolicy = value.cachePolicy;
            requestContext.metrics.responseCacheHit = true;
            age = Math.round((+new Date() - value.cacheTime) / 1000);
            return { data: value.data };
          }

          // Call hooks. Save values which will be used in willSendResponse as well.
          let extraCacheKeyData: any = null;
          if (options.sessionId) {
            sessionId = await options.sessionId(requestContext);
          }
          if (options.extraCacheKeyData) {
            extraCacheKeyData = await options.extraCacheKeyData(requestContext);
          }

          baseCacheKey = {
            source: requestContext.source!,
            operationName: requestContext.operationName,
            // Defensive copy just in case it somehow gets mutated.
            variables: { ...(requestContext.request.variables || {}) },
            extra: extraCacheKeyData,
          };

          // Note that we set up sessionId and baseCacheKey before doing this
          // check, so that we can still write the result to the cache even if
          // we are told not to read from the cache.
          if (
            options.shouldReadFromCache &&
            !options.shouldReadFromCache(requestContext)
          ) {
            return null;
          }

          if (sessionId === null) {
            return cacheGet({ sessionMode: SessionMode.NoSession });
          } else {
            const privateResponse = await cacheGet({
              sessionId,
              sessionMode: SessionMode.Private,
            });
            if (privateResponse !== null) {
              return privateResponse;
            }
            return cacheGet({ sessionMode: SessionMode.AuthenticatedPublic });
          }
        },

        async willSendResponse(requestContext) {
          const logger = requestContext.logger || console;

          if (!isGraphQLQuery(requestContext)) {
            return;
          }
          if (requestContext.metrics.responseCacheHit) {
            // Never write back to the cache what we just read from it. But do set the Age header!
            const http = requestContext.response.http;
            if (http && age !== null) {
              http.headers.set('age', age.toString());
            }
            return;
          }
          if (
            options.shouldWriteToCache &&
            !options.shouldWriteToCache(requestContext)
          ) {
            return;
          }

          const { response, overallCachePolicy } = requestContext;
          if (
            response.errors ||
            !response.data ||
            !overallCachePolicy ||
            overallCachePolicy.maxAge <= 0
          ) {
            // This plugin never caches errors or anything without a cache policy.
            //
            // There are two reasons we don't cache errors. The user-level
            // reason is that we think that in general errors are less cacheable
            // than real results, since they might indicate something transient
            // like a failure to talk to a backend. (If you need errors to be
            // cacheable, represent the erroneous condition explicitly in data
            // instead of out-of-band as an error.) The implementation reason is
            // that this lets us avoid complexities around serialization and
            // deserialization of GraphQL errors, and the distinction between
            // formatted and unformatted errors, etc.
            return;
          }

          const data = response.data!;

          // We're pretty sure that any path that calls willSendResponse with a
          // non-error response will have already called our execute hook above,
          // but let's just double-check that, since accidentally ignoring
          // sessionId could be a big security hole.
          if (!baseCacheKey) {
            throw new Error(
              'willSendResponse called without error, but execute not called?',
            );
          }

          function cacheSetInBackground(
            contextualCacheKeyFields: ContextualCacheKey,
          ) {
            const key = cacheKeyString({
              ...baseCacheKey!,
              ...contextualCacheKeyFields,
            });
            const value: CacheValue = {
              data,
              cachePolicy: overallCachePolicy!,
              cacheTime: +new Date(),
            };
            const serializedValue = JSON.stringify(value);
            // Note that this function converts key and response to strings before
            // doing anything asynchronous, so it can run in parallel with user code
            // without worrying about anything being mutated out from under it.
            //
            // Also note that the test suite assumes that this asynchronous function
            // still calls `cache.set` synchronously (ie, that it writes to
            // InMemoryLRUCache synchronously).
            cache
              .set(key, serializedValue, { ttl: overallCachePolicy!.maxAge })
              .catch(logger.warn);
          }

          const isPrivate = overallCachePolicy.scope === CacheScope.Private;
          if (isPrivate) {
            if (!options.sessionId) {
              logger.warn(
                'A GraphQL response used @cacheControl or setCacheHint to set cache hints with scope ' +
                  "Private, but you didn't define the sessionId hook for " +
                  'apollo-server-plugin-response-cache. Not caching.',
              );
              return;
            }
            if (sessionId === null) {
              // Private data shouldn't be cached for logged-out users.
              return;
            }
            cacheSetInBackground({
              sessionId,
              sessionMode: SessionMode.Private,
            });
          } else {
            cacheSetInBackground({
              sessionMode:
                sessionId === null
                  ? SessionMode.NoSession
                  : SessionMode.AuthenticatedPublic,
            });
          }
        },
      };
    },
  };
}
