/**
 * 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.camel.util.component;

import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.camel.RuntimeCamelException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Helper class for working with {@link ApiMethod}.
 */
public final class ApiMethodHelper<T extends Enum<T> & ApiMethod> {

    private static final Logger LOG = LoggerFactory.getLogger(ApiMethodHelper.class);

    // maps method name to ApiMethod
    private final Map<String, List<T>> methodMap;

    // maps method name to method arguments of the form Class type1, String name1, Class type2, String name2,...
    private final Map<String, List<Object>> argumentsMap;

    // maps argument name to argument type
    private final Map<String, Class<?>> validArguments;

    // maps aliases to actual method names
    private final Map<String, Set<String>> aliasesMap;

    // nullable args
    private final List<String> nullableArguments;

    /**
     * Create a helper to work with a {@link ApiMethod}, using optional method aliases.
     * @param apiMethodEnum {@link ApiMethod} enumeration class
     * @param aliases Aliases mapped to actual method names
     * @param nullableArguments names of arguments that default to null value
     */
    public ApiMethodHelper(Class<T> apiMethodEnum, Map<String, String> aliases, List<String> nullableArguments) {

        Map<String, List<T>> tmpMethodMap = new HashMap<>();
        Map<String, List<Object>> tmpArgumentsMap = new HashMap<>();
        Map<String, Class<?>> tmpValidArguments = new HashMap<>();
        Map<String, Set<String>> tmpAliasesMap = new HashMap<>();

        // validate ApiMethod Enum
        if (apiMethodEnum == null) {
            throw new IllegalArgumentException("ApiMethod enumeration cannot be null");
        }

        if (nullableArguments != null && !nullableArguments.isEmpty()) {
            this.nullableArguments = Collections.unmodifiableList(new ArrayList<>(nullableArguments));
        } else {
            this.nullableArguments = Collections.emptyList();
        }

        final Map<Pattern, String> aliasPatterns = new HashMap<>();
        for (Map.Entry<String, String> alias : aliases.entrySet()) {
            if (alias.getKey() == null || alias.getValue() == null) {
                throw new IllegalArgumentException("Alias pattern and replacement cannot be null");
            }
            aliasPatterns.put(Pattern.compile(alias.getKey()), alias.getValue());
        }

        LOG.debug("Processing {}", apiMethodEnum.getName());
        final T[] methods = apiMethodEnum.getEnumConstants();

        // load lookup maps
        for (T method : methods) {

            final String name = method.getName();

            // add method name aliases
            for (Map.Entry<Pattern, String> aliasEntry : aliasPatterns.entrySet()) {
                final Matcher matcher = aliasEntry.getKey().matcher(name);
                if (matcher.find()) {
                    // add method name alias
                    String alias = matcher.replaceAll(aliasEntry.getValue());
                    // convert first character to lowercase
                    assert alias.length() > 1;
                    final char firstChar = alias.charAt(0);
                    if (!Character.isLowerCase(firstChar)) {
                        final StringBuilder builder = new StringBuilder();
                        builder.append(Character.toLowerCase(firstChar)).append(alias.substring(1));
                        alias = builder.toString();
                    }
                    Set<String> names = tmpAliasesMap.get(alias);
                    if (names == null) {
                        names = new HashSet<>();
                        tmpAliasesMap.put(alias, names);
                    }
                    names.add(name);
                }
            }

            // map method name to Enum
            List<T> overloads = tmpMethodMap.get(name);
            if (overloads == null) {
                overloads = new ArrayList<>();
                tmpMethodMap.put(method.getName(), overloads);
            }
            overloads.add(method);

            // add arguments for this method
            List<Object> arguments = tmpArgumentsMap.get(name);
            if (arguments == null) {
                arguments = new ArrayList<>();
                tmpArgumentsMap.put(name, arguments);
            }

            // process all arguments for this method
            final int nArgs = method.getArgNames().size();
            final String[] argNames = method.getArgNames().toArray(new String[nArgs]);
            final Class<?>[] argTypes = method.getArgTypes().toArray(new Class[nArgs]);
            for (int i = 0; i < nArgs; i++) {
                final String argName = argNames[i];
                final Class<?> argType = argTypes[i];
                if (!arguments.contains(argName)) {
                    arguments.add(argType);
                    arguments.add(argName);
                }

                // also collect argument names for all methods, and detect clashes here
                final Class<?> previousType = tmpValidArguments.get(argName);
                if (previousType != null && previousType != argType) {
                    throw new IllegalArgumentException(String.format(
                        "Argument %s has ambiguous types (%s, %s) across methods!",
                        name, previousType, argType));
                } else if (previousType == null) {
                    tmpValidArguments.put(argName, argType);
                }
            }

        }

        // validate nullableArguments
        if (!tmpValidArguments.keySet().containsAll(this.nullableArguments)) {
            List<String> unknowns = new ArrayList<>(this.nullableArguments);
            unknowns.removeAll(tmpValidArguments.keySet());
            throw new IllegalArgumentException("Unknown nullable arguments " + unknowns.toString());
        }

        // validate aliases
        for (Map.Entry<String, Set<String>> entry : tmpAliasesMap.entrySet()) {

            // look for aliases that match multiple methods
            final Set<String> methodNames = entry.getValue();
            if (methodNames.size() > 1) {

                // get mapped methods
                final List<T> aliasedMethods = new ArrayList<>();
                for (String methodName : methodNames) {
                    List<T> mappedMethods = tmpMethodMap.get(methodName);
                    aliasedMethods.addAll(mappedMethods);
                }

                // look for argument overlap
                for (T method : aliasedMethods) {
                    final List<String> argNames = new ArrayList<>(method.getArgNames());
                    argNames.removeAll(this.nullableArguments);

                    final Set<T> ambiguousMethods = new HashSet<>();
                    for (T otherMethod : aliasedMethods) {
                        if (method != otherMethod) {
                            final List<String> otherArgsNames = new ArrayList<>(otherMethod.getArgNames());
                            otherArgsNames.removeAll(this.nullableArguments);

                            if (argNames.equals(otherArgsNames)) {
                                ambiguousMethods.add(method);
                                ambiguousMethods.add(otherMethod);
                            }
                        }
                    }

                    if (!ambiguousMethods.isEmpty()) {
                        throw new IllegalArgumentException(
                            String.format("Ambiguous alias %s for methods %s", entry.getKey(), ambiguousMethods));
                    }
                }
            }
        }

        this.methodMap = Collections.unmodifiableMap(tmpMethodMap);
        this.argumentsMap = Collections.unmodifiableMap(tmpArgumentsMap);
        this.validArguments = Collections.unmodifiableMap(tmpValidArguments);
        this.aliasesMap = Collections.unmodifiableMap(tmpAliasesMap);

        LOG.debug("Found {} unique method names in {} methods", tmpMethodMap.size(), methods.length);
    }

    /**
     * Gets methods that match the given name and arguments.<p/>
     * Note that the args list is a required subset of arguments for returned methods.
     *
     * @param name case sensitive method name or alias to lookup
     * @return non-null unmodifiable list of methods that take all of the given arguments, empty if there is no match
     */
    public List<ApiMethod> getCandidateMethods(String name) {
        return getCandidateMethods(name, Collections.emptyList());
    }

    /**
     * Gets methods that match the given name and arguments.<p/>
     * Note that the args list is a required subset of arguments for returned methods.
     *
     * @param name case sensitive method name or alias to lookup
     * @param argNames unordered required argument names
     * @return non-null unmodifiable list of methods that take all of the given arguments, empty if there is no match
     */
    public List<ApiMethod> getCandidateMethods(String name, Collection<String> argNames) {
        List<T> methods = methodMap.get(name);
        if (methods == null) {
            if (aliasesMap.containsKey(name)) {
                methods = new ArrayList<>();
                for (String method : aliasesMap.get(name)) {
                    methods.addAll(methodMap.get(method));
                }
            }
        }
        if (methods == null) {
            LOG.debug("No matching method for method {}", name);
            return Collections.emptyList();
        }
        int nArgs = argNames != null ? argNames.size() : 0;
        if (nArgs == 0) {
            LOG.debug("Found {} methods for method {}", methods.size(), name);
            return Collections.unmodifiableList(methods);
        } else {
            final List<ApiMethod> filteredSet = filterMethods(methods, MatchType.SUBSET, argNames);
            if (LOG.isDebugEnabled()) {
                LOG.debug("Found {} filtered methods for {}",
                    filteredSet.size(), name + argNames.toString().replace('[', '(').replace(']', ')'));
            }
            return filteredSet;
        }
    }

    /**
     * Filters a list of methods to those that take the given set of arguments.
     *
     * @param methods list of methods to filter
     * @param matchType whether the arguments are an exact match, a subset or a super set of method args
     * @return methods with arguments that satisfy the match type.<p/>
     * For SUPER_SET match, if methods with exact match are found, methods that take a subset are ignored
     */
    public List<ApiMethod> filterMethods(List<? extends ApiMethod> methods, MatchType matchType) {
        return filterMethods(methods, matchType, Collections.emptyList());
    }

    /**
     * Filters a list of methods to those that take the given set of arguments.
     *
     * @param methods list of methods to filter
     * @param matchType whether the arguments are an exact match, a subset or a super set of method args
     * @param argNames argument names to filter the list
     * @return methods with arguments that satisfy the match type.<p/>
     * For SUPER_SET match, if methods with exact match are found, methods that take a subset are ignored
     */
    public List<ApiMethod> filterMethods(List<? extends ApiMethod> methods, MatchType matchType, Collection<String> argNames) {
        // original arguments
        // supplied arguments with missing nullable arguments
        final List<String> withNullableArgsList;
        if (!nullableArguments.isEmpty()) {
            withNullableArgsList = new ArrayList<>(argNames);
            withNullableArgsList.addAll(nullableArguments);
        } else {
            withNullableArgsList = null;
        }

        // list of methods that have all args in the given names
        List<ApiMethod> result = new ArrayList<>();
        List<ApiMethod> extraArgs = null;
        List<ApiMethod> nullArgs = null;

        for (ApiMethod method : methods) {
            final List<String> methodArgs = method.getArgNames();
            switch (matchType) {
            case EXACT:
                // method must take all args, and no more
                if (methodArgs.containsAll(argNames) && argNames.containsAll(methodArgs)) {
                    result.add(method);
                }
                break;
            case SUBSET:
                // all args are required, method may take more
                if (methodArgs.containsAll(argNames)) {
                    result.add(method);
                }
                break;
            default:
            case SUPER_SET:
                // all method args must be present
                if (argNames.containsAll(methodArgs)) {
                    if (methodArgs.containsAll(argNames)) {
                        // prefer exact match to avoid unused args
                        result.add(method);
                    } else if (result.isEmpty()) {
                        // if result is empty, add method to extra args list
                        if (extraArgs == null) {
                            extraArgs = new ArrayList<>();
                        }
                        // method takes a subset, unused args
                        extraArgs.add(method);
                    }
                } else if (result.isEmpty() && extraArgs == null) {
                    // avoid looking for nullable args by checking for empty result and extraArgs
                    if (withNullableArgsList != null && withNullableArgsList.containsAll(methodArgs)) {
                        if (nullArgs == null) {
                            nullArgs = new ArrayList<>();
                        }
                        nullArgs.add(method);
                    }
                }
                break;
            }
        }

        List<ApiMethod> methodList = result.isEmpty()
            ? extraArgs == null
                ? nullArgs
                : extraArgs
            : result;

        // preference order is exact match, matches with extra args, matches with null args
        return methodList != null ? Collections.unmodifiableList(methodList) : Collections.emptyList();
    }

    /**
     * Gets argument types and names for all overloaded methods and aliases with the given name.
     * @param name method name, either an exact name or an alias, exact matches are checked first
     * @return list of arguments of the form Class type1, String name1, Class type2, String name2,...
     */
    public List<Object> getArguments(final String name) throws IllegalArgumentException {
        List<Object> arguments = argumentsMap.get(name);
        if (arguments == null) {
            if (aliasesMap.containsKey(name)) {
                arguments = new ArrayList<>();
                for (String method : aliasesMap.get(name)) {
                    arguments.addAll(argumentsMap.get(method));
                }
            }
        }
        if (arguments == null) {
            throw new IllegalArgumentException(name);
        }
        return Collections.unmodifiableList(arguments);
    }

    /**
     * Get missing properties.
     * @param methodName method name
     * @param argNames available arguments
     * @return Set of missing argument names
     */
    public Set<String> getMissingProperties(String methodName, Set<String> argNames) {
        final List<Object> argsWithTypes = getArguments(methodName);
        final Set<String> missingArgs = new HashSet<>();

        for (int i = 1; i < argsWithTypes.size(); i += 2) {
            final String name = (String) argsWithTypes.get(i);
            if (!argNames.contains(name)) {
                missingArgs.add(name);
            }
        }

        return missingArgs;
    }

    /**
     * Returns alias map.
     * @return alias names mapped to method names.
     */
    public Map<String, Set<String>> getAliases() {
        return aliasesMap;
    }

    /**
     * Returns argument types and names used by all methods.
     * @return map with argument names as keys, and types as values
     */
    public Map<String, Class<?>> allArguments() {
        return validArguments;
    }

    /**
     * Returns argument names that can be set to null if not specified.
     * @return list of argument names
     */
    public List<String> getNullableArguments() {
        return nullableArguments;
    }

    /**
     * Get the type for the given argument name.
     * @param argName argument name
     * @return argument type
     */
    public Class<?> getType(String argName) throws IllegalArgumentException {
        final Class<?> type = validArguments.get(argName);
        if (type == null) {
            throw new IllegalArgumentException(argName);
        }
        return type;
    }

    // this method is always called with Enum value lists, so the cast inside is safe
    // the alternative of trying to convert ApiMethod and associated classes to generic classes would a bear!!!
    @SuppressWarnings("unchecked")
    public static ApiMethod getHighestPriorityMethod(List<? extends ApiMethod> filteredMethods) {
        Comparable<ApiMethod> highest = null;
        for (ApiMethod method : filteredMethods) {
            if (highest == null || highest.compareTo(method) <= 0) {
                highest = (Comparable<ApiMethod>)method;
            }
        }
        return (ApiMethod)highest;
    }

    /**
     * Invokes given method with argument values from given properties.
     *
     * @param proxy Proxy object for invoke
     * @param method method to invoke
     * @param properties Map of arguments
     * @return result of method invocation
     * @throws org.apache.camel.RuntimeCamelException on errors
     */
    public static Object invokeMethod(Object proxy, ApiMethod method, Map<String, Object> properties)
        throws RuntimeCamelException {

        if (LOG.isDebugEnabled()) {
            LOG.debug("Invoking {} with arguments {}", method.getName(), properties);
        }

        final List<String> argNames = method.getArgNames();
        final Object[] values = new Object[argNames.size()];
        final List<Class<?>> argTypes = method.getArgTypes();
        final Class<?>[] types = argTypes.toArray(new Class[argTypes.size()]);
        int index = 0;
        for (String name : argNames) {
            Object value = properties.get(name);

            // is the parameter an array type?
            if (value != null && types[index].isArray()) {
                Class<?> type = types[index];

                if (value instanceof Collection) {
                    // convert collection to array
                    Collection<?> collection = (Collection<?>) value;
                    Object array = Array.newInstance(type.getComponentType(), collection.size());
                    if (array instanceof Object[]) {
                        collection.toArray((Object[]) array);
                    } else {
                        int i = 0;
                        for (Object el : collection) {
                            Array.set(array, i++, el);
                        }
                    }
                    value = array;
                } else if (value.getClass().isArray()
                        && type.getComponentType().isAssignableFrom(value.getClass().getComponentType())) {
                    // convert derived array to super array if needed
                    if (type.getComponentType() != value.getClass().getComponentType()) {
                        final int size = Array.getLength(value);
                        Object array = Array.newInstance(type.getComponentType(), size);
                        for (int i = 0; i < size; i++) {
                            Array.set(array, i, Array.get(value, i));
                        }
                        value = array;
                    }
                } else {
                    throw new IllegalArgumentException(
                        String.format("Cannot convert %s to %s", value.getClass(), type));
                }
            }

            values[index++] = value;
        }

        try {
            return method.getMethod().invoke(proxy, values);
        } catch (Throwable e) {
            if (e instanceof InvocationTargetException) {
                // get API exception
                final Throwable cause = e.getCause();
                e = (cause != null) ? cause : e;
            }
            throw new RuntimeCamelException(
                String.format("Error invoking %s with %s: %s", method.getName(), properties, e.getMessage()), e);
        }
    }

    public enum MatchType {
        EXACT, SUBSET, SUPER_SET
    }

}
