import {
  GraphQLSchema,
  GraphQLFieldResolver,
  specifiedRules,
  DocumentNode,
  getOperationAST,
  ExecutionArgs,
  GraphQLError,
  GraphQLFormattedError,
  validate as graphqlValidate,
  parse as graphqlParse,
  execute as graphqlExecute,
  Kind,
  ParseOptions,
} from 'graphql';
import type { DataSource } from 'apollo-datasource';
import type { PersistedQueryOptions } from './graphqlOptions';
import {
  symbolExecutionDispatcherWillResolveField,
  enablePluginsForSchemaResolvers,
  symbolUserFieldResolver,
} from './utils/schemaInstrumentation';
import {
  ApolloError,
  fromGraphQLError,
  SyntaxError,
  ValidationError,
  PersistedQueryNotSupportedError,
  PersistedQueryNotFoundError,
  formatApolloErrors,
  UserInputError,
} from 'apollo-server-errors';
import type {
  GraphQLRequest,
  GraphQLResponse,
  GraphQLRequestContext,
  GraphQLExecutor,
  GraphQLExecutionResult,
  ValidationRule,
} from 'apollo-server-types';
import type {
  ApolloServerPlugin,
  GraphQLRequestListener,
  GraphQLRequestContextDidResolveSource,
  GraphQLRequestContextExecutionDidStart,
  GraphQLRequestContextResponseForOperation,
  GraphQLRequestContextDidResolveOperation,
  GraphQLRequestContextParsingDidStart,
  GraphQLRequestContextValidationDidStart,
  GraphQLRequestContextWillSendResponse,
  GraphQLRequestContextDidEncounterErrors,
  GraphQLRequestExecutionListener,
} from 'apollo-server-plugin-base';

import { Dispatcher } from './utils/dispatcher';
import {
  KeyValueCache,
  PrefixingKeyValueCache,
} from '@apollo/utils.keyvaluecache';

export { GraphQLRequest, GraphQLResponse, GraphQLRequestContext };

import createSHA from './utils/createSHA';
import { HttpQueryError } from './runHttpQuery';
import type { DocumentStore } from './types';
import { Headers } from 'apollo-server-env';

export const APQ_CACHE_PREFIX = 'apq:';

function computeQueryHash(query: string) {
  return createSHA('sha256').update(query).digest('hex');
}

export interface GraphQLRequestPipelineConfig<TContext> {
  schema: GraphQLSchema;

  rootValue?: ((document: DocumentNode) => any) | any;
  validationRules?: ValidationRule[];
  executor?: GraphQLExecutor;
  fieldResolver?: GraphQLFieldResolver<any, TContext>;

  dataSources?: () => DataSources<TContext>;

  persistedQueries?: PersistedQueryOptions;

  formatError?: (error: GraphQLError) => GraphQLFormattedError;
  formatResponse?: (
    response: GraphQLResponse,
    requestContext: GraphQLRequestContext<TContext>,
  ) => GraphQLResponse | null;

  plugins?: ApolloServerPlugin[];
  documentStore?: DocumentStore | null;

  parseOptions?: ParseOptions;
}

export type DataSources<TContext> = {
  [name: string]: DataSource<TContext>;
};

type Mutable<T> = { -readonly [P in keyof T]: T[P] };

function isBadUserInputGraphQLError(error: GraphQLError): Boolean {
  return (
    error.nodes?.length === 1 &&
    error.nodes[0].kind === Kind.VARIABLE_DEFINITION &&
    (error.message.startsWith(
      `Variable "$${error.nodes[0].variable.name.value}" got invalid value `,
    ) ||
      error.message.startsWith(
        `Variable "$${error.nodes[0].variable.name.value}" of required type `,
      ) ||
      error.message.startsWith(
        `Variable "$${error.nodes[0].variable.name.value}" of non-null type `,
      ))
  );
}

export async function processGraphQLRequest<TContext>(
  config: GraphQLRequestPipelineConfig<TContext>,
  requestContext: Mutable<GraphQLRequestContext<TContext>>,
): Promise<GraphQLResponse> {
  // For legacy reasons, this exported method may exist without a `logger` on
  // the context.  We'll need to make sure we account for that, even though
  // all of our own machinery will certainly set it now.
  const logger = requestContext.logger || console;

  // If request context's `metrics` already exists, preserve it, but _ensure_ it
  // exists there and shorthand it for use throughout this function.
  const metrics = (requestContext.metrics =
    requestContext.metrics || Object.create(null));

  const dispatcher = await initializeRequestListenerDispatcher();
  await initializeDataSources();

  const request = requestContext.request;

  let { query, extensions } = request;

  let queryHash: string;

  let persistedQueryCache: KeyValueCache | undefined;
  metrics.persistedQueryHit = false;
  metrics.persistedQueryRegister = false;

  if (extensions?.persistedQuery) {
    // It looks like we've received a persisted query. Check if we
    // support them.
    if (!config.persistedQueries || !config.persistedQueries.cache) {
      return await sendErrorResponse(new PersistedQueryNotSupportedError());
    } else if (extensions.persistedQuery.version !== 1) {
      return await sendErrorResponse(
        new GraphQLError('Unsupported persisted query version'),
      );
    }

    // We'll store a reference to the persisted query cache so we can actually
    // do the write at a later point in the request pipeline processing.
    persistedQueryCache = config.persistedQueries.cache;

    // This is a bit hacky, but if `config` came from direct use of the old
    // apollo-server 1.0-style middleware (graphqlExpress etc, not via the
    // ApolloServer class), it won't have been converted to
    // PrefixingKeyValueCache yet.
    if (!(persistedQueryCache instanceof PrefixingKeyValueCache)) {
      persistedQueryCache = new PrefixingKeyValueCache(
        persistedQueryCache,
        APQ_CACHE_PREFIX,
      );
    }

    queryHash = extensions.persistedQuery.sha256Hash;

    if (query === undefined) {
      query = await persistedQueryCache.get(queryHash);
      if (query) {
        metrics.persistedQueryHit = true;
      } else {
        return await sendErrorResponse(new PersistedQueryNotFoundError());
      }
    } else {
      const computedQueryHash = computeQueryHash(query);

      // The provided hash must exactly match the SHA-256 hash of
      // the query string. This prevents hash hijacking, where a
      // new and potentially malicious query is associated with
      // an existing hash.
      if (queryHash !== computedQueryHash) {
        return await sendErrorResponse(
          new GraphQLError('provided sha does not match query'),
        );
      }

      // We won't write to the persisted query cache until later.
      // Deferring the writing gives plugins the ability to "win" from use of
      // the cache, but also have their say in whether or not the cache is
      // written to (by interrupting the request with an error).
      metrics.persistedQueryRegister = true;
    }
  } else if (query) {
    // TODO: We'll compute the APQ query hash to use as our cache key for
    // now, but this should be replaced with the new operation ID algorithm.
    queryHash = computeQueryHash(query);
  } else {
    return await sendErrorResponse(
      new GraphQLError(
        'GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension.',
      ),
    );
  }

  requestContext.queryHash = queryHash;
  requestContext.source = query;

  // Let the plugins know that we now have a STRING of what we hope will
  // parse and validate into a document we can execute on.  Unless we have
  // retrieved this from our APQ cache, there's no guarantee that it is
  // syntactically correct, so this string should not be trusted as a valid
  // document until after it's parsed and validated.
  await dispatcher.invokeHook(
    'didResolveSource',
    requestContext as GraphQLRequestContextDidResolveSource<TContext>,
  );

  // If we're configured with a document store (by default, we are), we'll
  // utilize the operation's hash to lookup the AST from the previously
  // parsed-and-validated operation.  Failure to retrieve anything from the
  // cache just means we're committed to doing the parsing and validation.
  if (config.documentStore) {
    try {
      requestContext.document = await config.documentStore.get(queryHash);
    } catch (err) {
      logger.warn(
        'An error occurred while attempting to read from the documentStore. ' +
          (err as Error)?.message || err,
      );
    }
  }

  // If we still don't have a document, we'll need to parse and validate it.
  // With success, we'll attempt to save it into the store for future use.
  if (!requestContext.document) {
    const parsingDidEnd = await dispatcher.invokeDidStartHook(
      'parsingDidStart',
      requestContext as GraphQLRequestContextParsingDidStart<TContext>,
    );

    try {
      requestContext.document = parse(query, config.parseOptions);
      await parsingDidEnd();
    } catch (syntaxError) {
      await parsingDidEnd(syntaxError as Error);
      // XXX: This cast is pretty sketchy, as other error types can be thrown
      // by parsingDidEnd!
      return await sendErrorResponse(syntaxError as GraphQLError, SyntaxError);
    }

    const validationDidEnd = await dispatcher.invokeDidStartHook(
      'validationDidStart',
      requestContext as GraphQLRequestContextValidationDidStart<TContext>,
    );

    const validationErrors = validate(requestContext.document);

    if (validationErrors.length === 0) {
      await validationDidEnd();
    } else {
      await validationDidEnd(validationErrors);
      return await sendErrorResponse(validationErrors, ValidationError);
    }

    if (config.documentStore) {
      // The underlying cache store behind the `documentStore` returns a
      // `Promise` which is resolved (or rejected), eventually, based on the
      // success or failure (respectively) of the cache save attempt.  While
      // it's certainly possible to `await` this `Promise`, we don't care about
      // whether or not it's successful at this point.  We'll instead proceed
      // to serve the rest of the request and just hope that this works out.
      // If it doesn't work, the next request will have another opportunity to
      // try again.  Errors will surface as warnings, as appropriate.
      //
      // While it shouldn't normally be necessary to wrap this `Promise` in a
      // `Promise.resolve` invocation, it seems that the underlying cache store
      // is returning a non-native `Promise` (e.g. Bluebird, etc.).
      Promise.resolve(
        config.documentStore.set(queryHash, requestContext.document),
      ).catch((err) =>
        logger.warn(
          'Could not store validated document. ' + err?.message || err,
        ),
      );
    }
  }

  // TODO: If we want to guarantee an operation has been set when invoking
  // `willExecuteOperation` and executionDidStart`, we need to throw an
  // error here and not leave this to `buildExecutionContext` in
  // `graphql-js`.
  const operation = getOperationAST(
    requestContext.document,
    request.operationName,
  );

  requestContext.operation = operation || undefined;
  // We'll set `operationName` to `null` for anonymous operations.
  requestContext.operationName = operation?.name?.value || null;

  try {
    await dispatcher.invokeHook(
      'didResolveOperation',
      requestContext as GraphQLRequestContextDidResolveOperation<TContext>,
    );
  } catch (err) {
    // XXX: This cast is pretty sketchy, as other error types can be thrown
    // by didResolveOperation!
    return await sendErrorResponse(err as GraphQLError);
  }

  // Now that we've gone through the pre-execution phases of the request
  // pipeline, and given plugins appropriate ability to object (by throwing
  // an error) and not actually write, we'll write to the cache if it was
  // determined earlier in the request pipeline that we should do so.
  if (metrics.persistedQueryRegister && persistedQueryCache) {
    // While it shouldn't normally be necessary to wrap this `Promise` in a
    // `Promise.resolve` invocation, it seems that the underlying cache store
    // is returning a non-native `Promise` (e.g. Bluebird, etc.).
    Promise.resolve(
      persistedQueryCache.set(
        queryHash,
        query,
        config.persistedQueries &&
          typeof config.persistedQueries.ttl !== 'undefined'
          ? {
              ttl: config.persistedQueries.ttl,
            }
          : Object.create(null),
      ),
    ).catch(logger.warn);
  }

  let response: GraphQLResponse | null =
    await dispatcher.invokeHooksUntilNonNull(
      'responseForOperation',
      requestContext as GraphQLRequestContextResponseForOperation<TContext>,
    );
  if (response == null) {
    // This execution dispatcher code is duplicated in `pluginTestHarness`
    // right now.

    const executionListeners: GraphQLRequestExecutionListener<TContext>[] = [];
    (
      await dispatcher.invokeHook(
        'executionDidStart',
        requestContext as GraphQLRequestContextExecutionDidStart<TContext>,
      )
    ).forEach((executionListener) => {
      if (executionListener) {
        executionListeners.push(executionListener);
      }
    });
    executionListeners.reverse();

    const executionDispatcher = new Dispatcher(executionListeners);

    if (executionDispatcher.hasHook('willResolveField')) {
      // Create a callback that will trigger the execution dispatcher's
      // `willResolveField` hook.  We will attach this to the context on a
      // symbol so it can be invoked by our `wrapField` method during execution.
      const invokeWillResolveField: GraphQLRequestExecutionListener<TContext>['willResolveField'] =
        (...args) =>
          executionDispatcher.invokeSyncDidStartHook(
            'willResolveField',
            ...args,
          );

      Object.defineProperty(
        requestContext.context,
        symbolExecutionDispatcherWillResolveField,
        { value: invokeWillResolveField },
      );

      // If the user has provided a custom field resolver, we will attach
      // it to the context so we can still invoke it after we've wrapped the
      // fields with `wrapField` within `enablePluginsForSchemaResolvers` of
      // the `schemaInstrumentation` module.
      if (config.fieldResolver) {
        Object.defineProperty(requestContext.context, symbolUserFieldResolver, {
          value: config.fieldResolver,
        });
      }

      // If the schema is already enabled, this is a no-op.  Otherwise, the
      // schema will be augmented so it is able to invoke willResolveField. Note
      // that if we never see a plugin with willResolveField then we will never
      // need to instrument the schema, which might be a small performance gain.
      // (For example, this can happen if you pass `fieldLevelInstrumentation:
      // () => false` to the usage reporting plugin and disable the cache
      // control plugin. We can consider changing the cache control plugin to
      // have a "static cache control only" mode that doesn't use
      // willResolveField too if this proves to be helpful in practice.)
      enablePluginsForSchemaResolvers(config.schema);
    }

    try {
      const result = await execute(
        requestContext as GraphQLRequestContextExecutionDidStart<TContext>,
      );

      // The first thing that execution does is coerce the request's variables
      // to the types declared in the operation, which can lead to errors if
      // they are of the wrong type. It also makes sure that all non-null
      // variables are required and get non-null values. If any of these things
      // lead to errors, we change them into UserInputError so that their code
      // doesn't end up being INTERNAL_SERVER_ERROR, since these are client
      // errors.
      //
      // This is hacky! Hopefully graphql-js will give us a way to separate
      // variable resolution from execution later; see
      // https://github.com/graphql/graphql-js/issues/3169
      const resultErrors = result.errors?.map((e) => {
        if (isBadUserInputGraphQLError(e)) {
          return fromGraphQLError(e, {
            errorClass: UserInputError,
          });
        }
        return e;
      });

      if (resultErrors) {
        await didEncounterErrors(resultErrors);
      }

      response = {
        ...result,
        errors: resultErrors ? formatErrors(resultErrors) : undefined,
      };

      await executionDispatcher.invokeHook('executionDidEnd');
    } catch (executionError) {
      await executionDispatcher.invokeHook(
        'executionDidEnd',
        executionError as Error,
      );
      // XXX: This cast is pretty sketchy, as other error types can be thrown
      // in the try block!
      return await sendErrorResponse(executionError as GraphQLError);
    }
  }

  if (config.formatResponse) {
    const formattedResponse: GraphQLResponse | null = config.formatResponse(
      response,
      requestContext,
    );
    if (formattedResponse != null) {
      response = formattedResponse;
    }
  }

  return sendResponse(response);

  function parse(query: string, parseOptions?: ParseOptions): DocumentNode {
    return graphqlParse(query, parseOptions);
  }

  function validate(document: DocumentNode): ReadonlyArray<GraphQLError> {
    let rules = specifiedRules;
    if (config.validationRules) {
      rules = rules.concat(config.validationRules);
    }

    return graphqlValidate(config.schema, document, rules);
  }

  async function execute(
    requestContext: GraphQLRequestContextExecutionDidStart<TContext>,
  ): Promise<GraphQLExecutionResult> {
    const { request, document } = requestContext;

    const executionArgs: ExecutionArgs = {
      schema: config.schema,
      document,
      rootValue:
        typeof config.rootValue === 'function'
          ? config.rootValue(document)
          : config.rootValue,
      contextValue: requestContext.context,
      variableValues: request.variables,
      operationName: request.operationName,
      fieldResolver: config.fieldResolver,
    };

    if (config.executor) {
      // XXX Nothing guarantees that the only errors thrown or returned
      // in result.errors are GraphQLErrors, even though other code
      // (eg usage reporting) assumes that.
      return await config.executor(requestContext);
    } else {
      return await graphqlExecute(executionArgs);
    }
  }

  async function sendResponse(
    response: GraphQLResponse,
  ): Promise<GraphQLResponse> {
    requestContext.response = {
      ...requestContext.response,
      errors: response.errors,
      data: response.data,
      extensions: response.extensions,
    };
    if (response.http) {
      if (!requestContext.response.http) {
        requestContext.response.http = {
          headers: new Headers(),
        };
      }
      if (response.http.status) {
        requestContext.response.http.status = response.http.status;
      }
      for (const [name, value] of response.http.headers) {
        requestContext.response.http.headers.set(name, value);
      }
    }
    await dispatcher.invokeHook(
      'willSendResponse',
      requestContext as GraphQLRequestContextWillSendResponse<TContext>,
    );
    return requestContext.response;
  }

  // Note that we ensure that all calls to didEncounterErrors are followed by
  // calls to willSendResponse. (The usage reporting plugin depends on this.)
  async function didEncounterErrors(errors: ReadonlyArray<GraphQLError>) {
    requestContext.errors = errors;

    return await dispatcher.invokeHook(
      'didEncounterErrors',
      requestContext as GraphQLRequestContextDidEncounterErrors<TContext>,
    );
  }

  async function sendErrorResponse(
    errorOrErrors: ReadonlyArray<GraphQLError> | GraphQLError,
    errorClass?: typeof ApolloError,
  ) {
    // If a single error is passed, it should still be encapsulated in an array.
    const errors = Array.isArray(errorOrErrors)
      ? errorOrErrors
      : [errorOrErrors];

    await didEncounterErrors(errors);

    const response: GraphQLResponse = {
      errors: formatErrors(
        errors.map((err) =>
          err instanceof ApolloError && !errorClass
            ? err
            : fromGraphQLError(
                err,
                errorClass && {
                  errorClass,
                },
              ),
        ),
      ),
    };

    // Persisted query errors (especially "not found") need to be uncached,
    // because hopefully we're about to fill in the APQ cache and the same
    // request will succeed next time. We also want a 200 response to avoid any
    // error handling that may mask the contents of an error response.
    if (
      errors.every(
        (err) =>
          err instanceof PersistedQueryNotSupportedError ||
          err instanceof PersistedQueryNotFoundError,
      )
    ) {
      response.http = {
        status: 200,
        headers: new Headers({
          'Cache-Control': 'private, no-cache, must-revalidate',
        }),
      };
    } else if (errors.length === 1 && errors[0] instanceof HttpQueryError) {
      response.http = {
        status: errors[0].statusCode,
        headers: new Headers(errors[0].headers),
      };
    }

    return sendResponse(response);
  }

  function formatErrors(
    errors: ReadonlyArray<GraphQLError>,
  ): ReadonlyArray<GraphQLFormattedError> {
    return formatApolloErrors(errors, {
      formatter: config.formatError,
      debug: requestContext.debug,
    });
  }

  async function initializeRequestListenerDispatcher(): Promise<
    Dispatcher<GraphQLRequestListener<TContext>>
  > {
    const requestListeners: GraphQLRequestListener<TContext>[] = [];
    if (config.plugins) {
      for (const plugin of config.plugins) {
        if (!plugin.requestDidStart) continue;
        const listener = await plugin.requestDidStart(requestContext);
        if (listener) {
          requestListeners.push(listener);
        }
      }
    }
    return new Dispatcher(requestListeners);
  }

  async function initializeDataSources() {
    if (config.dataSources) {
      const context = requestContext.context;

      const dataSources = config.dataSources();

      const initializers: any[] = [];
      for (const dataSource of Object.values(dataSources)) {
        if (dataSource.initialize) {
          initializers.push(
            dataSource.initialize({
              context,
              cache: requestContext.cache,
            }),
          );
        }
      }

      await Promise.all(initializers);

      if ('dataSources' in context) {
        throw new Error(
          'Please use the dataSources config option instead of putting dataSources on the context yourself.',
        );
      }

      (context as any).dataSources = dataSources;
    }
  }
}
