/*
 *  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 grails.gorm.validation

import groovy.transform.CompileStatic
import groovy.transform.ToString
import groovy.util.logging.Slf4j

import org.springframework.validation.Errors

import grails.gorm.validation.exceptions.ValidationConfigurationException
import org.grails.datastore.gorm.validation.constraints.BlankConstraint
import org.grails.datastore.gorm.validation.constraints.InListConstraint
import org.grails.datastore.gorm.validation.constraints.MatchesConstraint
import org.grails.datastore.gorm.validation.constraints.MaxConstraint
import org.grails.datastore.gorm.validation.constraints.MaxSizeConstraint
import org.grails.datastore.gorm.validation.constraints.MinConstraint
import org.grails.datastore.gorm.validation.constraints.MinSizeConstraint
import org.grails.datastore.gorm.validation.constraints.NotEqualConstraint
import org.grails.datastore.gorm.validation.constraints.NullableConstraint
import org.grails.datastore.gorm.validation.constraints.RangeConstraint
import org.grails.datastore.gorm.validation.constraints.ScaleConstraint
import org.grails.datastore.gorm.validation.constraints.SizeConstraint
import org.grails.datastore.gorm.validation.constraints.factory.ConstraintFactory
import org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry
import org.grails.datastore.mapping.reflect.ClassPropertyFetcher

/**
 * Default implementation of the {@link ConstrainedProperty} interface
 *
 * @author Graeme Rocher
 * @since 6.0
 */
@CompileStatic
@Slf4j
@ToString(includes = ['owningClass', 'propertyType', 'propertyName', 'appliedConstraints'])
class DefaultConstrainedProperty implements ConstrainedProperty {

    /**
     * The owning class
     */
    final Class<?> owningClass
    /**
     * The property type
     */
    final Class<?> propertyType
    /**
     * the property name
     */
    final String propertyName

    protected final ConstraintRegistry constraintRegistry
    protected final Map<String, Constraint> appliedConstraints = new LinkedHashMap<String, Constraint>()

    // simple constraints
    /** whether the property should be displayed */
    boolean display = true
    /**
     * whether the property is editable
     */
    boolean editable = true
    /**
     * The order of the property
     */
    int order
    /**
     * the format of the property (for example a date pattern)
     */
    String format
    /**
     * The widget to use to render the property
     */
    String widget
    /**
     * whether the property is a password
     */
    boolean password

    Map attributes = Collections.EMPTY_MAP // a map of attributes of property

    private Map<String, Object> metaConstraints = new HashMap<String, Object>()
    private final static ClassPropertyFetcher PROPERTY_FETCHER = ClassPropertyFetcher.forClass(DefaultConstrainedProperty)

    /**
     * Constructs a new ConstrainedProperty for the given arguments.
     *
     * @param owningClass The owning class
     * @param propertyName The name of the property
     * @param propertyType The property type
     */
    DefaultConstrainedProperty(Class<?> owningClass, String propertyName, Class<?> propertyType, ConstraintRegistry constraintRegistry) {
        this.owningClass = owningClass
        this.propertyName = propertyName
        this.propertyType = propertyType
        this.constraintRegistry = constraintRegistry

    }

    /**
     * @return Returns the appliedConstraints.
     */
    @Override
    Collection<Constraint> getAppliedConstraints() {
        return appliedConstraints.values()
    }

    /**
     * Obtains an applied constraint by name.
     * @param name The name of the constraint
     * @return The applied constraint
     */
    @Override
    Constraint getAppliedConstraint(String name) {
        return appliedConstraints.get(name)
    }

    /**
     * @param constraintName The name of the constraint to check
     * @return Returns true if the specified constraint name is being applied to this property
     */
    @Override
    boolean hasAppliedConstraint(String constraintName) {
        return appliedConstraints.containsKey(constraintName)
    }

    /**
     * @return Returns the max.
     */
    @Override
    Comparable getMax() {
        Comparable maxValue = null

        MaxConstraint maxConstraint = (MaxConstraint) appliedConstraints.get(MAX_CONSTRAINT)
        RangeConstraint rangeConstraint = (RangeConstraint) appliedConstraints.get(RANGE_CONSTRAINT)

        if (maxConstraint != null || rangeConstraint != null) {
            Comparable maxConstraintValue = maxConstraint == null ? null : maxConstraint.getMaxValue()
            Comparable rangeConstraintHighValue = rangeConstraint == null ? null : rangeConstraint.getRange().getTo()

            if (maxConstraintValue != null && rangeConstraintHighValue != null) {
                maxValue = (maxConstraintValue.compareTo(rangeConstraintHighValue) < 0) ? maxConstraintValue : rangeConstraintHighValue
            }
            else if (maxConstraintValue == null && rangeConstraintHighValue != null) {
                maxValue = rangeConstraintHighValue
            }
            else if (maxConstraintValue != null && rangeConstraintHighValue == null) {
                maxValue = maxConstraintValue
            }
        }

        return maxValue
    }

    /**
     * @param max The max to set.
     */
    @SuppressWarnings('rawtypes')
    void setMax(Comparable max) {
        String constraintName = MAX_CONSTRAINT
        if (max == null) {
            appliedConstraints.remove(constraintName)
            return
        }

        if (!propertyType.equals(max.getClass())) {
            throw new MissingPropertyException(constraintName, propertyType)
        }

        Range r = getRange()
        if (r != null) {
            log.warn('Range constraint already set ignoring constraint [' + constraintName + '] for value [' + max + ']')
            return
        }

        applyConstraintInternal(constraintName, max)
    }

    /**
     * @return Returns the min.
     */
    @Override
    Comparable getMin() {
        Comparable minValue = null

        MinConstraint minConstraint = (MinConstraint) appliedConstraints.get(MIN_CONSTRAINT)
        RangeConstraint rangeConstraint = (RangeConstraint) appliedConstraints.get(RANGE_CONSTRAINT)

        if (minConstraint != null || rangeConstraint != null) {
            Comparable minConstraintValue = minConstraint != null ? minConstraint.getMinValue() : null
            Comparable rangeConstraintLowValue = rangeConstraint != null ? rangeConstraint.getRange().getFrom() : null

            if (minConstraintValue != null && rangeConstraintLowValue != null) {
                minValue = (minConstraintValue.compareTo(rangeConstraintLowValue) > 0) ? minConstraintValue : rangeConstraintLowValue
            }
            else if (minConstraintValue == null && rangeConstraintLowValue != null) {
                minValue = rangeConstraintLowValue
            }
            else if (minConstraintValue != null && rangeConstraintLowValue == null) {
                minValue = minConstraintValue
            }
        }

        return minValue
    }

    /**
     * @param min The min to set.
     */
    @SuppressWarnings('rawtypes')
    void setMin(Comparable min) {
        if (min == null) {
            appliedConstraints.remove(MIN_CONSTRAINT)
            return
        }

        if (!propertyType.equals(min.getClass())) {
            throw new MissingPropertyException(MIN_CONSTRAINT, propertyType)
        }

        Range r = getRange()
        if (r != null) {
            log.warn('Range constraint already set ignoring constraint [' + MIN_CONSTRAINT + '] for value [' + min + ']')
            return
        }

        applyConstraintInternal(MIN_CONSTRAINT, min)
    }

    /**
     * @return Returns the inList.
     */
    @Override
    @SuppressWarnings('rawtypes')
    List getInList() {
        InListConstraint c = (InListConstraint) appliedConstraints.get(IN_LIST_CONSTRAINT)
        return c == null ? null : c.getList()
    }

    /**
     * @param inList The inList to set.
     */
    @SuppressWarnings('rawtypes')
    void setInList(List inList) {
        Constraint c = appliedConstraints.get(IN_LIST_CONSTRAINT)
        if (inList == null) {
            appliedConstraints.remove(IN_LIST_CONSTRAINT)
        }
        else {
            applyConstraintInternal(IN_LIST_CONSTRAINT, inList)
        }
    }

    /**
     * @return Returns the range.
     */
    @Override
    @SuppressWarnings('rawtypes')
    Range getRange() {
        RangeConstraint c = (RangeConstraint) appliedConstraints.get(RANGE_CONSTRAINT)
        return c == null ? null : c.getRange()
    }

    /**
     * @param range The range to set.
     */
    @SuppressWarnings('rawtypes')
    void setRange(Range range) {
        if (appliedConstraints.containsKey(MAX_CONSTRAINT)) {
            log.warn('Setting range constraint on property [' + propertyName + '] of class [' + this.owningClass + '] forced removal of max constraint')
            appliedConstraints.remove(MAX_CONSTRAINT)
        }
        if (appliedConstraints.containsKey(MIN_CONSTRAINT)) {
            log.warn('Setting range constraint on property [' + propertyName + '] of class [' + this.owningClass + '] forced removal of min constraint')
            appliedConstraints.remove(MIN_CONSTRAINT)
        }
        if (range == null) {
            appliedConstraints.remove(RANGE_CONSTRAINT)
        }
        else {
            applyConstraintInternal(RANGE_CONSTRAINT, range)
        }
    }

    /**
     * @return The scale, if defined for this property; null, otherwise
     */
    @Override
    Integer getScale() {
        ScaleConstraint scaleConstraint = (ScaleConstraint) appliedConstraints.get(SCALE_CONSTRAINT)
        return scaleConstraint == null ? null : scaleConstraint.getScale()
    }

    /**
     * @return Returns the size.
     */
    @Override
    @SuppressWarnings('rawtypes')
    Range getSize() {
        SizeConstraint c = (SizeConstraint) appliedConstraints.get(SIZE_CONSTRAINT)
        return c == null ? null : c.getRange()
    }

    /**
     * @param size The size to set.
     */
    @SuppressWarnings('rawtypes')
    void setSize(Range size) {
        Constraint c = appliedConstraints.get(SIZE_CONSTRAINT)
        if (size == null) {
            appliedConstraints.remove(SIZE_CONSTRAINT)
        }
        else {
            applyConstraintInternal(SIZE_CONSTRAINT, size)
        }
    }

    /**
     * @return the blank.
     */
    @Override
    boolean isBlank() {
        Object cons = appliedConstraints.get(BLANK_CONSTRAINT)
        return cons == null || (Boolean) ((BlankConstraint)cons).getParameter()
    }

    /**
     * @param blank The blank to set.
     */
    void setBlank(boolean blank) {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('Blank constraint can only be applied to a String property',
                    BLANK_CONSTRAINT, this.owningClass)
        }

        if (!blank) {
            applyConstraintInternal(BLANK_CONSTRAINT, blank)
        }
        else {
            appliedConstraints.remove(BLANK_CONSTRAINT)
        }
    }

    /**
     * @return Returns the email.
     */
    @Override
    boolean isEmail() {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('Email constraint only applies to a String property',
                    EMAIL_CONSTRAINT, this.owningClass)
        }

        return appliedConstraints.containsKey(EMAIL_CONSTRAINT)
    }

    /**
     * @param email The email to set.
     */
    void setEmail(boolean email) {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('Email constraint can only be applied to a String property',
                    EMAIL_CONSTRAINT, this.owningClass)
        }

        Constraint c = appliedConstraints.get(EMAIL_CONSTRAINT)
        if (email) {
            applyConstraintInternal(EMAIL_CONSTRAINT, email)
        }
        else {
            if (c != null) {
                appliedConstraints.remove(EMAIL_CONSTRAINT)
            }
        }
    }

    private boolean isNotValidStringType() {
        return !CharSequence.isAssignableFrom(propertyType)
    }

    /**
     * @return Returns the creditCard.
     */
    @Override
    boolean isCreditCard() {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('CreditCard constraint only applies to a String property',
                    CREDIT_CARD_CONSTRAINT, this.owningClass)
        }

        return appliedConstraints.containsKey(CREDIT_CARD_CONSTRAINT)
    }

    /**
     * @param creditCard The creditCard to set.
     */
    void setCreditCard(boolean creditCard) {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('CreditCard constraint only applies to a String property',
                    CREDIT_CARD_CONSTRAINT, this.owningClass)
        }

        Constraint c = appliedConstraints.get(CREDIT_CARD_CONSTRAINT)
        if (creditCard) {
            applyConstraintInternal(CREDIT_CARD_CONSTRAINT, creditCard)
        }
        else {
            if (c != null) {
                appliedConstraints.remove(CREDIT_CARD_CONSTRAINT)
            }
        }
    }

    /**
     * @return Returns the matches.
     */
    @Override
    String getMatches() {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('Matches constraint only applies to a String property',
                    MATCHES_CONSTRAINT, this.owningClass)
        }
        MatchesConstraint c = (MatchesConstraint) appliedConstraints.get(MATCHES_CONSTRAINT)
        return c == null ? null : c.regex
    }

    /**
     * @param regex The matches to set.
     */
    void setMatches(String regex) {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('Matches constraint can only be applied to a String property',
                    MATCHES_CONSTRAINT, this.owningClass)
        }

        if (regex == null) {
            appliedConstraints.remove(MATCHES_CONSTRAINT)
        }
        else {
            applyConstraintInternal(MATCHES_CONSTRAINT, regex)
        }
    }

    /**
     * @return Returns the notEqual.
     */
    @Override
    Object getNotEqual() {
        NotEqualConstraint c = (NotEqualConstraint) appliedConstraints.get(NOT_EQUAL_CONSTRAINT)
        return c == null ? null : c.getNotEqualTo()
    }

    /**
     * @return Returns the maxSize.
     */
    @Override
    Integer getMaxSize() {
        Integer maxSize = null

        MaxSizeConstraint maxSizeConstraint = (MaxSizeConstraint) appliedConstraints.get(MAX_SIZE_CONSTRAINT)
        SizeConstraint sizeConstraint = (SizeConstraint) appliedConstraints.get(SIZE_CONSTRAINT)

        if (maxSizeConstraint != null || sizeConstraint != null) {
            int maxSizeConstraintValue = maxSizeConstraint == null ? Integer.MAX_VALUE : maxSizeConstraint.getMaxSize()
            int sizeConstraintHighValue = sizeConstraint == null ? Integer.MAX_VALUE : sizeConstraint.getRange().getToInt()
            maxSize = Math.min(maxSizeConstraintValue, sizeConstraintHighValue)
        }

        return maxSize
    }

    /**
     * @param maxSize The maxSize to set.
     */
    void setMaxSize(Integer maxSize) {
        applyConstraintInternal(MAX_SIZE_CONSTRAINT, maxSize)
    }

    /**
     * @return Returns the minSize.
     */
    @Override
    Integer getMinSize() {
        Integer minSize = null

        MinSizeConstraint minSizeConstraint = (MinSizeConstraint) appliedConstraints.get(MIN_SIZE_CONSTRAINT)
        SizeConstraint sizeConstraint = (SizeConstraint) appliedConstraints.get(SIZE_CONSTRAINT)

        if (minSizeConstraint != null || sizeConstraint != null) {
            int minSizeConstraintValue = minSizeConstraint == null ? Integer.MIN_VALUE : minSizeConstraint.getMinSize()
            int sizeConstraintLowValue = sizeConstraint == null ? Integer.MIN_VALUE : sizeConstraint.getRange().getFromInt()

            minSize = Math.max(minSizeConstraintValue, sizeConstraintLowValue)
        }

        return minSize
    }

    /**
     * @param minSize The minLength to set.
     */
    void setMinSize(Integer minSize) {
        applyConstraintInternal(MIN_SIZE_CONSTRAINT, minSize)
    }

    /**
     * @param notEqual The notEqual to set.
     */
    void setNotEqual(Object notEqual) {
        if (notEqual == null) {
            appliedConstraints.remove(NOT_EQUAL_CONSTRAINT)
        }
        else {
            applyConstraintInternal(NOT_EQUAL_CONSTRAINT, notEqual)
        }
    }

    /**
     * @return Returns the nullable.
     */
    @Override
    boolean isNullable() {
        if (appliedConstraints.containsKey(NULLABLE_CONSTRAINT)) {
            NullableConstraint nc = (NullableConstraint) appliedConstraints.get(NULLABLE_CONSTRAINT)
            return nc.isNullable()
        }
        return false
    }

    /**
     * @param nullable The nullable to set.
     */
    void setNullable(boolean nullable) {
        applyConstraintInternal(NULLABLE_CONSTRAINT, nullable)
    }

    /**
     * @return Returns the url.
     */
    @Override
    boolean isUrl() {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('URL constraint can only be applied to a String property',
                    URL_CONSTRAINT, this.owningClass)
        }
        return appliedConstraints.containsKey(URL_CONSTRAINT)
    }

    /**
     * @param url The url to set.
     */
    void setUrl(boolean url) {
        if (isNotValidStringType()) {
            throw new MissingPropertyException('URL constraint can only be applied to a String property', URL_CONSTRAINT, this.owningClass)
        }

        Constraint c = appliedConstraints.get(URL_CONSTRAINT)
        if (url) {
            applyConstraintInternal(URL_CONSTRAINT, url)
        }
        else {
            if (c != null) {
                appliedConstraints.remove(URL_CONSTRAINT)
            }
        }
    }

    @SuppressWarnings('rawtypes')
    Map getAttributes() {
        return attributes
    }

    @SuppressWarnings('rawtypes')
    void setAttributes(Map attributes) {
        this.attributes = attributes
    }
    /**
     * Validate this constrainted property against specified property value
     *
     * @param target The target object to validate
     * @param propertyValue The value of the property to validate
     * @param errors The Errors instances to report errors to
     */
    void validate(Object target, Object propertyValue, Errors errors) {
        List<Constraint> delayedConstraints = new ArrayList<Constraint>()

        // validate only vetoing constraints first, putting non-vetoing into delayedConstraints
        for (Constraint c in appliedConstraints.values()) {
            if (c instanceof VetoingConstraint) {
                // stop validation process when constraint vetoes
                if (((VetoingConstraint) c).validateWithVetoing(target, propertyValue, errors)) {
                    return
                }
            }
            else {
                delayedConstraints.add(c)
            }
        }

        // process non-vetoing constraints
        for (Constraint c : delayedConstraints) {
            c.validate(target, propertyValue, errors)
        }
    }

    /**
     * Checks with this ConstraintedProperty instance supports applying the specified constraint.
     *
     * @param constraintName The name of the constraint
     * @return true if the constraint is supported
     */
    @Override
    boolean supportsContraint(String constraintName) {

        List<ConstraintFactory> constraintFactories = constraintRegistry.findConstraintFactories(constraintName)
        if (constraintFactories.isEmpty()) {
            return PROPERTY_FETCHER.getPropertyDescriptor(constraintName)?.getWriteMethod() != null
        }

        try {

            for (ConstraintFactory cf in constraintFactories) {
                if (cf.supports(propertyType)) {
                    return true
                }
            }
        }
        catch (Exception e) {
            log.error("Exception thrown instantiating constraint [$constraintName] to class [$owningClass]", e)
            throw new ValidationConfigurationException("Exception thrown instantiating constraint [$constraintName] to class [$owningClass]: ${e.message}", e)
        }
    }

    /**
     * Applies a constraint for the specified name and consraint value.
     *
     * @param constraintName The name of the constraint
     * @param constrainingValue The constraining value
     *
     * @throws ValidationConfigurationException Thrown when the specified constraint is not supported by this ConstrainedProperty. Use <code>supportsContraint(String constraintName)</code> to check before calling
     */
    @Override
    void applyConstraint(String constraintName, Object constrainingValue) {
        List<ConstraintFactory> constraintFactories = constraintRegistry.findConstraintFactories(constraintName)

        if (!constraintFactories.isEmpty()) {
            if (constrainingValue == null) {
                appliedConstraints.remove(constraintName)
            }
            else {
                try {
                    Constraint c = instantiateConstraint(constraintName, constrainingValue, true)
                    if (c != null) {
                        appliedConstraints.put(constraintName, c)
                    }
                }
                catch (Exception e) {
                    throw new ValidationConfigurationException("Exception thrown applying constraint [$constraintName] to class [$owningClass] for value [$constrainingValue]: ${e.message}", e)
                }
            }
        }
        else if (hasProperty(constraintName)) {
            ((GroovyObject) this).setProperty(constraintName, constrainingValue)
        }
        else {
            throw new ValidationConfigurationException("Constraint [$constraintName] is not supported for property [$propertyName] of class [$owningClass] with type [$propertyType]")
        }
    }

    protected void applyConstraintInternal(String constraintName, Object constrainingValue) {
        Constraint c = appliedConstraints.get(constraintName)
        if (c == null) {
            for (factory in constraintRegistry.findConstraintFactories(constraintName)) {
                c = factory.build(owningClass, propertyName, constrainingValue)
                if (c != null && c.supports(propertyType)) {
                    appliedConstraints.put(constraintName, c)
                    break
                }
            }
        }
    }

    @Override
    Class getOwner() {
        return this.owningClass
    }

    private Constraint instantiateConstraint(String constraintName, Object constraintValue, boolean validate) throws InstantiationException, IllegalAccessException {
        List<ConstraintFactory> candidateConstraints = constraintRegistry.findConstraintFactories(constraintName)

        for (ConstraintFactory constraintFactory in candidateConstraints) {

            Constraint c = constraintFactory.build(owningClass, propertyName, constraintValue)

            if (validate && c.isValid()) {
                return c
            }
            if (!validate) {
                return c
            }

        }
        return null
    }

    /**
     * Adds a meta constraints which is a non-validating informational constraint.
     *
     * @param name The name of the constraint
     * @param value The value
     */
    void addMetaConstraint(String name, Object value) {
        metaConstraints.put(name, value)
    }

    /**
     * Obtains the value of the named meta constraint.
     * @param name The name of the constraint
     * @return The value
     */
    Object getMetaConstraintValue(String name) {
        return metaConstraints.get(name)
    }

    /**
     * @return Returns the metaConstraints.
     */
    Map<String, Object> getMetaConstraints() {
        return metaConstraints
    }
}
