import {JSONSchema4Type, JSONSchema4TypeName} from 'json-schema'
import {includes, isPlainObject, map, omit} from 'lodash'
import {format} from 'util'
import {Options} from './'
import {applySchemaTyping} from './applySchemaTyping'
import type {AST, TInterface, TInterfaceParam, TIntersection, TNamedInterface, TTuple} from './types/AST'
import {
  hasStandaloneName,
  T_ANY,
  T_ANY_ADDITIONAL_PROPERTIES,
  T_UNKNOWN,
  T_UNKNOWN_ADDITIONAL_PROPERTIES,
} from './types/AST'
import type {
  EnumJSONSchema,
  JSONSchemaWithDefinitions,
  LinkedJSONSchema,
  NormalizedJSONSchema,
  SchemaSchema,
  SchemaType,
} from './types/JSONSchema'
import {Intersection, Types, getRootSchema, isBoolean, isPrimitive} from './types/JSONSchema'
import {memoize} from './memoize'
import {DereferencedPaths} from './resolver'
import {generateName, justName, log, maybeStripDefault} from './utils'

export type Processed = Map<NormalizedJSONSchema, Map<SchemaType, AST>>

export type UsedNames = Set<string>

export function parse(
  schema: NormalizedJSONSchema | JSONSchema4Type,
  options: Options,
  keyName?: string,
  processed: Processed = new Map(),
  usedNames = new Set<string>(),
): AST {
  if (isPrimitive(schema)) {
    if (isBoolean(schema)) {
      return parseBooleanSchema(schema, keyName, options)
    }

    return parseLiteral(schema, keyName)
  }

  const intersection = schema[Intersection]
  const types = schema[Types]

  if (intersection) {
    const ast = parseAsTypeWithCache(intersection, 'ALL_OF', options, keyName, processed, usedNames) as TIntersection

    types.forEach(type => {
      ast.params.push(parseAsTypeWithCache(schema, type, options, keyName, processed, usedNames))
    })

    log('blue', 'parser', 'Types:', [...types], 'Input:', schema, 'Output:', ast)
    return ast
  }

  if (types.size === 1) {
    const type = [...types][0]
    const ast = parseAsTypeWithCache(schema, type, options, keyName, processed, usedNames)
    log('blue', 'parser', 'Type:', type, 'Input:', schema, 'Output:', ast)
    return ast
  }

  throw new ReferenceError('Expected intersection schema. Please file an issue on GitHub.')
}

/**
 * A schema that refers back to itself can only be emitted as a named declaration:
 * an anonymous type has no way to mention itself, so the generator would try to
 * inline it forever. Recursive schemas usually have a name by the time they get
 * here (a `title`, an `$id`, a key in `definitions`), but not always -- eg. a
 * self-referencing `oneOf` that lives outside of `definitions`, or the copy the
 * resolver makes of a definition wherever a `$ref` to it carries sibling keywords.
 *
 * Guarantees that every reference cycle in the AST passes through a named type, by
 * naming anonymous types where it has to: after the `$ref` they were dereferenced
 * from when there is one, else after the closest property key or `$ref` above them.
 */
export function nameAnonymousRecursiveTypes(
  ast: AST,
  processed: Processed,
  dereferencedPaths: DereferencedPaths,
  usedNames: UsedNames,
): void {
  const refNames = new Map<AST, string>()
  processed.forEach((asts, schema) => {
    const name = justName(dereferencedPaths.get(schema))
    if (name) {
      asts.forEach(_ => refNames.set(_, name))
    }
  })

  // The generator emits a named type by reference (and declares it separately), and
  // inlines everything else -- so it recurses forever exactly when some cycle is made
  // of anonymous types only. Walk the AST the same way: a named type ends the current
  // path and becomes a root of its own, so an edge that leads back into the current
  // path (past its root) closes an all-anonymous cycle. Name one of that edge's two
  // ends; every cycle through the edge contains both. A type named part-way through a
  // walk can hide a second cycle that shares its path but not that type, so walk
  // again until a walk finds nothing to name (one extra walk, in practice).
  let named: boolean
  do {
    named = false
    const done = new Set<AST>()
    const roots = [ast]
    const path: AST[] = []
    const visit = (node: AST): void => {
      if (done.has(node)) {
        return
      }
      if (path.length && hasStandaloneName(node)) {
        roots.push(node)
        return
      }
      const index = path.indexOf(node)
      if (index > -1) {
        if (!path.slice(index).some(hasStandaloneName)) {
          const target = pickEnd(node, path[path.length - 1])
          target.standaloneName = generateName(refNames.get(target) ?? keyOf(target) ?? closestName(path), usedNames)
          named = true
        }
        return
      }
      path.push(node)
      subtrees(node).forEach(visit)
      path.pop()
      done.add(node)
    }
    while (roots.length) {
      visit(roots.pop()!)
    }
  } while (named)

  // Prefer the end that was reached through a `$ref` (the resolver's copies share
  // their children with the original, so a cycle through a copy is often entered at
  // a child rather than at the copy), then the end that isn't a list, so that the
  // alias reads `type Foo = string | Foo[]` rather than naming the array.
  function pickEnd(node: AST, source: AST): AST {
    if (refNames.has(node) !== refNames.has(source)) {
      return refNames.has(node) ? node : source
    }
    return isList(node) && !isList(source) ? source : node
  }

  // Last resort is the root the walk started from: the schema itself or a named type
  function closestName(path: AST[]): string {
    const above = [...path].reverse()
    return above.map(_ => refNames.get(_)).find(Boolean) ?? above.map(keyOf).find(Boolean) ?? path[0].standaloneName!
  }
}

/** A node's property key -- ignoring the placeholder that array items get */
function keyOf(ast: AST): string | undefined {
  return ast.keyName?.includes('{keyNameFromDefinition}') ? undefined : ast.keyName
}

function isList(ast: AST): boolean {
  return ast.type === 'ARRAY' || ast.type === 'TUPLE'
}

function subtrees(ast: AST): AST[] {
  switch (ast.type) {
    case 'ARRAY':
      return [ast.params]
    case 'INTERFACE':
      return ast.params.map(_ => _.ast).concat(ast.superTypes)
    case 'INTERSECTION':
    case 'UNION':
      return ast.params
    case 'TUPLE':
      return ast.spreadParam ? ast.params.concat(ast.spreadParam) : ast.params
    default:
      return []
  }
}

function parseAsTypeWithCache(
  schema: NormalizedJSONSchema,
  type: SchemaType,
  options: Options,
  keyName?: string,
  processed: Processed = new Map(),
  usedNames = new Set<string>(),
): AST {
  // If we've seen this node before, return it.
  let cachedTypeMap = processed.get(schema)
  if (!cachedTypeMap) {
    cachedTypeMap = new Map()
    processed.set(schema, cachedTypeMap)
  }
  const cachedAST = cachedTypeMap.get(type)
  if (cachedAST) {
    return cachedAST
  }

  // Cache processed ASTs before they are actually computed, then update
  // them in place using set(). This is to avoid cycles.
  // TODO: Investigate alternative approaches (lazy-computing nodes, etc.)
  const ast = {} as AST
  cachedTypeMap.set(type, ast)

  // Update the AST in place. This updates the `processed` cache, as well
  // as any nodes that directly reference the node.
  return Object.assign(ast, parseNonLiteral(schema, type, options, keyName, processed, usedNames))
}

function parseBooleanSchema(schema: boolean, keyName: string | undefined, options: Options): AST {
  if (schema) {
    return {
      keyName,
      type: options.unknownAny ? 'UNKNOWN' : 'ANY',
    }
  }

  return {
    keyName,
    type: 'NEVER',
  }
}

function parseLiteral(schema: JSONSchema4Type, keyName: string | undefined): AST {
  return {
    keyName,
    params: schema,
    type: 'LITERAL',
  }
}

function parseNonLiteral(
  schema: NormalizedJSONSchema,
  type: SchemaType,
  options: Options,
  keyName: string | undefined,
  processed: Processed,
  usedNames: UsedNames,
): AST {
  const definitions = getDefinitionsMemoized(getRootSchema(schema as any)) // TODO
  const keyNameFromDefinition = getDefinitionKeysMemoized(definitions).get(schema)

  switch (type) {
    case 'ALL_OF':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        // An `allOf` member made up entirely of keywords this tool doesn't implement (eg.
        // `if`/`then`/`else`, `not`) doesn't match any of the type matchers in `typesOfSchema`,
        // so it falls back to `newInterface`, which synthesizes a bare `{[k: string]: unknown}`
        // for it. Intersecting with that contributes no information, so drop it rather than
        // cluttering the output. Restricted to members with no keyword this tool does recognize,
        // so it never touches a member whose emptiness is due to its *own* type (eg. a bare
        // `{type: 'object'}`, or `{required: [...]}` with no matching `properties`) -- those stay
        // exactly as before.
        params: schema
          .allOf!.map(memberSchema => ({
            ast: parse(memberSchema, options, undefined, processed, usedNames),
            memberSchema,
          }))
          .filter(({ast, memberSchema}) => !(hasNoRecognizedKeywords(memberSchema) && isVacuousInterface(ast)))
          .map(({ast}) => ast),
        type: 'INTERSECTION',
      }
    case 'ANY':
      return {
        ...(options.unknownAny ? T_UNKNOWN : T_ANY),
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
      }
    case 'ANY_OF':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        params: schema.anyOf!.map(_ => parse(_, options, undefined, processed, usedNames)),
        type: 'UNION',
      }
    case 'BOOLEAN':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'BOOLEAN',
      }
    case 'CUSTOM_TYPE':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        params: schema.tsType!,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'CUSTOM_TYPE',
      }
    case 'NAMED_ENUM': {
      const enumName = standaloneName(schema, keyNameFromDefinition ?? keyName, usedNames, options)
      // A TypeScript enum declaration requires a name. In positions that supply
      // none (an `anyOf`/`oneOf` branch, say) fall back to a union of literals
      // rather than emitting a nameless `export enum { ... }`, which is invalid.
      if (!enumName) {
        return {
          comment: schema.description,
          deprecated: schema.deprecated,
          keyName,
          params: (schema as EnumJSONSchema).enum!.map(_ => parseLiteral(_, undefined)),
          type: 'UNION',
        }
      }
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: enumName,
        params: (schema as EnumJSONSchema).enum!.map((_, n) => ({
          ast: parseLiteral(_, undefined),
          keyName: schema.tsEnumNames![n],
        })),
        type: 'ENUM',
      }
    }
    case 'NAMED_SCHEMA':
      return newInterface(schema as SchemaSchema, options, processed, usedNames, keyName)
    case 'NEVER':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'NEVER',
      }
    case 'NULL':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'NULL',
      }
    case 'NUMBER':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'NUMBER',
      }
    case 'OBJECT':
      return {
        comment: schema.description,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'OBJECT',
        deprecated: schema.deprecated,
      }
    case 'ONE_OF':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        params: schema.oneOf!.map(_ => parse(_, options, undefined, processed, usedNames)),
        type: 'UNION',
      }
    case 'REFERENCE':
      throw Error(format('Refs should have been resolved by the resolver!', schema))
    case 'STRING':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'STRING',
      }
    case 'TYPED_ARRAY':
      if (Array.isArray(schema.items)) {
        // normalised to not be undefined
        const minItems = schema.minItems!
        const maxItems = schema.maxItems!
        const arrayType: TTuple = {
          comment: schema.description,
          deprecated: schema.deprecated,
          keyName,
          maxItems,
          minItems,
          standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
          params: schema.items.map(_ => parse(_, options, undefined, processed, usedNames)),
          type: 'TUPLE',
        }
        if (schema.additionalItems === true) {
          arrayType.spreadParam = options.unknownAny ? T_UNKNOWN : T_ANY
        } else if (schema.additionalItems) {
          arrayType.spreadParam = parse(schema.additionalItems, options, undefined, processed, usedNames)
        }
        return arrayType
      } else {
        return {
          comment: schema.description,
          deprecated: schema.deprecated,
          keyName,
          standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
          params: parse(schema.items!, options, `{keyNameFromDefinition}Items`, processed, usedNames),
          type: 'ARRAY',
        }
      }
    case 'UNION':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        params: (schema.type as JSONSchema4TypeName[]).map(type => {
          const member: LinkedJSONSchema = {...omit(schema, '$id', 'description', 'title'), type}
          maybeStripDefault(member)
          applySchemaTyping(member)
          return parse(member, options, undefined, processed, usedNames)
        }),
        type: 'UNION',
      }
    case 'UNNAMED_ENUM':
      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        params: (schema as EnumJSONSchema).enum!.map(_ => parseLiteral(_, undefined)),
        type: 'UNION',
      }
    case 'UNNAMED_SCHEMA':
      return newInterface(schema as SchemaSchema, options, processed, usedNames, keyName, keyNameFromDefinition)
    case 'UNTYPED_ARRAY':
      // normalised to not be undefined
      const minItems = schema.minItems!
      const maxItems = typeof schema.maxItems === 'number' ? schema.maxItems : -1
      const params = options.unknownAny ? T_UNKNOWN : T_ANY
      if (minItems > 0 || maxItems >= 0) {
        return {
          comment: schema.description,
          deprecated: schema.deprecated,
          keyName,
          maxItems: schema.maxItems,
          minItems,
          // create a tuple of length N
          params: Array(Math.max(maxItems, minItems) || 0).fill(params),
          // if there is no maximum, then add a spread item to collect the rest
          spreadParam: maxItems >= 0 ? undefined : params,
          standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
          type: 'TUPLE',
        }
      }

      return {
        comment: schema.description,
        deprecated: schema.deprecated,
        keyName,
        params,
        standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options),
        type: 'ARRAY',
      }
  }
}

// Keywords that some matcher in `typesOfSchema`, or the `additionalProperties`/`required`
// normalizer rules, actually keys off of. An `allOf` member made up exclusively of keywords
// outside this list (eg. `if`/`then`/`else`, `not`) is one this tool has no notion of at all,
// as opposed to eg. a bare `{type: 'object'}`, which the tool does recognize but currently
// renders no differently -- that distinction keeps `hasNoRecognizedKeywords` from also
// swallowing members whose current (separately unimplemented) behavior other schemas rely on.
// (`$ref` is deliberately omitted: by the time this runs, the resolver has already replaced
// every `$ref` node, so `case 'REFERENCE'` above never fires and no schema here can carry one.)
// Keep this in sync with the keywords `typesOfSchema.ts`'s matchers check.
const RECOGNIZED_ALL_OF_MEMBER_KEYWORDS = new Set([
  '$id',
  'additionalProperties',
  'allOf',
  'anyOf',
  'const',
  'default',
  'enum',
  'extends',
  'items',
  'oneOf',
  'patternProperties',
  'properties',
  'required',
  'tsEnumNames',
  'tsType',
  'type',
])

function hasNoRecognizedKeywords(schema: NormalizedJSONSchema): boolean {
  return Object.keys(schema).every(key => !RECOGNIZED_ALL_OF_MEMBER_KEYWORDS.has(key))
}

/**
 * True for a parsed AST that carries no information beyond the synthesized
 * `[k: string]: unknown`/`any` index signature `parseSchema` adds by default -- ie. an interface
 * with no properties, patternProperties, superTypes, comment, or standalone name of its own.
 * @see https://github.com/bcherny/json-schema-to-typescript/issues/369
 */
function isVacuousInterface(ast: AST): boolean {
  return (
    ast.type === 'INTERFACE' &&
    ast.standaloneName === undefined &&
    ast.comment === undefined &&
    !ast.deprecated &&
    ast.superTypes.length === 0 &&
    ast.params.length === 1 &&
    ast.params[0].isIndexSignature &&
    (ast.params[0].ast.type === 'ANY' || ast.params[0].ast.type === 'UNKNOWN')
  )
}

/**
 * Compute a schema name using a series of fallbacks
 */
function standaloneName(
  schema: NormalizedJSONSchema,
  keyNameFromDefinition: string | undefined,
  usedNames: UsedNames,
  options: Options,
): string | undefined {
  const name =
    options.customName?.(schema, keyNameFromDefinition) || schema.title || schema.$id || keyNameFromDefinition
  if (name) {
    return generateName(name, usedNames)
  }
}

function newInterface(
  schema: SchemaSchema,
  options: Options,
  processed: Processed,
  usedNames: UsedNames,
  keyName?: string,
  keyNameFromDefinition?: string,
): TInterface {
  const name = standaloneName(schema, keyNameFromDefinition, usedNames, options)!
  return {
    comment: schema.description,
    deprecated: schema.deprecated,
    keyName,
    params: parseSchema(schema, options, processed, usedNames, name),
    standaloneName: name,
    superTypes: parseSuperTypes(schema, options, processed, usedNames),
    type: 'INTERFACE',
  }
}

function parseSuperTypes(
  schema: SchemaSchema,
  options: Options,
  processed: Processed,
  usedNames: UsedNames,
): TNamedInterface[] {
  // Type assertion needed because of dereferencing step
  // TODO: Type it upstream
  const superTypes = schema.extends as SchemaSchema[] | undefined
  if (!superTypes) {
    return []
  }
  return superTypes.map(_ => parse(_, options, undefined, processed, usedNames) as TNamedInterface)
}

/**
 * Draft 4+ lists an object's required properties on the object schema (`required: [...]`).
 * Draft 3 instead flagged each property schema (`required: true`), and some generators still
 * emit that form. Support both, reading the draft 3 form only when it is strictly `true` so
 * that a property's own `required` array (which of *its* properties are required) is never
 * mistaken for the flag.
 */
function isRequired(parentSchema: SchemaSchema, key: string, propertySchema: NormalizedJSONSchema): boolean {
  return propertySchema.required === true || (parentSchema.required !== true && includes(parentSchema.required, key))
}

/**
 * Helper to parse schema properties into params on the parent schema's type
 */
function parseSchema(
  schema: SchemaSchema,
  options: Options,
  processed: Processed,
  usedNames: UsedNames,
  parentSchemaName: string,
): TInterfaceParam[] {
  const asts: TInterfaceParam[] = map(schema.properties, (value, key: string) => ({
    ast: parse(value, options, key, processed, usedNames),
    isIndexSignature: false,
    isPatternProperty: false,
    isRequired: isRequired(schema, key, value),
    isUnreachableDefinition: false,
    keyName: key,
  }))

  // rendered through the index signature (below), not as params of their own
  const patternProperties: TInterfaceParam[] = map(schema.patternProperties, (value, key: string) => {
    const ast = parse(value, options, key, processed, usedNames)
    const comment = `This interface was referenced by \`${parentSchemaName}\`'s JSON-Schema definition
via the \`patternProperty\` "${key.replace('*/', '*\\/')}".`
    ast.comment = ast.comment ? `${ast.comment}\n\n${comment}` : comment
    return {
      ast,
      isIndexSignature: false,
      isPatternProperty: true,
      isRequired: isRequired(schema, key, value),
      isUnreachableDefinition: false,
      keyName: key,
    }
  })

  const unreachableDefinitions: TInterfaceParam[] = !options.unreachableDefinitions
    ? []
    : map(schema.$defs, (value, key: string) => {
        const ast = parse(value, options, key, processed, usedNames)
        const comment = `This interface was referenced by \`${parentSchemaName}\`'s JSON-Schema
via the \`definition\` "${key}".`
        ast.comment = ast.comment ? `${ast.comment}\n\n${comment}` : comment
        ast.isUnreachableDefinition = true
        return {
          ast,
          isIndexSignature: false,
          isPatternProperty: false,
          isRequired: isRequired(schema, key, value),
          isUnreachableDefinition: true,
          keyName: key,
        }
      })

  // TypeScript cannot constrain keys by regex, so patternProperties are folded into the one
  // string index signature, typed as the union of their value types:
  let declaredOnly: TInterfaceParam[] = [] // listed only so that their named types get declared
  let indexSignatureMembers: TInterfaceParam[]
  switch (schema.additionalProperties) {
    case true: // already admits every value; the patterns are listed only to get their named types declared
      declaredOnly = patternProperties
      indexSignatureMembers = []
      break
    case undefined: // validate against the patterns alone, as if it were `false`
    case false:
      indexSignatureMembers = patternProperties
      break
    default:
      indexSignatureMembers = patternProperties.concat({
        ast: parse(schema.additionalProperties, options, '[k: string]', processed, usedNames),
        isIndexSignature: false,
        isPatternProperty: true,
        isRequired: false,
        isUnreachableDefinition: false,
        keyName: '[k: string]',
      })
  }

  if (!indexSignatureMembers.length && schema.additionalProperties === false) {
    return asts.concat(unreachableDefinitions)
  }

  const members = indexSignatureMembers.map(_ => _.ast)
  let indexSignature: AST
  if (!members.length) {
    indexSignature = options.unknownAny ? T_UNKNOWN_ADDITIONAL_PROPERTIES : T_ANY_ADDITIONAL_PROPERTIES
  } else if (members.length === 1) {
    indexSignature = members[0]
  } else {
    indexSignature = {
      // Members with a standalone name carry their comment on their own declaration;
      // the others' comments (which name their pattern) go on the index signature.
      comment:
        members
          .filter(_ => !hasStandaloneName(_) && _.comment)
          .map(_ => _.comment)
          .join('\n\n') || undefined,
      keyName: '[k: string]',
      type: 'UNION',
      params: members,
    }
  }

  // pass "true" for isRequired because in TS, properties
  // defined via index signatures are already optional
  const indexSignatureParam: TInterfaceParam = {
    ast: indexSignature,
    isIndexSignature: true,
    isPatternProperty: false,
    isRequired: true,
    isUnreachableDefinition: false,
    keyName: '[k: string]',
  }

  // The members of a union are also listed as non-rendered params, so that their named types are
  // still declared when the optimizer collapses it (e.g. `X | unknown` to `unknown`). They go
  // after the index signature: the optimizer rewrites only the first param that holds a given AST.
  if (indexSignatureMembers.length > 1) {
    declaredOnly = indexSignatureMembers
  }
  // Declaration order as on master: types from patternProperties before unreachable definitions,
  // a signature that comes from additionalProperties alone after them.
  return patternProperties.length
    ? asts.concat(indexSignatureParam, declaredOnly, unreachableDefinitions)
    : asts.concat(unreachableDefinitions, indexSignatureParam)
}

type Definitions = {[k: string]: NormalizedJSONSchema}

function getDefinitions(
  schema: NormalizedJSONSchema,
  isSchema = true,
  processed = new Set<NormalizedJSONSchema>(),
): Definitions {
  if (processed.has(schema)) {
    return {}
  }
  processed.add(schema)
  if (Array.isArray(schema)) {
    return schema.reduce(
      (prev, cur) => ({
        ...prev,
        ...getDefinitions(cur, false, processed),
      }),
      {},
    )
  }
  if (isPlainObject(schema)) {
    return {
      ...(isSchema && hasDefinitions(schema) ? schema.$defs : {}),
      ...Object.keys(schema).reduce<Definitions>(
        (prev, cur) => ({
          ...prev,
          ...getDefinitions(schema[cur], false, processed),
        }),
        {},
      ),
    }
  }
  return {}
}

const getDefinitionsMemoized = memoize(getDefinitions)

/**
 * Reverse index of `getDefinitions`: schema -> the first definition key that holds it,
 * built once per definitions object instead of scanning every key for every parsed node.
 */
const getDefinitionKeysMemoized = memoize((definitions: Definitions): Map<NormalizedJSONSchema, string> => {
  const keys = new Map<NormalizedJSONSchema, string>()
  for (const key of Object.keys(definitions)) {
    if (!keys.has(definitions[key])) {
      keys.set(definitions[key], key)
    }
  }
  return keys
})

/**
 * TODO: Reduce rate of false positives
 */
function hasDefinitions(schema: NormalizedJSONSchema): schema is JSONSchemaWithDefinitions {
  return '$defs' in schema
}
