/*
 *    GeoTools - The Open Source Java GIS Toolkit
 *    http://geotools.org
 *
 *    (C) 2007-2008, Open Source Geospatial Foundation (OSGeo)
 *
 *    This library is free software; you can redistribute it and/or
 *    modify it under the terms of the GNU Lesser General Public
 *    License as published by the Free Software Foundation;
 *    version 2.1 of the License.
 *
 *    This library is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 *    Lesser General Public License for more details.
 */
package org.geotools.metadata;

import java.io.File;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.UndeclaredThrowableException;
import java.net.URI;
import java.net.URL;
import java.net.URISyntaxException;
import java.net.MalformedURLException;
import java.util.LinkedHashSet;
import java.util.Collection;
import java.util.Iterator;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.opengis.annotation.UML;
import org.opengis.util.InternationalString;

import org.geotools.util.Utilities;
import org.geotools.resources.XArray;
import org.geotools.resources.Classes;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.ErrorKeys;
import org.geotools.util.CheckedCollection;
import org.geotools.util.SimpleInternationalString;


/**
 * The getters declared in a GeoAPI interface, together with setters (if any)
 * declared in the Geotools implementation.
 *
 * @source $URL: http://svn.osgeo.org/geotools/branches/2.7.x/modules/library/metadata/src/main/java/org/geotools/metadata/PropertyAccessor.java $
 * @version $Id: PropertyAccessor.java 31451 2008-09-08 08:27:50Z desruisseaux $
 * @author Martin Desruisseaux
 */
final class PropertyAccessor {
    /**
     * The locale to use for changing character case.
     */
    private static final Locale LOCALE = Locale.US;

    /**
     * The prefix for getters on boolean values.
     */
    private static final String IS = "is";

    /**
     * The prefix for getters (general case).
     */
    private static final String GET = "get";

    /**
     * The prefix for setters.
     */
    private static final String SET = "set";

    /**
     * Methods to exclude from {@link #getGetters}. They are method inherited from
     * {@link java.lang.Object}. Some of them, especially {@link Object#hashCode()}
     * {@link Object#toString()} and {@link Object#clone()}, may be declared explicitly
     * in some interface with a formal contract. Note: only no-argument methods need to
     * be declared in this list.
     */
    private static final String[] EXCLUDES = {
        "clone", "finalize", "getClass", "hashCode", "notify", "notifyAll", "toString", "wait"
    };

    /**
     * Getters shared between many instances of this class. Two different implementations
     * may share the same getters but different setters.
     */
    private static final Map<Class<?>, Method[]> SHARED_GETTERS = new HashMap<Class<?>, Method[]>();

    /**
     * The implemented metadata interface.
     */
    final Class<?> type;

    /**
     * The implementation class. The following condition must hold:
     *
     * <blockquote><pre>
     * type.{@linkplain Class#isAssignableFrom isAssignableFrom}(implementation);
     * </pre></blockquote>
     */
    final Class<?> implementation;

    /**
     * The getter methods. This array should not contain any null element.
     */
    private final Method[] getters;

    /**
     * The corresponding setter methods, or {@code null} if none. This array must have
     * the same length than {@link #getters}. For every {@code getters[i]} element,
     * {@code setters[i]} is the corresponding setter or {@code null} if there is none.
     */
    private final Method[] setters;

    /**
     * Index of getter or setter for a given name. The name must be all lower cases with
     * conversion done using {@link #LOCALE}. This map must be considered as immutable
     * after construction.
     */
    private final Map<String,Integer> mapping;

    /**
     * Creates a new property reader for the specified metadata implementation.
     *
     * @param  metadata The metadata implementation to wrap.
     * @param  type The interface implemented by the metadata.
     *         Should be the value returned by {@link #getType}.
     */
    PropertyAccessor(final Class<?> implementation, final Class<?> type) {
        this.implementation = implementation;
        this.type = type;
        assert type.isAssignableFrom(implementation) : implementation;
        getters = getGetters(type);
        mapping = new HashMap<String,Integer>(getters.length + (getters.length + 3) / 4);
        Method[] setters = null;
        final Class<?>[] arguments = new Class[1];
        for (int i=0; i<getters.length; i++) {
            /*
             * Fetch the getter and remind its name. We do the same for
             * the UML tag attached to the getter, if any.
             */
            final Integer index = i;
            Method getter  = getters[i];
            String name    = getter.getName();
            final int base = prefix(name).length();
            addMapping(name.substring(base), index);
            final UML annotation = getter.getAnnotation(UML.class);
            if (annotation != null) {
                addMapping(annotation.identifier(), index);
            }
            /*
             * Now try to infer the setter from the getter. We replace the "get" prefix by
             * "set" and look for a parameter of the same type than the getter return type.
             */
            Class<?> returnType = getter.getReturnType();
            arguments[0] = returnType;
            if (name.length() > base) {
                final char lo = name.charAt(base);
                final char up = Character.toUpperCase(lo);
                if (lo != up) {
                    name = SET + up + name.substring(base + 1);
                } else {
                    name = SET + name.substring(base);
                }
            }
            Method setter;
            try {
                setter = implementation.getMethod(name, arguments);
            } catch (NoSuchMethodException e) {
                /*
                 * If we found no setter method expecting an argument of the same type than the
                 * argument returned by the GeoAPI method,  try again with the type returned by
                 * the implementation class. It is typically the same type, but sometime it may
                 * be a subtype.
                 */
                try {
                    getter = implementation.getMethod(getter.getName(), (Class[]) null);
                } catch (NoSuchMethodException error) {
                    // Should never happen, since the implementation class
                    // implements the the interface where the getter come from.
                    throw new AssertionError(error);
                }
                if (returnType.equals(returnType = getter.getReturnType())) {
                    continue;
                }
                arguments[0] = returnType;
                try {
                    setter = implementation.getMethod(name, arguments);
                } catch (NoSuchMethodException ignore) {
                    continue;
                }
            }
            if (setters == null) {
                setters = new Method[getters.length];
            }
            setters[i] = setter;
        }
        this.setters = setters;
    }

    /**
     * Adds the given (name, index) pair to {@link #mapping}, making sure we don't
     * overwrite an existing entry with different value.
     */
    private void addMapping(String name, final Integer index) throws IllegalArgumentException {
        name = name.trim();
        if (name.length() != 0) {
            final String lower = name.toLowerCase(LOCALE);
            final Integer old = mapping.put(lower, index);
            if (old != null && !old.equals(index)) {
                throw new IllegalArgumentException(Errors.format(
                        ErrorKeys.PARAMETER_NAME_CLASH_$4, name, index, lower, old));
            }
        }
    }

    /**
     * Returns the metadata interface implemented by the specified implementation.
     * Only one metadata interface can be implemented.
     *
     * @param  metadata The metadata implementation to wraps.
     * @param  interfacePackage The root package for metadata interfaces.
     * @return The single interface, or {@code null} if none where found.
     */
    static Class<?> getType(Class<?> implementation, final String interfacePackage) {
        if (implementation != null && !implementation.isInterface()) {
            /*
             * Gets every interfaces from the supplied package in declaration order,
             * including the ones declared in the super-class.
             */
            final Set<Class<?>> interfaces = new LinkedHashSet<Class<?>>();
            do {
                getInterfaces(implementation, interfacePackage, interfaces);
                implementation = implementation.getSuperclass();
            } while (implementation != null);
            /*
             * If we found more than one interface, removes the
             * ones that are sub-interfaces of the other.
             */
            for (final Iterator<Class<?>> it=interfaces.iterator(); it.hasNext();) {
                final Class<?> candidate = it.next();
                for (final Class<?> child : interfaces) {
                    if (candidate != child && candidate.isAssignableFrom(child)) {
                        it.remove();
                        break;
                    }
                }
            }
            final Iterator<Class<?>> it=interfaces.iterator();
            if (it.hasNext()) {
                final Class<?> candidate = it.next();
                if (!it.hasNext()) {
                    return candidate;
                }
                // Found more than one interface; we don't know which one to pick.
                // Returns 'null' for now; the caller will thrown an exception.
            }
        }
        return null;
    }

    /**
     * Puts every interfaces for the given type in the specified collection.
     * This method invokes itself recursively for scanning parent interfaces.
     */
    private static void getInterfaces(final Class<?> type, final String interfacePackage,
            final Collection<Class<?>> interfaces)
    {
        for (final Class<?> candidate : type.getInterfaces()) {
            if (candidate.getName().startsWith(interfacePackage)) {
                interfaces.add(candidate);
            }
            getInterfaces(candidate, interfacePackage, interfaces);
        }
    }

    /**
     * Returns the getters. The returned array should never be modified,
     * since it may be shared among many instances of {@code PropertyAccessor}.
     *
     * @param  type The metadata interface.
     * @return The getters declared in the given interface (never {@code null}).
     */
    private static Method[] getGetters(final Class<?> type) {
        synchronized (SHARED_GETTERS) {
            Method[] getters = SHARED_GETTERS.get(type);
            if (getters == null) {
                getters = type.getMethods();
                int count = 0;
                for (int i=0; i<getters.length; i++) {
                    final Method candidate = getters[i];
                    if (candidate.getAnnotation(Deprecated.class) != null) {
                        // Ignores deprecated methods.
                        continue;
                    }
                    if (!candidate.getReturnType().equals(Void.TYPE) &&
                         candidate.getParameterTypes().length == 0)
                    {
                        /*
                         * We do not require a name starting with "get" or "is" prefix because some
                         * methods do not begin with such prefix, as in "ConformanceResult.pass()".
                         * Consequently we must provide special cases for no-arg methods inherited
                         * from java.lang.Object because some interfaces declare explicitly the
                         * contract for those methods.
                         *
                         * Note that testing candidate.getDeclaringClass().equals(Object.class)
                         * is not suffisient because the method may be overriden in a subclass.
                         */
                        final String name = candidate.getName();
                        if (!name.startsWith(SET) && !isExcluded(name)) {
                            getters[count++] = candidate;
                        }
                    }
                }
                getters = XArray.resize(getters, count);
                SHARED_GETTERS.put(type, getters);
            }
            return getters;
        }
    }

    /**
     * Returns {@code true} if the specified method is on the exclusion list.
     */
    private static boolean isExcluded(final String name) {
        for (int i=0; i<EXCLUDES.length; i++) {
            if (name.equals(EXCLUDES[i])) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns the prefix of the specified method name. If the method name don't starts with
     * a prefix (for example {@link org.opengis.metadata.quality.ConformanceResult#pass()}),
     * then this method returns an empty string.
     */
    private static String prefix(final String name) {
        if (name.startsWith(GET)) {
            return GET;
        }
        if (name.startsWith(IS)) {
            return IS;
        }
        if (name.startsWith(SET)) {
            return SET;
        }
        return "";
    }

    /**
     * Returns the number of properties that can be read.
     */
    final int count() {
        return getters.length;
    }

    /**
     * Returns the index of the specified property, or -1 if none.
     * The search is case-insensitive.
     *
     * @param  key The property to search.
     * @return The index of the given key, or -1 if none.
     */
    final int indexOf(String key) {
        key = key.trim().toLowerCase(LOCALE);
        final Integer index = mapping.get(key);
        return (index != null) ? index.intValue() : -1;
    }

    /**
     * Always returns the index of the specified property (never -1).
     * The search is case-insensitive.
     *
     * @param  key The property to search.
     * @return The index of the given key.
     * @throws IllegalArgumentException if the given key is not found.
     */
    final int requiredIndexOf(String key) throws IllegalArgumentException {
        key = key.trim();
        final Integer index = mapping.get(key.toLowerCase(LOCALE));
        if (index != null) {
            return index;
        }
        throw new IllegalArgumentException(Errors.format(ErrorKeys.UNKNOW_PARAMETER_NAME_$1, key));
    }

    /**
     * Returns {@code true} if the specified string starting at the specified index contains
     * no lower case characters. The characters don't have to be in upper case however (e.g.
     * non-alphabetic characters)
     */
    private static boolean isAcronym(final String name, int offset) {
        final int length = name.length();
        while (offset < length) {
            if (Character.isLowerCase(name.charAt(offset++))) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns the name of the property at the given index, or {@code null} if none.
     */
    final String name(final int index) {
        if (index >= 0 && index < getters.length) {
            String name = getters[index].getName();
            final int base = prefix(name).length();
            /*
             * Remove the "get" or "is" prefix and turn the first character after the
             * prefix into lower case. For example the method name "getTitle" will be
             * replaced by the property name "title". We will performs this operation
             * only if there is at least 1 character after the prefix.
             */
            if (name.length() > base) {
                if (isAcronym(name, base)) {
                    name = name.substring(base);
                } else {
                    final char up = name.charAt(base);
                    final char lo = Character.toLowerCase(up);
                    if (up != lo) {
                        name = lo + name.substring(base + 1);
                    } else {
                        name = name.substring(base);
                    }
                }
            }
            return name;
        }
        return null;
    }

    /**
     * Returns the value for the specified metadata, or {@code null} if none.
     */
    final Object get(final int index, final Object metadata) {
        return (index >= 0 && index < getters.length) ? get(getters[index], metadata) : null;
    }

    /**
     * Gets a value from the specified metadata. We do not expect any checked exception to
     * be thrown, since {@code org.opengis.metadata} do not declare any.
     *
     * @param method The method to use for the query.
     * @param metadata The metadata object to query.
     */
    private static Object get(final Method method, final Object metadata) {
        assert !method.getReturnType().equals(Void.TYPE) : method;
        try {
            return method.invoke(metadata, (Object[]) null);
        } catch (IllegalAccessException e) {
            // Should never happen since 'getters' should contains only public methods.
            throw new AssertionError(e);
        } catch (InvocationTargetException e) {
            final Throwable cause = e.getTargetException();
            if (cause instanceof RuntimeException) {
                throw (RuntimeException) cause;
            }
            if (cause instanceof Error) {
                throw (Error) cause;
            }
            throw new UndeclaredThrowableException(cause);
        }
    }

    /**
     * Sets a value for the specified metadata.
     *
     * @param  index The index of the property to set.
     * @param  metadata The metadata object on which to set the value.
     * @param  value The new value.
     * @return The old value.
     * @throws IllegalArgumentException if the specified property can't be set.
     * @throws ClassCastException if the given value is not of the expected type.
     */
    final Object set(final int index, final Object metadata, final Object value)
            throws IllegalArgumentException, ClassCastException
    {
        String key;
        if (index >= 0 && index < getters.length && setters != null) {
            final Method getter = getters[index];
            final Method setter = setters[index];
            if (setter != null) {
                final Object old = get(getter, metadata);
                set(getter, setter, metadata, new Object[] {value});
                return old;
            } else {
                key = getter.getName();
                key = key.substring(prefix(key).length());
            }
        } else {
            key = String.valueOf(index);
        }
        throw new IllegalArgumentException(Errors.format(ErrorKeys.ILLEGAL_ARGUMENT_$1, key));
    }

    /**
     * Sets a value for the specified metadata. We do not expect any checked exception to
     * be thrown.
     *
     * @param getter The method to use for fetching the previous value.
     * @param setter The method to use for setting the new value.
     * @param metadata The metadata object to query.
     * @param arguments The argument to give to the method to be invoked.
     * @throws ClassCastException if at least one element of the {@code arguments} array
     *         is not of the expected type.
     */
    private static void set(final Method getter, final Method setter,
                            final Object metadata, final Object[] arguments)
            throws ClassCastException
    {
        final Class<?>[] paramTypes = setter.getParameterTypes();
        for (int i=0; i<paramTypes.length; i++) {
            final Object argument = arguments[i];
            if (argument == null) {
                continue; // Null argument (which is valid): nothing to do.
            }
            final Class<?> paramType = paramTypes[i];
            if (Classes.primitiveToWrapper(paramType).isInstance(argument)) {
                continue; // Argument is of the expected type: nothing to do.
            }
            /*
             * If an argument is not of the expected type, tries to convert it.
             * We handle two cases:
             *
             *   - Strings to be converted to Number, File, URL, etc.
             *   - Singleton to be added into an existing collection.
             *
             * We check for the collection case first in order to extract the element
             * type, which will be used for String conversions (if applicable) later.
             * The collections are handled in one of the two ways below:
             *
             *   - If the user gives a collection, the user's collection replaces any
             *     previous one. The content of the previous collection is discarted.
             *
             *   - If the user gives a singleton, the single value is added to existing
             *     collection (if any). The previous values are not discarted. This
             *     allow for incremental filling of an attribute.
             */
            final Collection<?> addTo;
            final Class<?> elementType;
            if (Collection.class.isAssignableFrom(paramType) && !(argument instanceof Collection)) {
                // Expected a collection but got a singleton.
                addTo = (Collection) get(getter, metadata);
                if (addTo instanceof CheckedCollection) {
                    elementType = ((CheckedCollection) addTo).getElementType();
                } else {
                    Class<?> c = Classes.boundOfParameterizedAttribute(setter);
                    if (c == null) {
                        c = Classes.boundOfParameterizedAttribute(getter);
                        if (c == null) {
                            c = Object.class;
                        }
                    }
                    elementType = c;
                }
            } else {
                addTo = null;
                elementType = paramType;
            }
            /*
             * Handles the strings in a special way (converting to URI, URL, File,
             * Number, etc.). If there is no known way to parse the string, or if
             * the parsing failed, an exception is thrown.
             */
            Object parsed = null;
            Exception failure = null;
            if (elementType.isInstance(argument)) {
                parsed = argument;
            } else if (argument instanceof CharSequence) {
                final String text = argument.toString();
                if (InternationalString.class.isAssignableFrom(elementType)) {
                    parsed = new SimpleInternationalString(text);
                } else if (File.class.isAssignableFrom(elementType)) {
                    parsed = new File(text);
                } else if (URL.class.isAssignableFrom(elementType)) try {
                    parsed = new URL(text);
                } catch (MalformedURLException e) {
                    failure = e;
                } else if (URI.class.isAssignableFrom(elementType)) try {
                    parsed = new URI(text);
                } catch (URISyntaxException e) {
                    failure = e;
                } else try {
                    parsed = Classes.valueOf(elementType, text);
                } catch (RuntimeException e) {
                    // Include IllegalArgumentException and NumberFormatException
                    failure = e;
                }
            }
            /*
             * Checks if there is no known conversion, or if the conversion failed. In the later
             * case the parse failure is saved as the cause. We still throw a ClassCastException
             * since we get here because the argument was not of the expected type.
             */
            if (parsed == null) {
                final ClassCastException e = new ClassCastException(Errors.format(
                        ErrorKeys.ILLEGAL_CLASS_$2, argument.getClass(), elementType));
                e.initCause(failure);
                throw e;
            }
            /*
             * We now have an object of the appropriate type. If this is a singleton to be added in
             * an existing collection, add it now and set the new value to the whole collection. In
             * the later case, we rely on ModifiableMetadata.copyCollection(...) optimization for
             * detecting that the new collection is the same instance than the old one so there is
             * nothing to do. We could exit from the method, but let it goes in case the user define
             * (or override) the 'setFoo(...)' method in an other way.
             */
            if (addTo != null) {
                addUnsafe(addTo, parsed);
                parsed = addTo;
            }
            arguments[i] = parsed;
        }
        try {
            setter.invoke(metadata, arguments);
        } catch (IllegalAccessException e) {
            // Should never happen since 'setters' should contains only public methods.
            throw new AssertionError(e);
        } catch (InvocationTargetException e) {
            final Throwable cause = e.getTargetException();
            if (cause instanceof RuntimeException) {
                throw (RuntimeException) cause;
            }
            if (cause instanceof Error) {
                throw (Error) cause;
            }
            throw new UndeclaredThrowableException(cause);
        }
    }

    /**
     * Unsafe addition into a collection. In GeoTools implementation, the collection is actually
     * an instance of {@link CheckedCollection}, so the check will be performed at runtime.
     * However other implementations could use unchecked collection. There is not much we can do.
     */
    @SuppressWarnings("unchecked")
    private static void addUnsafe(final Collection<?> addTo, final Object element) {
        ((Collection) addTo).add(element);
    }

    /**
     * Compares the two specified metadata objects. The comparaison is <cite>shallow</cite>,
     * i.e. all metadata attributes are compared using the {@link Object#equals} method without
     * recursive call to this {@code shallowEquals} method for other metadata.
     * <p>
     * This method can optionaly excludes null values from the comparaison. In metadata,
     * null value often means "don't know", so in some occasion we want to consider two
     * metadata as different only if an attribute value is know for sure to be different.
     *
     * @param metadata1 The first metadata object to compare.
     * @param metadata2 The second metadata object to compare.
     * @param skipNulls If {@code true}, only non-null values will be compared.
     */
    public boolean shallowEquals(final Object metadata1, final Object metadata2, final boolean skipNulls) {
        assert type.isInstance(metadata1) : metadata1;
        assert type.isInstance(metadata2) : metadata2;
        for (int i=0; i<getters.length; i++) {
            final Method  method = getters[i];
            final Object  value1 = get(method, metadata1);
            final Object  value2 = get(method, metadata2);
            final boolean empty1 = isEmpty(value1);
            final boolean empty2 = isEmpty(value2);
            if (empty1 && empty2) {
                continue;
            }
            if (!Utilities.equals(value1, value2)) {
                if (!skipNulls || (!empty1 && !empty2)) {
                    return false;
                }
            }
        }
        return true;
    }

    /**
     * Copies all metadata from source to target. The source can be any implementation of
     * the metadata interface, but the target must be the implementation expected by this
     * class.
     *
     * @param  source The metadata to copy.
     * @param  target The target metadata.
     * @param  skipNulls If {@code true}, only non-null values will be copied.
     * @return {@code true} in case of success, or {@code false} if at least
     *         one setter method was not found.
     * @throws UnmodifiableMetadataException if the target metadata is unmodifiable.
     */
    public boolean shallowCopy(final Object source, final Object target, final boolean skipNulls)
            throws UnmodifiableMetadataException
    {
        boolean success = true;
        assert type          .isInstance(source) : source;
        assert implementation.isInstance(target) : target;
        final Object[] arguments = new Object[1];
        for (int i=0; i<getters.length; i++) {
            final Method getter = getters[i];
            arguments[0] = get(getter, source);
            if (!skipNulls || !isEmpty(arguments[0])) {
                if (setters == null) {
                    return false;
                }
                final Method setter = setters[i];
                if (setter != null) {
                    set(getter, setter, target, arguments);
                } else {
                    success = false;
                }
            }
        }
        return success;
    }

    /**
     * Replaces every properties in the specified metadata by their
     * {@linkplain ModifiableMetadata#unmodifiable unmodifiable variant.
     */
    final void freeze(final Object metadata) {
        assert implementation.isInstance(metadata) : metadata;
        if (setters != null) {
            final Object[] arguments = new Object[1];
            for (int i=0; i<getters.length; i++) {
                final Method setter = setters[i];
                if (setter != null) {
                    final Method getter = getters[i];
                    final Object source = get(getter, metadata);
                    final Object target = ModifiableMetadata.unmodifiable(source);
                    if (source != target) {
                        arguments[0] = target;
                        set(getter, setter, metadata, arguments);
                    }
                }
            }
        }
    }

    /**
     * Returns {@code true} if the metadata is modifiable. This method is not public because it
     * uses heuristic rules. In case of doubt, this method conservatively returns {@code true}.
     */
    final boolean isModifiable() {
        if (setters != null) {
            return true;
        }
        for (int i=0; i<getters.length; i++) {
            // Immutable objects usually don't need to be cloned. So if
            // an object is cloneable, it is probably not immutable.
            if (Cloneable.class.isAssignableFrom(getters[i].getReturnType())) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns a hash code for the specified metadata. The hash code is defined as the
     * sum of hash code values of all non-null properties. This is the same contract than
     * {@link java.util.Set#hashCode} and ensure that the hash code value is insensitive
     * to the ordering of properties.
     */
    public int hashCode(final Object metadata) {
        assert type.isInstance(metadata) : metadata;
        int code = 0;
        for (int i=0; i<getters.length; i++) {
            final Object value = get(getters[i], metadata);
            if (!isEmpty(value)) {
                code += value.hashCode();
            }
        }
        return code;
    }

    /**
     * Counts the number of non-null properties.
     */
    public int count(final Object metadata, final int max) {
        assert type.isInstance(metadata) : metadata;
        int count = 0;
        for (int i=0; i<getters.length; i++) {
            if (!isEmpty(get(getters[i], metadata))) {
                if (++count >= max) {
                    break;
                }
            }
        }
        return count;
    }

    /**
     * Returns {@code true} if the specified object is null or an empty collection,
     * array or string.
     */
    static boolean isEmpty(final Object value) {
        return value == null ||
                ((value instanceof Collection) && ((Collection) value).isEmpty()) ||
                ((value instanceof CharSequence) && value.toString().trim().length() == 0) ||
                (value.getClass().isArray() && Array.getLength(value) == 0);
    }
}
