import {
  getNamedType,
  GraphQLAbstractType,
  GraphQLArgument,
  GraphQLField,
  GraphQLInputField,
  GraphQLInputType,
  GraphQLInterfaceType,
  GraphQLNamedType,
  GraphQLObjectType,
  GraphQLOutputType,
  GraphQLScalarType,
  GraphQLUnionType,
  isEnumType,
  isInputObjectType,
  isInterfaceType,
  isListType,
  isNonNullType,
  isObjectType,
  isScalarType,
  isSpecifiedDirective,
  isSpecifiedScalarType,
  isUnionType,
} from 'graphql'
import type { TypegenInfo } from './builder'
import { SchemaDirectiveLocation } from './definitions/directive'
import { isNexusPrintedGenTyping, isNexusPrintedGenTypingImport } from './definitions/wrapping'
import type { NexusGraphQLSchema } from './definitions/_types'
import { TYPEGEN_HEADER } from './lang'
import type { StringLike } from './plugin'
import { hasNexusExtension, isNexusFieldExtension } from './extensions'
import {
  eachObj,
  getOwnPackage,
  graphql15InterfaceType,
  GroupedTypes,
  groupTypes,
  mapObj,
  mapValues,
  PrintedGenTypingImport,
  relativePathTo,
  resolveImportPath,
} from './utils'

const SpecifiedScalars = {
  ID: 'string',
  String: 'string',
  Float: 'number',
  Int: 'number',
  Boolean: 'boolean',
}
type SpecifiedScalarNames = keyof typeof SpecifiedScalars

type TypeFieldMapping = Record<string, Record<string, [string, string]>>
type TypeMapping = Record<string, string>
type RootTypeMapping = Record<string, string | Record<string, [string, string]>>

interface TypegenInfoWithFile extends TypegenInfo {
  typegenPath: string
  globalsPath?: string
  globalsHeaders?: string[]
  declareInputs?: boolean
  useReadonlyArrayForInputs?: boolean
}

/**
 * We track and output a few main things:
 *
 * 1. "root" types, or the values that fill the first argument for a given object type 2. "arg" types, the
 * values that are arguments to output fields. 3. "return" types, the values returned from the resolvers...
 * usually just list/nullable variations on the
 *     "root" types for other types 4. The names of all types, grouped by type.
 *
 * - Non-scalar types will get a dedicated "Root" type associated with it
 */
export class TypegenPrinter {
  private groupedTypes: GroupedTypes
  private printImports: Record<string, Record<string, boolean | string>>
  private hasDiscriminatedTypes: boolean

  constructor(protected schema: NexusGraphQLSchema, protected typegenInfo: TypegenInfoWithFile) {
    this.groupedTypes = groupTypes(schema)
    this.printImports = {}
    this.hasDiscriminatedTypes = false
  }

  print() {
    const body = [this.printCommon(), this.printPlugins()].join('\n\n')
    return [this.printHeaders(), body].join('\n\n')
  }

  printConfigured() {
    if (this.typegenInfo.globalsPath) {
      const plugins = this.printPlugins()
      const globalTypes = [this.printHeadersGlobal(), this.printDynamicImport(true), plugins].join('\n\n')

      // Reset printImports for the imports needed in the types
      this.printImports = {}

      const common = this.printCommon()
      const tsTypes = [this.printHeadersCommon(), common].join('\n\n')
      return {
        tsTypes,
        globalTypes,
      }
    }
    return {
      tsTypes: this.print(),
      globalTypes: null,
    }
  }

  private printCommon() {
    return [
      this.printInputTypeMap(),
      this.printEnumTypeMap(),
      this.printScalarTypeMap(),
      this.printObjectTypeMap(),
      this.printInterfaceTypeMap(),
      this.printUnionTypeMap(),
      this.printRootTypeDef(),
      this.printAllTypesMap(),
      this.printFieldTypesMap(),
      this.printFieldTypeNamesMap(),
      this.printArgTypeMap(),
      this.printAbstractTypeMembers(),
      this.printInheritedFieldMap(),
      this.printTypeNames('object', 'NexusGenObjectNames', 'NexusGenObjects'),
      this.printTypeNames('input', 'NexusGenInputNames', 'NexusGenInputs'),
      this.printTypeNames('enum', 'NexusGenEnumNames', 'NexusGenEnums'),
      this.printTypeNames('interface', 'NexusGenInterfaceNames', 'NexusGenInterfaces'),
      this.printTypeNames('scalar', 'NexusGenScalarNames', 'NexusGenScalars'),
      this.printTypeNames('union', 'NexusGenUnionNames', 'NexusGenUnions'),
      this.printDirectives(),
      this.printIsTypeOfObjectTypeNames('NexusGenObjectsUsingAbstractStrategyIsTypeOf'),
      this.printResolveTypeAbstractTypes('NexusGenAbstractsUsingStrategyResolveType'),
      this.printFeaturesConfig('NexusGenFeaturesConfig'),
      this.printGenTypeMap(),
    ].join('\n\n')
  }

  private printHeaders() {
    return [this.printHeadersCommon(), this.printHeadersGlobal()].join('\n')
  }

  private printHeadersCommon() {
    return [
      this.typegenInfo.headers.join('\n'),
      this.typegenInfo.imports.join('\n'),
      this.printDynamicImport(),
    ].join('\n')
  }

  private printDirectives() {
    const customDirectives = this.schema.getDirectives().filter((d) => !isSpecifiedDirective(d))
    const schemaDirectiveArgs: Record<string, readonly GraphQLArgument[] | undefined> = {}

    // Gather the mappings between directives, locations, etc.
    customDirectives.forEach((d) => {
      d.locations.forEach((l) => {
        if (SchemaDirectiveLocation.includes(l as any)) {
          schemaDirectiveArgs[d.name] = d.args ?? undefined
        }
      })
    })

    const directiveNames = Object.keys(schemaDirectiveArgs)
      .map((i) => JSON.stringify(i))
      .join(' | ')

    const toPrint: string[] = [`export type NexusGenDirectives = ${directiveNames || 'never'}`]

    // Print the mappings of the directive names -> args
    // NexusGenDirectiveArgs
    let directiveArgs = [`export interface NexusGenDirectiveArgs {`]

    eachObj(schemaDirectiveArgs, (val, key) => {
      if (val) {
        directiveArgs.push(`  ${key}: {`)
        val.forEach((arg) => {
          const [sep, rep] = this.normalizeArg(arg)
          directiveArgs.push(`    ${arg.name}${sep}${rep}`)
        })
        directiveArgs.push(`  }`)
      } else {
        directiveArgs.push(`  ${key}: never`)
      }
    })

    directiveArgs.push('}')

    toPrint.push(directiveArgs.join('\n'))

    return toPrint.join('\n\n')
  }

  private printHeadersGlobal() {
    const headers = [
      this.printDynamicInputFieldDefinitions(),
      this.printDynamicOutputFieldDefinitions(),
      this.printDynamicOutputPropertyDefinitions(),
      GLOBAL_DECLARATION,
    ]

    if (this.typegenInfo.globalsPath) {
      headers.unshift(
        `import type { NexusGenTypes } from '${relativePathTo(
          this.typegenInfo.typegenPath,
          this.typegenInfo.globalsPath ?? ''
        )}'`
      )
      headers.unshift(...(this.typegenInfo.globalsHeaders ?? []))
      headers.unshift(TYPEGEN_HEADER)
    }

    return headers.join('\n')
  }

  private printGenTypeMap() {
    return [`export interface NexusGenTypes {`]
      .concat([
        `  context: ${this.printContext()};`,
        `  inputTypes: NexusGenInputs;`,
        `  directives: NexusGenDirectives;`,
        `  directiveArgs: NexusGenDirectiveArgs;`,
        `  rootTypes: NexusGenRootTypes;`,
        `  inputTypeShapes: NexusGenInputs & NexusGenEnums & NexusGenScalars;`,
        `  argTypes: NexusGenArgTypes;`,
        `  fieldTypes: NexusGenFieldTypes;`,
        `  fieldTypeNames: NexusGenFieldTypeNames;`,
        `  allTypes: NexusGenAllTypes;`,
        `  typeInterfaces: NexusGenTypeInterfaces;`,
        `  objectNames: NexusGenObjectNames;`,
        `  inputNames: NexusGenInputNames;`,
        `  enumNames: NexusGenEnumNames;`,
        `  interfaceNames: NexusGenInterfaceNames;`,
        `  scalarNames: NexusGenScalarNames;`,
        `  unionNames: NexusGenUnionNames;`,
        `  allInputTypes: NexusGenTypes['inputNames'] | NexusGenTypes['enumNames'] | NexusGenTypes['scalarNames'];`,
        `  allOutputTypes: NexusGenTypes['objectNames'] | NexusGenTypes['enumNames'] | NexusGenTypes['unionNames'] | NexusGenTypes['interfaceNames'] | NexusGenTypes['scalarNames'];`,
        `  allNamedTypes: NexusGenTypes['allInputTypes'] | NexusGenTypes['allOutputTypes']`,
        `  abstractTypes: NexusGenTypes['interfaceNames'] | NexusGenTypes['unionNames'];`,
        `  abstractTypeMembers: NexusGenAbstractTypeMembers;`,
        `  objectsUsingAbstractStrategyIsTypeOf: NexusGenObjectsUsingAbstractStrategyIsTypeOf;`,
        `  abstractsUsingStrategyResolveType: NexusGenAbstractsUsingStrategyResolveType;`,
        `  features: NexusGenFeaturesConfig;`,
      ])
      .concat('}')
      .join('\n')
  }

  private printDynamicImport(forGlobal = false) {
    const { sourceTypings } = this.schema.extensions.nexus.config
    const { contextTypeImport } = this.typegenInfo
    const imports: string[] = []
    const importMap: Record<string, Set<string>> = {}
    const outputPath = this.typegenInfo.typegenPath
    const nexusSchemaImportId = this.typegenInfo.nexusSchemaImportId ?? getOwnPackage().name

    if (!this.printImports[nexusSchemaImportId]) {
      this.maybeAddCoreImport(forGlobal)
    }

    if (!forGlobal) {
      if (contextTypeImport) {
        const importPath = resolveImportPath(contextTypeImport, 'context', outputPath)
        importMap[importPath] = importMap[importPath] || new Set()
        importMap[importPath].add(
          contextTypeImport.alias
            ? `${contextTypeImport.export} as ${contextTypeImport.alias}`
            : contextTypeImport.export
        )
      }
      eachObj(sourceTypings, (rootType, typeName) => {
        if (typeof rootType !== 'string') {
          const importPath = resolveImportPath(rootType, typeName, outputPath)
          importMap[importPath] = importMap[importPath] || new Set()
          importMap[importPath].add(
            rootType.alias ? `${rootType.export} as ${rootType.alias}` : rootType.export
          )
        }
      })
      eachObj(importMap, (val, key) => {
        imports.push(`import type { ${Array.from(val).join(', ')} } from ${JSON.stringify(key)}`)
      })
    }

    eachObj(this.printImports, (val, key) => {
      const { default: def, ...rest } = val
      const idents = []
      if (def) {
        idents.push(def)
      }
      let bindings: string[] = []
      eachObj(rest, (alias, binding) => {
        bindings.push(alias !== true ? `${binding} as ${alias}` : `${binding}`)
      })
      if (bindings.length) {
        idents.push(`{ ${bindings.join(', ')} }`)
      }
      imports.push(`import type ${idents.join(', ')} from ${JSON.stringify(key)}`)
    })
    return imports.join('\n')
  }

  private maybeAddCoreImport(forGlobal = false) {
    const nexusSchemaImportId = this.typegenInfo.nexusSchemaImportId ?? getOwnPackage().name
    const {
      dynamicFields: { dynamicInputFields, dynamicOutputFields },
    } = this.schema.extensions.nexus.config

    let shouldAdd = false
    const hasDynamicFields = [dynamicInputFields, dynamicOutputFields].some((o) => Object.keys(o).length > 0)

    if (!this.typegenInfo.globalsPath) {
      shouldAdd = hasDynamicFields || this.hasDiscriminatedTypes
    } else {
      shouldAdd = forGlobal ? hasDynamicFields : this.hasDiscriminatedTypes
    }

    if (shouldAdd) {
      this.printImports[nexusSchemaImportId] = {
        core: true,
      }
    }
  }

  private printDynamicInputFieldDefinitions() {
    const { dynamicInputFields } = this.schema.extensions.nexus.config.dynamicFields
    // If there is nothing custom... exit
    if (!Object.keys(dynamicInputFields).length) {
      return []
    }
    return [`declare global {`, `  interface NexusGenCustomInputMethods<TypeName extends string> {`]
      .concat(
        mapObj(dynamicInputFields, (val, key) => {
          if (typeof val === 'string') {
            const baseType = this.schema.getType(val)
            return this.prependDoc(
              `    ${key}<FieldName extends string>(fieldName: FieldName, opts?: core.CommonInputFieldConfig<TypeName, FieldName>): void // ${JSON.stringify(
                val
              )};`,
              baseType?.description
            )
          }
          return this.prependDoc(
            `    ${key}${val.value.typeDefinition || `(...args: any): void`}`,
            val.value.typeDescription
          )
        })
      )
      .concat([`  }`, `}`])
      .join('\n')
  }

  private printDynamicOutputFieldDefinitions() {
    const { dynamicOutputFields } = this.schema.extensions.nexus.config.dynamicFields
    // If there is nothing custom... exit
    if (!Object.keys(dynamicOutputFields).length) {
      return []
    }
    return [`declare global {`, `  interface NexusGenCustomOutputMethods<TypeName extends string> {`]
      .concat(
        mapObj(dynamicOutputFields, (val, key) => {
          if (typeof val === 'string') {
            const baseType = this.schema.getType(val)
            return this.prependDoc(
              `    ${key}<FieldName extends string>(fieldName: FieldName, ...opts: core.ScalarOutSpread<TypeName, FieldName>): void // ${JSON.stringify(
                val
              )};`,
              baseType?.description
            )
          }
          return this.prependDoc(
            `    ${key}${val.value.typeDefinition || `(...args: any): void`}`,
            val.value.typeDescription
          )
        })
      )
      .concat([`  }`, `}`])
      .join('\n')
  }

  private prependDoc(typeDef: string, typeDescription?: string | null) {
    let outStr = ''
    if (typeDescription) {
      let parts = typeDescription.split('\n').map((f) => f.trimLeft())
      if (parts[0] === '') {
        parts = parts.slice(1)
      }
      if (parts[parts.length - 1] === '') {
        parts = parts.slice(0, -1)
      }
      outStr = ['    /**', ...parts.map((p) => `     *${p ? ` ${p}` : ''}`), '     */'].join('\n') + '\n'
    }
    return `${outStr}${typeDef}`
  }

  private printDynamicOutputPropertyDefinitions() {
    const { dynamicOutputProperties } = this.schema.extensions.nexus.config.dynamicFields
    // If there is nothing custom... exit
    if (!Object.keys(dynamicOutputProperties).length) {
      return []
    }
    return [`declare global {`, `  interface NexusGenCustomOutputProperties<TypeName extends string> {`]
      .concat(
        mapObj(dynamicOutputProperties, (val, key) => {
          return this.prependDoc(
            `    ${key}${val.value.typeDefinition || `: any`}`,
            val.value.typeDescription
          )
        })
      )
      .concat([`  }`, `}`])
      .join('\n')
  }

  private printInheritedFieldMap() {
    const hasInterfaces: (
      | (GraphQLInterfaceType & { getInterfaces(): ReadonlyArray<GraphQLInterfaceType> })
      | GraphQLObjectType
    )[] = []
    const withInterfaces = hasInterfaces
      .concat(this.groupedTypes.object, this.groupedTypes.interface.map(graphql15InterfaceType))
      .map((o) => {
        if (o.getInterfaces().length) {
          return [o.name, o.getInterfaces().map((i) => i.name)]
        }
        return null
      })
      .filter((f) => f) as [string, string[]][]

    return ['export interface NexusGenTypeInterfaces {']
      .concat(
        withInterfaces.map(([name, interfaces]) => {
          return `  ${name}: ${interfaces.map((i) => JSON.stringify(i)).join(' | ')}`
        })
      )
      .concat('}')
      .join('\n')
  }

  private printContext() {
    return this.typegenInfo.contextTypeImport?.alias || this.typegenInfo.contextTypeImport?.export || 'any'
  }

  private printAbstractTypeMembers() {
    return this.printTypeInterface('NexusGenAbstractTypeMembers', this.buildAbstractTypeMembers())
  }

  private buildAbstractTypeMembers() {
    const sourceMap: TypeMapping = {}
    const abstractTypes: (GraphQLInterfaceType | GraphQLUnionType)[] = []
    abstractTypes
      .concat(this.groupedTypes.union)
      .concat(this.groupedTypes.interface)
      .forEach((type) => {
        if (isInterfaceType(type)) {
          const possibleNames = this.schema.getPossibleTypes(type).map((t) => t.name)
          if (possibleNames.length > 0) {
            sourceMap[type.name] = possibleNames.map((val) => JSON.stringify(val)).join(' | ')
          }
        } else {
          sourceMap[type.name] = type
            .getTypes()
            .map((t) => JSON.stringify(t.name))
            .join(' | ')
        }
      })
    return sourceMap
  }

  private printTypeNames(name: keyof GroupedTypes, exportName: string, source: string) {
    const obj = this.groupedTypes[name] as GraphQLNamedType[]
    const typeDef = obj.length === 0 ? 'never' : `keyof ${source}`
    return `export type ${exportName} = ${typeDef};`
  }

  private printIsTypeOfObjectTypeNames(exportName: string) {
    const objectTypes = this.groupedTypes.object.filter((o) => o.isTypeOf !== undefined)
    const typeDef =
      objectTypes.length === 0
        ? 'never'
        : objectTypes
            .map((o) => JSON.stringify(o.name))
            .sort()
            .join(' | ')
    return `export type ${exportName} = ${typeDef};`
  }

  private printResolveTypeAbstractTypes(exportName: string) {
    const abstractTypes = [...this.groupedTypes.interface, ...this.groupedTypes.union].filter(
      (o) => o.resolveType !== undefined
    )
    const typeDef =
      abstractTypes.length === 0
        ? 'never'
        : abstractTypes

            .map((o) => JSON.stringify(o.name))
            .sort()
            .join(' | ')
    return `export type ${exportName} = ${typeDef};`
  }

  private printFeaturesConfig(exportName: string) {
    const abstractTypes = this.schema.extensions.nexus.config.features?.abstractTypeStrategies ?? {}
    const unionProps = renderObject(mapValues(abstractTypes, (val) => val ?? false))

    return [`export type ${exportName} = {`]
      .concat(`  abstractTypeStrategies: ${unionProps}`)
      .concat('}')
      .join('\n')
  }

  private buildEnumTypeMap() {
    const enumMap: TypeMapping = {}
    this.groupedTypes.enum.forEach((e) => {
      const sourceType = this.resolveSourceType(e.name)
      if (sourceType) {
        enumMap[e.name] = sourceType
      } else {
        const values = e.getValues().map((val) => JSON.stringify(val.value))
        enumMap[e.name] = values.join(' | ')
      }
    })
    return enumMap
  }

  private buildInputTypeMap() {
    const inputObjMap: TypeFieldMapping = {}
    this.groupedTypes.input.forEach((input) => {
      eachObj(input.getFields(), (field) => {
        inputObjMap[input.name] = inputObjMap[input.name] || {}
        inputObjMap[input.name][field.name] = this.normalizeArg(field)
      })
    })
    return inputObjMap
  }

  private buildScalarTypeMap() {
    const scalarMap: TypeMapping = {}
    this.groupedTypes.scalar.forEach((e) => {
      if (isSpecifiedScalarType(e)) {
        scalarMap[e.name] = this.resolveSourceType(e.name) ?? SpecifiedScalars[e.name as SpecifiedScalarNames]
        return
      }
      const sourceType = this.resolveSourceType(e.name)
      if (sourceType) {
        scalarMap[e.name] = sourceType
      } else {
        scalarMap[e.name] = 'any'
      }
    })
    return scalarMap
  }

  private printInputTypeMap() {
    const inputTypeMap = this.buildInputTypeMap()

    if (this.typegenInfo.declareInputs) {
      const declaredInputs: string[] = mapObj(inputTypeMap, (fields, inputName) =>
        this.printNamedObj(inputName, fields)
      )
      return [...declaredInputs, this.printNamedMap('NexusGenInputs', inputTypeMap)].join('\n\n')
    }

    return this.printTypeFieldInterface('NexusGenInputs', inputTypeMap, 'input type')
  }

  private printEnumTypeMap() {
    const enumTypeMap = this.buildEnumTypeMap()

    if (this.typegenInfo.declareInputs) {
      return [
        ...mapObj(enumTypeMap, (val, name) => `export type ${name} = ${val}`),
        this.printNamedMap('NexusGenEnums', enumTypeMap),
      ].join('\n\n')
    }

    return this.printTypeInterface('NexusGenEnums', enumTypeMap)
  }

  private printScalarTypeMap() {
    return this.printTypeInterface('NexusGenScalars', this.buildScalarTypeMap())
  }

  private shouldDiscriminateType(
    abstractType: GraphQLAbstractType,
    objectType: GraphQLObjectType
  ): 'required' | 'optional' | false {
    if (!this.schema.extensions.nexus.config.features?.abstractTypeStrategies?.__typename) {
      return false
    }

    if (abstractType.resolveType !== undefined) {
      return 'optional'
    }

    if (objectType.isTypeOf !== undefined) {
      return 'optional'
    }

    return 'required'
  }

  private maybeDiscriminate(abstractType: GraphQLAbstractType, objectType: GraphQLObjectType) {
    const requiredOrOptional = this.shouldDiscriminateType(abstractType, objectType)

    if (requiredOrOptional === false) {
      return `NexusGenRootTypes['${objectType.name}']`
    }

    this.hasDiscriminatedTypes = true

    return `core.Discriminate<'${objectType.name}', '${requiredOrOptional}'>`
  }

  private buildRootTypeMap(hasFields: Array<GraphQLInterfaceType | GraphQLObjectType | GraphQLUnionType>) {
    const rootTypeMap: RootTypeMapping = {}
    hasFields.forEach((type) => {
      const rootTyping = this.resolveSourceType(type.name)
      if (rootTyping) {
        rootTypeMap[type.name] = rootTyping
        return
      }
      if (isUnionType(type)) {
        rootTypeMap[type.name] = type
          .getTypes()
          .map((t) => this.maybeDiscriminate(type, t))
          .join(' | ')
      } else if (isInterfaceType(type)) {
        const possibleRoots = this.schema.getPossibleTypes(type).map((t) => this.maybeDiscriminate(type, t))
        if (possibleRoots.length > 0) {
          rootTypeMap[type.name] = possibleRoots.join(' | ')
        } else {
          rootTypeMap[type.name] = 'any'
        }
      } else if (type.name === 'Query' || type.name === 'Mutation') {
        rootTypeMap[type.name] = '{}'
      } else {
        eachObj(type.getFields(), (field) => {
          const obj = (rootTypeMap[type.name] = rootTypeMap[type.name] || {})
          const fieldSourceType = this.fieldSourceType(field, type)
          if (typeof obj !== 'string') {
            if (Array.isArray(fieldSourceType)) {
              for (const field of fieldSourceType) {
                obj[field.name] = [field.optional ? '?:' : ':', field.type]
              }
            } else if (typeof fieldSourceType === 'object') {
              obj[field.name] = [fieldSourceType.optional ? '?:' : ':', fieldSourceType.type]
            } else if (fieldSourceType) {
              obj[field.name] = [':', fieldSourceType]
            } else if (!this.hasResolver(field, type)) {
              obj[field.name] = [
                this.argSeparator(field.type as GraphQLInputType, false),
                this.printOutputType(field.type),
              ]
            }
          }
        })
      }
    })
    return rootTypeMap
  }

  private resolveSourceType(typeName: string): string | undefined {
    const rootTyping = this.schema.extensions.nexus.config.sourceTypings[typeName]
    if (rootTyping) {
      return typeof rootTyping === 'string' ? rootTyping : rootTyping.export
    }
    return (this.typegenInfo.sourceTypeMap as any)[typeName]
  }

  private fieldSourceType(
    field: GraphQLField<any, any>,
    // Used in test mocking
    _type: GraphQLObjectType
  ) {
    if (field.extensions && isNexusFieldExtension(field.extensions.nexus)) {
      return field.extensions.nexus.sourceType
    }
    return undefined
  }

  private hasResolver(
    field: GraphQLField<any, any>,
    // Used in test mocking
    _type: GraphQLObjectType
  ) {
    if (field.extensions && hasNexusExtension(field.extensions.nexus)) {
      return field.extensions.nexus.hasDefinedResolver
    }
    return Boolean(field.resolve)
  }

  private printObjectTypeMap() {
    return this.printRootTypeFieldInterface(
      'NexusGenObjects',
      this.buildRootTypeMap(this.groupedTypes.object)
    )
  }

  private printInterfaceTypeMap() {
    return this.printRootTypeFieldInterface(
      'NexusGenInterfaces',
      this.buildRootTypeMap(this.groupedTypes.interface)
    )
  }

  private printUnionTypeMap() {
    return this.printRootTypeFieldInterface('NexusGenUnions', this.buildRootTypeMap(this.groupedTypes.union))
  }

  private printRootTypeDef() {
    const toJoin: string[] = []
    if (this.groupedTypes.interface.length) {
      toJoin.push('NexusGenInterfaces')
    }
    if (this.groupedTypes.object.length) {
      toJoin.push('NexusGenObjects')
    }
    if (this.groupedTypes.union.length) {
      toJoin.push('NexusGenUnions')
    }
    return `export type NexusGenRootTypes = ${toJoin.join(' & ')}`
  }

  private printAllTypesMap() {
    const toJoin: string[] = ['NexusGenRootTypes']
    if (this.groupedTypes.scalar.length) {
      toJoin.push('NexusGenScalars')
    }
    if (this.groupedTypes.enum.length) {
      toJoin.push('NexusGenEnums')
    }
    return `export type NexusGenAllTypes = ${toJoin.join(' & ')}`
  }

  private buildArgTypeMap() {
    const argTypeMap: Record<string, TypeFieldMapping> = {}
    const hasFields: (GraphQLInterfaceType | GraphQLObjectType)[] = []
    hasFields
      .concat(this.groupedTypes.object)
      .concat(this.groupedTypes.interface)
      .forEach((type) => {
        eachObj(type.getFields(), (field) => {
          if (field.args.length > 0) {
            argTypeMap[type.name] = argTypeMap[type.name] || {}
            argTypeMap[type.name][field.name] = field.args.reduce((obj, arg) => {
              obj[arg.name] = this.normalizeArg(arg)
              return obj
            }, {} as Record<string, [string, string]>)
          }
        })
      })
    return argTypeMap
  }

  private printArgTypeMap() {
    const argTypeMap = this.buildArgTypeMap()
    if (this.typegenInfo.declareInputs) {
      const declaredArgs: string[] = []
      eachObj(argTypeMap, (fields, typeName) => {
        eachObj(fields, (args, fieldName) => {
          declaredArgs.push(this.printNamedObj(this.getArgsName(typeName, fieldName), args))
        })
      })
      return [...declaredArgs, this.printArgTypeFieldInterface(argTypeMap)].join('\n\n')
    }

    return this.printArgTypeFieldInterface(argTypeMap)
  }

  private getArgsName(typeName: string, fieldName: string) {
    return `${typeName}${fieldName.slice(0, 1).toUpperCase().concat(fieldName.slice(1))}Args`
  }

  private printNamedObj(name: string, obj: Record<string, [string, string]>) {
    return [
      `export interface ${name} {`,
      ...mapObj(obj, (val, key) => `  ${key}${val[0]} ${val[1]}`),
      `}`,
    ].join('\n')
  }

  private printNamedMap(name: string, obj: Record<string, any>) {
    return [`export interface ${name} {`, ...mapObj(obj, (val, key) => `  ${key}: ${key}`), `}`].join('\n')
  }

  private buildReturnTypeMap() {
    const returnTypeMap: TypeFieldMapping = {}
    const hasFields: (GraphQLInterfaceType | GraphQLObjectType)[] = []
    hasFields
      .concat(this.groupedTypes.object)
      .concat(this.groupedTypes.interface)
      .forEach((type) => {
        eachObj(type.getFields(), (field) => {
          returnTypeMap[type.name] = returnTypeMap[type.name] || {}
          returnTypeMap[type.name][field.name] = [':', this.printOutputType(field.type)]
        })
      })
    return returnTypeMap
  }

  private buildReturnTypeNamesMap() {
    const returnTypeMap: TypeFieldMapping = {}
    const hasFields: (GraphQLInterfaceType | GraphQLObjectType)[] = []
    hasFields
      .concat(this.groupedTypes.object)
      .concat(this.groupedTypes.interface)
      .forEach((type) => {
        eachObj(type.getFields(), (field) => {
          returnTypeMap[type.name] = returnTypeMap[type.name] || {}
          returnTypeMap[type.name][field.name] = [':', `'${getNamedType(field.type).name}'`]
        })
      })
    return returnTypeMap
  }

  private printOutputType(type: GraphQLOutputType) {
    const returnType = this.typeToArr(type)
    function combine(item: any[]): string {
      if (item.length === 1) {
        if (Array.isArray(item[0])) {
          const toPrint = combine(item[0])
          return `ReadonlyArray<${toPrint}>`
        }
        return item[0]
      }
      if (Array.isArray(item[1])) {
        const toPrint = combine(item[1])
        return `ReadonlyArray<${toPrint}> | null`
      }
      return `${item[1]} | null`
    }
    return `${combine(returnType)}; // ${type}`
  }

  private typeToArr(type: GraphQLOutputType): any[] {
    const typing = []
    if (isNonNullType(type)) {
      type = type.ofType
    } else {
      typing.push(null)
    }
    if (isListType(type)) {
      typing.push(this.typeToArr(type.ofType))
    } else if (isScalarType(type)) {
      typing.push(this.printScalar(type))
    } else if (isEnumType(type)) {
      if (this.typegenInfo.declareInputs) {
        typing.push(type.name)
      } else {
        typing.push(`NexusGenEnums['${type.name}']`)
      }
    } else if (isObjectType(type) || isInterfaceType(type) || isUnionType(type)) {
      typing.push(`NexusGenRootTypes['${type.name}']`)
    }
    return typing
  }

  private printFieldTypesMap() {
    return this.printTypeFieldInterface('NexusGenFieldTypes', this.buildReturnTypeMap(), 'field return type')
  }

  private printFieldTypeNamesMap() {
    return this.printTypeFieldInterface(
      'NexusGenFieldTypeNames',
      this.buildReturnTypeNamesMap(),
      'field return type name'
    )
  }

  private normalizeArg(arg: GraphQLInputField | GraphQLArgument): [string, string] {
    return [this.argSeparator(arg.type, Boolean(arg.defaultValue)), this.argTypeRepresentation(arg.type)]
  }

  private argSeparator(type: GraphQLInputType, hasDefaultValue: boolean) {
    if (hasDefaultValue || isNonNullType(type)) {
      return ':'
    }

    return '?:'
  }

  private argTypeRepresentation(arg: GraphQLInputType): string {
    const argType = this.argTypeArr(arg)
    const useReadonlyArrayForInputs = !!this.typegenInfo.useReadonlyArrayForInputs
    function combine(item: any[]): string {
      if (item.length === 1) {
        if (Array.isArray(item[0])) {
          const toPrint = combine(item[0])
          if (useReadonlyArrayForInputs) {
            return `ReadonlyArray<${toPrint}>`
          }
          return toPrint.indexOf('null') === -1 ? `${toPrint}[]` : `Array<${toPrint}>`
        }
        return item[0]
      }
      if (Array.isArray(item[1])) {
        const toPrint = combine(item[1])
        if (useReadonlyArrayForInputs) {
          return `ReadonlyArray<${toPrint}> | null`
        }
        return toPrint.indexOf('null') === -1 ? `${toPrint}[] | null` : `Array<${toPrint}> | null`
      }
      return `${item[1]} | null`
    }
    return `${combine(argType)}; // ${arg}`
  }

  private argTypeArr(arg: GraphQLInputType): any[] {
    const typing = []
    if (isNonNullType(arg)) {
      arg = arg.ofType
    } else {
      typing.push(null)
    }
    if (isListType(arg)) {
      typing.push(this.argTypeArr(arg.ofType))
    } else if (isScalarType(arg)) {
      typing.push(this.printScalar(arg))
    } else if (isEnumType(arg)) {
      if (this.typegenInfo.declareInputs) {
        typing.push(arg.name)
      } else {
        typing.push(`NexusGenEnums['${arg.name}']`)
      }
    } else if (isInputObjectType(arg)) {
      if (this.typegenInfo.declareInputs) {
        typing.push(arg.name)
      } else {
        typing.push(`NexusGenInputs['${arg.name}']`)
      }
    }
    return typing
  }

  private printTypeInterface(interfaceName: string, typeMapping: TypeMapping) {
    return [`export interface ${interfaceName} {`]
      .concat(mapObj(typeMapping, (val, key) => `  ${key}: ${val}`))
      .concat('}')
      .join('\n')
  }

  private printRootTypeFieldInterface(interfaceName: string, typeMapping: RootTypeMapping) {
    return [`export interface ${interfaceName} {`]
      .concat(
        mapObj(typeMapping, (val, key) => {
          if (typeof val === 'string') {
            return `  ${key}: ${val};`
          }
          if (Object.keys(val).length === 0) {
            return `  ${key}: {};`
          }
          return this.printObj('  ', 'root type')(val, key)
        })
      )
      .concat('}')
      .join('\n')
  }

  private printTypeFieldInterface(interfaceName: string, typeMapping: TypeFieldMapping, source: string) {
    return [`export interface ${interfaceName} {`]
      .concat(mapObj(typeMapping, this.printObj('  ', source)))
      .concat('}')
      .join('\n')
  }

  private printArgTypeFieldInterface(typeMapping: Record<string, TypeFieldMapping>) {
    return [`export interface NexusGenArgTypes {`]
      .concat(
        mapObj(typeMapping, (val, typeName) => {
          if (this.typegenInfo.declareInputs) {
            return [`  ${typeName}: {`]
              .concat(
                mapObj(val, (_, fieldName) => `    ${fieldName}: ${this.getArgsName(typeName, fieldName)}`)
              )
              .concat('  }')
              .join('\n')
          }
          return [`  ${typeName}: {`]
            .concat(mapObj(val, this.printObj('    ', 'args')))
            .concat('  }')
            .join('\n')
        })
      )
      .concat('}')
      .join('\n')
  }

  private printObj =
    (space: string, source: string) => (val: Record<string, [string, string]>, key: string) => {
      return [`${space}${key}: { // ${source}`]
        .concat(
          mapObj(val, (v2, k2) => {
            return `${space}  ${k2}${v2[0]} ${v2[1]}`
          })
        )
        .concat(`${space}}`)
        .join('\n')
    }

  private printScalar(type: GraphQLScalarType) {
    if (isSpecifiedScalarType(type)) {
      return this.resolveSourceType(type.name) ?? SpecifiedScalars[type.name as SpecifiedScalarNames]
    }
    return `NexusGenScalars['${type.name}']`
  }

  private printPlugins() {
    const pluginFieldExt: string[] = [
      `  interface NexusGenPluginFieldConfig<TypeName extends string, FieldName extends string> {`,
    ]
    const pluginInputFieldExt: string[] = [
      `  interface NexusGenPluginInputFieldConfig<TypeName extends string, FieldName extends string> {`,
    ]
    const pluginArgExt: string[] = [`  interface NexusGenPluginArgConfig {`]
    const pluginSchemaExt: string[] = [`  interface NexusGenPluginSchemaConfig {`]
    const pluginTypeExt: string[] = [`  interface NexusGenPluginTypeConfig<TypeName extends string> {`]
    const pluginInputTypeExt: string[] = [
      `  interface NexusGenPluginInputTypeConfig<TypeName extends string> {`,
    ]
    const printInlineDefs: string[] = []
    const plugins = this.schema.extensions.nexus.config.plugins || []
    plugins.forEach((plugin) => {
      if (plugin.config.fieldDefTypes) {
        pluginFieldExt.push(padLeft(this.printType(plugin.config.fieldDefTypes), '    '))
      }
      if (plugin.config.inputFieldDefTypes) {
        pluginInputFieldExt.push(padLeft(this.printType(plugin.config.inputFieldDefTypes), '    '))
      }
      if (plugin.config.objectTypeDefTypes) {
        pluginTypeExt.push(padLeft(this.printType(plugin.config.objectTypeDefTypes), '    '))
      }
      if (plugin.config.inputObjectTypeDefTypes) {
        pluginInputTypeExt.push(padLeft(this.printType(plugin.config.inputObjectTypeDefTypes), '    '))
      }
      if (plugin.config.argTypeDefTypes) {
        pluginArgExt.push(padLeft(this.printType(plugin.config.argTypeDefTypes), '    '))
      }
    })
    return [
      printInlineDefs.join('\n'),
      [
        'declare global {',
        [
          pluginTypeExt.concat('  }').join('\n'),
          pluginInputTypeExt.concat('  }').join('\n'),
          pluginFieldExt.concat('  }').join('\n'),
          pluginInputFieldExt.concat('  }').join('\n'),
          pluginSchemaExt.concat('  }').join('\n'),
          pluginArgExt.concat('  }').join('\n'),
        ].join('\n'),
        '}',
      ].join('\n'),
    ].join('\n')
  }

  private printType(strLike: StringLike | StringLike[]): string {
    if (Array.isArray(strLike)) {
      return strLike.map((s) => this.printType(s)).join('\n')
    }
    if (isNexusPrintedGenTyping(strLike)) {
      strLike.imports.forEach((i) => {
        this.addImport(i)
      })
      return strLike.toString()
    }
    if (isNexusPrintedGenTypingImport(strLike)) {
      this.addImport(strLike)
      return ''
    }
    return strLike
  }

  private addImport(i: PrintedGenTypingImport) {
    /* istanbul ignore if */
    if (!isNexusPrintedGenTypingImport(i)) {
      console.warn(`Expected printedGenTypingImport, saw ${i}`)
      return
    }
    this.printImports[i.config.module] = this.printImports[i.config.module] || {}
    if (i.config.default) {
      this.printImports[i.config.module].default = i.config.default
    }
    if (i.config.bindings) {
      i.config.bindings.forEach((binding) => {
        if (typeof binding === 'string') {
          this.printImports[i.config.module][binding] = true
        } else {
          this.printImports[i.config.module][binding[0]] = binding[1]
        }
      })
    }
  }
}

function padLeft(str: string, padding: string) {
  return str
    .split('\n')
    .map((s) => `${padding}${s}`)
    .join('\n')
}

const GLOBAL_DECLARATION = `
declare global {
  interface NexusGen extends NexusGenTypes {}
}`

function renderObject(object: Record<string, any>): string {
  return [
    '{',
    mapObj(object, (val, key) => {
      return `    ${key}: ${val}`
    }).join('\n'),
    '  }',
  ].join('\n')
}
