import { stringify, toRegExpString } from '@internals/utils'
import type { Schema, SchemaKeywordMapper, SchemaMapper } from '@kubb/plugin-oas'
import { createParser, findSchemaKeyword, isKeyword, schemaKeywords } from '@kubb/plugin-oas'
import type { Options } from './types.ts'

const fakerKeywordMapper = {
  any: () => 'undefined',
  unknown: () => 'undefined',
  void: () => 'undefined',
  number: (min?: number, max?: number) => {
    if (max !== undefined && min !== undefined) {
      return `faker.number.float({ min: ${min}, max: ${max} })`
    }

    if (max !== undefined) {
      return `faker.number.float({ max: ${max} })`
    }

    if (min !== undefined) {
      return `faker.number.float({ min: ${min} })`
    }

    return 'faker.number.float()'
  },
  integer: (min?: number, max?: number) => {
    if (max !== undefined && min !== undefined) {
      return `faker.number.int({ min: ${min}, max: ${max} })`
    }

    if (max !== undefined) {
      return `faker.number.int({ max: ${max} })`
    }

    if (min !== undefined) {
      return `faker.number.int({ min: ${min} })`
    }

    return 'faker.number.int()'
  },
  bigint: () => 'faker.number.bigInt()',
  string: (min?: number, max?: number) => {
    if (max !== undefined && min !== undefined) {
      return `faker.string.alpha({ length: { min: ${min}, max: ${max} } })`
    }

    if (max !== undefined) {
      return `faker.string.alpha({ length: ${max} })`
    }

    if (min !== undefined) {
      return `faker.string.alpha({ length: ${min} })`
    }

    return 'faker.string.alpha()'
  },
  boolean: () => 'faker.datatype.boolean()',
  undefined: () => 'undefined',
  null: () => 'null',
  array: (items: string[] = [], min?: number, max?: number) => {
    if (items.length > 1) {
      return `faker.helpers.arrayElements([${items.join(', ')}])`
    }
    const item = items.at(0)

    if (min !== undefined && max !== undefined) {
      return `faker.helpers.multiple(() => (${item}), { count: { min: ${min}, max: ${max} }})`
    }
    if (min !== undefined) {
      return `faker.helpers.multiple(() => (${item}), { count: ${min} })`
    }
    if (max !== undefined) {
      return `faker.helpers.multiple(() => (${item}), { count: { min: 0, max: ${max} }})`
    }

    return `faker.helpers.multiple(() => (${item}))`
  },
  tuple: (items: string[] = []) => `[${items.join(', ')}]`,
  enum: (items: Array<string | number | boolean | undefined> = [], type = 'any') => `faker.helpers.arrayElement<${type}>([${items.join(', ')}])`,
  union: (items: string[] = []) => `faker.helpers.arrayElement<any>([${items.join(', ')}])`,
  /**
   * ISO 8601
   */
  datetime: () => 'faker.date.anytime().toISOString()',
  /**
   * Type `'date'` Date
   * Type `'string'` ISO date format (YYYY-MM-DD)
   * @default ISO date format (YYYY-MM-DD)
   */
  date: (type: 'date' | 'string' = 'string', parser: Options['dateParser'] = 'faker') => {
    if (type === 'string') {
      if (parser !== 'faker') {
        return `${parser}(faker.date.anytime()).format("YYYY-MM-DD")`
      }
      return 'faker.date.anytime().toISOString().substring(0, 10)'
    }

    if (parser !== 'faker') {
      throw new Error(`type '${type}' and parser '${parser}' can not work together`)
    }

    return 'faker.date.anytime()'
  },
  /**
   * Type `'date'` Date
   * Type `'string'` ISO time format (HH:mm:ss[.SSSSSS])
   * @default ISO time format (HH:mm:ss[.SSSSSS])
   */
  time: (type: 'date' | 'string' = 'string', parser: Options['dateParser'] = 'faker') => {
    if (type === 'string') {
      if (parser !== 'faker') {
        return `${parser}(faker.date.anytime()).format("HH:mm:ss")`
      }
      return 'faker.date.anytime().toISOString().substring(11, 19)'
    }

    if (parser !== 'faker') {
      throw new Error(`type '${type}' and parser '${parser}' can not work together`)
    }

    return 'faker.date.anytime()'
  },
  uuid: () => 'faker.string.uuid()',
  url: () => 'faker.internet.url()',
  and: (items: string[] = []) => {
    // Handle empty array case
    if (items.length === 0) {
      return '{}'
    }

    // If only one item, return it as-is (no need to spread)
    // This fixes the issue with single refs to primitives like enums
    if (items.length === 1) {
      return items[0] ?? '{}'
    }

    // If multiple items, spread them together
    // This handles both object literals and multiple refs to objects
    return `{...${items.join(', ...')}}`
  },
  object: () => 'object',
  ref: () => 'ref',
  matches: (value = '', regexGenerator: 'faker' | 'randexp' = 'faker') => {
    if (regexGenerator === 'randexp') {
      return `${toRegExpString(value, 'RandExp')}.gen()`
    }
    return `faker.helpers.fromRegExp("${value}")`
  },
  email: () => 'faker.internet.email()',
  firstName: () => 'faker.person.firstName()',
  lastName: () => 'faker.person.lastName()',
  password: () => 'faker.internet.password()',
  phone: () => 'faker.phone.number()',
  blob: () => 'faker.image.url() as unknown as Blob',
  default: undefined,
  describe: undefined,
  const: (value?: string | number) => (value as string) ?? '',
  max: undefined,
  min: undefined,
  nullable: undefined,
  nullish: undefined,
  optional: undefined,
  readOnly: undefined,
  writeOnly: undefined,
  deprecated: undefined,
  example: undefined,
  schema: undefined,
  catchall: undefined,
  name: undefined,
  interface: undefined,
  exclusiveMaximum: undefined,
  exclusiveMinimum: undefined,
} satisfies SchemaMapper<string | null | undefined>

/**
 * @link based on https://github.com/cellular/oazapfts/blob/7ba226ebb15374e8483cc53e7532f1663179a22c/src/codegen/generate.ts#L398
 */

function schemaKeywordSorter(_a: Schema, b: Schema) {
  if (b.keyword === 'null') {
    return -1
  }

  return 0
}

export function joinItems(items: string[]): string {
  switch (items.length) {
    case 0:
      return 'undefined'
    case 1:
      return items[0]!
    default:
      return fakerKeywordMapper.union(items)
  }
}

/**
 * Returns true if any schema in the list contains a $ref that points to a type other
 * than rootTypeName (i.e. an indirect / mutual ref rather than a direct self-ref).
 * Recurses through array.items, union/and args, tuple items, and nested object properties.
 */
export function hasIndirectRef(schemas: Schema[], rootTypeName: string | undefined): boolean {
  return hasIndirectRefInner(schemas, rootTypeName, new WeakSet())
}

function hasIndirectRefInner(schemas: Schema[], rootTypeName: string | undefined, visited: WeakSet<Schema>): boolean {
  return schemas.some((schema) => {
    if (!('args' in schema)) {
      return false
    }
    if (visited.has(schema)) {
      return false
    }
    visited.add(schema)

    switch (schema.keyword) {
      case schemaKeywords.ref: {
        return schema.args?.name !== rootTypeName
      }
      case schemaKeywords.array: {
        return hasIndirectRefInner(schema.args?.items ?? [], rootTypeName, visited)
      }
      case schemaKeywords.union:
      case schemaKeywords.and: {
        return Array.isArray(schema.args) && hasIndirectRefInner(schema.args, rootTypeName, visited)
      }
      case schemaKeywords.tuple: {
        const items: Schema[] = Array.isArray(schema.args?.items) ? schema.args.items : schema.args?.items ? [schema.args.items] : []
        return hasIndirectRefInner(items, rootTypeName, visited)
      }
      case schemaKeywords.object: {
        const props = schema.args?.properties as Record<string, Schema[]> | undefined
        if (!props) return false
        return Object.values(props).some((propSchemas) => hasIndirectRefInner(propSchemas, rootTypeName, visited))
      }
    }
    return false
  })
}

type ParserOptions = {
  typeName?: string
  rootTypeName?: string
  regexGenerator?: 'faker' | 'randexp'
  canOverride?: boolean
  dateParser?: Options['dateParser']
  mapper?: Record<string, string>
}

export const parse = createParser<string, ParserOptions>({
  mapper: fakerKeywordMapper,
  handlers: {
    union(tree, options) {
      const { current, schema, name, siblings } = tree

      if (Array.isArray(current.args) && !current.args.length) {
        return ''
      }

      return fakerKeywordMapper.union(
        current.args
          .map((it) => this.parse({ schema, parent: current, name, current: it, siblings }, { ...options, canOverride: false }))
          .filter((x): x is string => Boolean(x)),
      )
    },
    and(tree, options) {
      const { current, schema, siblings } = tree

      return fakerKeywordMapper.and(
        current.args
          .map((it) => this.parse({ schema, parent: current, current: it, siblings }, { ...options, canOverride: false }))
          .filter((x): x is string => Boolean(x)),
      )
    },
    array(tree, options) {
      const { current, schema } = tree

      return fakerKeywordMapper.array(
        current.args.items
          .map((it) =>
            this.parse(
              {
                schema,
                parent: current,
                current: it,
                siblings: current.args.items,
              },
              {
                ...options,
                typeName: `NonNullable<${options.typeName}>[number]`,
                canOverride: false,
              },
            ),
          )
          .filter((x): x is string => Boolean(x)),
        current.args.min,
        current.args.max,
      )
    },
    enum(tree, options) {
      const { current, parent, name } = tree

      const isParentTuple = parent ? isKeyword(parent, schemaKeywords.tuple) : false

      if (isParentTuple) {
        return fakerKeywordMapper.enum(
          current.args.items.map((schema) => {
            if (schema.format === 'number') {
              return schema.value
            }

            if (schema.format === 'boolean') {
              return schema.value
            }
            return stringify(schema.value)
          }),
        )
      }

      return fakerKeywordMapper.enum(
        current.args.items.map((schema) => {
          if (schema.format === 'number') {
            return schema.value
          }
          if (schema.format === 'boolean') {
            return schema.value
          }
          return stringify(schema.value)
        }),
        // TODO replace this with getEnumNameFromSchema
        name ? options.typeName : undefined,
      )
    },
    ref(tree, options) {
      const { current, parent } = tree

      if (!current.args?.name) {
        throw new Error(`Name not defined for keyword ${current.keyword}`)
      }

      // Check if this is a self-referencing type (prevents infinite recursion)
      // The rootTypeName is the function name being generated (e.g., "createNode")
      // The current.args.name is the ref function name (e.g., "createNode")
      const isSelfReferencing = options.rootTypeName && current.args.name === options.rootTypeName

      if (isSelfReferencing) {
        // For self-referencing types, return undefined to prevent infinite recursion
        // This will result in empty arrays/objects by default
        return 'undefined as any'
      }

      // Check if the parent is an object or and keyword - in these cases, we don't want to pass data
      // because it would incorrectly forward the parent's data to a nested ref
      const isNestedInObjectOrAnd = parent && (isKeyword(parent, schemaKeywords.object) || isKeyword(parent, schemaKeywords.and))

      if (options.canOverride && !isNestedInObjectOrAnd) {
        return `${current.args.name}(data)`
      }

      return `${current.args.name}()`
    },
    object(tree, options) {
      const { current, schema } = tree

      const filteredEntries = Object.entries(current.args?.properties || {}).filter((item) => {
        const schema = item[1]
        return schema && typeof schema.map === 'function'
      })

      const anyIndirectRef = filteredEntries.some(([, schemas]) => hasIndirectRef(schemas, options.rootTypeName))

      const argsObject = filteredEntries
        .map(([name, schemas]) => {
          const nameSchema = schemas.find((schema) => schema.keyword === schemaKeywords.name) as SchemaKeywordMapper['name']
          const mappedName = nameSchema?.args || name

          // custom mapper(pluginOptions)
          // Use Object.hasOwn to avoid matching inherited properties like 'toString', 'valueOf', etc.
          if (options.mapper && Object.hasOwn(options.mapper, mappedName)) {
            return `"${name}": ${options.mapper?.[mappedName]}`
          }

          const parsed = joinItems(
            schemas
              .sort(schemaKeywordSorter)
              .map((it) =>
                this.parse(
                  {
                    schema,
                    name,
                    parent: current,
                    current: it,
                    siblings: schemas,
                  },
                  {
                    ...options,
                    typeName: `NonNullable<${options.typeName}>[${JSON.stringify(name)}]`,
                    canOverride: false,
                  },
                ),
              )
              .filter((x): x is string => Boolean(x)),
          )

          if (anyIndirectRef && hasIndirectRef(schemas, options.rootTypeName)) {
            return `get "${name}"() { return ${parsed} }`
          }

          return `"${name}": ${parsed}`
        })
        .join(',')

      if (anyIndirectRef && options.canOverride) {
        const separator = argsObject.length > 0 ? ',' : ''
        return `{${argsObject}${separator}...(data || {})}`
      }

      return `{${argsObject}}`
    },
    tuple(tree, options) {
      const { current, schema, siblings } = tree

      if (Array.isArray(current.args.items)) {
        return fakerKeywordMapper.tuple(
          current.args.items
            .map((it) => this.parse({ schema, parent: current, current: it, siblings }, { ...options, canOverride: false }))
            .filter((x): x is string => Boolean(x)),
        )
      }

      return this.parse({ schema, parent: current, current: current.args.items, siblings }, { ...options, canOverride: false })
    },
    const(tree, _options) {
      const { current } = tree

      if (current.args.format === 'number' && current.args.name !== undefined) {
        return fakerKeywordMapper.const(current.args.name?.toString())
      }
      return fakerKeywordMapper.const(stringify(current.args.value))
    },
    matches(tree, options) {
      const { current } = tree

      if (current.args) {
        return fakerKeywordMapper.matches(current.args, options.regexGenerator)
      }
      return undefined
    },
    null() {
      return fakerKeywordMapper.null()
    },
    undefined() {
      return fakerKeywordMapper.undefined()
    },
    any() {
      return fakerKeywordMapper.any()
    },
    string(tree, _options) {
      const { siblings } = tree

      if (siblings) {
        const minSchema = findSchemaKeyword(siblings, 'min')
        const maxSchema = findSchemaKeyword(siblings, 'max')

        return fakerKeywordMapper.string(minSchema?.args, maxSchema?.args)
      }

      return fakerKeywordMapper.string()
    },
    number(tree, _options) {
      const { siblings } = tree

      if (siblings) {
        const minSchema = findSchemaKeyword(siblings, 'min')
        const maxSchema = findSchemaKeyword(siblings, 'max')

        return fakerKeywordMapper.number(minSchema?.args, maxSchema?.args)
      }

      return fakerKeywordMapper.number()
    },
    integer(tree, _options) {
      const { siblings } = tree

      if (siblings) {
        const minSchema = findSchemaKeyword(siblings, 'min')
        const maxSchema = findSchemaKeyword(siblings, 'max')

        return fakerKeywordMapper.integer(minSchema?.args, maxSchema?.args)
      }

      return fakerKeywordMapper.integer()
    },
    bigint(_tree, _options) {
      return fakerKeywordMapper.bigint()
    },
    datetime() {
      return fakerKeywordMapper.datetime()
    },
    date(tree, options) {
      const { current } = tree

      return fakerKeywordMapper.date(current.args.type, options.dateParser)
    },
    time(tree, options) {
      const { current } = tree

      return fakerKeywordMapper.time(current.args.type, options.dateParser)
    },
  },
})
