/**
 * Copyright 2005-2018 The Kuali Foundation
 *
 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
 *
 * 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.kuali.rice.krad.web.bind;

import com.sun.accessibility.internal.resources.accessibility;
import org.apache.commons.lang.ObjectUtils;
import org.kuali.rice.core.api.CoreApiServiceLocator;
import org.kuali.rice.core.api.encryption.EncryptionService;
import org.kuali.rice.krad.uif.UifConstants;
import org.kuali.rice.krad.uif.lifecycle.ViewPostMetadata;
import org.kuali.rice.krad.uif.util.CopyUtils;
import org.kuali.rice.krad.uif.util.ObjectPropertyUtils;
import org.kuali.rice.krad.uif.view.ViewModel;
import org.kuali.rice.krad.util.KRADUtils;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.BeansException;
import org.springframework.beans.NotReadablePropertyException;
import org.springframework.beans.NullValueInNestedPathException;
import org.springframework.beans.PropertyAccessorUtils;
import org.springframework.beans.PropertyValue;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.beans.PropertyEditor;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Class is a top level BeanWrapper for a UIF View Model.
 *
 * <p>Registers custom property editors configured on the field associated with the property name for which
 * we are getting or setting a value. In addition determines if the field requires encryption and if so applies
 * the {@link UifEncryptionPropertyEditorWrapper}</p>
 *
 * @author Kuali Rice Team (rice.collab@kuali.org)
 */
public class UifViewBeanWrapper extends UifBeanWrapper {
    private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(UifViewBeanWrapper.class);

    // this stores all properties this wrapper has already checked
    // with the view so the service isn't called again
    private Set<String> processedProperties;

    private final UifBeanPropertyBindingResult bindingResult;

    public UifViewBeanWrapper(ViewModel model, UifBeanPropertyBindingResult bindingResult) {
        super(model);

        this.bindingResult = bindingResult;
        this.processedProperties = new HashSet<String>();
    }

//    /**
//     * {@inheritDoc}
//     */
//    @Override
//    public Class<?> getPropertyType(String propertyName) throws BeansException {
//        try {
//            PropertyDescriptor pd = getPropertyDescriptorInternal(propertyName);
//            if (pd != null) {
//                return pd.getPropertyType();
//            }
//
//            // Maybe an indexed/mapped property...
//            Object value = super.getPropertyValue(propertyName);
//            if (value != null) {
//                return value.getClass();
//            }
//
//            // Check to see if there is a custom editor,
//            // which might give an indication on the desired target type.
//            Class<?> editorType = guessPropertyTypeFromEditors(propertyName);
//            if (editorType != null) {
//                return editorType;
//            }
//        } catch (InvalidPropertyException ex) {
//            // Consider as not determinable.
//        }
//
//        return null;
//    }


    /**
     * Override to register property editors from the view before the value is retrieved.
     *
     * {@inheritDoc}
     */
    @Override
    protected Object getPropertyValue(String propertyName, boolean autoGrowNestedPaths) {
        registerEditorFromView(propertyName);

        return super.getPropertyValue(propertyName, autoGrowNestedPaths);
    }

    /**
     * Attempts to find a corresponding data field for the given property name in the current view or previous view,
     * then if the field has a property editor configured it is registered with the property editor registry to use
     * for this property.
     *
     * @param propertyName name of the property to find field and editor for
     */
    protected void registerEditorFromView(String propertyName) {
        // check if we already processed this property for this BeanWrapper instance
        if (processedProperties.contains(propertyName)) {
            return;
        }

        if (LOG.isDebugEnabled()) {
            LOG.debug("Attempting to find property editor for property '" + propertyName + "'");
        }

        ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
        if (viewPostMetadata == null) {
            return;
        }

        PropertyEditor propertyEditor = viewPostMetadata.getFieldEditor(propertyName);
        if (propertyEditor != null) {
            registerCustomEditor(null, propertyName, propertyEditor);
        }

        processedProperties.add(propertyName);
    }

    /**
     * Overridden to perform processing before and after the value is set.
     *
     * <p>First binding security is checked to determine whether the path allows binding. Next,
     * access security is checked to determine whether the value needs decrypted. Finally, if
     * change tracking is enabled, the original value is compared with the new for indicating a
     * modified path.</p>
     *
     * {@inheritDoc}
     */
    @Override
    public void setPropertyValue(PropertyValue pv) throws BeansException {
        boolean isPropertyAccessible = checkPropertyBindingAccess(pv.getName());
        if (!isPropertyAccessible) {
            return;
        }

        Object value = processValueBeforeSet(pv.getName(), pv.getValue());

        pv = new PropertyValue(pv, value);

        // save off the original value if we are change tracking
        boolean originalValueSaved = true;
        Object originalValue = null;
        if (bindingResult.isChangeTracking()) {
            try {
                originalValue = getPropertyValue(pv.getName(), true);
            } catch (Exception e) {
                // be failsafe here, if an exception happens here then we can't make any assumptions about whether
                // the property value changed or not
                originalValueSaved = false;
            }
        }

        // set the actual property value
        super.setPropertyValue(pv);

        // if we are change tracking and we saved original value, check if it's modified
        if (bindingResult.isChangeTracking() && originalValueSaved) {
            try {
                Object newValue = getPropertyValue(pv.getName());
                if (ObjectUtils.notEqual(originalValue, newValue)) {
                    // if they are not equal, it's been modified!
                    bindingResult.addModifiedPath(pv.getName());
                }
            } catch (Exception e) {
                // failsafe here as well
            }
        }
    }

    /**
     * Overridden to perform processing before and after the value is set.
     *
     * <p>First binding security is checked to determine whether the path allows binding. Next,
     * access security is checked to determine whether the value needs decrypted. Finally, if
     * change tracking is enabled, the original value is compared with the new for indicating a
     * modified path.</p>
     *
     * {@inheritDoc}
     */
    @Override
    public void setPropertyValue(String propertyName, Object value) throws BeansException {
        boolean isPropertyAccessible = checkPropertyBindingAccess(propertyName);
        if (!isPropertyAccessible) {
            return;
        }

        value = processValueBeforeSet(propertyName, value);

        // save off the original value
        boolean originalValueSaved = true;
        Object originalValue = null;
        try {
            originalValue = getPropertyValue(propertyName, true);
        } catch (Exception e) {
            // be failsafe here, if an exception happens here then we can't make any assumptions about whether
            // the property value changed or not
            originalValueSaved = false;
        }

        // set the actual property value
        super.setPropertyValue(propertyName, value);

        // only check if it's modified if we were able to save the original value
        if (originalValueSaved) {
            try {
                Object newValue = getPropertyValue(propertyName);
                if (ObjectUtils.notEqual(originalValue, newValue)) {
                    // if they are not equal, it's been modified!
                    bindingResult.addModifiedPath(propertyName);
                }
            } catch (Exception e) {
                // failsafe here as well
            }
        }
    }

    /**
     * Determines whether request binding is allowed for the given property name/path.
     *
     * <p>Binding access is determined by default based on the view's post metadata. A set of
     * accessible binding paths (populated during the view lifecycle) is maintained within this data.
     * Overrides can be specified using the annotations {@link org.kuali.rice.krad.web.bind.RequestProtected}
     * and {@link org.kuali.rice.krad.web.bind.RequestAccessible}.</p>
     *
     * <p>If the path is not accessible, it is recorded in the binding results suppressed fields. Controller
     * methods can accept the binding result and further handle these properties if necessary.</p>
     *
     * @param propertyName name/path of the property to check binding access for
     * @return boolean true if binding access is allowed, false if not allowed
     */
    protected boolean checkPropertyBindingAccess(String propertyName) {
        boolean isAccessible = false;

        // check for explicit property annotations that indicate access
        Boolean bindingAnnotationAccess = checkBindingAnnotationsInPath(propertyName);
        if (bindingAnnotationAccess != null) {
            isAccessible = bindingAnnotationAccess.booleanValue();
        } else {
            // default access, must be in view's accessible binding paths
            ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
            if ((viewPostMetadata != null) && (viewPostMetadata.getAccessibleBindingPaths() != null)) {
                isAccessible = viewPostMetadata.getAccessibleBindingPaths().contains(propertyName);

                if (!isAccessible && propertyName.contains("[")) {
                    String wildcardedPropertyName = propertyName.substring(0, propertyName.lastIndexOf("["))
                        + "[*" + propertyName.substring(propertyName.lastIndexOf("]"));
                    isAccessible = viewPostMetadata.getAccessibleBindingPaths().contains(wildcardedPropertyName);
                }
            }
        }

        if (!isAccessible) {
            LOG.debug("Request parameter sent for inaccessible binding path: " + propertyName);

            bindingResult.recordSuppressedField(propertyName);
        }

        return isAccessible;
    }

    /**
     * Determines whether one of the binding annotations is present within the given property path, and if
     * so returns whether access should be granted based on those annotation(s).
     *
     * <p>Binding annotations may occur anywhere in the property path. For example, if the path is 'object.field1',
     * a binding annotation may be present on the 'object' property or the 'field1' property. If multiple annotations
     * are found in the path, the annotation at the deepest level is taken. If both the protected and accessible
     * annotation are found at the same level, the protected access is used.</p>
     *
     * @param propertyPath path to look for annotations
     * @return Boolean true if an annotation is found and the access is allowed, false if an annotation is found
     * and the access is protected, null if no annotations where found in the path
     */
    protected Boolean checkBindingAnnotationsInPath(String propertyPath) {
        HttpServletRequest request =
                ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        while (!StringUtils.isEmpty(propertyPath)) {
            String nestedPath = ObjectPropertyUtils.getPathTail(propertyPath);
            String parentPropertyPath = ObjectPropertyUtils.removePathTail(propertyPath);

            Class<?> parentPropertyClass = getWrappedClass();

            // for nested paths, we need to get the class of the immediate parent
            if (!StringUtils.isEmpty(parentPropertyPath)) {
                parentPropertyClass = ObjectPropertyUtils.getPropertyType(getWrappedInstance(), parentPropertyPath);
            }

            // remove index or map key to get the correct property name
            if (org.apache.commons.lang.StringUtils.endsWith(nestedPath, "]")) {
                nestedPath = org.apache.commons.lang.StringUtils.substringBefore(nestedPath, "[");
            }

            RequestProtected protectedAnnotation = (RequestProtected) CopyUtils.getFieldAnnotation(parentPropertyClass,
                    nestedPath, RequestProtected.class);
            if ((protectedAnnotation != null) && annotationMatchesRequestMethod(protectedAnnotation.method(),
                    request.getMethod())) {
                return Boolean.FALSE;
            }

            RequestAccessible accessibleAnnotation = (RequestAccessible) CopyUtils.getFieldAnnotation(
                    parentPropertyClass, nestedPath, RequestAccessible.class);
            if (accessibleAnnotation != null) {
                boolean isAnnotationRequestMethod = annotationMatchesRequestMethod(accessibleAnnotation.method(),
                        request.getMethod());
                boolean isAnnotationMethodToCalls = annotationMatchesMethodToCalls(accessibleAnnotation.methodToCalls(),
                        request.getParameter(UifConstants.CONTROLLER_METHOD_DISPATCH_PARAMETER_NAME));
                if (isAnnotationRequestMethod && isAnnotationMethodToCalls) {
                    //((UifFormBase) this.bindingResult.getTarget()).getMethodToCall())) {
                    return Boolean.TRUE;
                }
            }

            propertyPath = parentPropertyPath;
        }

        return null;
    }

    /**
     * Indicates whether one of the given request accessible methods to call in the given array matches the
     * actual methodToCall of the request.
     *
     * @param annotationMethodToCalls array of request accessible methods to call to check against
     * @param methodToCall method to call of the request
     * @return boolean true if one of the annotation methods to call match, false if none match
     */
    protected boolean annotationMatchesMethodToCalls(String[] annotationMethodToCalls, String methodToCall) {
        // empty array of methods should match all
        if ((annotationMethodToCalls == null) || (annotationMethodToCalls.length == 0)) {
            return true;
        }

        for (String annotationMethodToCall : annotationMethodToCalls) {
            if (org.apache.commons.lang.StringUtils.equals(annotationMethodToCall, methodToCall)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Indicates whether one of the given request methods in the given array matches the actual method of
     * the request.
     *
     * @param annotationMethods array of request methods to check
     * @param requestMethod method of the request to match on
     * @return boolean true if one of the annotation methods match, false if none match
     */
    protected boolean annotationMatchesRequestMethod(RequestMethod[] annotationMethods, String requestMethod) {
        // empty array of methods should match all
        if ((annotationMethods == null) || (annotationMethods.length == 0)) {
            return true;
        }

        for (RequestMethod annotationMethod : annotationMethods) {
            if (org.apache.commons.lang.StringUtils.equals(annotationMethod.name(), requestMethod)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Registers any custom property editor for the property name/path, converts empty string values to null, and
     * calls helper method to decrypt secure values.
     *
     * @param propertyName name of the property
     * @param value value of the property to process
     * @return updated (possibly) property value
     */
    protected Object processValueBeforeSet(String propertyName, Object value) {
        registerEditorFromView(propertyName);

        Object processedValue = value;

        // Convert blank string values to null so empty strings are not set on the form as values (useful for legacy
        // checks) Jira: KULRICE-11424
        if (value instanceof String) {
            String propertyValue = (String) value;

            if (StringUtils.isEmpty(propertyValue)) {
                processedValue = null;
            } else {
                processedValue = decryptValueIfNecessary(propertyName, propertyValue);
            }
        }

        return processedValue;
    }

    /**
     * If the given property name is secure, decrypts the value by calling the encryption service.
     *
     * @param propertyName name of the property
     * @param propertyValue value of the property
     * @return String decrypted property value (or original value if not secure)
     */
    protected String decryptValueIfNecessary(String propertyName, String propertyValue) {
        // check security on field
        boolean isSecure = isSecure(getWrappedClass(), propertyName);

        if (org.apache.commons.lang.StringUtils.endsWith(propertyValue, EncryptionService.ENCRYPTION_POST_PREFIX)) {
            propertyValue = org.apache.commons.lang.StringUtils.removeEnd(propertyValue,
                    EncryptionService.ENCRYPTION_POST_PREFIX);
            isSecure = true;
        }

        // decrypt if the value is secure
        if (isSecure) {
            try {
                if (CoreApiServiceLocator.getEncryptionService().isEnabled()) {
                    propertyValue = CoreApiServiceLocator.getEncryptionService().decrypt(propertyValue);
                }
            } catch (GeneralSecurityException e) {
                throw new RuntimeException(e);
            }
        }

        return propertyValue;
    }

    /**
     * Checks whether the given property is secure.
     *
     * @param wrappedClass class the property is associated with
     * @param propertyPath path to the property
     * @return boolean true if the property is secure, false if not
     */
    protected boolean isSecure(Class<?> wrappedClass, String propertyPath) {
        if (KRADUtils.isSecure(propertyPath, wrappedClass)) {
            return true;
        }

        // since this is part of a set, we want to make sure nested paths grow
        setAutoGrowNestedPaths(true);

        BeanWrapperImpl beanWrapper;
        try {
            beanWrapper = getPropertyAccessorForPropertyPath(propertyPath);
        } catch (NotReadablePropertyException | NullValueInNestedPathException e) {
            LOG.debug("Bean wrapper was not found for " + propertyPath
                    + ", but since it cannot be accessed it will not be set as secure.", e);
            return false;
        }

        if (org.apache.commons.lang.StringUtils.isNotBlank(beanWrapper.getNestedPath())) {
            PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
            String nestedPropertyPath = org.apache.commons.lang.StringUtils.removeStart(tokens.canonicalName,
                    beanWrapper.getNestedPath());

            return isSecure(beanWrapper.getWrappedClass(), nestedPropertyPath);
        }

        return false;
    }

    /**
     * Overridden to copy property editor registration to the new bean wrapper.
     *
     * <p>This is necessary because spring only copies over the editors when a new bean wrapper is
     * created. The wrapper is then cached and use for subsequent calls. But the get calls could bring in
     * new custom editors we need to copy.</p>
     *
     * {@inheritDoc}
     */
    @Override
    protected BeanWrapperImpl getPropertyAccessorForPropertyPath(String propertyPath) {
        BeanWrapperImpl beanWrapper = (BeanWrapperImpl) super.getPropertyAccessorForPropertyPath(propertyPath);

        PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
        String canonicalName = tokens.canonicalName;

        int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(canonicalName);
        if (pos != -1) {
            canonicalName = canonicalName.substring(0, pos);
        }

        copyCustomEditorsTo(beanWrapper, canonicalName);

        return beanWrapper;
    }

    /**
     * Parse the given property name into the corresponding property name tokens.
     *
     * @param propertyName the property name to parse
     * @return representation of the parsed property tokens
     */
    private PropertyTokenHolder getPropertyNameTokens(String propertyName) {
        PropertyTokenHolder tokens = new PropertyTokenHolder();
        String actualName = null;
        List<String> keys = new ArrayList<String>(2);
        int searchIndex = 0;
        while (searchIndex != -1) {
            int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex);
            searchIndex = -1;
            if (keyStart != -1) {
                int keyEnd = propertyName.indexOf(PROPERTY_KEY_SUFFIX, keyStart + PROPERTY_KEY_PREFIX.length());
                if (keyEnd != -1) {
                    if (actualName == null) {
                        actualName = propertyName.substring(0, keyStart);
                    }
                    String key = propertyName.substring(keyStart + PROPERTY_KEY_PREFIX.length(), keyEnd);
                    if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith("\"") && key.endsWith("\""))) {
                        key = key.substring(1, key.length() - 1);
                    }
                    keys.add(key);
                    searchIndex = keyEnd + PROPERTY_KEY_SUFFIX.length();
                }
            }
        }
        tokens.actualName = (actualName != null ? actualName : propertyName);
        tokens.canonicalName = tokens.actualName;
        if (!keys.isEmpty()) {
            tokens.canonicalName += PROPERTY_KEY_PREFIX +
                    StringUtils.collectionToDelimitedString(keys, PROPERTY_KEY_SUFFIX + PROPERTY_KEY_PREFIX) +
                    PROPERTY_KEY_SUFFIX;
            tokens.keys = StringUtils.toStringArray(keys);
        }
        return tokens;
    }

    private static class PropertyTokenHolder {

        public String canonicalName;

        public String actualName;

        public String[] keys;
    }
}
