/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF 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.johnzon.jsonschema;

import static java.util.Arrays.asList;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import jakarta.json.JsonObject;
import jakarta.json.JsonValue;

import org.apache.johnzon.jsonschema.regex.JavaRegex;
import org.apache.johnzon.jsonschema.regex.JavascriptRegex;
import org.apache.johnzon.jsonschema.spi.ValidationContext;
import org.apache.johnzon.jsonschema.spi.ValidationExtension;
import org.apache.johnzon.jsonschema.spi.builtin.ContainsValidation;
import org.apache.johnzon.jsonschema.spi.builtin.EnumValidation;
import org.apache.johnzon.jsonschema.spi.builtin.ExclusiveMaximumValidation;
import org.apache.johnzon.jsonschema.spi.builtin.ExclusiveMinimumValidation;
import org.apache.johnzon.jsonschema.spi.builtin.IntegerValidation;
import org.apache.johnzon.jsonschema.spi.builtin.ItemsValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MaxItemsValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MaxLengthValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MaxPropertiesValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MaximumValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MinItemsValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MinLengthValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MinPropertiesValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MinimumValidation;
import org.apache.johnzon.jsonschema.spi.builtin.MultipleOfValidation;
import org.apache.johnzon.jsonschema.spi.builtin.PatternValidation;
import org.apache.johnzon.jsonschema.spi.builtin.RequiredValidation;
import org.apache.johnzon.jsonschema.spi.builtin.TypeValidation;
import org.apache.johnzon.jsonschema.spi.builtin.UniqueItemsValidation;

public class JsonSchemaValidatorFactory implements AutoCloseable {
    private static final String[] ROOT_PATH = new String[0];
    private static final Function<JsonValue, Stream<ValidationResult.ValidationError>> NO_VALIDATION = new Function<JsonValue, Stream<ValidationResult.ValidationError>>() {
        @Override
        public Stream<ValidationResult.ValidationError> apply(JsonValue jsonValue) {
            return Stream.empty();
        }

        @Override
        public String toString() {
            return "NoValidation";
        }
    };

    private final List<ValidationExtension> extensions = new ArrayList<>();

    // js is closer to default and actually most used in the industry
    private final AtomicReference<Function<String, Predicate<CharSequence>>> regexFactory = new AtomicReference<>(this::newRegexFactory);

    private Predicate<CharSequence> newRegexFactory(final String regex) {
        try {
            return new JavascriptRegex(regex);
        } catch (final RuntimeException re) {
            return new JavaRegex(regex);
        }
    }

    public JsonSchemaValidatorFactory() {
        extensions.addAll(createDefaultValidations());
        extensions.addAll(new ArrayList<>(StreamSupport.stream(ServiceLoader.load(ValidationExtension.class).spliterator(), false)
                .collect(toList())));
    }

    // see http://json-schema.org/latest/json-schema-validation.html
    public List<ValidationExtension> createDefaultValidations() {
        return asList(
                new RequiredValidation(),
                new TypeValidation(),
                new IntegerValidation(),
                new EnumValidation(),
                new MultipleOfValidation(),
                new MaximumValidation(),
                new MinimumValidation(),
                new ExclusiveMaximumValidation(),
                new ExclusiveMinimumValidation(),
                new MaxLengthValidation(),
                new MinLengthValidation(),
                new PatternValidation(regexFactory.get()),
                new ItemsValidation(this),
                new MaxItemsValidation(),
                new MinItemsValidation(),
                new UniqueItemsValidation(),
                new ContainsValidation(this),
                new MaxPropertiesValidation(),
                new MinPropertiesValidation()
                // TODO: dependencies, propertyNames, if/then/else, allOf/anyOf/oneOf/not,
                //       format validations
        );
    }

    public JsonSchemaValidatorFactory appendExtensions(final ValidationExtension... extensions) {
        this.extensions.addAll(asList(extensions));
        return this;
    }

    public JsonSchemaValidatorFactory setExtensions(final ValidationExtension... extensions) {
        this.extensions.clear();
        return appendExtensions(extensions);
    }

    public JsonSchemaValidatorFactory setRegexFactory(final Function<String, Predicate<CharSequence>> factory) {
        regexFactory.set(factory);
        return this;
    }

    public JsonSchemaValidator newInstance(final JsonObject schema) {
        return new JsonSchemaValidator(buildValidator(ROOT_PATH, schema, null));
    }

    @Override
    public void close() {
        // no-op for now
    }

    private Function<JsonValue, Stream<ValidationResult.ValidationError>> buildValidator(final String[] path,
                                                                                         final JsonObject schema,
                                                                                         final Function<JsonValue, JsonValue> valueProvider) {
        final List<Function<JsonValue, Stream<ValidationResult.ValidationError>>> directValidations = buildDirectValidations(path, schema, valueProvider).collect(toList());
        final Function<JsonValue, Stream<ValidationResult.ValidationError>> nestedValidations = buildPropertiesValidations(path, schema, valueProvider);
        final Function<JsonValue, Stream<ValidationResult.ValidationError>> dynamicNestedValidations = buildPatternPropertiesValidations(path, schema, valueProvider);
        final Function<JsonValue, Stream<ValidationResult.ValidationError>> fallbackNestedValidations = buildAdditionalPropertiesValidations(path, schema, valueProvider);
        return new ValidationsFunction(
                Stream.concat(
                        directValidations.stream(),
                        Stream.of(nestedValidations, dynamicNestedValidations, fallbackNestedValidations))
                    .collect(toList()));
    }

    private Stream<Function<JsonValue, Stream<ValidationResult.ValidationError>>> buildDirectValidations(final String[] path,
                                                                                                         final JsonObject schema,
                                                                                                         final Function<JsonValue, JsonValue> valueProvider) {
        final ValidationContext model = new ValidationContext(path, schema, valueProvider);
        return extensions.stream()
                .map(e -> e.create(model))
                .filter(Optional::isPresent)
                .map(Optional::get);
    }

    private Function<JsonValue, Stream<ValidationResult.ValidationError>> buildPropertiesValidations(final String[] path,
                                                                                                     final JsonObject schema,
                                                                                                     final Function<JsonValue, JsonValue> valueProvider) {
        return ofNullable(schema.get("properties"))
                .filter(it -> it.getValueType() == JsonValue.ValueType.OBJECT)
                .map(it -> it.asJsonObject().entrySet().stream()
                        .filter(obj -> obj.getValue().getValueType() == JsonValue.ValueType.OBJECT)
                        .map(obj -> {
                            final String key = obj.getKey();
                            final String[] fieldPath = Stream.concat(Stream.of(path), Stream.of(key)).toArray(String[]::new);
                            return buildValidator(fieldPath, obj.getValue().asJsonObject(), new ChainedValueAccessor(valueProvider, key));
                        })
                        .collect(toList()))
                .map(this::toFunction)
                .orElse(NO_VALIDATION);
    }

    // not the best impl but is it really an important case?
    private Function<JsonValue, Stream<ValidationResult.ValidationError>> buildPatternPropertiesValidations(final String[] path,
                                                                                                     final JsonObject schema,
                                                                                                     final Function<JsonValue, JsonValue> valueProvider) {
        return ofNullable(schema.get("patternProperties"))
                .filter(it -> it.getValueType() == JsonValue.ValueType.OBJECT)
                .map(it -> it.asJsonObject().entrySet().stream()
                        .filter(obj -> obj.getValue().getValueType() == JsonValue.ValueType.OBJECT)
                        .map(obj -> {
                            final Predicate<CharSequence> pattern = regexFactory.get().apply(obj.getKey());
                            final JsonObject currentSchema = obj.getValue().asJsonObject();
                            // no cache cause otherwise it could be in properties
                            return (Function<JsonValue, Stream<ValidationResult.ValidationError>>) root -> {
                                final JsonValue validable = Optional.ofNullable(valueProvider)
                                        .map(provider -> provider.apply(root))
                                        .orElse(root);
                                if (validable.getValueType() != JsonValue.ValueType.OBJECT) {
                                    return Stream.empty();
                                }
                                return validable.asJsonObject().entrySet().stream()
                                        .filter(e -> pattern.test(e.getKey()))
                                        .flatMap(e -> buildValidator(
                                                Stream.concat(Stream.of(path), Stream.of(e.getKey())).toArray(String[]::new),
                                                currentSchema,
                                                o -> o.asJsonObject().get(e.getKey()))
                                                .apply(validable));
                            };
                        })
                        .collect(toList()))
                .map(this::toFunction)
                .orElse(NO_VALIDATION);
    }

    private Function<JsonValue, Stream<ValidationResult.ValidationError>> buildAdditionalPropertiesValidations(final String[] path,
                                                                                                     final JsonObject schema,
                                                                                                     final Function<JsonValue, JsonValue> valueProvider) {
        final JsonValue additionalProperties = schema.get("additionalProperties");
        if (additionalProperties == null || additionalProperties.getValueType() == JsonValue.ValueType.TRUE) {
            return NO_VALIDATION;
        }
        if (additionalProperties.getValueType() != JsonValue.ValueType.OBJECT &&
                additionalProperties.getValueType() != JsonValue.ValueType.FALSE) {
            return NO_VALIDATION;
        }

        // a property is "additional" when it is neither declared in "properties" nor matched by "patternProperties"
        Predicate<String> additional = s -> true;
        if (schema.containsKey("properties")) {
            final Set<String> properties = schema.getJsonObject("properties").keySet();
            additional = additional.and(s -> !properties.contains(s));
        }
        if (schema.containsKey("patternProperties")) {
            final List<Predicate<CharSequence>> properties = schema.getJsonObject("patternProperties").keySet().stream()
                                                                   .map(regexFactory.get())
                                                                   .collect(toList());
            additional = additional.and(s -> properties.stream().noneMatch(p -> p.test(s)));
        }
        final Predicate<String> isAdditional = additional;

        if (additionalProperties.getValueType() == JsonValue.ValueType.FALSE) {
            return root -> {
                final JsonValue validable = extractValidable(root, valueProvider);
                if (validable == null || validable.getValueType() != JsonValue.ValueType.OBJECT) {
                    return Stream.empty();
                }
                return validable.asJsonObject().keySet().stream()
                                .filter(isAdditional)
                                .map(key -> new ValidationResult.ValidationError(
                                        Stream.concat(Stream.of(path), Stream.of(key)).collect(joining("/", "/", "")),
                                        "Additional property '" + key + "' is not allowed"));
            };
        }

        final JsonObject currentSchema = additionalProperties.asJsonObject();
        return root -> {
            final JsonValue validable = extractValidable(root, valueProvider);
            if (validable == null || validable.getValueType() != JsonValue.ValueType.OBJECT) {
                return Stream.empty();
            }
            return validable.asJsonObject().keySet().stream()
                            .filter(isAdditional)
                            .flatMap(key -> buildValidator(
                                    Stream.concat(Stream.of(path), Stream.of(key)).toArray(String[]::new),
                                    currentSchema,
                                    new ChainedValueAccessor(valueProvider, key)).apply(root));
        };
    }

    private static JsonValue extractValidable(final JsonValue root, final Function<JsonValue, JsonValue> valueProvider) {
        return valueProvider == null ? root : valueProvider.apply(root);
    }

    private Function<JsonValue, Stream<ValidationResult.ValidationError>> toFunction(
            final List<Function<JsonValue, Stream<ValidationResult.ValidationError>>> validations) {
        return new ValidationsFunction(validations);
    }

    private static class ValidationsFunction implements Function<JsonValue, Stream<ValidationResult.ValidationError>> {
        private final List<Function<JsonValue, Stream<ValidationResult.ValidationError>>> delegates;

        private ValidationsFunction(final List<Function<JsonValue, Stream<ValidationResult.ValidationError>>> validations) {
            // unwrap when possible to simplify the stack and make toString readable (debug)
            this.delegates = validations.stream()
                    .flatMap(it -> ValidationsFunction.class.isInstance(it) ? ValidationsFunction.class.cast(it).delegates.stream() : Stream.of(it))
                    .filter(it -> it != NO_VALIDATION)
                    .collect(toList());
        }

        @Override
        public Stream<ValidationResult.ValidationError> apply(final JsonValue jsonValue) {
            return delegates.stream().flatMap(v -> v.apply(jsonValue));
        }

        @Override
        public String toString() {
            return delegates.toString();
        }
    }

    private static class ChainedValueAccessor implements Function<JsonValue, JsonValue> {
        private final Function<JsonValue, JsonValue> parent;
        private final String key;

        private ChainedValueAccessor(final Function<JsonValue, JsonValue> valueProvider, final String key) {
            this.parent = valueProvider;
            this.key = key;
        }

        @Override
        public JsonValue apply(final JsonValue value) {
            final JsonValue root = parent == null ? value : parent.apply(value);
            if (root != null && root.getValueType() != JsonValue.ValueType.NULL && root.getValueType() == JsonValue.ValueType.OBJECT) {
                return root.asJsonObject().get(key);
            }
            return JsonValue.NULL;
        }

        @Override
        public String toString() {
            return "ChainedValueAccessor{" +
                    "parent=" + parent +
                    ", key='" + key + '\'' +
                    '}';
        }
    }
}
