/*
 * Copyright 2019 VicTools.
 *
 * Licensed 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 com.github.victools.jsonschema.module.javax.validation;

import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.MemberScope;
import com.github.victools.jsonschema.generator.MethodScope;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigPart;
import java.lang.annotation.Annotation;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Email;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Negative;
import javax.validation.constraints.NegativeOrZero;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Null;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import javax.validation.constraints.Size;

/**
 * JSON Schema Generation Module: based on annotations from the {@code javax.validation.constraints} package.
 * <ul>
 * <li>Determine whether a member is not nullable, base assumption being that all fields and method return values are nullable if not annotated.</li>
 * <li>Optionally: also indicate all explicitly not nullable fields/methods to be required.</li>
 * <li>Populate "minItems" and "maxItems" for containers (i.e. arrays and collections).</li>
 * <li>Populate "minLength", "maxLength" and "format" for strings.</li>
 * <li>Optionally: populate "pattern" for strings.</li>
 * <li>Populate "minimum"/"exclusiveMinimum" and "maximum"/"exclusiveMaximum" for numbers.</li>
 * </ul>
 */
public class JavaxValidationModule implements Module {

    private final Set<JavaxValidationOption> options;
    private Set<Class<?>> validationGroups;

    /**
     * Constructor.
     *
     * @param options features to enable
     */
    public JavaxValidationModule(JavaxValidationOption... options) {
        this.options = options == null ? Collections.emptySet() : new HashSet<>(Arrays.asList(options));
        // by default: ignore validation groups
        this.validationGroups = null;
    }

    /**
     * Add validation groups to be considered.
     * <ul>
     * <li>Never calling this method will result in all annotations to be picked-up.</li>
     * <li>Calling this without parameters will only consider those annotations where no groups are defined.</li>
     * <li>Calling this with not-null parameters will only consider those annotations without defined groups or where at least one matches.</li>
     * </ul>
     *
     * @param validationGroups validation groups to consider
     * @return this module instance (for chaining)
     */
    public JavaxValidationModule forValidationGroups(Class<?>... validationGroups) {
        if (validationGroups == null) {
            this.validationGroups = null;
        } else {
            this.validationGroups = new HashSet<>(Arrays.asList(validationGroups));
        }
        return this;
    }

    @Override
    public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
        SchemaGeneratorConfigPart<FieldScope> fieldConfigPart = builder.forFields();
        this.applyToConfigPart(fieldConfigPart);
        if (this.options.contains(JavaxValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED)) {
            fieldConfigPart.withRequiredCheck(this::isRequired);
        }

        SchemaGeneratorConfigPart<MethodScope> methodConfigPart = builder.forMethods();
        this.applyToConfigPart(methodConfigPart);
        if (this.options.contains(JavaxValidationOption.NOT_NULLABLE_METHOD_IS_REQUIRED)) {
            methodConfigPart.withRequiredCheck(this::isRequired);
        }
    }

    /**
     * Apply the various annotation-based resolvers for the given configuration part (this is expected to be executed for both fields and methods).
     *
     * @param configPart config builder part to add configurations to
     */
    private void applyToConfigPart(SchemaGeneratorConfigPart<?> configPart) {
        configPart.withNullableCheck(this::isNullable);
        configPart.withArrayMinItemsResolver(this::resolveArrayMinItems);
        configPart.withArrayMaxItemsResolver(this::resolveArrayMaxItems);
        configPart.withStringMinLengthResolver(this::resolveStringMinLength);
        configPart.withStringMaxLengthResolver(this::resolveStringMaxLength);
        configPart.withStringFormatResolver(this::resolveStringFormat);
        configPart.withNumberInclusiveMinimumResolver(this::resolveNumberInclusiveMinimum);
        configPart.withNumberExclusiveMinimumResolver(this::resolveNumberExclusiveMinimum);
        configPart.withNumberInclusiveMaximumResolver(this::resolveNumberInclusiveMaximum);
        configPart.withNumberExclusiveMaximumResolver(this::resolveNumberExclusiveMaximum);

        if (this.options.contains(JavaxValidationOption.INCLUDE_PATTERN_EXPRESSIONS)) {
            configPart.withStringPatternResolver(this::resolveStringPattern);
        }
    }

    /**
     * Retrieves the annotation instance of the given type, either from the field itself or (if not present) from its getter.
     * <br>
     * If the given field/method represents only a container item of the actual declared type, that container item's annotations are being checked.
     *
     * @param <A> type of annotation
     * @param member field or method to retrieve annotation instance from (or from a field's getter or getter method's field)
     * @param annotationClass type of annotation
     * @param validationGroupsLookup how to look-up the associated validation groups of an annotation instance
     * @return annotation instance (or {@code null})
     * @see MemberScope#getAnnotationConsideringFieldAndGetter(Class)
     * @see MemberScope#getContainerItemAnnotationConsideringFieldAndGetter(Class)
     */
    protected <A extends Annotation> A getAnnotationFromFieldOrGetter(MemberScope<?, ?> member, Class<A> annotationClass,
            Function<A, Class<?>[]> validationGroupsLookup) {
        A annotation;
        if (member.isFakeContainerItemScope()) {
            annotation = member.getContainerItemAnnotationConsideringFieldAndGetter(annotationClass);
        } else {
            annotation = member.getAnnotationConsideringFieldAndGetter(annotationClass);
        }
        if (annotation != null && this.validationGroups != null) {
            Class<?>[] associatedGroups = validationGroupsLookup.apply(annotation);
            if (associatedGroups.length > 0 && Collections.disjoint(this.validationGroups, Arrays.asList(associatedGroups))) {
                return null;
            }
        }
        return annotation;
    }

    /**
     * Determine whether a given field or method is annotated to be not nullable.
     *
     * @param member the field or method to check
     * @return whether member is annotated as nullable or not (returns null if not specified: assumption it is nullable then)
     */
    protected Boolean isNullable(MemberScope<?, ?> member) {
        Boolean result;
        if (this.getAnnotationFromFieldOrGetter(member, NotNull.class, NotNull::groups) != null
                || this.getAnnotationFromFieldOrGetter(member, NotBlank.class, NotBlank::groups) != null
                || this.getAnnotationFromFieldOrGetter(member, NotEmpty.class, NotEmpty::groups) != null) {
            // field is specifically NOT nullable
            result = Boolean.FALSE;
        } else if (this.getAnnotationFromFieldOrGetter(member, Null.class, Null::groups) != null) {
            // field is specifically null (and thereby nullable)
            result = Boolean.TRUE;
        } else {
            result = null;
        }
        return result;
    }

    /**
     * Determine whether a given field or method is deemed to be required in its parent type.
     *
     * @param member the field or method to check
     * @return whether member is deemed to be required or not
     */
    protected boolean isRequired(MemberScope<?, ?> member) {
        Boolean nullableCheckResult = this.isNullable(member);
        return Boolean.FALSE.equals(nullableCheckResult);
    }

    /**
     * Determine a given array type's minimum number of items.
     *
     * @param member the field or method to check
     * @return specified minimum number of array items (or null)
     * @see Size
     */
    protected Integer resolveArrayMinItems(MemberScope<?, ?> member) {
        if (member.isContainerType()) {
            Size sizeAnnotation = this.getAnnotationFromFieldOrGetter(member, Size.class, Size::groups);
            if (sizeAnnotation != null && sizeAnnotation.min() > 0) {
                // minimum length greater than the default 0 was specified
                return sizeAnnotation.min();
            }
            if (this.getAnnotationFromFieldOrGetter(member, NotEmpty.class, NotEmpty::groups) != null) {
                return 1;
            }
        }
        return null;
    }

    /**
     * Determine a given array type's maximum number of items.
     *
     * @param member the field or method to check
     * @return specified maximum number of array items (or null)
     * @see Size
     */
    protected Integer resolveArrayMaxItems(MemberScope<?, ?> member) {
        if (member.isContainerType()) {
            Size sizeAnnotation = this.getAnnotationFromFieldOrGetter(member, Size.class, Size::groups);
            if (sizeAnnotation != null && sizeAnnotation.max() < 2147483647) {
                // maximum length below the default 2147483647 was specified
                return sizeAnnotation.max();
            }
        }
        return null;
    }

    /**
     * Determine a given text type's minimum number of characters.
     *
     * @param member the field or method to check
     * @return specified minimum number of characters (or null)
     * @see Size
     * @see NotEmpty
     * @see NotBlank
     */
    protected Integer resolveStringMinLength(MemberScope<?, ?> member) {
        if (member.getType().isInstanceOf(CharSequence.class)) {
            Size sizeAnnotation = this.getAnnotationFromFieldOrGetter(member, Size.class, Size::groups);
            if (sizeAnnotation != null && sizeAnnotation.min() > 0) {
                // minimum length greater than the default 0 was specified
                return sizeAnnotation.min();
            }
            if (this.getAnnotationFromFieldOrGetter(member, NotEmpty.class, NotEmpty::groups) != null
                    || this.getAnnotationFromFieldOrGetter(member, NotBlank.class, NotBlank::groups) != null) {
                return 1;
            }
        }
        return null;
    }

    /**
     * Determine a given text type's maximum number of characters.
     *
     * @param member the field or method to check
     * @return specified minimum number of characters (or null)
     * @see Size
     */
    protected Integer resolveStringMaxLength(MemberScope<?, ?> member) {
        if (member.getType().isInstanceOf(CharSequence.class)) {
            Size sizeAnnotation = this.getAnnotationFromFieldOrGetter(member, Size.class, Size::groups);
            if (sizeAnnotation != null && sizeAnnotation.max() < 2147483647) {
                // maximum length below the default 2147483647 was specified
                return sizeAnnotation.max();
            }
        }
        return null;
    }

    /**
     * Determine a given text type's format.
     *
     * @param member the field or method to check
     * @return specified format (or null)
     * @see Email
     */
    protected String resolveStringFormat(MemberScope<?, ?> member) {
        if (member.getType().isInstanceOf(CharSequence.class)) {
            Email emailAnnotation = this.getAnnotationFromFieldOrGetter(member, Email.class, Email::groups);
            if (emailAnnotation != null) {
                // @Email annotation was found, indicate the respective format
                if (this.options.contains(JavaxValidationOption.PREFER_IDN_EMAIL_FORMAT)) {
                    // the option was set to rather return the value for the internationalised email format
                    return "idn-email";
                }
                // indicate standard internet email address format
                return "email";
            }
        }
        return null;
    }

    /**
     * Determine a given text type's pattern.
     *
     * @param member the field or method to check
     * @return specified pattern (or null)
     * @see Pattern
     */
    protected String resolveStringPattern(MemberScope<?, ?> member) {
        if (member.getType().isInstanceOf(CharSequence.class)) {
            Pattern patternAnnotation = this.getAnnotationFromFieldOrGetter(member, Pattern.class, Pattern::groups);
            if (patternAnnotation != null) {
                // @Pattern annotation was found, return its (mandatory) regular expression
                return patternAnnotation.regexp();
            }
            Email emailAnnotation = this.getAnnotationFromFieldOrGetter(member, Email.class, Email::groups);
            if (emailAnnotation != null && !".*".equals(emailAnnotation.regexp())) {
                // non-default regular expression on @Email annotation should also be considered
                return emailAnnotation.regexp();
            }
        }
        return null;
    }

    /**
     * Determine a number type's minimum (inclusive) value.
     *
     * @param member the field or method to check
     * @return specified inclusive minimum value (or null)
     * @see Min
     * @see DecimalMin
     * @see PositiveOrZero
     */
    protected BigDecimal resolveNumberInclusiveMinimum(MemberScope<?, ?> member) {
        Min minAnnotation = this.getAnnotationFromFieldOrGetter(member, Min.class, Min::groups);
        if (minAnnotation != null) {
            return new BigDecimal(minAnnotation.value());
        }
        DecimalMin decimalMinAnnotation = this.getAnnotationFromFieldOrGetter(member, DecimalMin.class, DecimalMin::groups);
        if (decimalMinAnnotation != null && decimalMinAnnotation.inclusive()) {
            return new BigDecimal(decimalMinAnnotation.value());
        }
        PositiveOrZero positiveAnnotation = this.getAnnotationFromFieldOrGetter(member, PositiveOrZero.class, PositiveOrZero::groups);
        if (positiveAnnotation != null) {
            return BigDecimal.ZERO;
        }
        return null;
    }

    /**
     * Determine a number type's minimum (exclusive) value.
     *
     * @param member the field or method to check
     * @return specified exclusive minimum value (or null)
     * @see DecimalMin
     * @see Positive
     */
    protected BigDecimal resolveNumberExclusiveMinimum(MemberScope<?, ?> member) {
        DecimalMin decimalMinAnnotation = this.getAnnotationFromFieldOrGetter(member, DecimalMin.class, DecimalMin::groups);
        if (decimalMinAnnotation != null && !decimalMinAnnotation.inclusive()) {
            return new BigDecimal(decimalMinAnnotation.value());
        }
        Positive positiveAnnotation = this.getAnnotationFromFieldOrGetter(member, Positive.class, Positive::groups);
        if (positiveAnnotation != null) {
            return BigDecimal.ZERO;
        }
        return null;
    }

    /**
     * Determine a number type's maximum (inclusive) value.
     *
     * @param member the field or method to check
     * @return specified inclusive maximum value (or null)
     * @see Max
     * @see DecimalMax#inclusive()
     * @see NegativeOrZero
     */
    protected BigDecimal resolveNumberInclusiveMaximum(MemberScope<?, ?> member) {
        Max maxAnnotation = this.getAnnotationFromFieldOrGetter(member, Max.class, Max::groups);
        if (maxAnnotation != null) {
            return new BigDecimal(maxAnnotation.value());
        }
        DecimalMax decimalMaxAnnotation = this.getAnnotationFromFieldOrGetter(member, DecimalMax.class, DecimalMax::groups);
        if (decimalMaxAnnotation != null && decimalMaxAnnotation.inclusive()) {
            return new BigDecimal(decimalMaxAnnotation.value());
        }
        NegativeOrZero negativeAnnotation = this.getAnnotationFromFieldOrGetter(member, NegativeOrZero.class, NegativeOrZero::groups);
        if (negativeAnnotation != null) {
            return BigDecimal.ZERO;
        }
        return null;
    }

    /**
     * Determine a number type's maximum (exclusive) value.
     *
     * @param member the field or method to check
     * @return specified exclusive maximum value (or null)
     * @see DecimalMax#inclusive()
     * @see Negative
     */
    protected BigDecimal resolveNumberExclusiveMaximum(MemberScope<?, ?> member) {
        DecimalMax decimalMaxAnnotation = this.getAnnotationFromFieldOrGetter(member, DecimalMax.class, DecimalMax::groups);
        if (decimalMaxAnnotation != null && !decimalMaxAnnotation.inclusive()) {
            return new BigDecimal(decimalMaxAnnotation.value());
        }
        Negative negativeAnnotation = this.getAnnotationFromFieldOrGetter(member, Negative.class, Negative::groups);
        if (negativeAnnotation != null) {
            return BigDecimal.ZERO;
        }
        return null;
    }
}