const {
  GraphQLObjectType,
  GraphQLInterfaceType
} = require('graphql');
const debug = require('debug')('talk:graph:schema');
const Joi = require('joi');

/**
 * XXX taken from graphql-js: src/execution/execute.js, because that function
 * is not exported
 *
 * If a resolve function is not given, then a default resolve behavior is used
 * which takes the property of the source object of the same name as the field
 * and returns it as the result, or if it's a function, returns the result
 * of calling that function.
 */
const defaultResolveFn = (source, args, context, {fieldName}) => {

  // ensure source is a value for which property access is acceptable.
  if (typeof source === 'object' || typeof source === 'function') {
    const property = source[fieldName];
    if (typeof property === 'function') {
      return source[fieldName](args, context);
    }
    return property;
  }
};

// This function is pretty much copied verbatim from the graphql-tools repo:
// https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
// With the small alteration that we look for the `resolveType` function on the
// schema so we can wrap post hooks around it to provide additional resolve
// points.
const forEachField = (schema, fn) => {
  const typeMap = schema.getTypeMap();
  Object.keys(typeMap).forEach((typeName) => {
    const type = typeMap[typeName];

    if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {

      // Here we capture the change to extract the resolve type. We pass this
      // with the `isResolveType = true` to introduce the specific beheviour.
      if ('resolveType' in type) {
        fn(type, typeName, '__resolveType', true);
      }

      const fields = type.getFields();
      Object.keys(fields).forEach((fieldName) => {
        const field = fields[fieldName];
        fn(field, typeName, fieldName);
      });
    }
  });
};

/**
 * Decorates the field with the post resolvers (if available) and attaches a
 * default type in the form of `Default${typeName}`.
 */
const decorateResolveFunction = (field, typeName, fieldName, post) => {

  // Cache the original resolverType function.
  let resolveType = field.resolveType;

  // defaultResolveType is the default type that is resolved on a resolver
  // when the interface being looked up is not defined.
  const defaultResolveType = `Default${typeName}`;

  // Return the function to handle the resolveType hooks.
  const defaultResolveFn = (obj, context, info) => {
    let type = resolveType(obj, context, info);

    // Only if a previous resolver was unable to resolve the field type do we
    // progress to the hooks (in order!) to resolve the field name until we
    // have resolved it.
    if (typeof type !== 'undefined' && type != null) {
      return type;
    }

    // All else fails, resort to the defaultResolveType.
    return defaultResolveType;
  };

  // This only needs to do something if post hooks are defined.
  if (post.length === 0) {

    // Set the default on the resolveType function.
    field.resolveType = defaultResolveFn;

    return;
  }

  // Ensure it matches the format we expect.
  Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`);

  // Return the function to handle the resolveType hooks.
  field.resolveType = (obj, context, info) => {
    let type = defaultResolveFn(obj, context, info);

    // Only if a previous resolver was unable to resolve the field type do we
    // progress to the hooks (in order!) to resolve the field name until we
    // have resolved it.
    if (typeof type !== 'undefined' && type != null && type !== defaultResolveType) {
      return type;
    }

    // We will walk through the post hooks until we find the right one. This
    // follows what redux does to combine existing reducers.
    for (let i = 0; i < post.length; i++) {
      let resolveType = post[i];
      let resolvedType = resolveType(obj, context, info);
      if (typeof resolvedType !== 'undefined' && resolvedType != null) {
        return resolvedType;
      }
    }

    return type;
  };
};

/**
 * Decorates the schema with pre and post hooks as provided by the Plugin
 * Manager.
 * @param  {GraphQLSchema} schema the schema to decorate
 * @param  {Array}         hooks  hooks to apply to the schema
 * @return {void}
 */
const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeName, fieldName, isResolveType = false) => {

  // Pull out the pre/post hooks from the available hooks.
  const {
    pre,
    post
  } = hooks

    // Only grab hooks that are associated with thie field and typeName.
    .filter(({hooks}) => (typeName in hooks) && (fieldName in hooks[typeName]))

    // Grab the hooks we need.
    .map(({plugin, hooks}) => ({plugin, hooks: hooks[typeName][fieldName]}))

    // Combine the pre/post hooks from each plugin into an array we can
    // execute.
    .reduce((acc, {plugin, hooks}) => {

      // Itterate over the hooks on the fields and look at it with a switch
      // block to check for misconfigured plugins.
      Object.keys(hooks).forEach((hook) => {
        switch (hook) {
        case 'pre':
          Joi.assert(hooks.pre, Joi.func().maxArity(4));

          debug(`adding pre hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);

          if (typeof hooks.pre !== 'function') {
            throw new Error(`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${plugin.name}' to be a function, it was a '${typeof hooks[hook]}'`);
          }

          acc.pre.push(hooks.pre);
          break;
        case 'post':
          Joi.assert(hooks.pre, Joi.func().maxArity(5));

          debug(`adding post hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);

          if (typeof hooks.post !== 'function') {
            throw new Error(`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${plugin.name}' to be a function, it was a '${typeof hooks[hook]}'`);
          }

          acc.post.unshift(hooks.post);
          break;
        default:
          throw new Error(`invalid hook '${hook}' on resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
        }
      });

      return acc;
    }, {
      pre: [],
      post: []
    });

  // If this is a resolve type, we need to do some specific things to handle
  // this type of field.
  if (isResolveType) {

    // Warn if we have any pre hooks.
    if (pre.length !== 0) {
      throw new Error(`invalid pre hooks were found for ${typeName}.${fieldName}, only post hooks are supported on the __resolveType hook`);
    }

    // Decorate the resolve function on the field with the new resolveType func.
    decorateResolveFunction(field, typeName, fieldName, post);
    return;
  }

  // If we have no hooks to add here, don't try to modify anything.
  if (pre.length === 0 && post.length === 0) {
    return;
  }

  // Cache the original resolve function, this emulates the beheviour found in
  // graphql-tools: https://github.com/apollographql/graphql-tools/blob/6e9cc124b10d673448386041e6c3d058bc205a02/src/schemaGenerator.ts#L423-L425
  let resolve = field.resolve;
  if (typeof resolve === 'undefined') {
    resolve = defaultResolveFn;
  }

  // Apply our async resolve function which will fire all pre functions (and
  // wait until they resolve) followed by waiting for the response and then
  // firing their post hooks. Lastly, we respond with the result of the
  // original resolver.
  field.resolve = async (obj, args, context, info) => {

    // Issue all pre hooks before we resolve the field.
    await Promise.all(pre.map((pre) => pre(obj, args, context, info)));

    // Resolve the field.
    let result = await resolve(obj, args, context, info);

    // Insure all post hooks after we've resolved the field with the result
    // passed in as the fifth argument.
    return await post.reduce(async (result, post) => {

      // Wait for the accumulator to resolve before we continue.
      result = await result;

      // Check to see if this post function accepts a result, if it does, we
      // expect that it modifies the result, otherwise, just fire the post hook,
      // wait till it's done, then move onto the next hook.
      if (post.length === 5) {
        return await post(obj, args, context, info, result);
      }

      // Wait for the post hook to finish.
      await post(obj, args, context, info);

      // Return the result, which we already awaited for before.
      return result;
    }, result);
  };
});

module.exports = {
  decorateWithHooks
};
