/**
 * Copyright (c) 2015-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict
 */

import {
  isObjectType,
  isInterfaceType,
  isUnionType,
  isEnumType,
  isInputObjectType,
  isNonNullType,
  isNamedType,
  isInputType,
  isOutputType,
} from './definition';
import type {
  GraphQLObjectType,
  GraphQLInterfaceType,
  GraphQLUnionType,
  GraphQLEnumType,
  GraphQLInputObjectType,
} from './definition';
import { isDirective } from './directives';
import type { GraphQLDirective } from './directives';
import { isIntrospectionType } from './introspection';
import { isSchema } from './schema';
import type { GraphQLSchema } from './schema';
import find from '../jsutils/find';
import invariant from '../jsutils/invariant';
import objectValues from '../jsutils/objectValues';
import { GraphQLError } from '../error/GraphQLError';
import type {
  ASTNode,
  ObjectTypeDefinitionNode,
  ObjectTypeExtensionNode,
  InterfaceTypeDefinitionNode,
  InterfaceTypeExtensionNode,
  FieldDefinitionNode,
  EnumValueDefinitionNode,
  InputValueDefinitionNode,
  NamedTypeNode,
  TypeNode,
} from '../language/ast';
import { isValidNameError } from '../utilities/assertValidName';
import { isEqualType, isTypeSubTypeOf } from '../utilities/typeComparators';

/**
 * Implements the "Type Validation" sub-sections of the specification's
 * "Type System" section.
 *
 * Validation runs synchronously, returning an array of encountered errors, or
 * an empty array if no errors were encountered and the Schema is valid.
 */
export function validateSchema(
  schema: GraphQLSchema,
): $ReadOnlyArray<GraphQLError> {
  // First check to ensure the provided value is in fact a GraphQLSchema.
  invariant(
    isSchema(schema),
    `Expected ${String(schema)} to be a GraphQL schema.`,
  );

  // If this Schema has already been validated, return the previous results.
  if (schema.__validationErrors) {
    return schema.__validationErrors;
  }

  // Validate the schema, producing a list of errors.
  const context = new SchemaValidationContext(schema);
  validateRootTypes(context);
  validateDirectives(context);
  validateTypes(context);

  // Persist the results of validation before returning to ensure validation
  // does not run multiple times for this schema.
  const errors = context.getErrors();
  schema.__validationErrors = errors;
  return errors;
}

/**
 * Utility function which asserts a schema is valid by throwing an error if
 * it is invalid.
 */
export function assertValidSchema(schema: GraphQLSchema): void {
  const errors = validateSchema(schema);
  if (errors.length !== 0) {
    throw new Error(errors.map(error => error.message).join('\n\n'));
  }
}

class SchemaValidationContext {
  +_errors: Array<GraphQLError>;
  +schema: GraphQLSchema;

  constructor(schema) {
    this._errors = [];
    this.schema = schema;
  }

  reportError(
    message: string,
    nodes?: $ReadOnlyArray<?ASTNode> | ?ASTNode,
  ): void {
    const _nodes = (Array.isArray(nodes) ? nodes : [nodes]).filter(Boolean);
    this.addError(new GraphQLError(message, _nodes));
  }

  addError(error: GraphQLError): void {
    this._errors.push(error);
  }

  getErrors(): $ReadOnlyArray<GraphQLError> {
    return this._errors;
  }
}

function validateRootTypes(context) {
  const schema = context.schema;
  const queryType = schema.getQueryType();
  if (!queryType) {
    context.reportError(`Query root type must be provided.`, schema.astNode);
  } else if (!isObjectType(queryType)) {
    context.reportError(
      `Query root type must be Object type, it cannot be ${String(queryType)}.`,
      getOperationTypeNode(schema, queryType, 'query'),
    );
  }

  const mutationType = schema.getMutationType();
  if (mutationType && !isObjectType(mutationType)) {
    context.reportError(
      'Mutation root type must be Object type if provided, it cannot be ' +
        `${String(mutationType)}.`,
      getOperationTypeNode(schema, mutationType, 'mutation'),
    );
  }

  const subscriptionType = schema.getSubscriptionType();
  if (subscriptionType && !isObjectType(subscriptionType)) {
    context.reportError(
      'Subscription root type must be Object type if provided, it cannot be ' +
        `${String(subscriptionType)}.`,
      getOperationTypeNode(schema, subscriptionType, 'subscription'),
    );
  }
}

function getOperationTypeNode(
  schema: GraphQLSchema,
  type: GraphQLObjectType,
  operation: string,
): ?ASTNode {
  const astNode = schema.astNode;
  const operationTypeNode =
    astNode &&
    astNode.operationTypes.find(
      operationType => operationType.operation === operation,
    );
  return operationTypeNode ? operationTypeNode.type : type && type.astNode;
}

function validateDirectives(context: SchemaValidationContext): void {
  const directives = context.schema.getDirectives();
  directives.forEach(directive => {
    // Ensure all directives are in fact GraphQL directives.
    if (!isDirective(directive)) {
      context.reportError(
        `Expected directive but got: ${String(directive)}.`,
        directive && directive.astNode,
      );
      return;
    }

    // Ensure they are named correctly.
    validateName(context, directive);

    // TODO: Ensure proper locations.

    // Ensure the arguments are valid.
    const argNames = Object.create(null);
    directive.args.forEach(arg => {
      const argName = arg.name;

      // Ensure they are named correctly.
      validateName(context, arg);

      // Ensure they are unique per directive.
      if (argNames[argName]) {
        context.reportError(
          `Argument @${directive.name}(${argName}:) can only be defined once.`,
          getAllDirectiveArgNodes(directive, argName),
        );
        return; // continue loop
      }
      argNames[argName] = true;

      // Ensure the type is an input type.
      if (!isInputType(arg.type)) {
        context.reportError(
          `The type of @${directive.name}(${argName}:) must be Input Type ` +
            `but got: ${String(arg.type)}.`,
          getDirectiveArgTypeNode(directive, argName),
        );
      }
    });
  });
}

function validateName(
  context: SchemaValidationContext,
  node: { +name: string, +astNode: ?ASTNode },
): void {
  // If a schema explicitly allows some legacy name which is no longer valid,
  // allow it to be assumed valid.
  if (
    context.schema.__allowedLegacyNames &&
    context.schema.__allowedLegacyNames.indexOf(node.name) !== -1
  ) {
    return;
  }
  // Ensure names are valid, however introspection types opt out.
  const error = isValidNameError(node.name, node.astNode || undefined);
  if (error) {
    context.addError(error);
  }
}

function validateTypes(context: SchemaValidationContext): void {
  const typeMap = context.schema.getTypeMap();
  objectValues(typeMap).forEach(type => {
    // Ensure all provided types are in fact GraphQL type.
    if (!isNamedType(type)) {
      context.reportError(
        `Expected GraphQL named type but got: ${String(type)}.`,
        type && type.astNode,
      );
      return;
    }

    // Ensure it is named correctly (excluding introspection types).
    if (!isIntrospectionType(type)) {
      validateName(context, type);
    }

    if (isObjectType(type)) {
      // Ensure fields are valid
      validateFields(context, type);

      // Ensure objects implement the interfaces they claim to.
      validateObjectInterfaces(context, type);
    } else if (isInterfaceType(type)) {
      // Ensure fields are valid.
      validateFields(context, type);
    } else if (isUnionType(type)) {
      // Ensure Unions include valid member types.
      validateUnionMembers(context, type);
    } else if (isEnumType(type)) {
      // Ensure Enums have valid values.
      validateEnumValues(context, type);
    } else if (isInputObjectType(type)) {
      // Ensure Input Object fields are valid.
      validateInputFields(context, type);
    }
  });
}

function validateFields(
  context: SchemaValidationContext,
  type: GraphQLObjectType | GraphQLInterfaceType,
): void {
  const fields = objectValues(type.getFields());

  // Objects and Interfaces both must define one or more fields.
  if (fields.length === 0) {
    context.reportError(
      `Type ${type.name} must define one or more fields.`,
      getAllObjectOrInterfaceNodes(type),
    );
  }

  fields.forEach(field => {
    // Ensure they are named correctly.
    validateName(context, field);

    // Ensure they were defined at most once.
    const fieldNodes = getAllFieldNodes(type, field.name);
    if (fieldNodes.length > 1) {
      context.reportError(
        `Field ${type.name}.${field.name} can only be defined once.`,
        fieldNodes,
      );
      return; // continue loop
    }

    // Ensure the type is an output type
    if (!isOutputType(field.type)) {
      context.reportError(
        `The type of ${type.name}.${field.name} must be Output Type ` +
          `but got: ${String(field.type)}.`,
        getFieldTypeNode(type, field.name),
      );
    }

    // Ensure the arguments are valid
    const argNames = Object.create(null);
    field.args.forEach(arg => {
      const argName = arg.name;

      // Ensure they are named correctly.
      validateName(context, arg);

      // Ensure they are unique per field.
      if (argNames[argName]) {
        context.reportError(
          `Field argument ${type.name}.${field.name}(${argName}:) can only ` +
            'be defined once.',
          getAllFieldArgNodes(type, field.name, argName),
        );
      }
      argNames[argName] = true;

      // Ensure the type is an input type
      if (!isInputType(arg.type)) {
        context.reportError(
          `The type of ${type.name}.${field.name}(${argName}:) must be Input ` +
            `Type but got: ${String(arg.type)}.`,
          getFieldArgTypeNode(type, field.name, argName),
        );
      }
    });
  });
}

function validateObjectInterfaces(
  context: SchemaValidationContext,
  object: GraphQLObjectType,
): void {
  const implementedTypeNames = Object.create(null);
  object.getInterfaces().forEach(iface => {
    if (!isInterfaceType(iface)) {
      context.reportError(
        `Type ${String(object)} must only implement Interface types, ` +
          `it cannot implement ${String(iface)}.`,
        getImplementsInterfaceNode(object, iface),
      );
      return;
    }

    if (implementedTypeNames[iface.name]) {
      context.reportError(
        `Type ${object.name} can only implement ${iface.name} once.`,
        getAllImplementsInterfaceNodes(object, iface),
      );
      return; // continue loop
    }
    implementedTypeNames[iface.name] = true;
    validateObjectImplementsInterface(context, object, iface);
  });
}

function validateObjectImplementsInterface(
  context: SchemaValidationContext,
  object: GraphQLObjectType,
  iface: GraphQLInterfaceType,
): void {
  const objectFieldMap = object.getFields();
  const ifaceFieldMap = iface.getFields();

  // Assert each interface field is implemented.
  Object.keys(ifaceFieldMap).forEach(fieldName => {
    const objectField = objectFieldMap[fieldName];
    const ifaceField = ifaceFieldMap[fieldName];

    // Assert interface field exists on object.
    if (!objectField) {
      context.reportError(
        `Interface field ${iface.name}.${fieldName} expected but ` +
          `${object.name} does not provide it.`,
        [getFieldNode(iface, fieldName), object.astNode],
      );
      // Continue loop over fields.
      return;
    }

    // Assert interface field type is satisfied by object field type, by being
    // a valid subtype. (covariant)
    if (!isTypeSubTypeOf(context.schema, objectField.type, ifaceField.type)) {
      context.reportError(
        `Interface field ${iface.name}.${fieldName} expects type ` +
          `${String(ifaceField.type)} but ${object.name}.${fieldName} ` +
          `is type ${String(objectField.type)}.`,
        [
          getFieldTypeNode(iface, fieldName),
          getFieldTypeNode(object, fieldName),
        ],
      );
    }

    // Assert each interface field arg is implemented.
    ifaceField.args.forEach(ifaceArg => {
      const argName = ifaceArg.name;
      const objectArg = find(objectField.args, arg => arg.name === argName);

      // Assert interface field arg exists on object field.
      if (!objectArg) {
        context.reportError(
          `Interface field argument ${iface.name}.${fieldName}(${argName}:) ` +
            `expected but ${object.name}.${fieldName} does not provide it.`,
          [
            getFieldArgNode(iface, fieldName, argName),
            getFieldNode(object, fieldName),
          ],
        );
        // Continue loop over arguments.
        return;
      }

      // Assert interface field arg type matches object field arg type.
      // (invariant)
      // TODO: change to contravariant?
      if (!isEqualType(ifaceArg.type, objectArg.type)) {
        context.reportError(
          `Interface field argument ${iface.name}.${fieldName}(${argName}:) ` +
            `expects type ${String(ifaceArg.type)} but ` +
            `${object.name}.${fieldName}(${argName}:) is type ` +
            `${String(objectArg.type)}.`,
          [
            getFieldArgTypeNode(iface, fieldName, argName),
            getFieldArgTypeNode(object, fieldName, argName),
          ],
        );
      }

      // TODO: validate default values?
    });

    // Assert additional arguments must not be required.
    objectField.args.forEach(objectArg => {
      const argName = objectArg.name;
      const ifaceArg = find(ifaceField.args, arg => arg.name === argName);
      if (!ifaceArg && isNonNullType(objectArg.type)) {
        context.reportError(
          `Object field argument ${object.name}.${fieldName}(${argName}:) ` +
            `is of required type ${String(objectArg.type)} but is not also ` +
            `provided by the Interface field ${iface.name}.${fieldName}.`,
          [
            getFieldArgTypeNode(object, fieldName, argName),
            getFieldNode(iface, fieldName),
          ],
        );
      }
    });
  });
}

function validateUnionMembers(
  context: SchemaValidationContext,
  union: GraphQLUnionType,
): void {
  const memberTypes = union.getTypes();

  if (memberTypes.length === 0) {
    context.reportError(
      `Union type ${union.name} must define one or more member types.`,
      union.astNode,
    );
  }

  const includedTypeNames = Object.create(null);
  memberTypes.forEach(memberType => {
    if (includedTypeNames[memberType.name]) {
      context.reportError(
        `Union type ${union.name} can only include type ` +
          `${memberType.name} once.`,
        getUnionMemberTypeNodes(union, memberType.name),
      );
      return; // continue loop
    }
    includedTypeNames[memberType.name] = true;
    if (!isObjectType(memberType)) {
      context.reportError(
        `Union type ${union.name} can only include Object types, ` +
          `it cannot include ${String(memberType)}.`,
        getUnionMemberTypeNodes(union, String(memberType)),
      );
    }
  });
}

function validateEnumValues(
  context: SchemaValidationContext,
  enumType: GraphQLEnumType,
): void {
  const enumValues = enumType.getValues();

  if (enumValues.length === 0) {
    context.reportError(
      `Enum type ${enumType.name} must define one or more values.`,
      enumType.astNode,
    );
  }

  enumValues.forEach(enumValue => {
    const valueName = enumValue.name;

    // Ensure no duplicates.
    const allNodes = getEnumValueNodes(enumType, valueName);
    if (allNodes && allNodes.length > 1) {
      context.reportError(
        `Enum type ${enumType.name} can include value ${valueName} only once.`,
        allNodes,
      );
    }

    // Ensure valid name.
    validateName(context, enumValue);
    if (valueName === 'true' || valueName === 'false' || valueName === 'null') {
      context.reportError(
        `Enum type ${enumType.name} cannot include value: ${valueName}.`,
        enumValue.astNode,
      );
    }
  });
}

function validateInputFields(
  context: SchemaValidationContext,
  inputObj: GraphQLInputObjectType,
): void {
  const fields = objectValues(inputObj.getFields());

  if (fields.length === 0) {
    context.reportError(
      `Input Object type ${inputObj.name} must define one or more fields.`,
      inputObj.astNode,
    );
  }

  // Ensure the arguments are valid
  fields.forEach(field => {
    // Ensure they are named correctly.
    validateName(context, field);

    // TODO: Ensure they are unique per field.

    // Ensure the type is an input type
    if (!isInputType(field.type)) {
      context.reportError(
        `The type of ${inputObj.name}.${field.name} must be Input Type ` +
          `but got: ${String(field.type)}.`,
        field.astNode && field.astNode.type,
      );
    }
  });
}

function getAllObjectNodes(
  type: GraphQLObjectType,
): $ReadOnlyArray<ObjectTypeDefinitionNode | ObjectTypeExtensionNode> {
  return type.astNode
    ? type.extensionASTNodes
      ? [type.astNode].concat(type.extensionASTNodes)
      : [type.astNode]
    : type.extensionASTNodes || [];
}

function getAllObjectOrInterfaceNodes(
  type: GraphQLObjectType | GraphQLInterfaceType,
): $ReadOnlyArray<
  | ObjectTypeDefinitionNode
  | ObjectTypeExtensionNode
  | InterfaceTypeDefinitionNode
  | InterfaceTypeExtensionNode,
> {
  return type.astNode
    ? type.extensionASTNodes
      ? [type.astNode].concat(type.extensionASTNodes)
      : [type.astNode]
    : type.extensionASTNodes || [];
}

function getImplementsInterfaceNode(
  type: GraphQLObjectType,
  iface: GraphQLInterfaceType,
): ?NamedTypeNode {
  return getAllImplementsInterfaceNodes(type, iface)[0];
}

function getAllImplementsInterfaceNodes(
  type: GraphQLObjectType,
  iface: GraphQLInterfaceType,
): $ReadOnlyArray<NamedTypeNode> {
  const implementsNodes = [];
  const astNodes = getAllObjectNodes(type);
  for (let i = 0; i < astNodes.length; i++) {
    const astNode = astNodes[i];
    if (astNode && astNode.interfaces) {
      astNode.interfaces.forEach(node => {
        if (node.name.value === iface.name) {
          implementsNodes.push(node);
        }
      });
    }
  }
  return implementsNodes;
}

function getFieldNode(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
): ?FieldDefinitionNode {
  return getAllFieldNodes(type, fieldName)[0];
}

function getAllFieldNodes(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
): $ReadOnlyArray<FieldDefinitionNode> {
  const fieldNodes = [];
  const astNodes = getAllObjectOrInterfaceNodes(type);
  for (let i = 0; i < astNodes.length; i++) {
    const astNode = astNodes[i];
    if (astNode && astNode.fields) {
      astNode.fields.forEach(node => {
        if (node.name.value === fieldName) {
          fieldNodes.push(node);
        }
      });
    }
  }
  return fieldNodes;
}

function getFieldTypeNode(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
): ?TypeNode {
  const fieldNode = getFieldNode(type, fieldName);
  return fieldNode && fieldNode.type;
}

function getFieldArgNode(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
  argName: string,
): ?InputValueDefinitionNode {
  return getAllFieldArgNodes(type, fieldName, argName)[0];
}

function getAllFieldArgNodes(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
  argName: string,
): $ReadOnlyArray<InputValueDefinitionNode> {
  const argNodes = [];
  const fieldNode = getFieldNode(type, fieldName);
  if (fieldNode && fieldNode.arguments) {
    fieldNode.arguments.forEach(node => {
      if (node.name.value === argName) {
        argNodes.push(node);
      }
    });
  }
  return argNodes;
}

function getFieldArgTypeNode(
  type: GraphQLObjectType | GraphQLInterfaceType,
  fieldName: string,
  argName: string,
): ?TypeNode {
  const fieldArgNode = getFieldArgNode(type, fieldName, argName);
  return fieldArgNode && fieldArgNode.type;
}

function getAllDirectiveArgNodes(
  directive: GraphQLDirective,
  argName: string,
): $ReadOnlyArray<InputValueDefinitionNode> {
  const argNodes = [];
  const directiveNode = directive.astNode;
  if (directiveNode && directiveNode.arguments) {
    directiveNode.arguments.forEach(node => {
      if (node.name.value === argName) {
        argNodes.push(node);
      }
    });
  }
  return argNodes;
}

function getDirectiveArgTypeNode(
  directive: GraphQLDirective,
  argName: string,
): ?TypeNode {
  const argNode = getAllDirectiveArgNodes(directive, argName)[0];
  return argNode && argNode.type;
}

function getUnionMemberTypeNodes(
  union: GraphQLUnionType,
  typeName: string,
): ?$ReadOnlyArray<NamedTypeNode> {
  return (
    union.astNode &&
    union.astNode.types &&
    union.astNode.types.filter(type => type.name.value === typeName)
  );
}

function getEnumValueNodes(
  enumType: GraphQLEnumType,
  valueName: string,
): ?$ReadOnlyArray<EnumValueDefinitionNode> {
  return (
    enumType.astNode &&
    enumType.astNode.values &&
    enumType.astNode.values.filter(value => value.name.value === valueName)
  );
}