/*
 * 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 flex.messaging.util;

import flex.messaging.io.TypeMarshaller;
import flex.messaging.io.TypeMarshallingContext;
import flex.messaging.MessageException;

import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.lang.reflect.Method;

/**
 * A utility class used to find a suitable method based on matching
 * signatures to the types of set of arguments. Since the arguments
 * may be from more loosely typed environments such as ActionScript,
 * a translator can be employed to handle type conversion. Note that
 * there isn't a great guarantee for which method will be selected
 * when several overloaded methods match very closely through the use
 * of various combinations of generic types.
 */
public class MethodMatcher {
    private final Map<MethodKey, Method> methodCache = new HashMap<MethodKey, Method>();
    private static final int ARGUMENT_CONVERSION_ERROR = 10006;
    private static final int CANNOT_INVOKE_METHOD = 10007;

    /**
     * Default constructor.
     */
    public MethodMatcher() {
    }

    /**
     * Utility method that searches a class for a given method, taking
     * into account the supplied parameters when evaluating overloaded
     * method signatures.
     *
     * @param c          The class.
     * @param methodName Desired method to search for
     * @param parameters Required to distinguish between overloaded methods of the same name
     * @return The best-match <tt>Method</tt>.
     */
    public Method getMethod(Class c, String methodName, List parameters) {
        // Keep track of the best method match found
        Match bestMatch = new Match(methodName);

        // Determine supplied parameter types.
        Class[] suppliedParamTypes = paramTypes(parameters);

        // Create a key to search our method cache
        MethodKey methodKey = new MethodKey(c, methodName, suppliedParamTypes);

        Method method = null;
        if (methodCache.containsKey(methodKey)) {
            method = methodCache.get(methodKey);

            String thisMethodName = method.getName();
            bestMatch.matchedMethodName = thisMethodName;
        } else {
            try // First, try an exact match.
            {
                method = c.getMethod(methodName, suppliedParamTypes);
                synchronized (methodCache) {
                    Method method2 = methodCache.get(methodKey);
                    if (method2 == null)
                        methodCache.put(methodKey, method);
                    else
                        method = method2;
                }
            } catch (SecurityException e) {
                // NOWARN
            } catch (NoSuchMethodException e) {
                // NOWARN
            }

            if (method == null) // Otherwise, search the long way.
            {
                Method[] methods = c.getMethods();
                for (Method thisMethod : c.getMethods()) {
                    String thisMethodName = thisMethod.getName();

                    // FIXME: Do we want to do this case-insensitively in Flex 2.0?
                    // First, search by name; for backwards compatibility
                    // we continue to check case-insensitively
                    if (!thisMethodName.equalsIgnoreCase(methodName))
                        continue;

                    // Next, search on params
                    Match currentMatch = new Match(methodName);
                    currentMatch.matchedMethodName = thisMethodName;

                    // If we've not yet had a match, this is our best match so far.
                    if (bestMatch.matchedMethodName == null)
                        bestMatch = currentMatch;

                    // Number of parameters must match.
                    Class[] desiredParamTypes = thisMethod.getParameterTypes();
                    currentMatch.methodParamTypes = desiredParamTypes;

                    if (desiredParamTypes.length != suppliedParamTypes.length)
                        continue;

                    currentMatch.matchedByNumberOfParams = true;

                    // If we've not yet matched any params, this is our best match so far.
                    if (!bestMatch.matchedByNumberOfParams && bestMatch.matchedParamCount == 0)
                        bestMatch = currentMatch;

                    // Parameter types must also be compatible. Don't actually convert
                    // the parameter just yet, only count the matches and exact matches.
                    convertParams(parameters, desiredParamTypes, currentMatch, false);

                    // If we've not yet had this many params match, this is our best match so far.
                    if (currentMatch.matchedParamCount >= bestMatch.matchedParamCount
                            && currentMatch.exactMatchedParamCount >= bestMatch.exactMatchedParamCount)
                        bestMatch = currentMatch;

                    // If all types were compatible, we have a match.
                    if (currentMatch.matchedParamCount == desiredParamTypes.length
                            && bestMatch == currentMatch) {
                        method = thisMethod;
                        synchronized (methodCache) {
                            Method method2 = methodCache.get(methodKey);
                            if (method2 == null || method2 != method)
                                methodCache.put(methodKey, method);
                            else
                                method = method2;
                        }
                        // Don't break as there might be other methods with the
                        // same number of arguments but with better match count.
                        // break;
                    }
                }
            }
        }

        if (method == null) {
            methodNotFound(methodName, suppliedParamTypes, bestMatch);
        } else if (bestMatch.paramTypeConversionFailure != null) {
            //Error occurred while attempting to convert an input argument's type.
            MessageException me = new MessageException();
            me.setMessage(ARGUMENT_CONVERSION_ERROR);
            me.setCode("Server.Processing");
            me.setRootCause(bestMatch.paramTypeConversionFailure);
            throw me;
        }

        // Call convertParams one last time before returning method. This ensures
        // that parameters List is converted using bestMatch.
        Class<?>[] desiredParamTypes = method.getParameterTypes();
        bestMatch.methodParamTypes = desiredParamTypes;
        convertParams(parameters, desiredParamTypes, bestMatch, true);
        return method;
    }


    /**
     * Utility method to convert a collection of parameters to desired types. We keep track
     * of the progress of the conversion to allow callers to gauge the success of the conversion.
     * This is important for ranking overloaded-methods and debugging purposes.
     *
     * @param parameters        actual parameters for an invocation
     * @param desiredParamTypes classes in the signature of a potential match for the invocation
     * @param currentMatch      the currently best known match
     * @param convert           determines whether the actual conversion should take place.
     */
    public static void convertParams(List parameters, Class[] desiredParamTypes, Match currentMatch, boolean convert) {
        int matchCount = 0;
        int exactMatchCount = 0;

        currentMatch.matchedParamCount = 0;
        currentMatch.convertedSuppliedTypes = new Class[desiredParamTypes.length];

        TypeMarshaller marshaller = TypeMarshallingContext.getTypeMarshaller();

        for (int i = 0; i < desiredParamTypes.length; i++) {
            Object param = parameters.get(i);

            // Consider null param to match
            if (param != null) {
                Object obj = null;
                Class objClass = null;

                if (marshaller != null) {
                    try {
                        obj = marshaller.convert(param, desiredParamTypes[i]);
                    } catch (MessageException ex) {
                        currentMatch.paramTypeConversionFailure = ex;
                        break;
                    } catch (ClassCastException ex) {
                        currentMatch.paramTypeConversionFailure = ex;
                        break;
                    }
                    // We need to catch the exception here as the conversion of parameter types could fail
                    catch (Exception e) {
                        currentMatch.paramTypeConversionFailure = e;
                        break;
                    }
                } else {
                    obj = param;
                }

                currentMatch.convertedSuppliedTypes[i] = (obj != null ? (objClass = obj.getClass()) : null);

                // Things match if we now have an object which is assignable from the
                // method param class type or if we have an Object which corresponds to
                // a primitive
                if (objClass != null && isAssignableFrom(desiredParamTypes[i], objClass)) {
                    // See if there's an exact match before parameter is converted.
                    if (isAssignableFrom(desiredParamTypes[i], param.getClass()))
                        exactMatchCount++;

                    if (convert) // Convert the parameter.
                        parameters.set(i, obj);

                    matchCount++;
                } else {
                    break;
                }
            } else {
                matchCount++;
            }
        }

        currentMatch.matchedParamCount = matchCount;
        currentMatch.exactMatchedParamCount = exactMatchCount;
    }

    private static boolean isAssignableFrom(Class first, Class second) {
        return (first.isAssignableFrom(second)) ||
                (first == Integer.TYPE && Integer.class.isAssignableFrom(second)) ||
                (first == Double.TYPE && Double.class.isAssignableFrom(second)) ||
                (first == Long.TYPE && Long.class.isAssignableFrom(second)) ||
                (first == Boolean.TYPE && Boolean.class.isAssignableFrom(second)) ||
                (first == Character.TYPE && Character.class.isAssignableFrom(second)) ||
                (first == Float.TYPE && Float.class.isAssignableFrom(second)) ||
                (first == Short.TYPE && Short.class.isAssignableFrom(second)) ||
                (first == Byte.TYPE && Byte.class.isAssignableFrom(second));
    }

    /**
     * Utility method that iterates over a collection of input
     * parameters to determine their types while logging
     * the class names to create a unique identifier for a
     * method signature.
     *
     * @param parameters - A list of supplied parameters.
     * @return An array of <tt>Class</tt> instances indicating the class of each corresponding parameter.
     */
    public static Class[] paramTypes(List parameters) {
        Class[] paramTypes = new Class[parameters.size()];
        for (int i = 0; i < paramTypes.length; i++) {
            Object p = parameters.get(i);
            paramTypes[i] = p == null ? Object.class : p.getClass();
        }
        return paramTypes;
    }

    /**
     * Utility method to provide more detailed information in the event that a search
     * for a specific method failed for the service class.
     *
     * @param methodName         the name of the missing method
     * @param suppliedParamTypes the types of parameters supplied for the search
     * @param bestMatch          the best match found during the search
     */
    public static void methodNotFound(String methodName, Class[] suppliedParamTypes, Match bestMatch) {
        // Set default error message...
        // Cannot invoke method '{methodName}'.
        int errorCode = CANNOT_INVOKE_METHOD;
        Object[] errorParams = new Object[]{methodName};
        String errorDetailVariant = "0";
        // Method '{methodName}' not found.
        Object[] errorDetailParams = new Object[]{methodName};

        if (bestMatch.matchedMethodName != null) {
            // Cannot invoke method '{bestMatch.matchedMethodName}'.
            errorCode = CANNOT_INVOKE_METHOD;
            errorParams = new Object[]{bestMatch.matchedMethodName};

            int suppliedParamCount = suppliedParamTypes.length;
            int expectedParamCount = bestMatch.methodParamTypes != null ? bestMatch.methodParamTypes.length : 0;

            if (suppliedParamCount != expectedParamCount) {
                // {suppliedParamCount} arguments were sent but {expectedParamCount} were expected.
                errorDetailVariant = "1";
                errorDetailParams = new Object[]{new Integer(suppliedParamCount), new Integer(expectedParamCount)};

            } else {
                String suppliedTypes = bestMatch.listTypes(suppliedParamTypes);
                String convertedTypes = bestMatch.listConvertedTypes();
                String expectedTypes = bestMatch.listExpectedTypes();

                if (expectedTypes != null) {
                    if (suppliedTypes != null) {
                        if (convertedTypes != null) {
                            // The expected argument types are ({expectedTypes})
                            // but the supplied types were ({suppliedTypes})
                            // and converted to ({convertedTypes}).
                            errorDetailVariant = "2";
                            errorDetailParams = new Object[]{expectedTypes, suppliedTypes, convertedTypes};
                        } else {
                            // The expected argument types are ({expectedTypes})
                            // but the supplied types were ({suppliedTypes})
                            // with none successfully converted.
                            errorDetailVariant = "3";
                            errorDetailParams = new Object[]{expectedTypes, suppliedTypes};
                        }
                    } else {
                        // The expected argument types are ({expectedTypes})
                        // but no arguments were provided.
                        errorDetailVariant = "4";
                        errorDetailParams = new Object[]{expectedTypes};
                    }
                } else {
                    // No arguments were expected but the following types were supplied (suppliedTypes)
                    errorDetailVariant = "5";
                    errorDetailParams = new Object[]{suppliedTypes};
                }
            }
        }

        MessageException ex = new MessageException();
        ex.setMessage(errorCode, errorParams);
        ex.setCode(MessageException.CODE_SERVER_RESOURCE_UNAVAILABLE);
        if (errorDetailVariant != null)
            ex.setDetails(errorCode, errorDetailVariant, errorDetailParams);

        if (bestMatch.paramTypeConversionFailure != null)
            ex.setRootCause(bestMatch.paramTypeConversionFailure);

        throw ex;
    }

    /**
     * A utility class to help rank methods in the search
     * for a best match, given a name and collection of
     * input parameters.
     */
    public static class Match {
        /**
         * Constructor.
         *
         * @param name the name of the method to match
         */
        public Match(String name) {
            this.methodName = name;
        }

        /**
         * Returns true if desired and found method names match.
         *
         * @return true if desired and found method names match
         */
        public boolean matchedExactlyByName() {
            return matchedMethodName != null ? matchedMethodName.equals(methodName) : false;
        }

        /**
         * Returns true if desired and found method names match only when case is ignored.
         *
         * @return true if desired and found method names match only when case is ignored
         */
        public boolean matchedLooselyByName() {
            return matchedMethodName != null ?
                    (!matchedExactlyByName() && matchedMethodName.equalsIgnoreCase(methodName)) : false;
        }


        /**
         * Lists the classes in the signature of the method matched.
         *
         * @return the classes in the signature of the method matched
         */
        public String listExpectedTypes() {
            return listTypes(methodParamTypes);
        }


        /**
         * Lists the classes corresponding to actual invocation parameters once they have been
         * converted as best they could to match the classes in the invoked method's signature.
         *
         * @return the classes corresponding to actual invocation parameters once they have been
         * converted as best they could to match the classes in the invoked method's signature
         */
        public String listConvertedTypes() {
            return listTypes(convertedSuppliedTypes);
        }

        /**
         * Creates a string representation of the class names in the array of types passed into
         * this method.
         *
         * @param types an array of types whose names are to be listed
         * @return a string representation of the class names in the array of types
         */
        public String listTypes(Class[] types) {
            if (types == null || types.length == 0)
                return null;

            StringBuffer sb = new StringBuffer();

            for (int i = 0; i < types.length; i++) {
                if (i > 0)
                    sb.append(", ");

                Class c = types[i];

                if (c != null) {
                    if (c.isArray()) {
                        c = c.getComponentType();
                        sb.append(c.getName()).append("[]");
                    } else {
                        sb.append(c.getName());
                    }
                } else {
                    sb.append("null");
                }
            }

            return sb.toString();
        }

        final String methodName;
        String matchedMethodName;

        boolean matchedByNumberOfParams;
        int matchedParamCount;
        int exactMatchedParamCount;
        Class[] methodParamTypes;
        Class[] convertedSuppliedTypes;
        Exception paramTypeConversionFailure;
    }
}
