/*
 * 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.commons.beanutils2;


import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.ref.Reference;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;


/**
 * A MappedPropertyDescriptor describes one mapped property.
 * Mapped properties are multivalued properties like indexed properties
 * but that are accessed with a String key instead of an index.
 * Such property values are typically stored in a Map collection.
 * For this class to work properly, a mapped value must have
 * getter and setter methods of the form
 * <p>{@code get<strong>Property</strong>(String key)} and
 * <p>{@code set<strong>Property</strong>(String key, Object value)},
 * <p>where {@code <strong>Property</strong>} must be replaced
 * by the name of the property.
 * @see java.beans.PropertyDescriptor
 *
 */
public class MappedPropertyDescriptor extends PropertyDescriptor {

    /**
     * Holds a {@link Method} in a {@link SoftReference} so that it
     * it doesn't prevent any ClassLoader being garbage collected, but
     * tries to re-create the method if the method reference has been
     * released.
     *
     * See https://issues.apache.org/jira/browse/BEANUTILS-291
     */
    private static class MappedMethodReference {
        private String className;
        private String methodName;
        private Reference<Method> methodRef;
        private Reference<Class<?>> classRef;
        private Reference<Class<?>> writeParamTypeRef0;
        private Reference<Class<?>> writeParamTypeRef1;
        private String[] writeParamClassNames;
        MappedMethodReference(final Method m) {
            if (m != null) {
                className = m.getDeclaringClass().getName();
                methodName = m.getName();
                methodRef = new SoftReference<>(m);
                classRef = new WeakReference<>(m.getDeclaringClass());
                final Class<?>[] types = m.getParameterTypes();
                if (types.length == 2) {
                    writeParamTypeRef0 = new WeakReference<>(types[0]);
                    writeParamTypeRef1 = new WeakReference<>(types[1]);
                    writeParamClassNames = new String[2];
                    writeParamClassNames[0] = types[0].getName();
                    writeParamClassNames[1] = types[1].getName();
                }
            }
        }
        private Method get() {
            if (methodRef == null) {
                return null;
            }
            Method m = methodRef.get();
            if (m == null) {
                Class<?> clazz = classRef.get();
                if (clazz == null) {
                    clazz = reLoadClass();
                    if (clazz != null) {
                        classRef = new WeakReference<>(clazz);
                    }
                }
                if (clazz == null) {
                    throw new RuntimeException("Method " + methodName + " for " +
                            className + " could not be reconstructed - class reference has gone");
                }
                Class<?>[] paramTypes = null;
                if (writeParamClassNames != null) {
                    paramTypes = new Class[2];
                    paramTypes[0] = writeParamTypeRef0.get();
                    if (paramTypes[0] == null) {
                        paramTypes[0] = reLoadClass(writeParamClassNames[0]);
                        if (paramTypes[0] != null) {
                            writeParamTypeRef0 = new WeakReference<>(paramTypes[0]);
                        }
                    }
                    paramTypes[1] = writeParamTypeRef1.get();
                    if (paramTypes[1] == null) {
                        paramTypes[1] = reLoadClass(writeParamClassNames[1]);
                        if (paramTypes[1] != null) {
                            writeParamTypeRef1 = new WeakReference<>(paramTypes[1]);
                        }
                    }
                } else {
                    paramTypes = STRING_CLASS_PARAMETER;
                }
                try {
                    m = clazz.getMethod(methodName, paramTypes);
                    // Un-comment following line for testing
                    // System.out.println("Recreated Method " + methodName + " for " + className);
                } catch (final NoSuchMethodException e) {
                    throw new RuntimeException("Method " + methodName + " for " +
                            className + " could not be reconstructed - method not found");
                }
                methodRef = new SoftReference<>(m);
            }
            return m;
        }

        /**
         * Try to re-load the class
         */
        private Class<?> reLoadClass() {
            return reLoadClass(className);
        }

        /**
         * Try to re-load the class
         */
        private Class<?> reLoadClass(final String name) {

            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

            // Try the context class loader
            if (classLoader != null) {
                try {
                    return classLoader.loadClass(name);
                } catch (final ClassNotFoundException e) {
                    // ignore
                }
            }

            // Try this class's class loader
            classLoader = MappedPropertyDescriptor.class.getClassLoader();
            try {
                return classLoader.loadClass(name);
            } catch (final ClassNotFoundException e) {
                return null;
            }
        }
    }

    /**
     * The parameter types array for the reader method signature.
     */
    private static final Class<?>[] STRING_CLASS_PARAMETER = new Class[]{String.class};

    /**
     * Gets a capitalized version of the specified property name.
     *
     * @param s The property name
     */
    private static String capitalizePropertyName(final String s) {
        if (s.isEmpty()) {
            return s;
        }

        final char[] chars = s.toCharArray();
        chars[0] = Character.toUpperCase(chars[0]);
        return new String(chars);
    }

    /**
     * Find a method on a class with a specified parameter list.
     */
    private static Method getMethod(final Class<?> clazz, final String methodName, final Class<?>[] parameterTypes)
                                           throws IntrospectionException {
        if (methodName == null) {
            return null;
        }

        final Method method = MethodUtils.getMatchingAccessibleMethod(clazz, methodName, parameterTypes);
        if (method != null) {
            return method;
        }

        final int parameterCount = parameterTypes == null ? 0 : parameterTypes.length;

        // No Method found
        throw new IntrospectionException("No method \"" + methodName +
                "\" with " + parameterCount + " parameter(s) of matching types.");
    }

    /**
     * Find a method on a class with a specified number of parameters.
     */
    private static Method getMethod(final Class<?> clazz, final String methodName, final int parameterCount)
            throws IntrospectionException {
        if (methodName == null) {
            return null;
        }

        final Method method = internalGetMethod(clazz, methodName, parameterCount);
        if (method != null) {
            return method;
        }

        // No Method found
        throw new IntrospectionException("No method \"" + methodName +
                "\" with " + parameterCount + " parameter(s)");
    }

    /**
     * Find a method on a class with a specified number of parameters.
     */
    private static Method internalGetMethod(final Class<?> initial, final String methodName,
                                            final int parameterCount) {
        // For overridden methods we need to find the most derived version.
        // So we start with the given class and walk up the superclass chain.
        for (Class<?> clazz = initial; clazz != null; clazz = clazz.getSuperclass()) {
            final Method[] methods = clazz.getDeclaredMethods();
            for (final Method method : methods) {
                if (method == null) {
                    continue;
                }
                // skip static methods.
                final int mods = method.getModifiers();
                if (!Modifier.isPublic(mods) ||
                    Modifier.isStatic(mods)) {
                    continue;
                }
                if (method.getName().equals(methodName) &&
                        method.getParameterTypes().length == parameterCount) {
                    return method;
                }
            }
        }

        // Now check any inherited interfaces.  This is necessary both when
        // the argument class is itself an interface, and when the argument
        // class is an abstract class.
        final Class<?>[] interfaces = initial.getInterfaces();
        for (final Class<?> interface1 : interfaces) {
            final Method method = internalGetMethod(interface1, methodName, parameterCount);
            if (method != null) {
                return method;
            }
        }

        return null;
    }

    /**
     * The underlying data type of the property we are describing.
     */
    private Reference<Class<?>> mappedPropertyTypeRef;

    /**
     * The reader method for this property (if any).
     */
    private MappedMethodReference mappedReadMethodRef;

    /**
     * The writer method for this property (if any).
     */
    private MappedMethodReference mappedWriteMethodRef;

    /**
     * Constructs a MappedPropertyDescriptor for a property that follows
     * the standard Java convention by having getFoo and setFoo
     * accessor methods, with the addition of a String parameter (the key).
     * Thus if the argument name is "fred", it will
     * assume that the writer method is "setFred" and the reader method
     * is "getFred".  Note that the property name should start with a lower
     * case character, which will be capitalized in the method names.
     *
     * @param propertyName The programmatic name of the property.
     * @param beanClass The Class object for the target bean.  For
     *        example sun.beans.OurButton.class.
     *
     * @throws IntrospectionException if an exception occurs during
     *              introspection.
     */
    public MappedPropertyDescriptor(final String propertyName, final Class<?> beanClass)
            throws IntrospectionException {
        super(propertyName, null, null);

        if (propertyName == null || propertyName.isEmpty()) {
            throw new IntrospectionException("bad property name: " +
                    propertyName + " on class: " + beanClass.getClass().getName());
        }

        setName(propertyName);
        final String base = capitalizePropertyName(propertyName);

        // Look for mapped read method and matching write method
        Method mappedReadMethod = null;
        Method mappedWriteMethod = null;
        try {
            try {
                mappedReadMethod = getMethod(beanClass, "get" + base,
                        STRING_CLASS_PARAMETER);
            } catch (final IntrospectionException e) {
                mappedReadMethod = getMethod(beanClass, "is" + base,
                        STRING_CLASS_PARAMETER);
            }
            final Class<?>[] params = { String.class, mappedReadMethod.getReturnType() };
            mappedWriteMethod = getMethod(beanClass, "set" + base, params);
        } catch (final IntrospectionException e) {
            /* Swallow IntrospectionException
             * TODO: Why?
             */
        }

        // If there's no read method, then look for just a write method
        if (mappedReadMethod == null) {
            mappedWriteMethod = getMethod(beanClass, "set" + base, 2);
        }

        if (mappedReadMethod == null && mappedWriteMethod == null) {
            throw new IntrospectionException("Property '" + propertyName +
                    "' not found on " +
                    beanClass.getName());
        }
        mappedReadMethodRef  = new MappedMethodReference(mappedReadMethod);
        mappedWriteMethodRef = new MappedMethodReference(mappedWriteMethod);

        findMappedPropertyType();
    }

    /**
     * This constructor takes the name of a mapped property, and method
     * names for reading and writing the property.
     *
     * @param propertyName The programmatic name of the property.
     * @param beanClass The Class object for the target bean.  For
     *        example sun.beans.OurButton.class.
     * @param mappedGetterName The name of the method used for
     *          reading one of the property values.  May be null if the
     *          property is write-only.
     * @param mappedSetterName The name of the method used for writing
     *          one of the property values.  May be null if the property is
     *          read-only.
     *
     * @throws IntrospectionException if an exception occurs during
     *              introspection.
     */
    public MappedPropertyDescriptor(final String propertyName, final Class<?> beanClass,
                                    final String mappedGetterName, final String mappedSetterName)
            throws IntrospectionException {
        super(propertyName, null, null);

        if (propertyName == null || propertyName.isEmpty()) {
            throw new IntrospectionException("bad property name: " +
                    propertyName);
        }
        setName(propertyName);

        // search the mapped get and set methods
        Method mappedReadMethod;
        Method mappedWriteMethod = null;
        mappedReadMethod =
            getMethod(beanClass, mappedGetterName, STRING_CLASS_PARAMETER);

        if (mappedReadMethod != null) {
            final Class<?>[] params = { String.class, mappedReadMethod.getReturnType() };
            mappedWriteMethod =
                getMethod(beanClass, mappedSetterName, params);
        } else {
            mappedWriteMethod =
                getMethod(beanClass, mappedSetterName, 2);
        }
        mappedReadMethodRef  = new MappedMethodReference(mappedReadMethod);
        mappedWriteMethodRef = new MappedMethodReference(mappedWriteMethod);

        findMappedPropertyType();
    }

    /**
     * This constructor takes the name of a mapped property, and Method
     * objects for reading and writing the property.
     *
     * @param propertyName The programmatic name of the property.
     * @param mappedGetter The method used for reading one of
     *          the property values.  May be null if the property
     *          is write-only.
     * @param mappedSetter The method used for writing one the
     *          property values.  May be null if the property is read-only.
     *
     * @throws IntrospectionException if an exception occurs during
     *              introspection.
     */
    public MappedPropertyDescriptor(final String propertyName,
                                    final Method mappedGetter, final Method mappedSetter)
            throws IntrospectionException {
        super(propertyName, mappedGetter, mappedSetter);

        if (propertyName == null || propertyName.isEmpty()) {
            throw new IntrospectionException("bad property name: " +
                    propertyName);
        }

        setName(propertyName);
        mappedReadMethodRef  = new MappedMethodReference(mappedGetter);
        mappedWriteMethodRef = new MappedMethodReference(mappedSetter);
        findMappedPropertyType();
    }

    /**
     * Introspect our bean class to identify the corresponding getter
     * and setter methods.
     */
    private void findMappedPropertyType() throws IntrospectionException {
        final Method mappedReadMethod  = getMappedReadMethod();
        final Method mappedWriteMethod = getMappedWriteMethod();
        Class<?> mappedPropertyType = null;
        if (mappedReadMethod != null) {
            if (mappedReadMethod.getParameterTypes().length != 1) {
                throw new IntrospectionException
                        ("bad mapped read method arg count");
            }
            mappedPropertyType = mappedReadMethod.getReturnType();
            if (mappedPropertyType == Void.TYPE) {
                throw new IntrospectionException
                        ("mapped read method " +
                        mappedReadMethod.getName() + " returns void");
            }
        }

        if (mappedWriteMethod != null) {
            final Class<?>[] params = mappedWriteMethod.getParameterTypes();
            if (params.length != 2) {
                throw new IntrospectionException
                        ("bad mapped write method arg count");
            }
            if (mappedPropertyType != null &&
                    mappedPropertyType != params[1]) {
                throw new IntrospectionException
                        ("type mismatch between mapped read and write methods");
            }
            mappedPropertyType = params[1];
        }
        mappedPropertyTypeRef = new SoftReference<>(mappedPropertyType);
    }

    /**
     * Gets the Class object for the property values.
     *
     * @return The Java type info for the property values.  Note that
     * the "Class" object may describe a built-in Java type such as "int".
     * The result may be "null" if this is a mapped property that
     * does not support non-keyed access.
     * <p>
     * This is the type that will be returned by the mappedReadMethod.
     */
    public Class<?> getMappedPropertyType() {
        return mappedPropertyTypeRef.get();
    }

    /**
     * Gets the method that should be used to read one of the property value.
     *
     * @return The method that should be used to read the property value.
     * May return null if the property can't be read.
     */
    public Method getMappedReadMethod() {
        return mappedReadMethodRef.get();
    }

    /**
     * Gets the method that should be used to write one of the property value.
     *
     * @return The method that should be used to write one of the property value.
     * May return null if the property can't be written.
     */
    public Method getMappedWriteMethod() {
        return mappedWriteMethodRef.get();
    }

    /**
     * Sets the method that should be used to read one of the property value.
     *
     * @param mappedGetter The mapped getter method.
     * @throws IntrospectionException If an error occurs finding the
     * mapped property
     */
    public void setMappedReadMethod(final Method mappedGetter)
            throws IntrospectionException {
        mappedReadMethodRef = new MappedMethodReference(mappedGetter);
        findMappedPropertyType();
    }

    /**
     * Sets the method that should be used to write the property value.
     *
     * @param mappedSetter The mapped setter method.
     * @throws IntrospectionException If an error occurs finding the
     * mapped property
     */
    public void setMappedWriteMethod(final Method mappedSetter)
            throws IntrospectionException {
        mappedWriteMethodRef = new MappedMethodReference(mappedSetter);
        findMappedPropertyType();
    }
}
