import { _isBetween, _lazyValue, _round } from '@naturalcycles/js-lib'
import { _assert } from '@naturalcycles/js-lib/error'
import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object'
import { _substringAfterLast } from '@naturalcycles/js-lib/string'
import type { AnyObject } from '@naturalcycles/js-lib/types'
import type { Options, ValidateFunction } from 'ajv/dist/2020.js'
import { Ajv2020 } from 'ajv/dist/2020.js'
import { validTLDs } from '../tlds.js'
import type {
  CustomConverterFn,
  CustomValidatorFn,
  JSchema,
  JsonSchemaIsoDateOptions,
  JsonSchemaIsoMonthOptions,
  JsonSchemaStringEmailOptions,
} from './jSchema.js'

// oxlint-disable unicorn/prefer-code-point

const AJV_OPTIONS: Options = {
  removeAdditional: true,
  allErrors: true,
  // https://ajv.js.org/options.html#usedefaults
  useDefaults: 'empty', // this will mutate your input!
  // these are important and kept same as default:
  // https://ajv.js.org/options.html#coercetypes
  coerceTypes: false, // while `false` - it won't mutate your input
  strictTypes: true,
  strictTuples: true,
  allowUnionTypes: true, // supports oneOf/anyOf schemas
}

const AJV_NON_MUTATING_OPTIONS: Options = {
  ...AJV_OPTIONS,
  removeAdditional: false,
  useDefaults: false,
}

const AJV_MUTATING_COERCING_OPTIONS: Options = {
  ...AJV_OPTIONS,
  coerceTypes: true,
}

/**
 * Return cached instance of Ajv with default (recommended) options.
 *
 * This function should be used as much as possible,
 * to benefit from cached Ajv instance.
 */
export const getAjv = _lazyValue(createAjv)

/**
 * Returns cached instance of Ajv, which is non-mutating.
 *
 * To be used in places where we only need to know if an item is valid or not,
 * and are not interested in transforming the data.
 */
export const getNonMutatingAjv = _lazyValue(() => createAjv(AJV_NON_MUTATING_OPTIONS))

/**
 * Returns cached instance of Ajv, which is coercing data.
 *
 * To be used in places where we know that we are going to receive data with the wrong type,
 * typically: request path params and request query params.
 */
export const getCoercingAjv = _lazyValue(() => createAjv(AJV_MUTATING_COERCING_OPTIONS))

/**
 * Create Ajv with modified defaults.
 *
 * !!! Please note that this function is EXPENSIVE computationally !!!
 *
 * https://ajv.js.org/options.html
 */
export function createAjv(opt?: Options): Ajv2020 {
  const ajv = new Ajv2020({
    ...AJV_OPTIONS,
    ...opt,
  })

  // Adds $merge, $patch keywords
  // https://github.com/ajv-validator/ajv-merge-patch
  // Kirill: temporarily disabled, as it creates a noise of CVE warnings
  // require('ajv-merge-patch')(ajv)

  ajv.addKeyword({
    keyword: 'transform',
    type: 'string',
    modifying: true,
    schemaType: 'object',
    errors: true,
    validate: function validate(
      transform: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number },
      data: string,
      _schema,
      ctx,
    ) {
      if (!data) return true

      let transformedData = data

      if (transform.trim) {
        transformedData = transformedData.trim()
      }

      if (transform.toLowerCase) {
        transformedData = transformedData.toLocaleLowerCase()
      }

      if (transform.toUpperCase) {
        transformedData = transformedData.toLocaleUpperCase()
      }

      if (typeof transform.truncate === 'number' && transform.truncate >= 0) {
        transformedData = transformedData.slice(0, transform.truncate)

        if (transform.trim) {
          transformedData = transformedData.trim()
        }
      }

      // Explicit check for `undefined` because parentDataProperty can be `0` when it comes to arrays.
      if (ctx?.parentData && typeof ctx.parentDataProperty !== 'undefined') {
        ctx.parentData[ctx.parentDataProperty] = transformedData
      }

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'instanceof',
    modifying: false,
    schemaType: 'string',
    validate(instanceOf: string, data: unknown, _schema, _ctx) {
      if (typeof data !== 'object') return false
      if (data === null) return false

      let proto = Object.getPrototypeOf(data)
      while (proto) {
        if (proto.constructor?.name === instanceOf) return true
        proto = Object.getPrototypeOf(proto)
      }

      return false
    },
  })

  ajv.addKeyword({
    keyword: 'Set2',
    type: ['array', 'object'],
    modifying: true,
    errors: true,
    schemaType: 'object',
    compile(innerSchema, _parentSchema, _it) {
      const validateItem: ValidateFunction = ajv.compile(innerSchema)

      function validateSet(data: any, ctx: any): boolean {
        let set: Set2

        const isIterable = data === null || typeof data[Symbol.iterator] === 'function'

        if (data instanceof Set2) {
          set = data
        } else if (isIterable && ctx?.parentData) {
          set = new Set2(data)
        } else if (isIterable && !ctx?.parentData) {
          ;(validateSet as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message:
                'can only transform an Iterable into a Set2 when the schema is in an object or an array schema. This is an Ajv limitation.',
            },
          ]
          return false
        } else {
          ;(validateSet as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message: 'must be a Set2 object (or optionally an Iterable in some cases)',
            },
          ]
          return false
        }

        let idx = 0
        for (const value of set.values()) {
          if (!validateItem(value)) {
            ;(validateSet as any).errors = [
              {
                instancePath: (ctx?.instancePath ?? '') + '/' + idx,
                message: `invalid set item at index ${idx}`,
                params: { errors: validateItem.errors },
              },
            ]
            return false
          }
          idx++
        }

        if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
          ctx.parentData[ctx.parentDataProperty] = set
        }

        return true
      }

      return validateSet
    },
  })

  ajv.addKeyword({
    keyword: 'Buffer',
    modifying: true,
    errors: true,
    schemaType: 'boolean',
    compile(_innerSchema, _parentSchema, _it) {
      function validateBuffer(data: any, ctx: any): boolean {
        let buffer: Buffer

        if (data === null) return false

        const isValid =
          data instanceof Buffer ||
          data instanceof ArrayBuffer ||
          Array.isArray(data) ||
          typeof data === 'string'
        if (!isValid) return false

        if (data instanceof Buffer) {
          buffer = data
        } else if (isValid && ctx?.parentData) {
          buffer = Buffer.from(data as any)
        } else if (isValid && !ctx?.parentData) {
          ;(validateBuffer as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message:
                'can only transform data into a Buffer when the schema is in an object or an array schema. This is an Ajv limitation.',
            },
          ]
          return false
        } else {
          ;(validateBuffer as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message:
                'must be a Buffer object (or optionally an Array-like object or ArrayBuffer in some cases)',
            },
          ]
          return false
        }

        if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
          ctx.parentData[ctx.parentDataProperty] = buffer
        }

        return true
      }

      return validateBuffer
    },
  })

  ajv.addKeyword({
    keyword: 'email',
    type: 'string',
    modifying: true,
    errors: true,
    schemaType: 'object',
    validate: function validate(opt: JsonSchemaStringEmailOptions, data: string, _schema, ctx) {
      const { checkTLD } = opt
      const cleanData = data.trim()

      // from `ajv-formats`
      const EMAIL_REGEX =
        /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i
      const result = cleanData.match(EMAIL_REGEX)

      if (!result) {
        ;(validate as any).errors = [
          {
            instancePath: ctx?.instancePath ?? '',
            message: `is not a valid email address`,
          },
        ]
        return false
      }

      if (checkTLD) {
        const tld = _substringAfterLast(cleanData, '.')
        if (!validTLDs.has(tld)) {
          ;(validate as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message: `has an invalid TLD`,
            },
          ]
          return false
        }
      }

      if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
        ctx.parentData[ctx.parentDataProperty] = cleanData
      }

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'IsoDate',
    type: 'string',
    modifying: false,
    errors: true,
    schemaType: 'object',
    validate: function validate(opt: JsonSchemaIsoDateOptions, data: string, _schema, ctx) {
      const hasOptions = Object.keys(opt).length > 0

      const isValid = isIsoDateValid(data)
      if (!isValid) {
        ;(validate as any).errors = [
          {
            instancePath: ctx?.instancePath ?? '',
            message: `is an invalid IsoDate`,
          },
        ]

        return false
      }

      if (!hasOptions) return true

      const { before, sameOrBefore, after, sameOrAfter } = opt
      const errors: any[] = []

      if (before) {
        const isParamValid = isIsoDateValid(before)
        const isRuleValid = isParamValid && before > data

        if (!isRuleValid) {
          errors.push({
            instancePath: ctx?.instancePath ?? '',
            message: `is not before ${before}`,
          })
        }
      }

      if (sameOrBefore) {
        const isParamValid = isIsoDateValid(sameOrBefore)
        const isRuleValid = isParamValid && sameOrBefore >= data

        if (!isRuleValid) {
          errors.push({
            instancePath: ctx?.instancePath ?? '',
            message: `is not the same or before ${sameOrBefore}`,
          })
        }
      }

      if (after) {
        const isParamValid = isIsoDateValid(after)
        const isRuleValid = isParamValid && after < data

        if (!isRuleValid) {
          errors.push({
            instancePath: ctx?.instancePath ?? '',
            message: `is not after ${after}`,
          })
        }
      }

      if (sameOrAfter) {
        const isParamValid = isIsoDateValid(sameOrAfter)
        const isRuleValid = isParamValid && sameOrAfter <= data

        if (!isRuleValid) {
          errors.push({
            instancePath: ctx?.instancePath ?? '',
            message: `is not the same or after ${sameOrAfter}`,
          })
        }
      }

      if (errors.length === 0) return true
      ;(validate as any).errors = errors
      return false
    },
  })

  ajv.addKeyword({
    keyword: 'IsoDateTime',
    type: 'string',
    modifying: false,
    errors: true,
    schemaType: 'boolean',
    validate: function validate(_opt: JsonSchemaIsoMonthOptions, data: string, _schema, ctx) {
      const isValid = isIsoDateTimeValid(data)
      if (isValid) return true
      ;(validate as any).errors = [
        {
          instancePath: ctx?.instancePath ?? '',
          message: `is an invalid IsoDateTime`,
        },
      ]
      return false
    },
  })

  ajv.addKeyword({
    keyword: 'IsoMonth',
    type: 'string',
    modifying: false,
    errors: true,
    schemaType: 'object',
    validate: function validate(_opt: true, data: string, _schema, ctx) {
      const isValid = isIsoMonthValid(data)
      if (isValid) return true
      ;(validate as any).errors = [
        {
          instancePath: ctx?.instancePath ?? '',
          message: `is an invalid IsoMonth`,
        },
      ]
      return false
    },
  })

  ajv.addKeyword({
    keyword: 'errorMessages',
    schemaType: 'object',
  })

  ajv.addKeyword({
    keyword: 'hasIsOfTypeCheck',
    schemaType: 'boolean',
  })

  ajv.addKeyword({
    keyword: 'optionalValues',
    type: ['string', 'number', 'boolean', 'null'],
    modifying: true,
    errors: false,
    schemaType: 'array',
    validate: function validate(
      optionalValues: (string | number | boolean)[],
      data: string | number | boolean,
      _schema,
      ctx,
    ) {
      if (!optionalValues) return true

      _assert(
        ctx?.parentData && ctx.parentDataProperty !== undefined,
        'You should only use `optional([x, y, z])` on a property of an object, or on an element of an array due to Ajv mutation issues.',
      )

      if (!optionalValues.includes(data)) return true

      ctx.parentData[ctx.parentDataProperty] = undefined

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'keySchema',
    type: 'object',
    modifying: true,
    errors: false,
    schemaType: 'object',
    compile(innerSchema, _parentSchema, _it) {
      const isValidKeyFn: ValidateFunction = ajv.compile(innerSchema)

      function validate(data: AnyObject, _ctx: any): boolean {
        if (typeof data !== 'object' || data === null) return true

        for (const key of Object.keys(data)) {
          if (!isValidKeyFn(key)) {
            delete data[key]
          }
        }

        return true
      }

      return validate
    },
  })

  // Validates that the value is undefined. Used in record/stringMap with optional value schemas
  // to allow undefined values in patternProperties via anyOf.
  ajv.addKeyword({
    keyword: 'isUndefined',
    modifying: false,
    errors: false,
    schemaType: 'boolean',
    validate: (_schema: boolean, data: unknown) => data === undefined,
  })

  // This is added because Ajv validates the `min/maxProperties` before validating the properties.
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
  // if the property validation would strip `foo` from the data.
  // And Ajv would return `{}` as a successful validation.
  // Since the keyword validation runs after JSON Schema validation,
  // here we can make sure the number of properties are right ex-post property validation.
  // It's named with the `2` suffix, because `minProperties` is reserved.
  // And `maxProperties` does not suffer from this error due to the nature of how maximum works.
  ajv.addKeyword({
    keyword: 'minProperties2',
    type: 'object',
    modifying: false,
    errors: true,
    schemaType: 'number',
    validate: function validate(minProperties: number, data: AnyObject, _schema, ctx) {
      if (typeof data !== 'object') return true

      const numberOfProperties = Object.entries(data).filter(([, v]) => v !== undefined).length
      const isValid = numberOfProperties >= minProperties
      if (!isValid) {
        ;(validate as any).errors = [
          {
            instancePath: ctx?.instancePath ?? '',
            message: `must NOT have fewer than ${minProperties} properties`,
          },
        ]
      }

      return isValid
    },
  })

  ajv.addKeyword({
    keyword: 'exclusiveProperties',
    type: 'object',
    modifying: false,
    errors: true,
    schemaType: 'array',
    validate: function validate(exclusiveProperties: string[][], data: AnyObject, _schema, ctx) {
      if (typeof data !== 'object') return true

      for (const props of exclusiveProperties) {
        let numberOfDefinedProperties = 0
        for (const prop of props) {
          if (data[prop] !== undefined) numberOfDefinedProperties++
          if (numberOfDefinedProperties > 1) {
            ;(validate as any).errors = [
              {
                instancePath: ctx?.instancePath ?? '',
                message: `must have only one of the "${props.join(', ')}" properties`,
              },
            ]
            return false
          }
        }
      }

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'anyOfBy',
    type: 'object',
    modifying: true,
    errors: true,
    schemaType: 'object',
    compile(config, _parentSchema, _it) {
      const { propertyName, schemaDictionary } = config

      const isValidFnByKey: Record<any, ValidateFunction> = _mapObject(
        schemaDictionary as Record<any, JSchema<any, any>>,
        (key, value) => {
          return [key, ajv.compile(value)]
        },
      )

      function validate(data: AnyObject, ctx: any): boolean {
        if (typeof data !== 'object' || data === null) return true

        const determinant = data[propertyName]
        const isValidFn = isValidFnByKey[determinant]
        if (!isValidFn) {
          ;(validate as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message: `could not find a suitable schema to validate against based on "${propertyName}"`,
            },
          ]
          return false
        }

        const result = isValidFn(data)
        if (!result) {
          ;(validate as any).errors = isValidFn.errors
        }
        return result
      }

      return validate
    },
  })

  ajv.addKeyword({
    keyword: 'anyOfThese',
    modifying: true,
    errors: true,
    schemaType: 'array',
    compile(schemas: JSchema<any, any>[], _parentSchema, _it) {
      const validators = schemas.map(schema => ajv.compile(schema))

      function validate(data: AnyObject, ctx: any): boolean {
        let correctValidator: ValidateFunction<unknown> | undefined
        let result = false
        let clonedData: any

        // Try each validator until we find one that works!
        for (const validator of validators) {
          clonedData = isPrimitive(data) ? _deepCopy(data) : data
          result = validator(clonedData)
          if (result) {
            correctValidator = validator
            break
          }
        }

        if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
          // If we found a validator and the data is valid and we are validating a property inside an object,
          // then we can inject our result and be done with it.
          ctx.parentData[ctx.parentDataProperty] = clonedData
        } else if (result) {
          // If we found a validator but we are not validating a property inside an object,
          // then we must re-run the validation so that the mutations caused by Ajv
          // will be done on the input data, not only on the clone.
          result = correctValidator!(data)
        } else {
          // If we didn't find a fitting schema,
          // we add our own error.
          ;(validate as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message: `could not find a suitable schema to validate against`,
            },
          ]
        }

        return result
      }

      return validate
    },
  })

  ajv.addKeyword({
    keyword: 'precision',
    type: ['number'],
    modifying: true,
    errors: false,
    schemaType: 'number',
    validate: function validate(numberOfDigits: number, data: number, _schema, ctx) {
      if (!numberOfDigits) return true

      _assert(
        ctx?.parentData && ctx.parentDataProperty !== undefined,
        'You should only use `precision(n)` on a property of an object, or on an element of an array due to Ajv mutation issues.',
      )

      ctx.parentData[ctx.parentDataProperty] = _round(data, 10 ** (-1 * numberOfDigits))

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'customValidations',
    modifying: false,
    errors: true,
    schemaType: 'array',
    validate: function validate(customValidations: CustomValidatorFn[], data: any, _schema, ctx) {
      if (!customValidations?.length) return true

      for (const validator of customValidations) {
        const error = validator(data)
        if (error) {
          ;(validate as any).errors = [
            {
              instancePath: ctx?.instancePath ?? '',
              message: error,
            },
          ]
          return false
        }
      }

      return true
    },
  })

  ajv.addKeyword({
    keyword: 'customConversions',
    modifying: true,
    errors: false,
    schemaType: 'array',
    validate: function validate(
      customConversions: CustomConverterFn<any>[],
      data: any,
      _schema,
      ctx,
    ) {
      if (!customConversions?.length) return true

      _assert(
        ctx?.parentData && ctx.parentDataProperty !== undefined,
        'You should only use `convert()` on a property of an object, or on an element of an array due to Ajv mutation issues.',
      )

      for (const converter of customConversions) {
        data = converter(data)
      }

      ctx.parentData[ctx.parentDataProperty] = data

      return true
    },
  })

  // postValidation is handled in AjvSchema.getValidationResult, not by Ajv itself.
  // We register it here so Ajv's strict mode doesn't reject the keyword.
  ajv.addKeyword({
    keyword: 'postValidation',
    valid: true,
  })

  return ajv
}

const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

const DASH_CODE = '-'.charCodeAt(0)
const ZERO_CODE = '0'.charCodeAt(0)
const PLUS_CODE = '+'.charCodeAt(0)
const COLON_CODE = ':'.charCodeAt(0)

/**
 * This is a performance optimized correct validation
 * for ISO dates formatted as YYYY-MM-DD.
 *
 * - Slightly more performant than using `localDate`.
 * - More performant than string splitting and `Number()` conversions
 * - Less performant than regex, but it does not allow invalid dates.
 */
function isIsoDateValid(s: string): boolean {
  // must be exactly "YYYY-MM-DD"
  if (s.length !== 10) return false
  if (s.charCodeAt(4) !== DASH_CODE || s.charCodeAt(7) !== DASH_CODE) return false

  // fast parse numbers without substrings/Number()
  const year =
    (s.charCodeAt(0) - ZERO_CODE) * 1000 +
    (s.charCodeAt(1) - ZERO_CODE) * 100 +
    (s.charCodeAt(2) - ZERO_CODE) * 10 +
    (s.charCodeAt(3) - ZERO_CODE)

  const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE)
  const day = (s.charCodeAt(8) - ZERO_CODE) * 10 + (s.charCodeAt(9) - ZERO_CODE)

  if (month < 1 || month > 12 || day < 1) return false

  if (month !== 2) {
    return day <= monthLengths[month]!
  }

  const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
  return day <= (isLeap ? 29 : 28)
}

/**
 * This is a performance optimized correct validation
 * for ISO datetimes formatted as "YYYY-MM-DDTHH:MM:SS" followed by
 * nothing, "Z" or "+hh:mm" or "-hh:mm".
 *
 * - Slightly more performant than using `localTime`.
 * - More performant than string splitting and `Number()` conversions
 * - Less performant than regex, but it does not allow invalid dates.
 */
function isIsoDateTimeValid(s: string): boolean {
  if (s.length < 19 || s.length > 25) return false
  if (s.charCodeAt(10) !== 84) return false // 'T'

  const datePart = s.slice(0, 10) // YYYY-MM-DD
  if (!isIsoDateValid(datePart)) return false

  const timePart = s.slice(11, 19) // HH:MM:SS
  if (!isIsoTimeValid(timePart)) return false

  const zonePart = s.slice(19) // nothing or Z or +/-hh:mm
  if (!isIsoTimezoneValid(zonePart)) return false

  return true
}

/**
 * This is a performance optimized correct validation
 * for ISO times formatted as "HH:MM:SS".
 *
 * - Slightly more performant than using `localTime`.
 * - More performant than string splitting and `Number()` conversions
 * - Less performant than regex, but it does not allow invalid dates.
 */
function isIsoTimeValid(s: string): boolean {
  if (s.length !== 8) return false
  if (s.charCodeAt(2) !== COLON_CODE || s.charCodeAt(5) !== COLON_CODE) return false

  const hour = (s.charCodeAt(0) - ZERO_CODE) * 10 + (s.charCodeAt(1) - ZERO_CODE)
  if (hour < 0 || hour > 23) return false

  const minute = (s.charCodeAt(3) - ZERO_CODE) * 10 + (s.charCodeAt(4) - ZERO_CODE)
  if (minute < 0 || minute > 59) return false

  const second = (s.charCodeAt(6) - ZERO_CODE) * 10 + (s.charCodeAt(7) - ZERO_CODE)
  if (second < 0 || second > 59) return false

  return true
}

/**
 * This is a performance optimized correct validation
 * for the timezone suffix of ISO times
 * formatted as "Z" or "+HH:MM" or "-HH:MM".
 *
 * It also accepts an empty string.
 */
function isIsoTimezoneValid(s: string): boolean {
  if (s === '') return true
  if (s === 'Z') return true
  if (s.length !== 6) return false
  if (s.charCodeAt(0) !== PLUS_CODE && s.charCodeAt(0) !== DASH_CODE) return false
  if (s.charCodeAt(3) !== COLON_CODE) return false

  const isWestern = s[0] === '-'
  const isEastern = s[0] === '+'

  const hour = (s.charCodeAt(1) - ZERO_CODE) * 10 + (s.charCodeAt(2) - ZERO_CODE)
  if (hour < 0) return false
  if (isWestern && hour > 12) return false
  if (isEastern && hour > 14) return false

  const minute = (s.charCodeAt(4) - ZERO_CODE) * 10 + (s.charCodeAt(5) - ZERO_CODE)
  if (minute < 0 || minute > 59) return false

  if (isEastern && hour === 14 && minute > 0) return false // max is +14:00
  if (isWestern && hour === 12 && minute > 0) return false // min is -12:00

  return true
}

/**
 * This is a performance optimized correct validation
 * for ISO month formatted as "YYYY-MM".
 */
function isIsoMonthValid(s: string): boolean {
  // must be exactly "YYYY-MM"
  if (s.length !== 7) return false
  if (s.charCodeAt(4) !== DASH_CODE) return false

  // fast parse numbers without substrings/Number()
  const year =
    (s.charCodeAt(0) - ZERO_CODE) * 1000 +
    (s.charCodeAt(1) - ZERO_CODE) * 100 +
    (s.charCodeAt(2) - ZERO_CODE) * 10 +
    (s.charCodeAt(3) - ZERO_CODE)

  const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE)

  return _isBetween(year, 1900, 2500, '[]') && _isBetween(month, 1, 12, '[]')
}

function isPrimitive(data: any): boolean {
  return data !== null && typeof data === 'object'
}
