import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from '@deepseek-ai/cosmokit'
import type { StandardSchemaV1 } from '@standard-schema/spec'

const kSchema = Symbol.for('schemastery')
const kValidationError = Symbol.for('ValidationError')

declare global {
  namespace Schemastery {
    /** Convert primitive constructors, constants, and existing schemas into a schema type. */
    export type From<X> =
      | X extends string | number | boolean ? Schema<X>
      : X extends Schema ? X
      : X extends typeof String ? Schema<string>
      : X extends typeof Number ? Schema<number>
      : X extends typeof Boolean ? Schema<boolean>
      : X extends typeof Function ? Schema<Function, (...args: any[]) => any>
      : X extends Constructor<infer S> ? Schema<S>
      : never

    type TypeS1<X> = X extends Schema<infer S, unknown> ? S : never
    type Inverse<X> = X extends Schema<any, infer Y> ? (arg: Y) => void : never

    /** Input type accepted by a schema-like value. */
    export type TypeS<X> = TypeS1<From<X>>
    /** Output type returned by a schema-like value after validation. */
    export type TypeT<X> = ReturnType<From<X>>
    /** Resolver callback used by custom schema types registered with `Schema.extend()`. */
    export type Resolve = (data: any, schema: Schema, options: Options, strict?: boolean) => [any, any?]

    /** Input type accepted by one schema in an intersection. */
    export type IntersectS<X> = From<X> extends Schema<infer S, unknown> ? S : never
    /** Output type returned by one schema in an intersection. */
    export type IntersectT<X> = Inverse<From<X>> extends ((arg: infer T) => void) ? T : never

    type TupleS<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeS<L>?, ...TupleS<R>] : any[]
    type TupleT<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeT<L>?, ...TupleT<R>] : any[]
    type ObjectS<X extends Dict> = { [K in keyof X]?: TypeS<X[K]> | null } & Dict
    type ObjectT<X extends Dict> = { [K in keyof X]: TypeT<X[K]> } & Dict
    type Constructor<T = any> = new (...args: any[]) => T

    /** Static constructor and factory methods exposed by the default `Schema` export. */
    export interface Static {
      <T = any>(options: Partial<Schema<T>>): Schema<T>
      new <T = any>(options: Partial<Schema<T>>): Schema<T>
      prototype: Schema
      /** Validate a value against a schema node and return `[output, adaptedInput?]`. */
      resolve: Resolve
      /** Infer a schema from a primitive value, constructor, or existing schema. */
      from<X = any>(source?: X): From<X>
      /** Register a resolver for a custom schema `type`. */
      extend(type: string, resolve: Resolve): void
      /** Accept any value without validation. */
      any<T = any>(): Schema<T>
      /** Accept only nullable input. */
      never(): Schema<never>
      /** Accept exactly one constant value. */
      const<const T>(value: T): Schema<T>
      /** Accept strings, with optional metadata constraints added by instance methods. */
      string(): Schema<string>
      /** Accept numbers, with optional range and step constraints. */
      number(): Schema<number>
      /** Accept non-negative integer numbers. */
      natural(): Schema<number>
      /** Accept a number between 0 and 1 and mark it as a slider. */
      percent(): Schema<number>
      /** Accept booleans. */
      boolean(): Schema<boolean>
      /** Accept `Date` instances or parse datetime strings into `Date` objects. */
      date(): Schema<string | Date, Date>
      /** Accept `RegExp` instances or parse strings into regular expressions. */
      regExp(flag?: string): Schema<string | RegExp, RegExp>
      /** Accept binary sources and normalize them to `ArrayBufferLike`. */
      arrayBuffer(): Schema<Binary.Source, ArrayBufferLike>
      arrayBuffer(encoding: 'hex' | 'base64'): Schema<Binary.Source | string, ArrayBufferLike>
      /** Accept a numeric bitset or string keys and normalize to a number. */
      bitset<K extends string>(bits: Partial<Record<K, number>>): Schema<number | readonly K[], number>
      /** Accept functions. */
      function(): Schema<Function, (...args: any[]) => any>
      /** Accept instances of a constructor or objects whose constructor name matches. */
      is(constructor: string): Schema
      is<T>(constructor: Constructor<T>): Schema<T>
      /** Accept arrays whose elements match `inner`. */
      array<X>(inner: X): Schema<TypeS<X>[], TypeT<X>[]>
      /** Accept plain objects with values matching `inner` and optional key schema. */
      dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<TypeS<X>, TypeS<Y>>, Dict<TypeT<X>, TypeT<Y>>>
      /** Accept tuple arrays where each index matches the corresponding schema. */
      tuple<const X extends readonly any[]>(list: X): Schema<TupleS<X>, TupleT<X>>
      /** Accept plain objects whose declared properties match the schema dictionary. */
      object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
      /** Accept values matching at least one schema in `list`. */
      union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
      /** Accept values matching every schema in `list`, merging object outputs. */
      intersect<const X>(list: readonly X[]): Schema<IntersectS<X>, IntersectT<X>>
      /** Validate with `inner`, then convert the result with `callback`. */
      transform<X, T>(inner: X, callback: (value: TypeS<X>, options: Schemastery.Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
      /** Defer construction of a recursive schema until validation or serialization. */
      lazy<X extends Schema>(callback: () => X): X
      ValidationError: typeof ValidationError
    }

    /** Runtime validation options shared by all schema calls. */
    interface Options {
      /** Remove invalid object properties instead of throwing when possible. */
      autofix?: boolean
      /** Skip validation for selected values and schema nodes. */
      ignore?(data: any, schema: Schema): boolean
      /** Path used to format nested validation errors. */
      path?: (keyof any)[]
    }

    /** UI and validation metadata attached by schema builder methods. */
    export interface Meta<T = any> {
      default?: T extends {} ? Partial<T> : T
      required?: boolean
      disabled?: boolean
      collapse?: boolean
      badges?: { text: string; type: string }[]
      hidden?: boolean
      loose?: boolean
      role?: string
      extra?: any
      link?: string
      description?: string | Dict<string>
      comment?: string
      pattern?: { source: string; flags?: string }
      max?: number
      min?: number
      step?: number
    }
  }

  /** Callable schema instance that validates input and returns normalized output. */
  interface Schemastery<S = any, T = S> {
    (data?: S | null, options?: Schemastery.Options): T
    new (data?: S | null, options?: Schemastery.Options): T
    [kSchema]: true
    uid: number
    meta: Schemastery.Meta<T>
    type: string
    sKey?: Schema
    inner?: Schema
    list?: Schema[]
    dict?: Dict<Schema>
    bits?: Dict<number>
    callback?: Function
    constructor?: string | Function
    builder?: Function
    value?: T
    refs?: Dict<Schema>
    preserve?: boolean
    '~standard': StandardSchemaV1.Props // <S, T>
    /** Format this schema as a compact TypeScript-like type string. */
    toString(inline?: boolean): string
    /** Serialize this schema, preserving shared and recursive references. */
    toJSON(): Schema<S, T>
    /** Mark nullable input as invalid unless a default supplies a fallback. */
    required(value?: boolean): Schema<S, T>
    /** Hide this schema node from UI renderers. */
    hidden(value?: boolean): Schema<S, T>
    /** Return the default value instead of throwing when validation fails. */
    loose(value?: boolean): Schema<S, T>
    /** Attach a renderer role and optional role-specific metadata. */
    role(text: string, extra?: any): Schema<S, T>
    /** Attach an external documentation link. */
    link(link: string): Schema<S, T>
    /** Set the fallback value used for nullable input. */
    default(value: T): Schema<S, T>
    /** Attach an auxiliary comment for documentation or form UIs. */
    comment(text: string): Schema<S, T>
    /** Attach a localized or plain description for documentation or form UIs. */
    description(text: string): Schema<S, T>
    /** Mark this schema node as disabled for form UIs. */
    disabled(value?: boolean): Schema<S, T>
    /** Request collapsed rendering for nested form UIs. */
    collapse(value?: boolean): Schema<S, T>
    /** Add a deprecated badge to this schema node. */
    deprecated(): Schema<S, T>
    /** Add an experimental badge to this schema node. */
    experimental(): Schema<S, T>
    /** Require strings to match a regular expression. */
    pattern(regexp: RegExp): Schema<S, T>
    /** Set an inclusive maximum for numbers or collection lengths. */
    max(value: number): Schema<S, T>
    /** Set an inclusive minimum for numbers or collection lengths. */
    min(value: number): Schema<S, T>
    /** Set the numeric increment constraint. */
    step(value: number): Schema<S, T>
    /** Add or replace an object property schema. */
    set(key: string, value: Schema): Schema<S, T>
    /** Append a tuple, union, or intersection member schema. */
    push(value: Schema): Schema<S, T>
    /** Remove values equal to schema defaults from normalized output. */
    simplify(value?: any): any
    /** Return a schema clone with descriptions merged from locale messages. */
    i18n(messages: Dict): Schema<S, T>
    /** Attach arbitrary metadata consumed by form renderers and downstream tools. */
    extra<K extends keyof Schemastery.Meta>(key: K, value: Schemastery.Meta[K]): Schema<S, T>
  }
}

declare namespace globalThis {
  // eslint-disable-next-line @typescript-eslint/naming-convention
  export let __schemastery_index__: number
  export let __schemastery_refs__: Record<number, Schema> | undefined
}

globalThis.__schemastery_index__ ??= 0
globalThis.__schemastery_refs__ = undefined

class ValidationError extends TypeError {
  name = 'ValidationError'

  constructor(message: string, public options: Schemastery.Options) {
    let prefix = '$'
    for (const segment of options.path || []) {
      if (typeof segment === 'string') {
        prefix += '.' + segment
      } else if (typeof segment === 'number') {
        prefix += '[' + segment + ']'
      } else if (typeof segment === 'symbol') {
        prefix += `[Symbol(${segment.toString()})]`
      }
    }
    if (prefix.startsWith('.')) prefix = prefix.slice(1)
    super((prefix === '$' ? '' : `${prefix} `) + message)
  }

  static is(error: any): error is ValidationError {
    return !!error?.[kValidationError]
  }
}

Object.defineProperty(ValidationError.prototype, kValidationError, {
  value: true,
})

type Schema<S = any, T = S> = Schemastery<S, T>

const Schema = function (options: Schema) {
  const schema = function (data: any, options: Schemastery.Options = {}) {
    return Schema.resolve(data, schema, options)[0]
  } as Schema

  if (options.refs) {
    const refs = valueMap(options.refs, options => new Schema(options))
    const getRef = (uid: any) => refs[uid]!
    for (const key in refs) {
      const options = refs[key]!
      options.sKey = getRef(options.sKey)
      options.inner = getRef(options.inner)
      options.list = options.list && options.list.map(getRef)
      options.dict = options.dict && valueMap(options.dict, getRef)
    }
    return refs[options.uid!]
  }

  Object.assign(schema, options)
  if (typeof schema.callback === 'string') {
    try {
      // eslint-disable-next-line no-new-func
      schema.callback = new Function('return ' + schema.callback)()
    } catch {}
  }
  Object.defineProperty(schema, 'uid', { value: globalThis.__schemastery_index__++ })
  Object.setPrototypeOf(schema, Schema.prototype)
  schema.meta ||= {}
  schema.toString = schema.toString.bind(schema)
  return schema
} as Schemastery.Static

Schema.prototype = Object.create(Function.prototype)

Schema.prototype[kSchema] = true

Object.defineProperty(Schema.prototype, '~standard', {
  get(this: Schema) {
    return {
      version: 1,
      vendor: 'schemastery',
      validate: (value: unknown) => {
        try {
          return { value: Schema.resolve(value, this, {})[0] }
        } catch (error) {
          if (ValidationError.is(error)) {
            return { issues: [{ message: error.message, path: error.options.path }] }
          }
          throw error
        }
      },
    }
  },
})

Schema.ValidationError = ValidationError

Schema.prototype.toJSON = function toJSON() {
  if (globalThis.__schemastery_refs__) {
    globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }))
    return this.uid as any
  }

  globalThis.__schemastery_refs__ = { [this.uid]: { ...this } as Schema }
  globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }))
  const result = { uid: this.uid, refs: globalThis.__schemastery_refs__ }
  globalThis.__schemastery_refs__ = undefined
  return result
}

Schema.prototype.set = function set(key, value) {
  this.dict![key] = value
  return this
}

Schema.prototype.push = function push(value) {
  this.list!.push(value)
  return this
}

function mergeDesc(original: undefined | string | Dict<string>, messages: Dict) {
  const result: Dict<string> = typeof original === 'string' ? { '': original } : { ...original }
  for (const locale in messages) {
    const value = messages[locale]
    if (value?.$description || value?.$desc) {
      result[locale] = value.$description || value.$desc
    } else if (typeof value === 'string') {
      result[locale] = value
    }
  }
  return result
}

function getInner(value: any) {
  return value?.$value ?? value?.$inner
}

function extractKeys(data: any) {
  return filterKeys(data ?? {}, key => !key.startsWith('$'))
}

Schema.prototype.i18n = function i18n(messages) {
  const schema = Schema(this)
  const desc = mergeDesc(schema.meta.description, messages)
  if (Object.keys(desc).length) schema.meta.description = desc
  if (schema.dict) {
    schema.dict = valueMap(schema.dict, (inner, key) => {
      return inner.i18n(valueMap(messages, (data) => getInner(data)?.[key] ?? data?.[key]))
    })
  }
  if (schema.list) {
    schema.list = schema.list!.map((inner, index) => {
      return inner.i18n(valueMap(messages, (data = {}) => {
        if (Array.isArray(getInner(data))) return getInner(data)[index]
        if (Array.isArray(data)) return data[index]
        return extractKeys(data)
      }))
    })
  }
  if (schema.inner) {
    schema.inner = schema.inner.i18n(valueMap(messages, (data) => {
      if (getInner(data)) return getInner(data)
      return extractKeys(data)
    }))
  }
  if (schema.sKey) {
    schema.sKey = schema.sKey.i18n(valueMap(messages, (data) => data?.$key))
  }
  return schema
}

Schema.prototype.extra = function extra(key, value) {
  const schema = Schema(this)
  schema.meta = { ...schema.meta, [key]: value }
  return schema
}

for (const key of ['required', 'disabled', 'collapse', 'hidden', 'loose']) {
  Object.assign(Schema.prototype, {
    [key](this: Schema, value = true) {
      const schema = Schema(this)
      schema.meta = { ...schema.meta, [key]: value }
      return schema
    },
  })
}

Schema.prototype.deprecated = function deprecated() {
  const schema = Schema(this)
  schema.meta.badges ||= []
  schema.meta.badges.push({ text: 'deprecated', type: 'danger' })
  return schema
}

Schema.prototype.experimental = function experimental() {
  const schema = Schema(this)
  schema.meta.badges ||= []
  schema.meta.badges.push({ text: 'experimental', type: 'warning' })
  return schema
}

Schema.prototype.pattern = function pattern(regexp) {
  const schema = Schema(this)
  const pattern = pick(regexp, ['source', 'flags'])
  schema.meta = { ...schema.meta, pattern }
  return schema
}

Schema.prototype.simplify = function simplify(this: Schema, value) {
  if (deepEqual(value, this.meta.default, this.type === 'dict')) return null
  if (isNullable(value)) return value
  if (this.type === 'object' || this.type === 'dict') {
    const result: Dict = {}
    for (const key in value) {
      const schema = this.type === 'object' ? this.dict![key] : this.inner
      const item = schema?.simplify(value[key])
      if (this.type === 'dict' || !isNullable(item)) result[key] = item
    }
    if (deepEqual(result, this.meta.default, this.type === 'dict')) return null
    return result
  } else if (this.type === 'array' || this.type === 'tuple') {
    const result: any[] = []
    ;(value as any[]).forEach((value, index) => {
      const schema = this.type === 'array' ? this.inner : this.list![index]
      const item = schema ? schema.simplify(value) : value
      result.push(item)
    })
    return result
  } else if (this.type === 'intersect') {
    const result: Dict = {}
    for (const item of this.list!) {
      Object.assign(result, item.simplify(value))
    }
    return result
  } else if (this.type === 'union') {
    for (const schema of this.list!) {
      try {
        Schema.resolve(value, schema, {})
        return schema.simplify(value)
      } catch {}
    }
  }
  return value
}

Schema.prototype.toString = function toString(inline?: boolean) {
  return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`
}

Schema.prototype.role = function role(role, extra) {
  const schema = Schema(this)
  schema.meta = { ...schema.meta, role, extra }
  return schema
}

for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 'step']) {
  Object.assign(Schema.prototype, {
    [key](this: Schema, value: any) {
      const schema = Schema(this)
      schema.meta = { ...schema.meta, [key]: value }
      return schema
    },
  })
}

const resolvers: Dict<Schemastery.Resolve> = {}

Schema.extend = function extend(type, resolve) {
  resolvers[type] = resolve
}

Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
  if (!schema) return [data]
  if (options.ignore?.(data, schema)) return [data]

  if (isNullable(data) && schema.type !== 'lazy') {
    if (schema.meta.required) throw new ValidationError(`missing required value`, options)
    let current = schema
    let fallback = schema.meta.default
    while (current?.type === 'intersect' && isNullable(fallback)) {
      current = current.list![0]
      fallback = current?.meta.default
    }
    if (isNullable(fallback)) return [data]
    data = clone(fallback)
  }

  const callback = resolvers[schema.type]
  if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options)

  try {
    return callback(data, schema, options, strict)
  } catch (error) {
    if (!schema.meta.loose) throw error
    return [schema.meta.default]
  }
}

Schema.from = function from(source: any) {
  if (isNullable(source)) {
    return Schema.any()
  } else if (['string', 'number', 'boolean'].includes(typeof source)) {
    return Schema.const(source).required()
  } else if (source[kSchema]) {
    return source
  } else if (typeof source === 'function') {
    switch (source) {
      case String: return Schema.string().required()
      case Number: return Schema.number().required()
      case Boolean: return Schema.boolean().required()
      case Function: return Schema.function().required()
      default: return Schema.is(source).required()
    }
  } else {
    throw new TypeError(`cannot infer schema from ${source}`)
  }
}

Schema.lazy = function lazy(builder) {
  const toJSON = () => {
    if (!schema.inner![kSchema]) {
      schema.inner = schema.builder!()
      schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
    }
    return schema.inner!.toJSON()
  }
  const schema = new Schema({ type: 'lazy', builder, inner: { toJSON } as any })
  return schema as any
}

Schema.natural = function natural() {
  return Schema.number().step(1).min(0)
}

Schema.percent = function percent() {
  return Schema.number().step(0.01).min(0).max(1).role('slider')
}

Schema.date = function date() {
  return Schema.union([
    Schema.is(Date),
    Schema.transform(Schema.string().role('datetime'), (value, options) => {
      const date = new Date(value)
      if (isNaN(+date)) throw new ValidationError(`invalid date "${value}"`, options)
      return date
    }, true),
  ])
}

Schema.regExp = function regExp(flag = '') {
  return Schema.union([
    Schema.is(RegExp),
    Schema.transform(Schema.string().role('regexp', { flag }), (value, options) => {
      try {
        return new RegExp(value, flag)
      } catch (e: any) {
        throw new ValidationError(e.message, options)
      }
    }, true),
  ])
}

Schema.arrayBuffer = function arrayBuffer(encoding?: 'hex' | 'base64'): any {
  return Schema.union([
    Schema.is(ArrayBuffer),
    Schema.is(SharedArrayBuffer),
    Schema.transform(Schema.any<ArrayBufferView>(), (value, options) => {
      if (Binary.isSource(value)) return Binary.fromSource(value)
      throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options)
    }, true),
    ...encoding ? [Schema.transform(Schema.string(), (value, options) => {
      try {
        return encoding === 'base64'
          ? Binary.fromBase64(value)
          : Binary.fromHex(value)
      } catch (e: any) {
        throw new ValidationError(e.message, options)
      }
    }, true)] as const : [],
  ])
}

Schema.extend('lazy', (data, schema, options, strict) => {
  if (!schema.inner![kSchema]) {
    schema.inner = schema.builder!()
    schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
  }
  return Schema.resolve(data, schema.inner!, options, strict)
})

Schema.extend('any', (data) => {
  return [data]
})

Schema.extend('never', (data, _, options) => {
  throw new ValidationError(`expected nullable but got ${data}`, options)
})

Schema.extend('const', (data, { value }, options) => {
  if (deepEqual(data, value)) return [value]
  throw new ValidationError(`expected ${value} but got ${data}`, options)
})

function checkWithinRange(data: number, meta: Schemastery.Meta<any>, description: string, options: Schemastery.Options, skipMin = false) {
  const { max = Infinity, min = -Infinity } = meta
  if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options)
  if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options)
}

Schema.extend('string', (data, { meta }, options) => {
  if (typeof data !== 'string') throw new ValidationError(`expected string but got ${data}`, options)
  if (meta.pattern) {
    const regexp = new RegExp(meta.pattern.source, meta.pattern.flags)
    if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options)
  }
  checkWithinRange(data.length, meta, 'string length', options)
  return [data]
})

function decimalShift(data: number, digits: number) {
  const str = data.toString()
  if (str.includes('e')) return data * Math.pow(10, digits)
  const index = str.indexOf('.')
  if (index === -1) return data * Math.pow(10, digits)
  const frac = str.slice(index + 1)
  const integer = str.slice(0, index)
  if (frac.length <= digits) return +(integer + frac.padEnd(digits, '0'))
  return +(integer + frac.slice(0, digits) + '.' + frac.slice(digits))
}

function isMultipleOf(data: number, min: number, step: number) {
  step = Math.abs(step)
  if (!/^\d+\.\d+$/.test(step.toString())) {
    return (data - min) % step === 0
  }
  const index = step.toString().indexOf('.')
  const digits = step.toString().slice(index + 1).length
  return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0
}

Schema.extend('number', (data, { meta }, options) => {
  if (typeof data !== 'number') throw new ValidationError(`expected number but got ${data}`, options)
  checkWithinRange(data, meta, 'number', options)
  const { step } = meta
  if (step && !isMultipleOf(data, meta.min ?? 0, step)) {
    throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options)
  }
  return [data]
})

Schema.extend('boolean', (data, _, options) => {
  if (typeof data === 'boolean') return [data]
  throw new ValidationError(`expected boolean but got ${data}`, options)
})

Schema.extend('bitset', (data, { bits, meta }, options) => {
  let value = 0, keys: string[] = []
  if (typeof data === 'number') {
    value = data
    for (const key in bits!) {
      if (data & bits![key]!) {
        keys.push(key)
      }
    }
  } else if (Array.isArray(data)) {
    keys = data
    for (const key of keys) {
      if (typeof key !== 'string') throw new ValidationError(`expected string but got ${key}`, options)
      if (key in bits!) value |= bits![key]!
    }
  } else {
    throw new ValidationError(`expected number or array but got ${data}`, options)
  }
  if (value === meta.default) return [value]
  return [value, keys]
})

Schema.extend('function', (data, _, options) => {
  if (typeof data === 'function') return [data]
  throw new ValidationError(`expected function but got ${data}`, options)
})

Schema.extend('is', (data, { constructor }, options) => {
  if (typeof constructor === 'function') {
    if (data instanceof constructor) return [data]
    throw new ValidationError(`expected ${constructor.name} but got ${data}`, options)
  } else {
    if (isNullable(data)) {
      throw new ValidationError(`expected ${constructor} but got ${data}`, options)
    }
    let prototype = Object.getPrototypeOf(data)
    while (prototype) {
      if (prototype.constructor?.name === constructor) return [data]
      prototype = Object.getPrototypeOf(prototype)
    }
    throw new ValidationError(`expected ${constructor} but got ${data}`, options)
  }
})

function property(data: any, key: keyof any, schema: Schema, options: Schemastery.Options) {
  try {
    const [value, adapted] = Schema.resolve(data[key], schema, {
      ...options,
      path: [...options.path || [], key],
    })
    if (adapted !== undefined) data[key] = adapted
    return value
  } catch (e) {
    if (!options?.autofix) throw e
    delete data[key]
    return schema.meta.default
  }
}

Schema.extend('array', (data, { inner, meta }, options) => {
  if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
  checkWithinRange(data.length, meta, 'array length', options, !isNullable(inner!.meta.default))
  return [data.map((_, index) => property(data, index, inner!, options))]
})

Schema.extend('dict', (data, { inner, sKey }, options, strict) => {
  if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
  const result: any = {}
  for (const key in data) {
    let rKey: string
    try {
      rKey = Schema.resolve(key, sKey!, options)[0]
    } catch (error) {
      if (strict) continue
      throw error
    }
    result[rKey] = property(data, key, inner!, options)
    data[rKey] = data[key]
    if (key !== rKey) delete data[key]
  }
  return [result]
})

Schema.extend('tuple', (data, { list }, options, strict) => {
  if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
  const result = list!.map((inner, index) => property(data, index, inner, options))
  if (strict) return [result]
  result.push(...data.slice(list!.length))
  return [result]
})

function merge(result: any, data: any) {
  for (const key in data) {
    if (key in result) continue
    result[key] = data[key]
  }
}

Schema.extend('object', (data, { dict }, options, strict) => {
  if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
  const result: any = {}
  for (const key in dict) {
    const value = property(data, key, dict![key]!, options)
    if (!isNullable(value) || key in data) {
      result[key] = value
    }
  }
  if (!strict) merge(result, data)
  return [result]
})

Schema.extend('union', (data, { list, toString }, options, strict) => {
  const messages: any[] = []
  for (const inner of list!) {
    try {
      return Schema.resolve(data, inner, options, strict)
    } catch (error) {
      messages.push(error)
    }
  }
  throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
})

Schema.extend('intersect', (data, { list, toString }, options, strict) => {
  if (!list!.length) return [data]
  let result
  for (const inner of list!) {
    const value: any = Schema.resolve(data, inner, options, true)[0]
    if (isNullable(value)) continue
    if (isNullable(result)) {
      result = value
    } else if (typeof result !== typeof value) {
      throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
    } else if (typeof value === 'object') {
      merge(result ??= {}, value)
    } else if (result !== value) {
      throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
    }
  }
  if (!strict && isPlainObject(data)) merge(result, data)
  return [result]
})

Schema.extend('transform', (data, { inner, callback, preserve }, options) => {
  const [result, adapted = data] = Schema.resolve(data, inner!, options, true)
  if (preserve) {
    return [callback!(result)]
  // } else if (isPlainObject(data)) {
  //   const temp: any = {}
  //   for (const key in result) {
  //     if (!(key in data)) continue
  //     temp[key] = data[key]
  //     delete data[key]
  //   }
  //   Object.assign(data, callback!(temp))
  //   return [callback!(result)]
  } else {
    return [callback!(result), callback!(adapted)]
  }
})

type Formatter = (schema: Schema, inline?: boolean) => string
const formatters: Dict<Formatter> = {}

function defineMethod(name: string, keys: (keyof Schema)[], format: Formatter) {
  formatters[name] = format
  Object.assign(Schema, {
    [name](...args: any[]) {
      const schema = new Schema({ type: name } as Schema)
      keys.forEach((key, index) => {
        switch (key) {
          case 'sKey': schema.sKey = args[index] ?? Schema.string(); break
          case 'inner': schema.inner = Schema.from(args[index]); break
          case 'list': schema.list = args[index].map(Schema.from); break
          case 'dict': schema.dict = valueMap(args[index], Schema.from); break
          case 'bits': {
            schema.bits = {}
            for (const key in args[index]) {
              if (typeof args[index][key] !== 'number') continue
              schema.bits[key] = args[index][key]
            }
            break
          }
          case 'callback': {
            const callback = schema.callback = args[index]
            ;callback['toJSON'] ||= () => callback.toString()
            break
          }
          case 'constructor': {
            const constructor = schema.constructor = args[index]
            if (typeof constructor === 'function') {
              ;constructor['toJSON'] ||= () => constructor['name']
            }
            break
          }
          default: schema[key] = args[index] as never
        }
      })
      if (name === 'object' || name === 'dict') {
        schema.meta.default = {}
      } else if (name === 'array' || name === 'tuple') {
        schema.meta.default = []
      } else if (name === 'bitset') {
        schema.meta.default = 0
      }
      return schema
    },
  })
}

defineMethod('is', ['constructor'], ({ constructor }) => {
  if (typeof constructor === 'function') {
    return constructor.name
  } else {
    return constructor!
  }
})

defineMethod('any', [], () => 'any')
defineMethod('never', [], () => 'never')
defineMethod('const', ['value'], ({ value }) => typeof value === 'string' ? JSON.stringify(value) : value)
defineMethod('string', [], () => 'string')
defineMethod('number', [], () => 'number')
defineMethod('boolean', [], () => 'boolean')
defineMethod('bitset', ['bits'], () => 'bitset')
defineMethod('function', [], () => 'function')
defineMethod('array', ['inner'], ({ inner }) => `${inner!.toString(true)}[]`)
defineMethod('dict', ['inner', 'sKey'], ({ inner, sKey }) => `{ [key: ${sKey!.toString()}]: ${inner!.toString()} }`)
defineMethod('tuple', ['list'], ({ list }) => `[${list!.map((inner) => inner.toString()).join(', ')}]`)

defineMethod('object', ['dict'], ({ dict }) => {
  if (Object.keys(dict!).length === 0) return '{}'
  return `{ ${Object.entries(dict!).map(([key, inner]) => {
    return `${key}${inner!.meta.required ? '' : '?'}: ${inner!.toString()}`
  }).join(', ')} }`
})

defineMethod('union', ['list'], ({ list }, inline) => {
  const result = list!.map(({ toString: format }) => format()).join(' | ')
  return inline ? `(${result})` : result
})

defineMethod('intersect', ['list'], ({ list }) => {
  return `${list!.map((inner) => inner.toString(true)).join(' & ')}`
})

defineMethod('transform', ['inner', 'callback', 'preserve'], ({ inner }, isInner) => inner!.toString(isInner))

export default Schema
