/**
 * Forked from graphql-js printSchema.ts file @ v16.0.0
 * This file has been modified to support printing federated
 * schema, including associated federation directives.
 */
import {
  GraphQLSchema,
  isSpecifiedDirective,
  isIntrospectionType,
  isSpecifiedScalarType,
  GraphQLNamedType,
  GraphQLDirective,
  isScalarType,
  isObjectType,
  isInterfaceType,
  isUnionType,
  isEnumType,
  isInputObjectType,
  GraphQLScalarType,
  GraphQLObjectType,
  GraphQLInterfaceType,
  GraphQLUnionType,
  GraphQLEnumType,
  GraphQLInputObjectType,
  GraphQLArgument,
  GraphQLInputField,
  astFromValue,
  print,
  GraphQLField,
  GraphQLEnumValue,
  DEFAULT_DEPRECATION_REASON,
  Kind,
} from 'graphql';
import type {
  FederationType,
  FederationField,
  FieldSet,
} from '../composition/types';
import { Maybe } from '../composition';
import { assert } from '../utilities';
import { printFieldSet } from '../composition/utils';

interface PrintingContext {
  // Apollo addition: we need access to a map from serviceName to its corresponding
  // sanitized / uniquified enum value `Name` from the `join__Graph` enum
  graphNameToEnumValueName?: Record<string, string>;
}

// Apollo change: we need service and url information for the join__Graph enum
export function printSupergraphSdl(
  schema: GraphQLSchema,
  graphNameToEnumValueName: Record<string, string>,
): string {
  const context: PrintingContext = {
    graphNameToEnumValueName,
  }

  return printFilteredSchema(
    schema,
    (n) => !isSpecifiedDirective(n),
    isDefinedType,
    context,
  );
}

export function printIntrospectionSchema(schema: GraphQLSchema): string {
  return printFilteredSchema(
    schema,
    isSpecifiedDirective,
    isIntrospectionType,
    // Apollo change: no printing context needed for introspection
    {},
  );
}

function isDefinedType(type: GraphQLNamedType): boolean {
  return !isSpecifiedScalarType(type) && !isIntrospectionType(type);
}

function printFilteredSchema(
  schema: GraphQLSchema,
  directiveFilter: (type: GraphQLDirective) => boolean,
  typeFilter: (type: GraphQLNamedType) => boolean,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
): string {
  const directives = schema.getDirectives().filter(directiveFilter);
  const types = Object.values(schema.getTypeMap()).filter(typeFilter);

  return (
    [
      printSchemaDefinition(schema),
      ...directives.map((directive) => printDirective(directive)),
      ...types.map((type) => printType(type, context)),
    ]
      .filter(Boolean)
      .join('\n\n') + '\n'
  );
}

function printSchemaDefinition(schema: GraphQLSchema): string {
  // Apollo removal: we always print the schema definition
  // if (schema.description == null && isSchemaOfCommonNames(schema)) {
  //   return;
  // }
  const operationTypes = [];

  const queryType = schema.getQueryType();
  if (queryType) {
    operationTypes.push(`  query: ${queryType.name}`);
  }

  const mutationType = schema.getMutationType();
  if (mutationType) {
    operationTypes.push(`  mutation: ${mutationType.name}`);
  }

  const subscriptionType = schema.getSubscriptionType();
  if (subscriptionType) {
    operationTypes.push(`  subscription: ${subscriptionType.name}`);
  }

  return (
    printDescription(schema) +
    'schema' +
    // Apollo change: print @core directive usages on schema node
    printCoreDirectives(schema) +
    `\n{\n${operationTypes.join('\n')}\n}`
  );
}

type CoreDirectiveMap = Record<
  string,
  { feature: string; purpose?: 'EXECUTION' }
>;

const alwaysIncludedCoreDirectives: CoreDirectiveMap = {
  core: { feature: 'https://specs.apollo.dev/core/v0.2' },
  join: { feature: 'https://specs.apollo.dev/join/v0.1', purpose: 'EXECUTION' },
};

const supportedCoreDirectives: CoreDirectiveMap = {
  tag: { feature: 'https://specs.apollo.dev/tag/v0.1' },
};

function printCoreDirectives(schema: GraphQLSchema) {
  const supportedCoreDirectiveNames = Object.keys(supportedCoreDirectives);
  const schemaDirectiveNames = schema.getDirectives().map(({ name }) => name);
  const supportedCoreDirectiveNamesToInclude = schemaDirectiveNames.filter((name) =>
    supportedCoreDirectiveNames.includes(name),
  );
  const supportedCoreDirectivesToInclude =
    supportedCoreDirectiveNamesToInclude.map(
      (name) => supportedCoreDirectives[name],
    );

  return [
    ...Object.values(alwaysIncludedCoreDirectives),
    ...supportedCoreDirectivesToInclude,
  ].map(
    ({ feature, purpose }) =>
      `\n  @core(feature: ${printStringLiteral(feature)}${
        purpose ? `, for: ${purpose}` : ''
      })`,
  );
}

export function printType(
  type: GraphQLNamedType,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
): string {
  if (isScalarType(type)) {
    return printScalar(type);
  }
  if (isObjectType(type)) {
    return printObject(type, context);
  }
  if (isInterfaceType(type)) {
    return printInterface(type, context);
  }
  if (isUnionType(type)) {
    return printUnion(type);
  }
  if (isEnumType(type)) {
    return printEnum(type);
  }
  if (isInputObjectType(type)) {
    return printInputObject(type);
  }

  // graphql-js uses an internal fn `inspect` but this is a `never` case anyhow
  throw Error('Unexpected type: ' + (type as GraphQLNamedType).toString());
}

function printScalar(type: GraphQLScalarType): string {
  return (
    printDescription(type) + `scalar ${type.name}` + printSpecifiedByURL(type)
  );
}

function printImplementedInterfaces(
  type: GraphQLObjectType | GraphQLInterfaceType,
): string {
  const interfaces = type.getInterfaces();
  return interfaces.length
    ? ' implements ' + interfaces.map((i) => i.name).join(' & ')
    : '';
}

function printObject(
  type: GraphQLObjectType,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
): string {
  return (
    printDescription(type) +
    `type ${type.name}` +
    printImplementedInterfaces(type) +
    // Apollo addition for printing @join__owner and @join__type usages
    printTypeJoinDirectives(type, context) +
    printKnownDirectiveUsagesOnType(type) +
    printFields(type, context)
  );
}

// Apollo addition: print @tag usages (+ other future Apollo-specific directives)
function printKnownDirectiveUsagesOnType(
  type: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType,
): string {
  const tagUsages =
    (type.extensions?.federation as FederationType)?.directiveUsages?.get(
      'tag',
    ) ?? [];
  if (tagUsages.length === 0) return '';

  return '\n  ' + tagUsages.map(print).join('\n  ');
}

// Apollo addition: print @join__owner and @join__type usages
function printTypeJoinDirectives(
  type: GraphQLObjectType | GraphQLInterfaceType,
  context: PrintingContext,
): string {
  const metadata: FederationType = type.extensions?.federation as FederationType;
  if (!metadata) return '';

  const { serviceName: ownerService, keys } = metadata;
  if (!ownerService || !keys) return '';

  // Separate owner @keys from the rest of the @keys so we can print them
  // adjacent to the @owner directive.
  const { [ownerService]: ownerKeys = [], ...restKeys } = keys;
  const ownerEntry: [string, FieldSet[]] = [
    ownerService,
    ownerKeys,
  ];
  const restEntries = Object.entries(restKeys);

  // We don't want to print an owner for interface types
  const shouldPrintOwner = isObjectType(type);

  const ownerGraphEnumValue = context.graphNameToEnumValueName?.[ownerService];
  assert(
    ownerGraphEnumValue,
    `Unexpected enum value missing for subgraph ${ownerService}`,
  );
  const joinOwnerString = shouldPrintOwner
    ? `\n  @join__owner(graph: ${ownerGraphEnumValue})`
    : '';

  return (
    joinOwnerString +
    [ownerEntry, ...restEntries]
      .map(([service, keys = []]) =>
        keys
          .map((selections) => {
            const typeGraphEnumValue =
              context.graphNameToEnumValueName?.[service];
            assert(
              typeGraphEnumValue,
              `Unexpected enum value missing for subgraph ${service}`,
            );
            return `\n  @join__type(graph: ${typeGraphEnumValue}, key: ${printStringLiteral(
              printFieldSet(selections),
            )})`;
          })
          .join(''),
      )
      .join('')
  );
}

function printInterface(
  type: GraphQLInterfaceType,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
): string {
  return (
    printDescription(type) +
    `interface ${type.name}` +
    printImplementedInterfaces(type) +
    // Apollo addition for printing @join__owner and @join__type usages
    printTypeJoinDirectives(type, context) +
    printKnownDirectiveUsagesOnType(type) +
    printFields(type, context)
  );
}

function printUnion(type: GraphQLUnionType): string {
  const types = type.getTypes();
  // Apollo addition: print @tag usages
  const knownDirectiveUsages = printKnownDirectiveUsagesOnType(type);
  const possibleTypes = types.length
    ? `${knownDirectiveUsages.length ? '\n' : ' '}= ` + types.join(' | ')
    : '';
  return (
    printDescription(type) +
    'union ' +
    type.name +
    // Apollo addition: print @tag usages
    knownDirectiveUsages +
    possibleTypes
  );
}

function printEnum(type: GraphQLEnumType): string {
  const values = type
    .getValues()
    .map(
      (value, i) =>
        printDescription(value, '  ', !i) +
        '  ' +
        value.name +
        printDeprecated(value.deprecationReason) +
        // Apollo addition: print federation directives on `join__Graph` enum values
        printDirectivesOnEnumValue(type, value),
    );

  return (
    printDescription(type) + `enum ${type.name}` + printBlock(values)
  );
}

// Apollo addition: print federation directives on `join__Graph` enum values
function printDirectivesOnEnumValue(type: GraphQLEnumType, value: GraphQLEnumValue) {
  if (type.name === "join__Graph") {
    return ` @join__graph(name: ${printStringLiteral((value.value.name))} url: ${printStringLiteral(value.value.url ?? '')})`
  }
  return '';
}

function printInputObject(type: GraphQLInputObjectType): string {
  const fields = Object.values(type.getFields()).map(
    (f, i) => printDescription(f, '  ', !i) + '  ' + printInputValue(f),
  );
  return printDescription(type) + `input ${type.name}` + printBlock(fields);
}

function printFields(
  type: GraphQLObjectType | GraphQLInterfaceType,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
) {
  const fields = Object.values(type.getFields()).map(
    (f, i) =>
      printDescription(f, '  ', !i) +
      '  ' +
      f.name +
      printArgs(f.args, '  ') +
      ': ' +
      String(f.type) +
      printDeprecated(f.deprecationReason) +
      // Apollo addition: print directives on fields
      // We don't want to print field owner directives on fields belonging to an interface type
      (isObjectType(type)
        ? printJoinFieldDirectives(f, type, context) +
          printKnownDirectiveUsagesOnFields(f)
        : ''),
  );

  // Apollo addition: for entities, we want to print the block on a new line.
  // This is just a formatting nice-to-have.
  const isEntity = Boolean((type.extensions?.federation as any)?.keys);
  const hasTags = Boolean((
    type.extensions?.federation as any)?.directiveUsages?.get('tag')?.length,
  );

  return printBlock(fields, isEntity || hasTags);
}

/**
 * Apollo addition: print @join__field directives
 *
 * @param field
 * @param parentType
 */
function printJoinFieldDirectives(
  field: GraphQLField<any, any>,
  parentType: GraphQLObjectType | GraphQLInterfaceType,
  // Apollo addition - see `PrintingContext` type for details
  context: PrintingContext,
): string {
  const directiveArgs: string[] = [];

  const fieldMetadata: FederationField | undefined =
    field.extensions?.federation as FederationField;

  let serviceName = fieldMetadata?.serviceName;

  // For entities (which we detect through the existence of `keys`),
  // although the join spec doesn't currently require `@join__field(graph:)` when
  // a field can be resolved from the owning service, the code we used
  // previously did include it in those cases. And especially since we want to
  // remove type ownership, I think it makes to keep the same behavior.
  if (!serviceName && (parentType.extensions?.federation as any).keys) {
    serviceName = (parentType.extensions?.federation as any).serviceName;
  }

  if (serviceName) {
    const enumValue = context.graphNameToEnumValueName?.[serviceName];
    assert(
      enumValue,
      `Unexpected enum value missing for subgraph ${serviceName}`,
    );

    directiveArgs.push(`graph: ${enumValue}`);
  }

  const requires = fieldMetadata?.requires;

  if (requires && requires.length > 0) {
    directiveArgs.push(
      `requires: ${printStringLiteral(printFieldSet(requires))}`,
    );
  }

  const provides = fieldMetadata?.provides;

  if (provides && provides.length > 0) {
    directiveArgs.push(
      `provides: ${printStringLiteral(printFieldSet(provides))}`,
    );
  }

  // A directive without arguments isn't valid (nor useful).
  if (directiveArgs.length < 1) return '';

  return ` @join__field(${directiveArgs.join(', ')})`;
}

// Apollo addition: print `@tag` directives (and possibly other future known
// directives) found in subgraph SDL into the supergraph SDL
function printKnownDirectiveUsagesOnFields(field: GraphQLField<any, any>) {
  const tagUsages = (
    field.extensions?.federation as FederationField
  )?.directiveUsages?.get('tag');
  if (!tagUsages || tagUsages.length < 1) return '';
  return ` ${tagUsages
    .slice()
    .sort((a, b) => a.name.value.localeCompare(b.name.value))
    .map(print)
    .join(' ')}`;
};

// Apollo addition: `onNewLine` is a formatting nice-to-have for printing
// types that have a list of directives attached, i.e. an entity.
function printBlock(items: string[], onNewLine?: boolean) {
  return items.length !== 0
    ? onNewLine
      ? '\n{\n' + items.join('\n') + '\n}'
      : ' {\n' + items.join('\n') + '\n}'
    : '';
}

function printArgs(args: readonly GraphQLArgument[], indentation = '') {
  if (args.length === 0) {
    return '';
  }

  // If every arg does not have a description, print them on one line.
  if (args.every((arg) => !arg.description)) {
    return '(' + args.map(printInputValue).join(', ') + ')';
  }

  return (
    '(\n' +
    args
      .map(
        (arg, i) =>
          printDescription(arg, '  ' + indentation, !i) +
          '  ' +
          indentation +
          printInputValue(arg),
      )
      .join('\n') +
    '\n' +
    indentation +
    ')'
  );
}

function printInputValue(arg: GraphQLInputField) {
  const defaultAST = astFromValue(arg.defaultValue, arg.type);
  let argDecl = arg.name + ': ' + String(arg.type);
  if (defaultAST) {
    argDecl += ` = ${print(defaultAST)}`;
  }
  return argDecl + printDeprecated(arg.deprecationReason);
}

function printDirective(directive: GraphQLDirective) {
  return (
    printDescription(directive) +
    'directive @' +
    directive.name +
    printArgs(directive.args) +
    (directive.isRepeatable ? ' repeatable' : '') +
    ' on ' +
    directive.locations.join(' | ')
  );
}

function printDeprecated(reason: Maybe<string>): string {
  if (reason == null) {
    return '';
  }
  if (reason !== DEFAULT_DEPRECATION_REASON) {
    const astValue = print({ kind: Kind.STRING, value: reason });
    return ` @deprecated(reason: ${astValue})`;
  }
  return ' @deprecated';
}

// Apollo addition: support both specifiedByURL and specifiedByURL - these
// happen across v15 and v16.
function printSpecifiedByURL(scalar: GraphQLScalarType): string {
  // @ts-ignore (accomodate breaking change across 15.x -> 16.x)
  const specifiedByURL = scalar.specifiedByUrl ?? scalar.specifiedByURL;
  if (specifiedByURL == null) {
    return '';
  }
  const astValue = print({
    kind: Kind.STRING,
    value: specifiedByURL,
  });
  return ` @specifiedBy(url: ${astValue})`;
}

function printDescription(
  def: { description?: Maybe<string> },
  indentation: string = '',
  firstInBlock: boolean = true,
): string {
  const { description } = def;
  if (description == null) {
    return '';
  }

  const preferMultipleLines = description.length > 70;
  const blockString = printBlockString(description, preferMultipleLines);
  const prefix =
    indentation && !firstInBlock ? '\n' + indentation : indentation;

  return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n';
}

// Apollo addition
// Using JSON.stringify ensures that we will generate a valid string literal,
// escaping quote marks, backslashes, etc. when needed.
// The `graphql-js` printer also does this when printing out a `StringValue`:
// https://github.com/graphql/graphql-js/blob/d4bcde8d3e7a7cb8462044ff21122a3996af8655/src/language/printer.js#L109-L112
function printStringLiteral(value: string) {
  return JSON.stringify(value);
}

/**
 * Print a block string in the indented block form by adding a leading and
 * trailing blank line. However, if a block string starts with whitespace and is
 * a single-line, adding a leading blank line would strip that whitespace.
 */
 export function printBlockString(
   value: string,
   preferMultipleLines: boolean = false,
 ): string {
   const isSingleLine = !value.includes('\n');
   const hasLeadingSpace = value[0] === ' ' || value[0] === '\t';
   const hasTrailingQuote = value[value.length - 1] === '"';
   const hasTrailingSlash = value[value.length - 1] === '\\';
   const printAsMultipleLines =
     !isSingleLine ||
     hasTrailingQuote ||
     hasTrailingSlash ||
     preferMultipleLines;

   let result = '';
   // Format a multi-line block quote to account for leading space.
   if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) {
     result += '\n';
   }
   result += value;
   if (printAsMultipleLines) {
     result += '\n';
   }

   return '"""' + result.replace(/"""/g, '\\"""') + '"""';
 }
