import { BaseGenerator, type FileMetaBase } from '@kubb/core'
import transformers, { pascalCase } from '@kubb/core/transformers'
import { getUniqueName } from '@kubb/core/utils'

import { isNullable, isReference } from '@kubb/oas'
import { isDeepEqual, isNumber, uniqueWith } from 'remeda'
import { isKeyword, schemaKeywords } from './SchemaMapper.ts'
import { getSchemaFactory } from './utils/getSchemaFactory.ts'
import { getSchemas } from './utils/getSchemas.ts'

import type { Plugin, PluginFactoryOptions, PluginManager, ResolveNameParams } from '@kubb/core'
import type * as KubbFile from '@kubb/fs/types'

import type { Oas, OpenAPIV3, SchemaObject, contentType } from '@kubb/oas'
import type { Schema, SchemaKeywordMapper } from './SchemaMapper.ts'
import type { Generator } from './generator.tsx'
import type { OperationSchema, Override, Refs } from './types.ts'

export type GetSchemaGeneratorOptions<T extends SchemaGenerator<any, any, any>> = T extends SchemaGenerator<infer Options, any, any> ? Options : never

export type SchemaMethodResult<TFileMeta extends FileMetaBase> = Promise<KubbFile.File<TFileMeta> | Array<KubbFile.File<TFileMeta>> | null>

type Context<TOptions, TPluginOptions extends PluginFactoryOptions> = {
  oas: Oas
  pluginManager: PluginManager
  /**
   * Current plugin
   */
  plugin: Plugin<TPluginOptions>
  mode: KubbFile.Mode
  include?: Array<'schemas' | 'responses' | 'requestBodies'>
  override: Array<Override<TOptions>> | undefined
  contentType?: contentType
  output?: string
}

export type SchemaGeneratorOptions = {
  dateType: false | 'string' | 'stringOffset' | 'stringLocal' | 'date'
  unknownType: 'any' | 'unknown' | 'void'
  enumType?: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal'
  enumSuffix?: string
  usedEnumNames?: Record<string, number>
  mapper?: Record<string, string>
  typed?: boolean
  transformers: {
    /**
     * Customize the names based on the type that is provided by the plugin.
     */
    name?: (name: ResolveNameParams['name'], type?: ResolveNameParams['type']) => string
    /**
     * Receive schema and name(propertName) and return FakerMeta array
     * TODO TODO add docs
     * @beta
     */
    schema?: (schemaProps: SchemaProps, defaultSchemas: Schema[]) => Schema[] | undefined
  }
}

export type SchemaGeneratorBuildOptions = Omit<OperationSchema, 'name' | 'schema'>

type SchemaProps = {
  schema?: SchemaObject
  name?: string
  parentName?: string
}

export class SchemaGenerator<
  TOptions extends SchemaGeneratorOptions = SchemaGeneratorOptions,
  TPluginOptions extends PluginFactoryOptions = PluginFactoryOptions,
  TFileMeta extends FileMetaBase = FileMetaBase,
> extends BaseGenerator<TOptions, Context<TOptions, TPluginOptions>> {
  // Collect the types of all referenced schemas, so we can export them later
  refs: Refs = {}

  // Keep track of already used type aliases
  #usedAliasNames: Record<string, number> = {}

  /**
   * Creates a type node from a given schema.
   * Delegates to getBaseTypeFromSchema internally and
   * optionally adds a union with null.
   */
  parse(props: SchemaProps): Schema[] {
    const options = this.#getOptions(props)

    const defaultSchemas = this.#parseSchemaObject(props)
    const schemas = options.transformers?.schema?.(props, defaultSchemas) || defaultSchemas || []

    return uniqueWith(schemas, isDeepEqual)
  }

  deepSearch<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): Array<SchemaKeywordMapper[T]> {
    return SchemaGenerator.deepSearch<T>(tree, keyword)
  }

  find<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined {
    return SchemaGenerator.find<T>(tree, keyword)
  }

  static deepSearch<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): Array<SchemaKeywordMapper[T]> {
    const foundItems: SchemaKeywordMapper[T][] = []

    tree?.forEach((schema) => {
      if (schema.keyword === keyword) {
        foundItems.push(schema as SchemaKeywordMapper[T])
      }

      if (isKeyword(schema, schemaKeywords.object)) {
        Object.values(schema.args?.properties || {}).forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>(entrySchema, keyword))
        })

        Object.values(schema.args?.additionalProperties || {}).forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword))
        })
      }

      if (isKeyword(schema, schemaKeywords.array)) {
        schema.args.items.forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword))
        })
      }

      if (isKeyword(schema, schemaKeywords.and)) {
        schema.args.forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword))
        })
      }

      if (isKeyword(schema, schemaKeywords.tuple)) {
        schema.args.items.forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword))
        })
      }

      if (isKeyword(schema, schemaKeywords.union)) {
        schema.args.forEach((entrySchema) => {
          foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword))
        })
      }
    })

    return foundItems
  }

  static findInObject<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined {
    let foundItem: SchemaKeywordMapper[T] | undefined = undefined

    tree?.forEach((schema) => {
      if (!foundItem && schema.keyword === keyword) {
        foundItem = schema as SchemaKeywordMapper[T]
      }

      if (isKeyword(schema, schemaKeywords.object)) {
        Object.values(schema.args?.properties || {}).forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>(entrySchema, keyword)
          }
        })

        Object.values(schema.args?.additionalProperties || {}).forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>([entrySchema], keyword)
          }
        })
      }
    })

    return foundItem
  }

  static find<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined {
    let foundItem: SchemaKeywordMapper[T] | undefined = undefined

    tree?.forEach((schema) => {
      if (!foundItem && schema.keyword === keyword) {
        foundItem = schema as SchemaKeywordMapper[T]
      }

      if (isKeyword(schema, schemaKeywords.array)) {
        schema.args.items.forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>([entrySchema], keyword)
          }
        })
      }

      if (isKeyword(schema, schemaKeywords.and)) {
        schema.args.forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>([entrySchema], keyword)
          }
        })
      }

      if (isKeyword(schema, schemaKeywords.tuple)) {
        schema.args.items.forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>([entrySchema], keyword)
          }
        })
      }

      if (isKeyword(schema, schemaKeywords.union)) {
        schema.args.forEach((entrySchema) => {
          if (!foundItem) {
            foundItem = SchemaGenerator.find<T>([entrySchema], keyword)
          }
        })
      }
    })

    return foundItem
  }

  #getUsedEnumNames(props: SchemaProps) {
    const options = this.#getOptions(props)

    return options.usedEnumNames || {}
  }

  #getOptions({ name }: SchemaProps): Partial<TOptions> {
    const { override = [] } = this.context

    return {
      ...this.options,
      ...(override.find(({ pattern, type }) => {
        if (name && type === 'schemaName') {
          return !!name.match(pattern)
        }

        return false
      })?.options || {}),
    }
  }

  #getUnknownReturn(props: SchemaProps) {
    const options = this.#getOptions(props)

    if (options.unknownType === 'any') {
      return schemaKeywords.any
    }
    if (options.unknownType === 'void') {
      return schemaKeywords.void
    }

    return schemaKeywords.unknown
  }

  /**
   * Recursively creates a type literal with the given props.
   */
  #parseProperties({ schema, name }: SchemaProps): Schema[] {
    const properties = schema?.properties || {}
    const additionalProperties = schema?.additionalProperties
    const required = schema?.required

    const propertiesSchemas = Object.keys(properties)
      .map((propertyName) => {
        const validationFunctions: Schema[] = []
        const propertySchema = properties[propertyName] as SchemaObject

        const isRequired = Array.isArray(required) ? required?.includes(propertyName) : !!required
        const nullable = propertySchema.nullable ?? propertySchema['x-nullable'] ?? false

        validationFunctions.push(...this.parse({ schema: propertySchema, name: propertyName, parentName: name }))

        validationFunctions.push({
          keyword: schemaKeywords.name,
          args: propertyName,
        })

        if (!isRequired && nullable) {
          validationFunctions.push({ keyword: schemaKeywords.nullish })
        } else if (!isRequired) {
          validationFunctions.push({ keyword: schemaKeywords.optional })
        }

        return {
          [propertyName]: validationFunctions,
        }
      })
      .reduce((acc, curr) => ({ ...acc, ...curr }), {})
    let additionalPropertiesSchemas: Schema[] = []

    if (additionalProperties) {
      additionalPropertiesSchemas =
        additionalProperties === true || !Object.keys(additionalProperties).length
          ? [{ keyword: this.#getUnknownReturn({ schema, name }) }]
          : this.parse({ schema: additionalProperties as SchemaObject, parentName: name })
    }

    return [
      {
        keyword: schemaKeywords.object,
        args: {
          properties: propertiesSchemas,
          additionalProperties: additionalPropertiesSchemas,
        },
      },
    ]
  }

  /**
   * Create a type alias for the schema referenced by the given ReferenceObject
   */
  #getRefAlias(obj: OpenAPIV3.ReferenceObject): Schema[] {
    const { $ref } = obj
    let ref = this.refs[$ref]

    const originalName = getUniqueName($ref.replace(/.+\//, ''), this.#usedAliasNames)
    const propertyName = this.context.pluginManager.resolveName({
      name: originalName,
      pluginKey: this.context.plugin.key,
      type: 'function',
    })

    if (ref) {
      return [
        {
          keyword: schemaKeywords.ref,
          args: { name: ref.propertyName, path: ref.path, isImportable: !!this.context.oas.get($ref) },
        },
      ]
    }

    const fileName = this.context.pluginManager.resolveName({
      name: originalName,
      pluginKey: this.context.plugin.key,
      type: 'file',
    })
    const file = this.context.pluginManager.getFile({
      name: fileName,
      pluginKey: this.context.plugin.key,
      extname: '.ts',
    })

    ref = this.refs[$ref] = {
      propertyName,
      originalName,
      path: file.path,
    }

    return [
      {
        keyword: schemaKeywords.ref,
        args: { name: ref.propertyName, path: ref?.path, isImportable: !!this.context.oas.get($ref) },
      },
    ]
  }

  #getParsedSchemaObject(schema?: SchemaObject) {
    const parsedSchema = getSchemaFactory(this.context.oas)(schema)
    return parsedSchema
  }

  /**
   * This is the very core of the OpenAPI to TS conversion - it takes a
   * schema and returns the appropriate type.
   */
  #parseSchemaObject({ schema: _schema, name, parentName }: SchemaProps): Schema[] {
    const options = this.#getOptions({ schema: _schema, name })
    const unknownReturn = this.#getUnknownReturn({ schema: _schema, name })
    const { schema, version } = this.#getParsedSchemaObject(_schema)
    if (!schema) {
      return [{ keyword: unknownReturn }]
    }

    const baseItems: Schema[] = [
      {
        keyword: schemaKeywords.schema,
        args: {
          type: schema.type as any,
          format: schema.format,
        },
      },
    ]
    const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined
    const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined
    const nullable = isNullable(schema)
    const defaultNullAndNullable = schema.default === null && nullable

    if (schema.default !== undefined && !defaultNullAndNullable && !Array.isArray(schema.default)) {
      if (typeof schema.default === 'string') {
        baseItems.push({
          keyword: schemaKeywords.default,
          args: transformers.stringify(schema.default),
        })
      } else if (typeof schema.default === 'boolean') {
        baseItems.push({
          keyword: schemaKeywords.default,
          args: schema.default ?? false,
        })
      } else {
        baseItems.push({
          keyword: schemaKeywords.default,
          args: schema.default,
        })
      }
    }

    if (schema.deprecated) {
      baseItems.push({
        keyword: schemaKeywords.deprecated,
      })
    }

    if (schema.description) {
      baseItems.push({
        keyword: schemaKeywords.describe,
        args: schema.description,
      })
    }

    if (max !== undefined) {
      baseItems.unshift({ keyword: schemaKeywords.max, args: max })
    }

    if (min !== undefined) {
      baseItems.unshift({ keyword: schemaKeywords.min, args: min })
    }

    if (nullable) {
      baseItems.push({ keyword: schemaKeywords.nullable })
    }

    if (schema.type && Array.isArray(schema.type)) {
      const [_schema, nullable] = schema.type

      if (nullable === 'null') {
        baseItems.push({ keyword: schemaKeywords.nullable })
      }
    }

    if (schema.readOnly) {
      baseItems.push({ keyword: schemaKeywords.readOnly })
    }

    if (schema.writeOnly) {
      baseItems.push({ keyword: schemaKeywords.writeOnly })
    }

    if (isReference(schema)) {
      return [
        ...this.#getRefAlias(schema),
        nullable && { keyword: schemaKeywords.nullable },
        schema.readOnly && { keyword: schemaKeywords.readOnly },
        schema.writeOnly && { keyword: schemaKeywords.writeOnly },
        {
          keyword: schemaKeywords.schema,
          args: {
            type: schema.type as any,
            format: schema.format,
          },
        },
      ].filter(Boolean)
    }

    if (schema.oneOf) {
      // union
      const schemaWithoutOneOf = { ...schema, oneOf: undefined }

      const union: Schema = {
        keyword: schemaKeywords.union,
        args: schema.oneOf
          .map((item) => {
            return item && this.parse({ schema: item as SchemaObject, name, parentName })[0]
          })
          .filter(Boolean)
          .filter((item) => {
            return item && item.keyword !== unknownReturn
          }),
      }
      if (schemaWithoutOneOf.properties) {
        const propertySchemas = this.parse({ schema: schemaWithoutOneOf, name, parentName })

        union.args = [
          ...union.args.map((arg) => {
            return {
              keyword: schemaKeywords.and,
              args: [arg, ...propertySchemas],
            }
          }),
        ]
      }

      return [union, ...baseItems]
    }

    if (schema.anyOf) {
      // union
      const schemaWithoutAnyOf = { ...schema, anyOf: undefined }

      const union: Schema = {
        keyword: schemaKeywords.union,
        args: schema.anyOf
          .map((item) => {
            return item && this.parse({ schema: item as SchemaObject, name, parentName })[0]
          })
          .filter(Boolean)
          .filter((item) => {
            return item && item.keyword !== unknownReturn
          })
          .map((item) => {
            if (isKeyword(item, schemaKeywords.object)) {
              return {
                ...item,
                args: {
                  ...item.args,
                  strict: true,
                },
              }
            }
            return item
          }),
      }
      if (schemaWithoutAnyOf.properties) {
        return [...this.parse({ schema: schemaWithoutAnyOf, name, parentName }), union, ...baseItems]
      }

      return [union, ...baseItems]
    }
    if (schema.allOf) {
      // intersection/add
      const schemaWithoutAllOf = { ...schema, allOf: undefined }

      const and: Schema = {
        keyword: schemaKeywords.and,
        args: schema.allOf
          .map((item) => {
            return item && this.parse({ schema: item as SchemaObject, name, parentName })[0]
          })
          .filter(Boolean)
          .filter((item) => {
            return item && item.keyword !== unknownReturn
          }),
      }

      if (schemaWithoutAllOf.required) {
        // TODO use of Required ts helper instead
        const schemas = schema.allOf
          .map((item) => {
            if (isReference(item)) {
              return this.context.oas.get(item.$ref) as SchemaObject
            }
          })
          .filter(Boolean)

        const items = schemaWithoutAllOf.required
          .filter((key) => {
            // filter out keys that are already part of the properties(reduce duplicated keys(https://github.com/kubb-labs/kubb/issues/1492)
            if (schemaWithoutAllOf.properties) {
              return !Object.keys(schemaWithoutAllOf.properties).includes(key)
            }

            // schema should include required fields when necessary https://github.com/kubb-labs/kubb/issues/1522
            return true
          })
          .map((key) => {
            const schema = schemas.find((item) => item.properties && Object.keys(item.properties).find((propertyKey) => propertyKey === key))

            if (schema?.properties?.[key]) {
              return {
                ...schema,
                properties: {
                  [key]: schema.properties[key],
                },
                required: [key],
              }
            }
          })
          .filter(Boolean)

        and.args = [...(and.args || []), ...items.flatMap((item) => this.parse({ schema: item as SchemaObject, name, parentName }))]
      }

      if (schemaWithoutAllOf.properties) {
        and.args = [...(and.args || []), ...this.parse({ schema: schemaWithoutAllOf, name, parentName })]
      }

      return [and, ...baseItems]
    }

    if (schema.enum) {
      if (options.enumSuffix === '') {
        throw new Error('EnumSuffix set to an empty string does not work')
      }

      const enumName = getUniqueName(pascalCase([parentName, name, options.enumSuffix].join(' ')), this.#getUsedEnumNames({ schema, name }))
      const typeName = this.context.pluginManager.resolveName({
        name: enumName,
        pluginKey: this.context.plugin.key,
        type: 'type',
      })

      const nullableEnum = schema.enum.includes(null)
      if (nullableEnum) {
        baseItems.push({ keyword: schemaKeywords.nullable })
      }
      const filteredValues = schema.enum.filter((value) => value !== null)

      // x-enumNames has priority
      const extensionEnums = ['x-enumNames', 'x-enum-varnames']
        .filter((extensionKey) => extensionKey in schema)
        .map((extensionKey) => {
          return [
            {
              keyword: schemaKeywords.enum,
              args: {
                name,
                typeName,
                asConst: false,
                items: [...new Set(schema[extensionKey as keyof typeof schema] as string[])].map((name: string | number, index) => ({
                  name: transformers.stringify(name),
                  value: schema.enum?.[index] as string | number,
                  format: isNumber(schema.enum?.[index]) ? 'number' : 'string',
                })),
              },
            },
            ...baseItems.filter(
              (item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches,
            ),
          ]
        })

      if (schema.type === 'number' || schema.type === 'integer') {
        // we cannot use z.enum when enum type is number/integer
        const enumNames = extensionEnums[0]?.find((item) => isKeyword(item, schemaKeywords.enum)) as unknown as SchemaKeywordMapper['enum']
        return [
          {
            keyword: schemaKeywords.enum,
            args: {
              name: enumName,
              typeName,
              asConst: true,
              items: enumNames?.args?.items
                ? [...new Set(enumNames.args.items)].map(({ name, value }) => ({
                    name,
                    value,
                    format: 'number',
                  }))
                : [...new Set(filteredValues)].map((value: string) => {
                    return {
                      name: value,
                      value,
                      format: 'number',
                    }
                  }),
            },
          },
          ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches),
        ]
      }

      if (schema.type === 'boolean') {
        // we cannot use z.enum when enum type is boolean
        const enumNames = extensionEnums[0]?.find((item) => isKeyword(item, schemaKeywords.enum)) as unknown as SchemaKeywordMapper['enum']
        return [
          {
            keyword: schemaKeywords.enum,
            args: {
              name: enumName,
              typeName,
              asConst: true,
              items: enumNames?.args?.items
                ? [...new Set(enumNames.args.items)].map(({ name, value }) => ({
                    name,
                    value,
                    format: 'boolean',
                  }))
                : [...new Set(filteredValues)].map((value: string) => {
                    return {
                      name: value,
                      value,
                      format: 'boolean',
                    }
                  }),
            },
          },
          ...baseItems.filter((item) => item.keyword !== schemaKeywords.matches),
        ]
      }

      if (extensionEnums.length > 0 && extensionEnums[0]) {
        return extensionEnums[0]
      }

      return [
        {
          keyword: schemaKeywords.enum,
          args: {
            name: enumName,
            typeName,
            asConst: false,
            items: [...new Set(filteredValues)].map((value: string) => ({
              name: transformers.stringify(value),
              value,
              format: isNumber(value) ? 'number' : 'string',
            })),
          },
        },
        ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches),
      ]
    }

    if ('prefixItems' in schema) {
      const prefixItems = schema.prefixItems as SchemaObject[]
      const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined
      const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined

      return [
        {
          keyword: schemaKeywords.tuple,
          args: {
            min,
            max,
            items: prefixItems
              .map((item) => {
                return this.parse({ schema: item, name, parentName })[0]
              })
              .filter(Boolean),
          },
        },
        ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max),
      ]
    }

    if (version === '3.1' && 'const' in schema) {
      // const keyword takes precendence over the actual type.
      if (schema['const']) {
        return [
          {
            keyword: schemaKeywords.const,
            args: {
              name: schema['const'],
              format: typeof schema['const'] === 'number' ? 'number' : 'string',
              value: schema['const'],
            },
          },
          ...baseItems,
        ]
      }
      return [{ keyword: schemaKeywords.null }]
    }

    /**
     * > Structural validation alone may be insufficient to allow an application to correctly utilize certain values. The "format"
     * > annotation keyword is defined to allow schema authors to convey semantic information for a fixed subset of values which are
     * > accurately described by authoritative resources, be they RFCs or other external specifications.
     *
     * In other words: format is more specific than type alone, hence it should override the type value, if possible.
     *
     * see also https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
     */
    if (schema.format) {
      switch (schema.format) {
        case 'binary':
          baseItems.push({ keyword: schemaKeywords.blob })
          return baseItems
        case 'date-time':
          if (options.dateType) {
            if (options.dateType === 'date') {
              baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'date' } })

              return baseItems
            }

            if (options.dateType === 'stringOffset') {
              baseItems.unshift({ keyword: schemaKeywords.datetime, args: { offset: true } })
              return baseItems
            }

            if (options.dateType === 'stringLocal') {
              baseItems.unshift({ keyword: schemaKeywords.datetime, args: { local: true } })
              return baseItems
            }

            baseItems.unshift({ keyword: schemaKeywords.datetime, args: { offset: false } })

            return baseItems
          }
          break
        case 'date':
          if (options.dateType) {
            if (options.dateType === 'date') {
              baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'date' } })

              return baseItems
            }

            baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'string' } })

            return baseItems
          }
          break
        case 'time':
          if (options.dateType) {
            if (options.dateType === 'date') {
              baseItems.unshift({ keyword: schemaKeywords.time, args: { type: 'date' } })

              return baseItems
            }

            baseItems.unshift({ keyword: schemaKeywords.time, args: { type: 'string' } })

            return baseItems
          }
          break
        case 'uuid':
          baseItems.unshift({ keyword: schemaKeywords.uuid })
          return baseItems
        case 'email':
        case 'idn-email':
          baseItems.unshift({ keyword: schemaKeywords.email })
          return baseItems
        case 'uri':
        case 'ipv4':
        case 'ipv6':
        case 'uri-reference':
        case 'hostname':
        case 'idn-hostname':
          baseItems.unshift({ keyword: schemaKeywords.url })
          return baseItems
        // case 'duration':
        // case 'json-pointer':
        // case 'relative-json-pointer':
        default:
          // formats not yet implemented: ignore.
          break
      }
    }

    if (schema.pattern) {
      baseItems.unshift({
        keyword: schemaKeywords.matches,
        args: schema.pattern,
      })

      return baseItems
    }

    // type based logic
    if ('items' in schema || schema.type === ('array' as 'string')) {
      const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined
      const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined
      const items = this.parse({ schema: 'items' in schema ? (schema.items as SchemaObject) : [], name, parentName })
      const unique = !!schema.uniqueItems

      return [
        {
          keyword: schemaKeywords.array,
          args: {
            items,
            min,
            max,
            unique,
          },
        },
        ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max),
      ]
    }

    if (schema.properties || schema.additionalProperties) {
      return [...this.#parseProperties({ schema, name }), ...baseItems]
    }

    if (schema.type) {
      if (Array.isArray(schema.type)) {
        // OPENAPI v3.1.0: https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0
        const [type] = schema.type as Array<OpenAPIV3.NonArraySchemaObjectType>

        return [
          ...this.parse({
            schema: {
              ...schema,
              type,
            },
            name,
            parentName,
          }),
          ...baseItems,
        ].filter(Boolean)
      }

      if (!['boolean', 'object', 'number', 'string', 'integer', 'null'].includes(schema.type)) {
        this.context.pluginManager.logger.emit('warning', `Schema type '${schema.type}' is not valid for schema ${parentName}.${name}`)
      }

      // 'string' | 'number' | 'integer' | 'boolean'
      return [{ keyword: schema.type }, ...baseItems]
    }

    return [{ keyword: unknownReturn }]
  }

  async build(...generators: Array<Generator<TPluginOptions>>): Promise<Array<KubbFile.File<TFileMeta>>> {
    const { oas, contentType, include } = this.context

    oas.resolveDiscriminators()

    const schemas = getSchemas({ oas, contentType, includes: include })

    const promises = Object.entries(schemas).reduce((acc, [name, value]) => {
      if (!value) {
        return acc
      }

      const options = this.#getOptions({ name })
      const promiseOperation = this.schema.call(this, name, value, {
        ...this.options,
        ...options,
      })

      if (promiseOperation) {
        acc.push(promiseOperation)
      }

      generators?.forEach((generator) => {
        const tree = this.parse({ schema: value, name: name })

        const promise = generator.schema?.({
          instance: this,
          schema: {
            name,
            value,
            tree,
          },
          options: {
            ...this.options,
            ...options,
          },
        } as any) as Promise<Array<KubbFile.File<TFileMeta>>>

        if (promise) {
          acc.push(promise)
        }
      })

      return acc
    }, [] as SchemaMethodResult<TFileMeta>[])

    const files = await Promise.all(promises)

    // using .flat because schemaGenerator[method] can return an array of files or just one file
    return files.flat().filter(Boolean)
  }

  /**
   * Schema
   */
  async schema(_name: string, _object: SchemaObject, _options: TOptions): SchemaMethodResult<TFileMeta> {
    return []
  }
}
