class_name JSONSchema
extends Reference


# JSON Schema main script
# Inherits from Reference for easy use

const SMALL_FLOAT_THRESHOLD = 0.001
const MAX_DECIMAL_PLACES = 3

const DEF_KEY_NAME = "schema root"
const DEF_ERROR_STRING = "##error##"

const JST_ARRAY = "array"
const JST_BOOLEAN = "boolean"
const JST_INTEGER = "integer"
const JST_NULL = "null"
const JST_NUMBER = "number"
const JST_OBJECT = "object"
const JST_STRING = "string"

const JSKW_TYPE = "type"
const JSKW_PROP = "properties"
const JSKW_REQ = "required"
const JSKW_TITLE = "title"
const JSKW_DESCR = "description"
const JSKW_DEFAULT = "default"
const JSKW_EXAMPLES = "examples"
const JSKW_COMMENT = "$comment"
const JSKW_ENUM = "enum"
const JSKW_CONST = "const"
const JSKW_PREFIX_ITEMS = "prefixItems"
const JSKW_ITEMS = "items"
const JSKW_MIN_ITEMS = "minItems"
const JSKW_MAX_ITEMS = "maxItems"
const JSKW_CONTAINS = "contains"
const JSKW_ADD_ITEMS = "additionalItems"
const JSKW_UNIQUE_ITEMS = "uniqueItems"
const JSKW_MULT_OF = "multipleOf"
const JSKW_MINIMUM = "minimum"
const JSKW_MIN_EX = "exclusiveMinimum"
const JSKW_MAXIMUM = "maximum"
const JSKW_MAX_EX = "exclusiveMaximum"
const JSKW_PROP_ADD = "additionalProperties"
const JSKW_PROP_PATTERN = "patternProperties"
const JSKW_PROP_NAMES = "propertyNames"
const JSKW_PROP_MIN = "minProperties"
const JSKW_PROP_MAX = "maxProperties"
const JSKW_DEPEND = "dependencies"
const JSKW_LENGTH_MIN = "minLength"
const JSKW_LENGTH_MAX = "maxLength"
const JSKW_PATTERN = "pattern"
const JSKW_FORMAT = "format"
const JSKW_COLOR = "color"

const JSM_GREATER = "greater"
const JSM_GREATER_EQ = "greater or equal"
const JSM_LESS = "less"
const JSM_LESS_EQ = "less or equal"
const JSM_OBJ_DICT = "object (dictionary)"

const JSL_AND = "%s and %s"
const JSL_OR = "%s or %s"

const ERR_SCHEMA_FALSE = "Schema declared as deny all"
const ERR_WRONG_SCHEMA_GEN = "Schema error: "
const ERR_WRONG_SCHEMA_TYPE = "Schema error: schema must be empty object or object with 'type' keyword or boolean value"
const ERR_WRONG_SHEMA_NOTA = "Schema error: expected that all elements of '%s.%s' must be '%s'"
const ERR_WRONG_PROP_TYPE = "Schema error: any schema item must be object with 'type' keyword"
const ERR_REQ_PROP_GEN = "Schema error: expected array of required properties for '%s'"
const ERR_REQ_PROP_MISSING = "Missing required property: '%s' for '%s'"
const ERR_NO_PROP_ADD = "Additional properties are not required: found '%s'"
const ERR_FEW_PROP = "%d propertie(s) are not enough properties, at least %d are required"
const ERR_MORE_PROP = "%d propertie(s) are too many properties, at most %d are allowed"
const ERR_FEW_ITEMS = "%s item(s) are not enough items, at least %s are required"
const ERR_MORE_ITEMS = "%s item(s) are too many items, at most %s are allowed"
const ERR_INVALID_JSON_GEN = "Validation fails with message: %s"
const ERR_INVALID_JSON_EXT = "Invalid JSON data passed with message: %s"
const ERR_TYPE_MISMATCH_GEN = "Type mismatch: expected %s for '%s'"
const ERR_INVALID_NUMBER = "The %s key that equals %s should have a maximum of %s decimal places"
const ERR_INVALID_MULT = "Multiplier in key %s that equals %s must be greater or equal to %s"
const ERR_MULT_D = "Key %s that equal %d must be multiple of %d"
const ERR_MULT_F = "Key %s that equal %f must be multiple of %f"
const ERR_RANGE_D = "Key %s that equal %d must be %s than %d"
const ERR_RANGE_F = "Key %s that equal %f must be %s than %f"
const ERR_RANGE_S = "Length of '%s' (%d) %s than declared (%d)"
const ERR_WRONG_PATTERN = "String '%s' does not match its corresponding pattern"
const ERR_FORMAT = "String '%s' does not match its corresponding format '%s'"

# This is one and only function that need you to call outside
# If all validation checks passes, this return empty String
func validate(json_data : String, schema: String) -> String:
	var error : String = ""

	# General validation input data as JSON file
	error = validate_json(json_data)
	if error: return ERR_INVALID_JSON_EXT % error

	# General validation input schema as JSONSchema file
	error = validate_json(schema)
	if error: return ERR_WRONG_SCHEMA_GEN + error
	var parsed_schema = parse_json(schema)
	match typeof(parsed_schema):
		TYPE_BOOL:
			if !parsed_schema:
				return ERR_INVALID_JSON_GEN % ERR_SCHEMA_FALSE
			else:
				return ""
		TYPE_DICTIONARY:
			if parsed_schema.empty():
				return ""
			elif parsed_schema.keys().size() > 0 && !parsed_schema.has(JSKW_TYPE):
				return ERR_WRONG_SCHEMA_TYPE
		_: return ERR_WRONG_SCHEMA_TYPE

	# All inputs seems valid. Begin type validation
	error = _type_selection(json_data, parsed_schema)

	# Normal return empty string, meaning OK
	return error

func _to_string():
	return "[JSONSchema:%d]" % get_instance_id()

# TODO: title, description, default, examples, $comment, enum, const
func _type_selection(json_data: String, schema: Dictionary, key: String = DEF_KEY_NAME) -> String:
	# If the schema is an empty object it always passes validation
	if schema.empty():
		return ""

	if typeof(schema) == TYPE_BOOL:
		# If the schema is true it always passes validation
		if schema:
			return ""
		# If the schema is false it always vales validation
		else:
			return ERR_INVALID_JSON_GEN + "false is always invalid"

	var typearr: Array = _var_to_array(schema.type)
	var parsed_data = parse_json(json_data)
	var error: String = ERR_TYPE_MISMATCH_GEN % [typearr, key]
	for type in typearr:
		match type:
			JST_ARRAY:
				if typeof(parsed_data) == TYPE_ARRAY:
					error = _validate_array(parsed_data, schema, key)
				else:
					error = ERR_TYPE_MISMATCH_GEN % [[JST_ARRAY], key]
			JST_BOOLEAN:
				if typeof(parsed_data) != TYPE_BOOL:
					return ERR_TYPE_MISMATCH_GEN % [[JST_BOOLEAN], key]
				else:
					error = ""
			JST_INTEGER:
				if typeof(parsed_data) == TYPE_INT:
					error = _validate_integer(parsed_data, schema, key)
				if typeof(parsed_data) == TYPE_REAL && parsed_data == int(parsed_data):
					error = _validate_integer(int(parsed_data), schema, key)
			JST_NULL:
				if typeof(parsed_data) != TYPE_NIL:
					return ERR_TYPE_MISMATCH_GEN % [[JST_NULL], key]
				else:
					error = ""
			JST_NUMBER:
				if typeof(parsed_data) == TYPE_REAL:
					error = _validate_number(parsed_data, schema, key)
				else:
					error = ERR_TYPE_MISMATCH_GEN % [[JST_NUMBER], key]
			JST_OBJECT:
				if typeof(parsed_data) == TYPE_DICTIONARY:
					error = _validate_object(parsed_data, schema, key)
				else:
					error = ERR_TYPE_MISMATCH_GEN % [[JST_OBJECT], key]
			JST_STRING:
				if typeof(parsed_data) == TYPE_STRING:
					error = _validate_string(parsed_data, schema, key)
				else:
					error = ERR_TYPE_MISMATCH_GEN % [[JST_STRING], key]
	return error


func _var_to_array(variant) -> Array:
	var result : Array = []
	if typeof(variant) == TYPE_ARRAY:
		result = variant
	else:
		result.append(variant)
	return result

func _validate_array(input_data: Array, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# TODO: contains minContains maxContains uniqueItems

	# Initialize variables
	var error : String = "" # Variable to store any error messages
	var items_array : Array # Array of items in the schema
	var suberror : Array = [] # Array of suberrors in each item
	var additional_items_schema: Dictionary # Schema for additional items in the input data
	var is_additional_item_allowed: bool # Flag to check if additional items are allowed

	# Check if minItems key exists in the schema
	if input_schema.has(JSKW_MIN_ITEMS):
		# Check if non negative number
		if input_schema.minItems < 0:
			return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number."

		if input_data.size() < input_schema.minItems:
			return ERR_FEW_ITEMS % [input_data.size(), input_schema.minItems]

	# Check if maxItems key exists in the schema
	if input_schema.has(JSKW_MAX_ITEMS):
			# Check if non negative number
			if input_schema.maxItems < 0:
				return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number."

			if input_data.size() > input_schema.maxItems:
				return ERR_MORE_ITEMS % [input_data.size(), input_schema.maxItems]

	# Check if prefixItems key exists in the schema
	if input_schema.has(JSKW_PREFIX_ITEMS):
		# Check if items key exists in the schema
		if not input_schema.has(JSKW_ITEMS):
			return ERR_REQ_PROP_MISSING % [JSKW_ITEMS, JSKW_PREFIX_ITEMS]

		# Return error if items key is not a bool or a dictionary
		if not typeof(input_schema.items) == TYPE_DICTIONARY and not typeof(input_schema.items) == TYPE_BOOL:
			return ERR_WRONG_SCHEMA_TYPE

		if typeof(input_schema.items) == TYPE_BOOL:
			# Check if additional items in the input data are allowed
			if input_schema.items == false:
				# Check if there are more items in the input data than specified in prefixItems
				if input_data.size() > input_schema.prefixItems.size():
					# Create an error message if there are more items than allowed
					var substr := "Array '%s' is of size %s but no addition items allowed." % [input_data, input_data.size()]
					return ERR_INVALID_JSON_GEN % substr
			# If the 'items' key is set to true all types are allowed for addition items.
			else:
				additional_items_schema = {}

		# Check if items key is a dictionary
		if typeof(input_schema.items) == TYPE_DICTIONARY:
			# Any items after the specified ones in prefixItems have to be validated with this schema
			# Set the schema for additional array items
			additional_items_schema = input_schema.items

		# Check if all entries in prefixItems are a dictionary
		for schema in input_schema.prefixItems:
			if typeof(schema) != TYPE_DICTIONARY:
				return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT]

		# Check every item in the input data
		for index in input_data.size():
			var item = input_data[index]
			var current_schema: Dictionary
			var key_substr: String

			if index <= input_schema.prefixItems.size() - 1:
				# As long as there are prefixItems in the array work with those
				current_schema = input_schema.prefixItems[index]
				key_substr = ".prefixItems"
			else:
				# After that use the items schema
				current_schema = additional_items_schema
				key_substr = ".items"

			var sub_error_message := _type_selection(JSON.print(item), current_schema, property_name + key_substr + "["+String(index)+"]")
			if not sub_error_message == "":
				suberror.append(sub_error_message)

		if suberror.size() > 0:
			return ERR_INVALID_JSON_GEN % String(suberror)

		# Return inside this if block, because we don't want to validate the items key twice.
		return error

	# Check if items key exists in the schema
	if input_schema.has(JSKW_ITEMS):
		#'items' must be an object
		if not typeof(input_schema.items) == TYPE_DICTIONARY:
			return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT]

		# Check every item of input Array on
		for index in input_data.size():
			index = index - 1

			# Validate the array item with the schema defined by the 'items' key
			var sub_error_message := _type_selection(JSON.print(input_data[index]), input_schema.items, property_name+"["+String(index)+"]")
			if not sub_error_message == "":
				suberror.append(sub_error_message)

			if suberror.size() > 0:
				return ERR_INVALID_JSON_GEN % String(suberror)

	return error

func _validate_boolean(input_data: bool, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# nothing to check
	return ""

func _validate_integer(input_data: int, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# all processing is performed in
	return _validate_number(input_data, input_schema, property_name)

func _validate_null(input_data, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# nothing to check
	return ""

func _validate_number(input_data: float, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	var types: Array = _var_to_array(input_schema.type)
	# integer mode turns on only if types has integer and has not number
	var integer_mode: bool = types.has(JST_INTEGER) && !types.has(JST_NUMBER)

	# Processing multiple check
	if input_schema.has(JSKW_MULT_OF):
		var mult: float
		var mod: float
		var is_zero: bool

		# Get the multipleOf value from the schema and convert to float
		mult = float(input_schema[JSKW_MULT_OF])
		# Convert to integer if integer_mode is enabled
		mult = int(mult) if integer_mode else mult

		# Check if the number has more decimal places then allowed
		var decimal_places := str(input_data).get_slice('.', 1)
		if not decimal_places.empty() and decimal_places.length() > MAX_DECIMAL_PLACES:
			return ERR_INVALID_NUMBER % [property_name, input_data, str(MAX_DECIMAL_PLACES)]

		# Check if multipleOf is smaller than SMALL_FLOAT_THRESHOLD
		if not mult >= SMALL_FLOAT_THRESHOLD:
			return ERR_INVALID_MULT % [property_name, mult, str(SMALL_FLOAT_THRESHOLD)]

		# Multiply by a big number if input is smaller than 1 to prevent float issues
		if input_data < 1.0 or mult < 1.0:
			mod = fmod(input_data * 1000, mult * 1000)
		else:
			mod = fmod(input_data, mult)

		# Check if the remainder is close to zero
		is_zero = is_zero_approx(mod)

		# Return error message if remainder is not close to zero
		if not is_zero:
			if integer_mode:
				return ERR_MULT_D % [property_name, input_data, mult]
			else:
				return ERR_MULT_F % [property_name, input_data, mult]

	# processing minimum check
	if input_schema.has(JSKW_MINIMUM):
		var minimum = float(input_schema[JSKW_MINIMUM])
		minimum = int(minimum) if integer_mode else minimum
		if input_data < minimum:
			if integer_mode:
				return ERR_RANGE_D % [property_name, input_data, JSM_GREATER_EQ, minimum]
			else:
				return ERR_RANGE_F % [property_name, input_data, JSM_GREATER_EQ, minimum]

	# processing exclusive minimum check
	if input_schema.has(JSKW_MIN_EX):
		var minimum = float(input_schema[JSKW_MIN_EX])
		minimum = int(minimum) if integer_mode else minimum
		if input_data <= minimum:
			if integer_mode:
				return ERR_RANGE_D % [property_name, input_data, JSM_GREATER, minimum]
			else:
				return ERR_RANGE_F % [property_name, input_data, JSM_GREATER, minimum]

	# processing maximum check
	if input_schema.has(JSKW_MAXIMUM):
		var maximum = float(input_schema[JSKW_MAXIMUM])
		maximum = int(maximum) if integer_mode else maximum
		if input_data > maximum:
			if integer_mode:
				return ERR_RANGE_D % [property_name, input_data, JSM_LESS_EQ, maximum]
			else:
				return ERR_RANGE_F % [property_name, input_data, JSM_LESS_EQ, maximum]

	# processing exclusive minimum check
	if input_schema.has(JSKW_MAX_EX):
		var maximum = float(input_schema[JSKW_MAX_EX])
		maximum = int(maximum) if integer_mode else maximum
		if input_data >= maximum:
			if integer_mode:
				return ERR_RANGE_D % [property_name, input_data, JSM_LESS, maximum]
			else:
				return ERR_RANGE_F % [property_name, input_data, JSM_LESS, maximum]

	return ""

func _validate_object(input_data: Dictionary, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# TODO: patternProperties
	var error : String = ""

	# Process dependencies
	if input_schema.has(JSKW_DEPEND):
		for dependency in input_schema.dependencies.keys():
			if input_data.has(dependency):
				match typeof(input_schema.dependencies[dependency]):
					TYPE_ARRAY:
						if input_schema.has(JSKW_REQ):
							for property in input_schema.dependencies[dependency]:
								input_schema.required.append(property)
						else:
							input_schema.required = input_schema.dependencies[dependency]
					TYPE_DICTIONARY:
						for key in input_schema.dependencies[dependency].keys():
							if input_schema.has(key):
								match typeof(input_schema[key]):
									TYPE_ARRAY:
										for element in input_schema.dependencies[dependency][key]:
											input_schema[key].append(element)
									TYPE_DICTIONARY:
										for element in input_schema.dependencies[dependency][key].keys():
											input_schema[key][element] = input_schema.dependencies[dependency][key][element]
									_:
										input_schema[key] = input_schema.dependencies[dependency][key]
							else:
								input_schema[key] = input_schema.dependencies[dependency][key]
					_:
						return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_ARRAY, JSM_OBJ_DICT], property_name]

	# Process properties
	if input_schema.has(JSKW_PROP):

		# Process required
		if input_schema.has(JSKW_REQ):
			if typeof(input_schema.required) != TYPE_ARRAY: return ERR_REQ_PROP_GEN % property_name
			for i in input_schema.required:
				if !input_data.has(i): return ERR_REQ_PROP_MISSING % [i, property_name]

		# Continue validating schema subelements
		if typeof(input_schema.properties) != TYPE_DICTIONARY:
			return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name]

		# Process property items
		for key in input_schema.properties:
			if !input_schema.properties[key].has(JSKW_TYPE):
				return ERR_WRONG_PROP_TYPE
			if input_data.has(key):
				error = _type_selection(JSON.print(input_data[key]), input_schema.properties[key], key)
			else:
				pass
			if error: return error

	# Process additional properties
	if input_schema.has(JSKW_PROP_ADD):
		match typeof(input_schema.additionalProperties):
			TYPE_BOOL:
				if not input_schema.additionalProperties:
					for key in input_data:
						if not input_schema.properties.has(key):
							return ERR_NO_PROP_ADD % key
			TYPE_DICTIONARY:
				for key in input_data:
					if not input_schema.properties.has(key):
						return _type_selection(JSON.print(input_data[key]), input_schema.additionalProperties, key)
			_:
				return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_BOOLEAN, JSM_OBJ_DICT], property_name]

	# Process properties names
	if input_schema.has(JSKW_PROP_NAMES):
		if typeof(input_schema.propertyNames) != TYPE_DICTIONARY:
			return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name]
		for key in input_data:
			error = _validate_string(key, input_schema.propertyNames, key)
			if error: return error

	# Process minProperties maxProperties
	if input_schema.has(JSKW_PROP_MIN):
		if typeof(input_schema[JSKW_PROP_MIN]) != TYPE_REAL:
			return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name]
		if input_data.keys().size() < input_schema[JSKW_PROP_MIN]:
			return ERR_FEW_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MIN]]

	if input_schema.has(JSKW_PROP_MAX):
		if typeof(input_schema[JSKW_PROP_MAX]) != TYPE_REAL:
			return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name]
		if input_data.keys().size() > input_schema[JSKW_PROP_MAX]:
			return ERR_MORE_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MAX]]

	return error

func _validate_string(input_data: String, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String:
	# TODO: format
	var error : String = ""
	if input_schema.has(JSKW_LENGTH_MIN):
		if not (typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_REAL):
			return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MIN]
		if input_data.length() < input_schema[JSKW_LENGTH_MIN]:
			return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_LESS ,input_schema[JSKW_LENGTH_MIN]]

	if input_schema.has(JSKW_LENGTH_MAX):
		if not (typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_REAL):
			return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MAX]
		if input_data.length() > input_schema[JSKW_LENGTH_MAX]:
			return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_GREATER, input_schema[JSKW_LENGTH_MAX]]

	if input_schema.has(JSKW_PATTERN):
		if not (typeof(input_schema[JSKW_PATTERN]) == TYPE_STRING):
			return ERR_TYPE_MISMATCH_GEN % [JST_STRING, property_name+"."+JSKW_PATTERN]
		var regex = RegEx.new()
		regex.compile(input_schema[JSKW_PATTERN])
		if regex.search(input_data) == null:
			return ERR_INVALID_JSON_GEN % ERR_WRONG_PATTERN % property_name

	if input_schema.has(JSKW_FORMAT):
		# validate "color" format
		if input_schema.format.to_lower() == JSKW_COLOR:
			if not input_data.is_valid_html_color():
				return ERR_INVALID_JSON_GEN % ERR_FORMAT % [property_name, JSKW_COLOR]

	return error
