/*
 * Copyright (c) 2019, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 * WSO2 Inc. licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.apache.synapse.commons.json.jsonprocessor.validators;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.synapse.commons.json.jsonprocessor.constants.ValidatorConstants;
import org.apache.synapse.commons.json.jsonprocessor.exceptions.ParserException;
import org.apache.synapse.commons.json.jsonprocessor.exceptions.ValidatorException;
import org.apache.synapse.commons.json.jsonprocessor.utils.DataTypeConverter;
import org.apache.synapse.commons.json.jsonprocessor.utils.GSONDataTypeConverter;
import org.apache.synapse.commons.json.jsonprocessor.utils.JsonProcessorUtils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This class will validate json objects according to the schema.
 */
public class ObjectValidator {

    private final static String ADDITIONAL_PROPERTIES = "additionalProperties";
    private final static String MAX_PROPERTIES = "maxProperties";
    private final static String MIN_PROPERTIES = "minProperties";
    private final static String PATTERN_PROPERTIES = "patternProperties";
    private final static String REQUIRED = "required";

    // Use without instantiating.
    private ObjectValidator() {

    }

    /**
     * This method will validate a given JSON input object according to
     *
     * @param object JSON Object
     * @param schema JSON schema
     * @return validated object
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    public static JsonObject validateObject(JsonObject object, JsonObject schema) throws ParserException,
            ValidatorException {
        int minimumProperties = -1;
        int maximumProperties = -1;
        // check whether all the required elements are present.
        // keyword MUST be an array. Elements of this array, if any, MUST be strings, and MUST be unique.
        if (schema.has(REQUIRED)) {
            JsonArray requiredArray = schema.getAsJsonArray(REQUIRED);
            for (JsonElement element : requiredArray) {
                if (!object.has(element.getAsString())) {
                    throw new ValidatorException("Input object : " + object.toString() +
                            " does not contains all the elements required in the schema : " + schema.toString());
                }
            }
        }
        if (schema.has(MIN_PROPERTIES)) {
            String minPropertiesString = JsonProcessorUtils.replaceEnclosingQuotes(
                    schema.get(MIN_PROPERTIES).getAsString());
            if (!minPropertiesString.isEmpty()) {
                minimumProperties = DataTypeConverter.convertToInt(minPropertiesString);
            }
        }
        if (schema.has(MAX_PROPERTIES)) {
            String maxPropertiesString = JsonProcessorUtils.replaceEnclosingQuotes(
                    schema.get(MAX_PROPERTIES).getAsString());
            if (!maxPropertiesString.isEmpty()) {
                maximumProperties = DataTypeConverter.convertToInt(maxPropertiesString);
            }
        }
        JsonObject schemaObject = null;
        if (schema.has("properties")) {
            schemaObject = (JsonObject) schema.get("properties");
        }
        Set<Map.Entry<String, JsonElement>> entryInput = object.entrySet();

        // doing structural validation
        doStructuralValidation(maximumProperties, minimumProperties, entryInput);

        ArrayList<String> inputProperties = new ArrayList<>();
        ArrayList<String> patternProperties = new ArrayList<>();
        ArrayList<String> schemaProperties = new ArrayList<>();

        if (schemaObject != null && !schemaObject.entrySet().isEmpty()) {
            for (Map.Entry<String, JsonElement> entry : schemaObject.entrySet()) {
                if (object.has(entry.getKey())) {
                    schemaProperties.add(entry.getKey());
                }
            }
        }

        // validate children elements according to the schema only if properties are defined for each item.
        processSchemaProperties(schemaObject, entryInput, inputProperties);

        // handling pattern properties
        if (schema.has(PATTERN_PROPERTIES)) {
            processPatternProperties(object, schema, patternProperties);
        }

        // handling additionalProperties
        processAdditionalProperties(object, schema, inputProperties, patternProperties, schemaProperties);

        return object;
    }

    /**
     * This method will process a given JSON object according to "properties" in respective JSON schema.
     *
     * @param schemaObject    JSON schema.
     * @param entryInput      JSON input as map.
     * @param inputProperties array which should be updated with matching properties in schema.
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void processSchemaProperties(JsonObject schemaObject, Set<Map.Entry<String, JsonElement>>
            entryInput, ArrayList<String> inputProperties) throws ValidatorException, ParserException {
        if (schemaObject != null) {
            // Calling validation for internal items.
            for (Map.Entry<String, JsonElement> entry : entryInput) {
                String keyValue = entry.getKey();
                inputProperties.add(keyValue);
                if (schemaObject.has(keyValue) && schemaObject.get(keyValue).isJsonObject()) {
                    JsonObject schemaObj = schemaObject.getAsJsonObject(keyValue);
                    if (schemaObj.has(ValidatorConstants.TYPE_KEY)) {
                        JsonElement typeElement = schemaObj.get(ValidatorConstants.TYPE_KEY);
                        if (typeElement.isJsonArray()) {
                            ArrayList<String> types = new ArrayList<>();
                            // Handling multiple types
                            JsonArray array = (JsonArray) typeElement;
                            for (JsonElement element : array) {
                                types.add(JsonProcessorUtils.replaceEnclosingQuotes(element.toString()));
                            }
                            validateAndUpdateEntriesMap(schemaObject, entry, types);
                        } else {
                            validateAndUpdateEntriesMap(schemaObject, entry,
                                    JsonProcessorUtils.replaceEnclosingQuotes(typeElement.toString()));
                        }
                    }
                }
            }
        }
    }

    /**
     * This method will validate and update the entries of input object map (multi-type).
     *
     * @param schemaObject JSON schema.
     * @param entry        entry from the map.
     * @param types        data types (multi-type support).
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void validateAndUpdateEntriesMap(JsonObject schemaObject, Map.Entry<String, JsonElement> entry,
                                                    ArrayList<String> types) throws ValidatorException,
            ParserException {
        if (schemaObject.has(entry.getKey()) && schemaObject.get(entry.getKey()).isJsonObject()) {
            JsonObject schema = schemaObject.get(entry.getKey()).getAsJsonObject();
            if (entry.getValue().isJsonObject()) {
                multiTypeCheckObject(entry, types, schema);
            } else if (entry.getValue().isJsonArray()) {
                multiTypeCheckArray(entry, types, schema);
            } else if (entry.getValue().isJsonNull()) {
                multiTypeCheckNull(entry, types, schema);
            } else if (entry.getValue().isJsonPrimitive()) {
                multiTypeCheckPrimitive(entry, types, schema);
            }
        }
    }

    // Try to correct a JSON object against an schema with multiple types.
    private static void multiTypeCheckObject(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                             JsonObject schema) throws ParserException, ValidatorException {
        boolean validated = false;
        for (String type : types) {
            if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
                entry.setValue(ObjectValidator.validateObject(entry.getValue().getAsJsonObject(), schema));
                validated = true;
            }
        }
        if (!validated) {
            for (String type : types) {
                if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                    // Single element array correction
                    JsonArray array = new JsonArray();
                    array.add(entry.getValue().getAsJsonObject());
                    entry.setValue(array);
                    entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                            entry.getValue().toString()), schema));
                    validated = true;
                }
            }
        }
        if (!validated) {
            throw new ValidatorException("JSON object found " + entry.getValue() + " without matching " +
                    "schema " + schema.toString());
        }
    }

    // Try to correct a JSON array against an schema with multiple types.
    private static void multiTypeCheckArray(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                            JsonObject schema) throws ParserException, ValidatorException {
        boolean validated = false;
        for (String type : types) {
            if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                        entry.getValue().toString()), schema));
                validated = true;
            }
        }
        if (!validated) {
            throw new ValidatorException("JSON array found " + entry.getValue() + " without matching " +
                    "schema " + schema.toString());
        }
    }

    // Try to correct a JSON null against an schema with multiple types.
    private static void multiTypeCheckNull(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                           JsonObject schema) throws ParserException, ValidatorException {
        boolean validated = false;
        for (String type : types) {
            if (ValidatorConstants.NULL_KEYS.contains(type)) {
                entry.setValue(JsonNull.INSTANCE);
                validated = true;
            }
        }
        if (!validated) {
            throw new ValidatorException("JSON null found " + entry.getValue() + " without matching " +
                    "schema " + schema.toString());
        }
    }

    // Try to correct a JSON primitive (nominal, numeric, boolean) against an schema with multiple types.
    private static void multiTypeCheckPrimitive(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                                JsonObject schema) throws ParserException, ValidatorException {

        String value = entry.getValue().getAsString();
        boolean validated = false;
        if ("true".equals(value) || "false".equals(value)) {
            // if boolean check for boolean type if type is nominal convert to string
            for (String type : types) {
                if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                    entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
                    validated = true;
                }
            }
            if (!validated) {
                for (String type : types) {
                    if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                        entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
            }
            if (!validated) {
                throw new ValidatorException("Boolean found " + entry.getValue() + " without matching " +
                        "schema " + schema.toString());
            }
        } else if (NumberUtils.isParsable(value)) {
            // if number check for numeric types if type is nominal convert to string
            for (String type : types) {
                if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                    entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
                    validated = true;
                }
            }
            if (!validated) {
                for (String type : types) {
                    if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                        entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
            }
            if (!validated) {
                throw new ValidatorException("Number found " + entry.getValue() + " without matching " +
                        "schema " + schema.toString());
            }
        } else {
            // if string check for all types (nominal, boolean, numeric) and try to parse
            for (String type : types) {
                if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                    entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                    validated = true;
                }
            }
            if (!validated) {
                for (String type : types) {
                    if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                        entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
            }
            if (!validated) {
                for (String type : types) {
                    if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                        entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
            }
            if (!validated) {
                throw new ValidatorException(entry.getValue() + " not matching with the schema " + schema.toString());
            }
        }
    }

    /**
     * This method will validate and update the entries of input object map.
     *
     * @param schemaObject JSON schema.
     * @param entry        entry from the map.
     * @param type        data type.
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void validateAndUpdateEntriesMap(JsonObject schemaObject, Map.Entry<String, JsonElement> entry,
                                                    String type) throws ValidatorException,
            ParserException {
        if (schemaObject.has(entry.getKey()) && schemaObject.get(entry.getKey()).isJsonObject()) {
            JsonObject schema = schemaObject.get(entry.getKey()).getAsJsonObject();
            if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
            } else if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
            } else if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
            } else if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(entry.getValue()
                        .toString()), schema));
            } else if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
                entry.setValue(ObjectValidator.validateObject(entry.getValue().getAsJsonObject(), schema));
            } else if (ValidatorConstants.NULL_KEYS.contains(type)) {
                if (entry.getValue() != null) {
                    NullValidator.validateNull(schema, entry.getValue().toString());
                }
                entry.setValue(JsonNull.INSTANCE);
            }
        }
    }

    /**
     * This method will handle the additional properties constraint.
     *
     * @param object            input JSON object.
     * @param schema            JSON schema.
     * @param inputProperties   array which contains all keys of input object.
     * @param patternProperties array which contains all matching keys with pattern properties.
     * @param schemaProperties  array which contains all schema keys.
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void processAdditionalProperties(JsonObject object, JsonObject schema, ArrayList<String>
            inputProperties, ArrayList<String> patternProperties, ArrayList<String> schemaProperties)
            throws ParserException, ValidatorException {
        if (schema.has(ADDITIONAL_PROPERTIES)) {
            inputProperties.removeAll(schemaProperties);
            inputProperties.removeAll(patternProperties);
            if (schema.get(ADDITIONAL_PROPERTIES).isJsonPrimitive()) {
                boolean allowAdditional = DataTypeConverter.convertToBoolean(schema.get(ADDITIONAL_PROPERTIES)
                        .getAsString());
                if (!allowAdditional && !inputProperties.isEmpty()) {
                    throw new ValidatorException("Input object " + object.toString() +
                            " has additional properties than schema " + schema.toString() +
                            " and additional properties are not allowed");
                }
            } else if (schema.get(ADDITIONAL_PROPERTIES).isJsonObject()) {
                JsonObject additionalSchema = schema.get(ADDITIONAL_PROPERTIES).getAsJsonObject();
                validateMultipleObjectsUsingOneSchema(inputProperties, object, additionalSchema);
            }
        }
    }

    /**
     * This method will process input objects matching with "patternProperties" regular expression.
     *
     * @param object            input JSON object.
     * @param schema            input schema.
     * @param patternProperties arrayList which should be updated with matching keys of patternProperty.
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void processPatternProperties(JsonObject object, JsonObject schema, ArrayList<String>
            patternProperties) throws ParserException,
            ValidatorException {
        Set<String> inputObjectKeyset = object.keySet();
        JsonObject patternsObject = schema.getAsJsonObject(PATTERN_PROPERTIES);
        Set<Map.Entry<String, JsonElement>> patterns = patternsObject.entrySet();
        for (Map.Entry<String, JsonElement> pattern : patterns) {
            String regex = pattern.getKey();
            JsonObject tempSchema = pattern.getValue().getAsJsonObject();
            if (tempSchema.has(ValidatorConstants.TYPE_KEY)) {
                String type = JsonProcessorUtils.replaceEnclosingQuotes(
                        tempSchema.get(ValidatorConstants.TYPE_KEY).getAsString());
                // get the list of keys matched the regular expression
                ArrayList<String> matchingKeys = getMatchRegexAgainstStringSet(inputObjectKeyset, regex);
                for (String key : matchingKeys) {
                    // updating patternProperties array for later use in additional properties.
                    if (!patternProperties.contains(key)) {
                        patternProperties.add(key);
                    }
                    parseAndReplaceValues(object, tempSchema, type, key);
                }
            } else {
                throw new ValidatorException("Schema for object must have a " +
                        "type declaration : " + tempSchema.toString());
            }

        }
    }

    /**
     * Get matching set of keys inside object, for a given regular expression.
     *
     * @param keyset set of keys inside object.
     * @param regex  regular expression.
     * @return array of matching keys.
     */
    private static ArrayList<String> getMatchRegexAgainstStringSet(Set<String> keyset, String regex) {
        ArrayList<String> matchingKeys = new ArrayList<>();
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher;
        for (String item : keyset) {
            matcher = pattern.matcher(item);
            if (matcher.find()) {
                matchingKeys.add(item);
            }
        }
        return matchingKeys;
    }

    /**
     * Doing structural validations for a given JSON object according to schema.
     *
     * @param maximumProperties maximum number of properties allowed.
     * @param minimumProperties minimum number of properties allowed.
     * @param entryInput        input object as a Map.
     * @throws ValidatorException validation exception occurs.
     */
    private static void doStructuralValidation(int maximumProperties, int minimumProperties, Set<Map.Entry<String,
            JsonElement>> entryInput) throws ValidatorException {
        int numOfProperties = entryInput.size();

        if (minimumProperties != -1 && numOfProperties < minimumProperties) {
            throw new ValidatorException("Object violates the minimum number of properties " +
                    "constraint. Input object has less number of properties than allowed minimum " +
                    minimumProperties);
        }
        if (maximumProperties != -1 && numOfProperties > maximumProperties) {
            throw new ValidatorException("Object violates the maximum number of properties " +
                    "constraint. Input object has higher number of properties than allowed maximum " +
                    maximumProperties);
        }
    }

    /**
     * Given a single schema object and array of keys, this method will validate all input object keys
     * according to the schema.
     *
     * @param keysArray array of keys.
     * @param input     input object.
     * @param schema    schema object.
     */
    private static void validateMultipleObjectsUsingOneSchema(ArrayList<String> keysArray, JsonObject input,
                                                              JsonObject schema) throws ValidatorException,
            ParserException {
        if (!schema.entrySet().isEmpty() && !keysArray.isEmpty()) {
            if (schema.has(ValidatorConstants.TYPE_KEY)) {
                String type = JsonProcessorUtils.replaceEnclosingQuotes(
                        schema.get(ValidatorConstants.TYPE_KEY).toString());
                for (String key : keysArray) {
                    parseAndReplaceValues(input, schema, type, key);
                }
            } else {
                throw new ValidatorException("Schema for array must have a " +
                        "type declaration : " + schema.toString());
            }
        }
    }

    /**
     * This method will parse and replace specific object defined by the key.
     *
     * @param input  input object.
     * @param schema JSON schema.
     * @param type   type of the object.
     * @param key    key of the specific object/ element.
     * @throws ParserException    Exception occurred in data type conversions.
     * @throws ValidatorException Exception occurred in schema validations.
     */
    private static void parseAndReplaceValues(JsonObject input, JsonObject schema, String type, String key) throws
            ParserException, ValidatorException {
        JsonElement result = null;
        if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
            result = NumericValidator.validateNumeric(schema, input.get(key).toString());
        } else if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
            result = StringValidator.validateNominal(schema, input.get(key).toString());
        } else if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
            result = BooleanValidator.validateBoolean(schema, input.get(key).toString());
        } else if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
            result = ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                    input.get(key).toString()), schema);
        } else if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
            result = ObjectValidator.validateObject(input.get(key).getAsJsonObject(), schema);
        } else if (ValidatorConstants.NULL_KEYS.contains(type)) {
            if (input.get(key) != null) {
                NullValidator.validateNull(schema, input.get(key).toString());
            }
            result = JsonNull.INSTANCE;
        }
        input.remove(key);
        input.add(key, result);
    }
}
