/*
 *  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
 *
 *    https://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.grails.datastore.gorm.validation.constraints.eval;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;

import groovy.lang.Closure;
import groovy.lang.GroovyObject;
import groovy.lang.MetaBeanProperty;
import groovy.lang.MetaMethod;
import groovy.lang.MetaProperty;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.context.MessageSource;
import org.springframework.context.support.StaticMessageSource;

import grails.gorm.validation.Constrained;
import grails.gorm.validation.ConstrainedProperty;
import grails.gorm.validation.DefaultConstrainedProperty;
import grails.gorm.validation.exceptions.ValidationConfigurationException;
import org.grails.datastore.gorm.validation.constraints.builder.ConstrainedPropertyBuilder;
import org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry;
import org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry;
import org.grails.datastore.mapping.config.AuditMetadataType;
import org.grails.datastore.mapping.config.Property;
import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext;
import org.grails.datastore.mapping.model.AuditMetadataUtils;
import org.grails.datastore.mapping.model.MappingContext;
import org.grails.datastore.mapping.model.PersistentEntity;
import org.grails.datastore.mapping.model.PersistentProperty;
import org.grails.datastore.mapping.model.config.GormProperties;
import org.grails.datastore.mapping.model.types.Identity;
import org.grails.datastore.mapping.model.types.ToOne;
import org.grails.datastore.mapping.reflect.ClassPropertyFetcher;
import org.grails.datastore.mapping.reflect.NameUtils;

/**
 * Evaluates constraints for entities
 *
 * @author Graeme Rocher
 * @since 6.0
 */
public class DefaultConstraintEvaluator implements ConstraintsEvaluator {
    private static final Logger LOG = LoggerFactory.getLogger(DefaultConstraintEvaluator.class);

    protected final ConstraintRegistry constraintRegistry;
    protected final MappingContext mappingContext;
    protected final Map<String, Object> defaultConstraints;
    protected final boolean cacheAutoTimestampAnnotations;
    protected final boolean defaultNullable;

    public DefaultConstraintEvaluator() {
        this(new DefaultConstraintRegistry(new StaticMessageSource()), new KeyValueMappingContext("default"), Collections.<String, Object>emptyMap(), true);
    }

    public DefaultConstraintEvaluator(Map<String, Object> defaultConstraints) {
        this(new DefaultConstraintRegistry(new StaticMessageSource()), new KeyValueMappingContext("default"), defaultConstraints, true);
    }

    public DefaultConstraintEvaluator(MessageSource messageSource, MappingContext mappingContext) {
        this(new DefaultConstraintRegistry(messageSource), mappingContext, Collections.<String, Object>emptyMap(), true);
    }

    public DefaultConstraintEvaluator(MessageSource messageSource, MappingContext mappingContext, Map<String, Object> defaultConstraints) {
        this(new DefaultConstraintRegistry(messageSource), mappingContext, defaultConstraints, true);
    }

    public DefaultConstraintEvaluator(MessageSource messageSource) {
        this(new DefaultConstraintRegistry(messageSource), new KeyValueMappingContext("default"), Collections.<String, Object>emptyMap(), true);
    }

    public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map<String, Object> defaultConstraints) {
        this(constraintRegistry, mappingContext, defaultConstraints, true);
    }

    public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map<String, Object> defaultConstraints, boolean cacheAutoTimestampAnnotations) {
        this(constraintRegistry, mappingContext, defaultConstraints, cacheAutoTimestampAnnotations, true);
    }

    public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map<String, Object> defaultConstraints, boolean cacheAutoTimestampAnnotations, boolean defaultNullable) {
        this.constraintRegistry = constraintRegistry;
        this.mappingContext = mappingContext;
        this.defaultConstraints = defaultConstraints;
        this.cacheAutoTimestampAnnotations = cacheAutoTimestampAnnotations;
        this.defaultNullable = defaultNullable;
    }

    @Override
    public Map<String, Object> getDefaultConstraints() {
        return null;
    }

    @Override
    public Map<String, ConstrainedProperty> evaluate(@SuppressWarnings("rawtypes") Class cls) {
        return evaluate(cls, false);
    }

    @Override
    public Map<String, ConstrainedProperty> evaluate(@SuppressWarnings("rawtypes") Class theClass, boolean defaultNullable) {
        return evaluate(theClass, defaultNullable, false);
    }

    @Override
    public Map<String, ConstrainedProperty> evaluate(Class<?> theClass, boolean defaultNullable, boolean useOnlyAdHocConstraints, Closure... adHocConstraintsClosures) {
        List<Closure> constraints = useOnlyAdHocConstraints ? new ArrayList<>() : ClassPropertyFetcher.getStaticPropertyValuesFromInheritanceHierarchy(theClass, PROPERTY_NAME, Closure.class);
        if (adHocConstraintsClosures != null) {
            constraints.addAll(Arrays.asList(adHocConstraintsClosures));
        }
        ConstrainedPropertyBuilder delegate = newConstrainedPropertyBuilder(theClass);
        delegate.setDefaultNullable(defaultNullable);
        delegate.setAllowDynamic(useOnlyAdHocConstraints);
        // Evaluate all the constraints closures in the inheritance chain
        for (Closure c : constraints) {

            if (c != null) {
                c = (Closure<?>) c.clone();
                c.setResolveStrategy(Closure.DELEGATE_ONLY);
                c.setDelegate(delegate);
                c.call();
            }
        }

        Map<String, ConstrainedProperty> constrainedProperties = delegate.getConstrainedProperties();
        PersistentEntity entity = mappingContext.getPersistentEntity(theClass.getName());
        List<PersistentProperty> properties = null;
        if (entity != null) {

            properties = entity.getPersistentProperties();
            if (properties != null) {

                for (PersistentProperty p : properties) {
                    // assume no formula issues if Hibernate isn't available to avoid CNFE
                    Property mappedForm = p.getMapping().getMappedForm();
                    PersistentProperty version = entity.getVersion();
                    if (canPropertyBeConstrained(p) && !p.equals(version)) {
                        if (mappedForm.isDerived()) {
                            if (constrainedProperties.remove(p.getName()) != null) {
                                LOG.warn("Derived properties may not be constrained. Property [" + p.getName() + "] of domain class " + theClass.getName() + " will not be checked during validation.");
                            }
                        } else {
                            final String propertyName = p.getName();
                            ConstrainedProperty cp = constrainedProperties.get(propertyName);
                            if (cp == null) {
                                DefaultConstrainedProperty constrainedProperty = new DefaultConstrainedProperty(entity.getJavaClass(), propertyName, p.getType(), constraintRegistry);
                                cp = constrainedProperty;
                                constrainedProperty.setOrder(constrainedProperties.size() + 1);
                                constrainedProperties.put(propertyName, cp);
                            }
                            // Make sure all fields are required by default, unless
                            // specified otherwise by the constraints
                            // If the field is a Java entity annotated with @Entity skip this
                            applyDefaultConstraints(propertyName, p, cp, defaultConstraints);
                        }
                    }
                }
            }
        }

        if (properties == null || properties.size() == 0) {
            final Set<Map.Entry<String, ConstrainedProperty>> entrySet = constrainedProperties.entrySet();
            for (Map.Entry<String, ConstrainedProperty> entry : entrySet) {
                final ConstrainedProperty constrainedProperty = entry.getValue();
                applyDefaultConstraints(entry.getKey(), null, constrainedProperty, defaultConstraints);
                if (!constrainedProperty.hasAppliedConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT)) {
                    applyDefaultNullableConstraint(constrainedProperty, defaultNullable);
                }
            }

            ClassPropertyFetcher cpf = ClassPropertyFetcher.forClass(theClass);
            List<MetaProperty> metaProperties = cpf.getMetaProperties();
            for (MetaProperty metaProperty : metaProperties) {
                String propertyName = metaProperty.getName();
                if (!constrainedProperties.containsKey(propertyName) && NameUtils.isNotConfigurational(propertyName)) {
                    Class propertyType = metaProperty.getType();
                    if (metaProperty instanceof MetaBeanProperty) {
                        MetaBeanProperty beanProperty = (MetaBeanProperty) metaProperty;
                        MetaMethod getter = beanProperty.getGetter();
                        // getters of type Boolean should start with 'get' not 'is'
                        if (Boolean.class == propertyType && getter != null && getter.getName().startsWith("is")) {
                            continue;
                        }
                    }
                    if (!defaultNullable) {
                        DefaultConstrainedProperty constrainedProperty = new DefaultConstrainedProperty(theClass, propertyName, propertyType, constraintRegistry);
                        constrainedProperty.setOrder(constrainedProperties.size() + 1);
                        constrainedProperties.put(propertyName, constrainedProperty);
                        applyDefaultConstraints(propertyName, null, constrainedProperty, defaultConstraints);
                        if (!constrainedProperty.hasAppliedConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT)) {
                            applyDefaultNullableConstraint(constrainedProperty, defaultNullable);
                        }
                    }
                }
            }
        }

        applySharedConstraints(delegate, constrainedProperties);

        return constrainedProperties;
    }

    public ConstrainedPropertyBuilder newConstrainedPropertyBuilder(Class<?> theClass) {
        return new ConstrainedPropertyBuilder(this.mappingContext, this.constraintRegistry, theClass, defaultConstraints);
    }

    protected void applySharedConstraints(
            ConstrainedPropertyBuilder constrainedPropertyBuilder,
            Map<String, ConstrainedProperty> constrainedProperties) {
        for (Map.Entry<String, ConstrainedProperty> entry : constrainedProperties.entrySet()) {
            String propertyName = entry.getKey();
            Constrained constrainedProperty = entry.getValue();
            String sharedConstraintReference = constrainedPropertyBuilder.getSharedConstraint(propertyName);
            if (sharedConstraintReference != null && defaultConstraints != null) {
                Object o = defaultConstraints.get(sharedConstraintReference);
                if (o instanceof Map) {
                    @SuppressWarnings({ "unchecked", "rawtypes" })
                    Map<String, Object> constraintsWithinSharedConstraint = (Map) o;
                    for (Map.Entry<String, Object> e : constraintsWithinSharedConstraint.entrySet()) {
                        constrainedProperty.applyConstraint(e.getKey(), e.getValue());
                    }
                } else {
                    throw new ValidationConfigurationException("Property [" +
                            constrainedProperty.getOwner().getName() + '.' + propertyName +
                            "] references shared constraint [" + sharedConstraintReference +
                            ":" + o + "], which doesn't exist!");
                }
            }
        }
    }

    protected boolean canPropertyBeConstrained(PersistentProperty property) {
        return true;
    }

    @SuppressWarnings("unchecked")
    protected void applyDefaultConstraints(String propertyName, PersistentProperty persistentProperty,
                                           ConstrainedProperty cp, Map<String, Object> defaultConstraints) {

        if (defaultConstraints != null && !defaultConstraints.isEmpty()) {
            if (defaultConstraints.containsKey("*")) {
                final Object o = defaultConstraints.get("*");
                if (o instanceof Map) {
                    Map<String, Object> globalConstraints = (Map<String, Object>) o;
                    applyMapOfConstraints(globalConstraints, propertyName, persistentProperty, cp);
                }
            }
        }

        if (canApplyNullableConstraint(propertyName, persistentProperty, cp)) {
            applyDefaultNullableConstraint(persistentProperty, cp);
        }
    }

    protected void applyDefaultNullableConstraint(PersistentProperty p, ConstrainedProperty cp) {
        // Grails 8: persistent properties are nullable by default, aligning Grails with the rest of
        // the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data, Micronaut Data and
        // Jakarta Bean Validation are all "nullable unless you say otherwise"). To restore the
        // legacy required-by-default behavior, either set the boolean flag:
        //     grails.gorm.default.nullable = false
        // or apply a wildcard default constraint:
        //     grails.gorm.default.constraints = { '*'(nullable: false) }
        applyDefaultNullableConstraint(cp, defaultNullable);
    }

    protected void applyDefaultNullableConstraint(ConstrainedProperty cp, boolean defaultNullable) {
        boolean isCollection = Collection.class.isAssignableFrom(cp.getPropertyType()) || Map.class.isAssignableFrom(cp.getPropertyType());
        cp.applyConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT, isCollection || defaultNullable);
    }

    protected boolean canApplyNullableConstraint(String propertyName, PersistentProperty property, Constrained constrained) {
        if (property == null || property.getType() == null) return false;

        final PersistentEntity domainClass = property.getOwner();
        // only apply default nullable to Groovy entities not legacy Java ones
        if (!GroovyObject.class.isAssignableFrom(domainClass.getJavaClass())) return false;

        final PersistentProperty versionProperty = domainClass.getVersion();
        final boolean isVersion = versionProperty != null && versionProperty.equals(property);
        return !constrained.hasAppliedConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT) &&
                isConstrainableProperty(property, propertyName) && !isVersion;
    }

    protected void applyMapOfConstraints(Map<String, Object> constraints, String propertyName, PersistentProperty persistentProperty, ConstrainedProperty cp) {
        for (Map.Entry<String, Object> entry : constraints.entrySet()) {
            String constraintName = entry.getKey();
            Object constrainingValue = entry.getValue();
            if (!cp.hasAppliedConstraint(constraintName) && cp.supportsContraint(constraintName)) {
                if (ConstrainedProperty.NULLABLE_CONSTRAINT.equals(constraintName)) {
                    if (isConstrainableProperty(persistentProperty, propertyName)) {
                        cp.applyConstraint(constraintName, constrainingValue);
                    }
                }
                else {
                    cp.applyConstraint(constraintName, constrainingValue);
                }
            }
        }
    }

    protected boolean isConstrainableProperty(PersistentProperty persistentProperty, String propertyName) {
        if (persistentProperty == null) {
            return NameUtils.isNotConfigurational(propertyName);
        }
        else {
            // Check if property has @CreatedDate or @LastModifiedDate annotations
            // Note: We only exclude timestamp date fields (CREATED/UPDATED), not auditor fields (CREATED_BY/UPDATED_BY)
            // because auditor fields can have various types and should support constraints (e.g., maxSize for String)
            AuditMetadataType auditMetadataType = AuditMetadataUtils.getAuditMetadataType(persistentProperty, cacheAutoTimestampAnnotations);
            if (auditMetadataType == AuditMetadataType.CREATED || auditMetadataType == AuditMetadataType.UPDATED) {
                return false;
            }

            return !propertyName.equals(GormProperties.VERSION) &&
                    !propertyName.equals(GormProperties.DATE_CREATED) &&
                    !propertyName.equals(GormProperties.LAST_UPDATED) &&
                    !(persistentProperty instanceof Identity) &&
                    !(persistentProperty.getMapping().getMappedForm().isDerived()) &&
                    !((persistentProperty instanceof ToOne) && ((ToOne) persistentProperty).isBidirectional() && ((ToOne) persistentProperty).isCircular());
        }

    }

}
