/**
 * @prettier
 */
import { List, Map } from "immutable"

export const upperFirst = (value) => {
  if (typeof value === "string") {
    return `${value.charAt(0).toUpperCase()}${value.slice(1)}`
  }
  return value
}

/**
 * Lookup can be `basic` or `extended`. By default the lookup is `extended`.
 */
export const makeGetTitle = (fnAccessor) => {
  const getTitle = (schema, { lookup = "extended" } = {}) => {
    const fn = fnAccessor()

    if (schema?.title != null) return fn.upperFirst(String(schema.title))
    if (lookup === "extended") {
      if (schema?.$anchor != null) return fn.upperFirst(String(schema.$anchor))
      if (schema?.$id != null) return String(schema.$id)
    }

    return ""
  }

  return getTitle
}

export const makeGetType = (fnAccessor) => {
  const getType = (schema, processedSchemas = new WeakSet()) => {
    const fn = fnAccessor()

    if (schema == null) {
      return "any"
    }

    if (fn.isBooleanJSONSchema(schema)) {
      return schema ? "any" : "never"
    }

    if (typeof schema !== "object") {
      return "any"
    }

    if (processedSchemas.has(schema)) {
      return "any" // detect a cycle
    }
    processedSchemas.add(schema)

    const { type, prefixItems, items } = schema

    const getArrayType = () => {
      if (Array.isArray(prefixItems)) {
        const prefixItemsTypes = prefixItems.map((itemSchema) =>
          getType(itemSchema, processedSchemas)
        )
        const itemsType = items ? getType(items, processedSchemas) : "any"
        return `array<[${prefixItemsTypes.join(", ")}], ${itemsType}>`
      } else if (items) {
        const itemsType = getType(items, processedSchemas)
        return `array<${itemsType}>`
      } else {
        return "array<any>"
      }
    }

    const inferType = () => {
      if (
        Object.hasOwn(schema, "prefixItems") ||
        Object.hasOwn(schema, "items") ||
        Object.hasOwn(schema, "contains")
      ) {
        return getArrayType()
      } else if (
        Object.hasOwn(schema, "properties") ||
        Object.hasOwn(schema, "additionalProperties") ||
        Object.hasOwn(schema, "patternProperties")
      ) {
        return "object"
      } else if (["int32", "int64"].includes(schema.format)) {
        // OpenAPI 3.1.0 integer custom formats
        return "integer"
      } else if (["float", "double"].includes(schema.format)) {
        // OpenAPI 3.1.0 number custom formats
        return "number"
      } else if (
        Object.hasOwn(schema, "minimum") ||
        Object.hasOwn(schema, "maximum") ||
        Object.hasOwn(schema, "exclusiveMinimum") ||
        Object.hasOwn(schema, "exclusiveMaximum") ||
        Object.hasOwn(schema, "multipleOf")
      ) {
        return "number | integer"
      } else if (
        Object.hasOwn(schema, "pattern") ||
        Object.hasOwn(schema, "format") ||
        Object.hasOwn(schema, "minLength") ||
        Object.hasOwn(schema, "maxLength") ||
        Object.hasOwn(schema, "contentEncoding") ||
        Object.hasOwn(schema, "contentMediaType")
      ) {
        return "string"
      } else if (typeof schema.const !== "undefined") {
        if (schema.const === null) {
          return "null"
        } else if (typeof schema.const === "boolean") {
          return "boolean"
        } else if (typeof schema.const === "number") {
          return Number.isInteger(schema.const) ? "integer" : "number"
        } else if (typeof schema.const === "string") {
          return "string"
        } else if (Array.isArray(schema.const)) {
          return "array<any>"
        } else if (typeof schema.const === "object") {
          return "object"
        }
      }
      return null
    }

    if (schema.not && getType(schema.not) === "any") {
      return "never"
    }

    const typeString = Array.isArray(type)
      ? type.map((t) => (t === "array" ? getArrayType() : t)).join(" | ")
      : type === "array"
        ? getArrayType()
        : [
              "null",
              "boolean",
              "object",
              "array",
              "number",
              "integer",
              "string",
            ].includes(type)
          ? type
          : inferType()

    const handleCombiningKeywords = (keyword, separator) => {
      if (Array.isArray(schema[keyword])) {
        const combinedTypes = schema[keyword].map((subSchema) =>
          getType(subSchema, processedSchemas)
        )
        return `(${combinedTypes.join(separator)})`
      }
      return null
    }

    const oneOfString = handleCombiningKeywords("oneOf", " | ")
    const anyOfString = handleCombiningKeywords("anyOf", " | ")
    const allOfString = handleCombiningKeywords("allOf", " & ")

    const combinedStrings = [typeString, oneOfString, anyOfString, allOfString]
      .filter(Boolean)
      .join(" | ")

    processedSchemas.delete(schema)

    return combinedStrings || "any"
  }

  return getType
}

export const isBooleanJSONSchema = (schema) => typeof schema === "boolean"

export const hasKeyword = (schema, keyword) =>
  schema !== null &&
  typeof schema === "object" &&
  Object.hasOwn(schema, keyword)

export const makeIsExpandable = (fnAccessor) => {
  const isExpandable = (schema) => {
    const fn = fnAccessor()

    return (
      schema?.$schema ||
      schema?.$vocabulary ||
      schema?.$id ||
      schema?.$anchor ||
      schema?.$dynamicAnchor ||
      schema?.$ref ||
      schema?.$dynamicRef ||
      schema?.$defs ||
      schema?.$comment ||
      schema?.allOf ||
      schema?.anyOf ||
      schema?.oneOf ||
      fn.hasKeyword(schema, "not") ||
      fn.hasKeyword(schema, "if") ||
      fn.hasKeyword(schema, "then") ||
      fn.hasKeyword(schema, "else") ||
      schema?.dependentSchemas ||
      schema?.prefixItems ||
      fn.hasKeyword(schema, "items") ||
      fn.hasKeyword(schema, "contains") ||
      schema?.properties ||
      schema?.patternProperties ||
      fn.hasKeyword(schema, "additionalProperties") ||
      fn.hasKeyword(schema, "propertyNames") ||
      fn.hasKeyword(schema, "unevaluatedItems") ||
      fn.hasKeyword(schema, "unevaluatedProperties") ||
      schema?.description ||
      schema?.enum ||
      fn.hasKeyword(schema, "const") ||
      fn.hasKeyword(schema, "contentSchema") ||
      fn.hasKeyword(schema, "default") ||
      schema?.examples ||
      fn.getExtensionKeywords(schema).length > 0
    )
  }

  return isExpandable
}

export const stringify = (value) => {
  if (
    value === null ||
    ["number", "bigint", "boolean"].includes(typeof value)
  ) {
    return String(value)
  }

  if (Array.isArray(value)) {
    return `[${value.map(stringify).join(", ")}]`
  }

  return JSON.stringify(value)
}

const stringifyConstraintMultipleOf = (schema) => {
  if (typeof schema?.multipleOf !== "number") return null
  if (schema.multipleOf <= 0) return null
  if (schema.multipleOf === 1) return null

  const { multipleOf } = schema

  if (Number.isInteger(multipleOf)) {
    return `multiple of ${multipleOf}`
  }

  const decimalPlaces = multipleOf.toString().split(".")[1].length
  const factor = 10 ** decimalPlaces
  const numerator = multipleOf * factor
  const denominator = factor
  return `multiple of ${numerator}/${denominator}`
}

const stringifyConstraintNumberRange = (schema) => {
  const minimum = schema?.minimum
  const maximum = schema?.maximum
  const exclusiveMinimum = schema?.exclusiveMinimum
  const exclusiveMaximum = schema?.exclusiveMaximum
  const hasMinimum = typeof minimum === "number"
  const hasMaximum = typeof maximum === "number"
  const hasExclusiveMinimum = typeof exclusiveMinimum === "number"
  const hasExclusiveMaximum = typeof exclusiveMaximum === "number"
  const isMinExclusive = hasExclusiveMinimum && (!hasMinimum || minimum < exclusiveMinimum) // prettier-ignore
  const isMaxExclusive = hasExclusiveMaximum && (!hasMaximum || maximum > exclusiveMaximum) // prettier-ignore

  if (
    (hasMinimum || hasExclusiveMinimum) &&
    (hasMaximum || hasExclusiveMaximum)
  ) {
    const minSymbol = isMinExclusive ? "(" : "["
    const maxSymbol = isMaxExclusive ? ")" : "]"
    const minValue = isMinExclusive ? exclusiveMinimum : minimum
    const maxValue = isMaxExclusive ? exclusiveMaximum : maximum
    return `${minSymbol}${minValue}, ${maxValue}${maxSymbol}`
  }
  if (hasMinimum || hasExclusiveMinimum) {
    const minSymbol = isMinExclusive ? ">" : "≥"
    const minValue = isMinExclusive ? exclusiveMinimum : minimum
    return `${minSymbol} ${minValue}`
  }
  if (hasMaximum || hasExclusiveMaximum) {
    const maxSymbol = isMaxExclusive ? "<" : "≤"
    const maxValue = isMaxExclusive ? exclusiveMaximum : maximum
    return `${maxSymbol} ${maxValue}`
  }

  return null
}

const stringifyConstraintRange = (label, min, max) => {
  const hasMin = typeof min === "number"
  const hasMax = typeof max === "number"

  if (hasMin && hasMax) {
    if (min === max) {
      return `${min} ${label}`
    } else {
      return `[${min}, ${max}] ${label}`
    }
  }
  if (hasMin) {
    return `≥ ${min} ${label}`
  }
  if (hasMax) {
    return `≤ ${max} ${label}`
  }

  return null
}

export const stringifyConstraints = (schema) => {
  const constraints = []

  // validation Keywords for Numeric Instances (number and integer)
  const multipleOf = stringifyConstraintMultipleOf(schema)
  if (multipleOf !== null) {
    constraints.push({ scope: "number", value: multipleOf })
  }
  const numberRange = stringifyConstraintNumberRange(schema)
  if (numberRange !== null) {
    constraints.push({ scope: "number", value: numberRange })
  }

  // vocabularies for Semantic Content With "format"
  if (schema?.format) {
    constraints.push({ scope: "string", value: schema.format })
  }

  // validation Keywords for Strings
  const stringRange = stringifyConstraintRange(
    "characters",
    schema?.minLength,
    schema?.maxLength
  )
  if (stringRange !== null) {
    constraints.push({ scope: "string", value: stringRange })
  }
  if (schema?.pattern) {
    constraints.push({ scope: "string", value: `matches ${schema?.pattern}` })
  }

  // vocabulary for the Contents of String-Encoded Data
  if (schema?.contentMediaType) {
    constraints.push({
      scope: "string",
      value: `media type: ${schema.contentMediaType}`,
    })
  }
  if (schema?.contentEncoding) {
    constraints.push({
      scope: "string",
      value: `encoding: ${schema.contentEncoding}`,
    })
  }

  // validation Keywords for Arrays
  const arrayRange = stringifyConstraintRange(
    schema?.uniqueItems ? "unique items" : "items",
    schema?.minItems,
    schema?.maxItems
  )
  if (arrayRange !== null) {
    constraints.push({ scope: "array", value: arrayRange })
  }
  if (schema?.uniqueItems && !arrayRange) {
    constraints.push({ scope: "array", value: "unique" })
  }
  const containsRange = stringifyConstraintRange(
    "contained items",
    schema?.minContains,
    schema?.maxContains
  )
  if (containsRange !== null) {
    constraints.push({ scope: "array", value: containsRange })
  }

  // validation Keywords for Objects
  const objectRange = stringifyConstraintRange(
    "properties",
    schema?.minProperties,
    schema?.maxProperties
  )
  if (objectRange !== null) {
    constraints.push({ scope: "object", value: objectRange })
  }

  return constraints
}

export const getDependentRequired = (propertyName, schema) => {
  if (!schema?.dependentRequired) return []

  return Array.from(
    Object.entries(schema.dependentRequired).reduce((acc, [prop, list]) => {
      if (!Array.isArray(list)) return acc
      if (!list.includes(propertyName)) return acc

      acc.add(prop)

      return acc
    }, new Set())
  )
}

export const isPlainObject = (value) =>
  typeof value === "object" &&
  value !== null &&
  !Array.isArray(value) &&
  (Object.getPrototypeOf(value) === null ||
    Object.getPrototypeOf(value) === Object.prototype)

export const isEmptyObject = (value) =>
  isPlainObject(value) && Object.keys(value).length === 0

export const isEmptyArray = (value) =>
  Array.isArray(value) && value.length === 0

export const difference = (value, comparisonValue) => {
  const comparisonSet = new Set(comparisonValue)
  return value.filter((item) => !comparisonSet.has(item))
}

export const getSchemaKeywords = () => {
  return [
    // core vocabulary
    "$schema",
    "$vocabulary",
    "$id",
    "$anchor",
    "$dynamicAnchor",
    "$dynamicRef",
    "$ref",
    "$defs",
    "$comment",
    // applicator vocabulary
    "allOf",
    "anyOf",
    "oneOf",
    "not",
    "if",
    "then",
    "else",
    "dependentSchemas",
    "prefixItems",
    "items",
    "contains",
    "properties",
    "patternProperties",
    "additionalProperties",
    "propertyNames",
    // unevaluated Locations vocabulary
    "unevaluatedItems",
    "unevaluatedProperties",
    // validation vocabulary
    // validation Keywords for Any Instance Type
    "type",
    "enum",
    "const",
    // validation Keywords for Numeric Instances (number and integer)
    "multipleOf",
    "maximum",
    "exclusiveMaximum",
    "minimum",
    "exclusiveMinimum",
    // validation Keywords for Strings
    "maxLength",
    "minLength",
    "pattern",
    // validation Keywords for Arrays
    "maxItems",
    "minItems",
    "uniqueItems",
    "maxContains",
    "minContains",
    // validation Keywords for Objects
    "maxProperties",
    "minProperties",
    "required",
    "dependentRequired",
    // basic Meta-Data Annotations vocabulary
    "title",
    "description",
    "default",
    "deprecated",
    "readOnly",
    "writeOnly",
    "examples",
    // semantic Content With "format" vocabulary
    "format",
    // contents of String-Encoded Data vocabulary
    "contentEncoding",
    "contentMediaType",
    "contentSchema",
  ]
}

export const makeGetExtensionKeywords = (fnAccessor) => {
  const getExtensionKeywords = (schema) => {
    const fn = fnAccessor()
    const keywords = fn.getSchemaKeywords()

    return isPlainObject(schema)
      ? difference(Object.keys(schema), keywords)
      : []
  }

  return getExtensionKeywords
}

export const hasSchemaType = (schema, type) => {
  const isSchemaImmutable = Map.isMap(schema)

  if (!isSchemaImmutable && !isPlainObject(schema)) {
    return false
  }

  const hasType = (schemaType) =>
    type === schemaType || (Array.isArray(type) && type.includes(schemaType))

  const schemaType = isSchemaImmutable ? schema.get("type") : schema.type

  if (List.isList(schemaType) || Array.isArray(schemaType)) {
    return schemaType.some((t) => hasType(t))
  }

  return hasType(schemaType)
}
