/** @category Validation Rules */

import { GraphQLError } from '../../error/GraphQLError.ts';

import type {
  DirectiveNode,
  FragmentDefinitionNode,
  FragmentSpreadNode,
  SelectionSetNode,
} from '../../language/ast.ts';
import { OperationTypeNode } from '../../language/ast.ts';
import { Kind } from '../../language/kinds.ts';
import type { ASTVisitor } from '../../language/visitor.ts';

import {
  GraphQLDeferDirective,
  GraphQLIncludeDirective,
  GraphQLSkipDirective,
  GraphQLStreamDirective,
} from '../../type/directives.ts';

import type { ValidationContext } from '../ValidationContext.ts';

function ifArgumentCanBeFalse(node: DirectiveNode): boolean {
  // @defer(if: false) / @stream(if: false)
  // @defer(if: $shouldDefer) / @stream(if: $shouldStream)
  const ifArgument = node.arguments?.find((arg) => arg.name.value === 'if');
  if (!ifArgument) {
    return false;
  }
  if (ifArgument.value.kind === Kind.BOOLEAN) {
    if (ifArgument.value.value) {
      return false;
    }
  } else if (ifArgument.value.kind !== Kind.VARIABLE) {
    return false;
  }
  return true;
}

function canBeSkippedViaSkipDirective(node: DirectiveNode): boolean {
  // @skip(if: true)
  // @skip(if: $shouldSkip)
  const ifArgument = node.arguments?.find((arg) => arg.name.value === 'if');
  if (!ifArgument) {
    // Missing `if` is reported by ProvidedRequiredArgumentsRule. For this rule,
    // treat malformed @skip as potentially skipped to avoid duplicate errors.
    return true;
  }

  if (ifArgument.value.kind === Kind.BOOLEAN) {
    // If argument is a Static boolean
    if (ifArgument.value.value) {
      // always skipped
      return true;
    }
    // Never skipped
    return false;
  }

  // Can be skipped via variable
  return true;
}

function canBeSkippedViaIncludeDirective(node: DirectiveNode): boolean {
  // @include(if: false)
  // @include(if: $shouldInclude)
  const ifArgument = node.arguments?.find((arg) => arg.name.value === 'if');
  if (!ifArgument) {
    // Missing `if` is reported by ProvidedRequiredArgumentsRule. For this rule,
    // treat malformed @include as not skippable.
    return false;
  }
  if (ifArgument?.value.kind === Kind.BOOLEAN) {
    // If argument is a Static boolean
    if (ifArgument.value.value) {
      // Never skipped
      return false;
    }
    // Always skipped
    return true;
  }

  // Can be skipped via variable
  return true;
}

/**
 * Defer And Stream Directives Are Used On Valid Operations
 *
 * A GraphQL document is only valid if defer and stream directives are not used on root mutation or subscription types.
 * @param context - The validation context used while checking the document.
 * @returns A visitor that reports validation errors for this rule.
 * @example
 * ```ts
 * import { parse } from 'graphql/language';
 * import { buildSchema } from 'graphql/utilities';
 * import {
 *   validate,
 *   DeferStreamDirectiveOnValidOperationsRule,
 * } from 'graphql/validation';
 *
 * const schema = buildSchema(`
 *   type Query {
 *     message: Message
 *   }
 *
 *   type Subscription {
 *     message: Message
 *   }
 *
 *   type Message {
 *     body: String
 *   }
 * `);
 * const invalidDocument = parse(`
 *   subscription {
 *     message {
 *       ...MessageBody @defer
 *     }
 *   }
 *
 *   fragment MessageBody on Message {
 *     body
 *   }
 * `);
 * const validDocument = parse(`
 *   subscription {
 *     message {
 *       ...MessageBody @defer(if: false)
 *     }
 *   }
 *
 *   fragment MessageBody on Message {
 *     body
 *   }
 * `);
 *
 * validate(schema, invalidDocument, [DeferStreamDirectiveOnValidOperationsRule])
 *   .length; // => 1
 * validate(schema, validDocument, [DeferStreamDirectiveOnValidOperationsRule]); // => []
 * ```
 */
export function DeferStreamDirectiveOnValidOperationsRule(
  context: ValidationContext,
): ASTVisitor {
  return {
    OperationDefinition(operation) {
      if (operation.operation !== OperationTypeNode.SUBSCRIPTION) {
        return;
      }
      const document = context.getDocument();
      const fragments = new Map<string, FragmentDefinitionNode>();

      for (const definition of document.definitions) {
        if (definition.kind === Kind.FRAGMENT_DEFINITION) {
          fragments.set(definition.name.value, definition);
        }
      }
      const visitedFragments = new Set<string>();
      forbidUnconditionalDeferStream({
        context,
        fragments,
        selectionSet: operation.selectionSet,
        parentNodes: [],
        visitedFragments,
      });
    },
  };
}

function forbidUnconditionalDeferStream({
  context,
  fragments,
  selectionSet,
  parentNodes,
  visitedFragments,
}: {
  context: ValidationContext;
  fragments: Map<string, FragmentDefinitionNode>;
  selectionSet: SelectionSetNode;
  parentNodes: Array<FragmentSpreadNode>;
  visitedFragments: Set<string>;
}) {
  for (const selection of selectionSet.selections) {
    const skip = selection.directives?.find(
      (d) => d.name.value === GraphQLSkipDirective.name,
    );
    if (skip && canBeSkippedViaSkipDirective(skip)) {
      continue;
    }
    const include = selection.directives?.find(
      (d) => d.name.value === GraphQLIncludeDirective.name,
    );
    if (include && canBeSkippedViaIncludeDirective(include)) {
      continue;
    }
    for (const directive of selection.directives ?? []) {
      if (directive.name.value === GraphQLDeferDirective.name) {
        if (!ifArgumentCanBeFalse(directive)) {
          context.reportError(
            new GraphQLError(
              'Defer directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.',
              { nodes: [directive, ...parentNodes] },
            ),
          );
        }
      } else if (directive.name.value === GraphQLStreamDirective.name) {
        if (!ifArgumentCanBeFalse(directive)) {
          context.reportError(
            new GraphQLError(
              'Stream directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.',
              { nodes: [directive, ...parentNodes] },
            ),
          );
        }
      }
    }
    if (selection.kind === 'FragmentSpread') {
      const fragmentName = selection.name.value;
      if (visitedFragments.has(fragmentName)) {
        continue;
      }
      visitedFragments.add(fragmentName);
      const fragment = fragments.get(fragmentName);
      if (fragment) {
        forbidUnconditionalDeferStream({
          context,
          fragments,
          parentNodes: [selection, ...parentNodes],
          selectionSet: fragment?.selectionSet,
          visitedFragments,
        });
      }
    } else if (selection.selectionSet) {
      forbidUnconditionalDeferStream({
        context,
        fragments,
        selectionSet: selection.selectionSet,
        parentNodes,
        visitedFragments,
      });
    }
  }
}

// - For each {selection} in {selectionSet}:
//   - If {selection} provides the `@skip` directive, and the "if" argument on that
//     directive is not the boolean value {true}:
//     - Continue to the next {selection} in {selectionSet}.
//   - If {selection} provides the `@include` directive, and the "if" argument on
//     that directive is not the boolean value {false}:
//     - Continue to the next {selection} in {selectionSet}.
//   - For each {directive} on {selection}:
//     - If {directive} is `@defer` or `@stream`:
//       - Let {if} be the argument named "if" on {directive}.
//       - {if} must be defined.
//       - Let {argumentValue} be the value passed to {if}.
//       - {argumentValue} must be a variable, or the boolean value "false".
//   - If {selection} is a {FragmentSpread}:
//     - Let {fragmentSpreadName} be the name of {selection}.
//     - If {fragmentSpreadName} is in {visitedFragments}, continue with the next
//       {selection} in {selectionSet}.
//     - Add {fragmentSpreadName} to {visitedFragments}.
//     - Let {fragment} be the Fragment in the current Document whose name is
//       {fragmentSpreadName}.
//     - Let {fragmentSelectionSet} be the selection set of {selection}.
//     - {ForbidUnconditionalDeferStream(fragmentSelectionSet)}
//   - If {selection} is an {InlineFragment}:
//     - Let {fragmentSelectionSet} be the selection set of {selection}.
//     - {ForbidUnconditionalDeferStream(fragmentSelectionSet)}
//   - If {selection} is a {Field}:
//     - Let {fieldSelectionSet} be the selection set of {selection}.
//     - {ForbidUnconditionalDeferStream(fieldSelectionSet)}